Created
April 4, 2025 08:29
-
-
Save herveGuigoz/2bb775caee9856365d1f7ea54b1961e9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ApiClient { | |
ApiClient({ | |
required String baseURL, | |
required http.Client client, | |
List<HttpInterceptor>? interceptors, | |
}) : _client = HttpClient(baseURL: baseURL, interceptors: interceptors, client: client); | |
/// HTTP client to send requests | |
final HttpClient _client; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:convert'; | |
import 'package:api/src/core/exceptions.dart'; | |
import 'package:flutter/foundation.dart'; | |
/// A REST client for making HTTP requests. | |
abstract class HttpClientInterface { | |
/// Sends a GET request to the given [path]. | |
Future<Map<String, Object?>> get( | |
String path, { | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
/// Sends a POST request to the given [path]. | |
Future<Map<String, Object?>> post( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
/// Sends a PUT request to the given [path]. | |
Future<Map<String, Object?>> put( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
/// Sends a DELETE request to the given [path]. | |
Future<Map<String, Object?>> delete( | |
String path, { | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
/// Sends a PATCH request to the given [path]. | |
Future<Map<String, Object?>> patch( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
} | |
/// Base class for all HTTP clients. | |
@immutable | |
abstract base class AbstractHttpClient implements HttpClientInterface { | |
/// {@macro abstract_http_client} | |
const AbstractHttpClient({required this.baseURL}); | |
/// The base url for the client | |
final String baseURL; | |
static final _jsonUTF8 = json.fuse(utf8); | |
@override | |
Future<Map<String, Object?>> get( | |
String path, { | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}) { | |
return send( | |
path, | |
method: 'GET', | |
headers: headers, | |
queryParams: queryParams, | |
); | |
} | |
@override | |
Future<Map<String, Object?>> post( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}) { | |
return send( | |
path, | |
method: 'POST', | |
body: body, | |
headers: headers, | |
queryParams: queryParams, | |
); | |
} | |
@override | |
Future<Map<String, Object?>> put( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}) { | |
return send( | |
path, | |
method: 'PUT', | |
body: body, | |
headers: headers, | |
queryParams: queryParams, | |
); | |
} | |
@override | |
Future<Map<String, Object?>> patch( | |
String path, { | |
required Map<String, Object?> body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}) { | |
return send( | |
path, | |
method: 'PATCH', | |
body: body, | |
headers: headers, | |
queryParams: queryParams, | |
); | |
} | |
@override | |
Future<Map<String, Object?>> delete( | |
String path, { | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}) async { | |
return send( | |
path, | |
method: 'DELETE', | |
headers: headers, | |
queryParams: queryParams, | |
); | |
} | |
@protected | |
Future<Map<String, Object?>> send( | |
String path, { | |
required String method, | |
Map<String, Object?>? body, | |
Map<String, String>? headers, | |
Map<String, String>? queryParams, | |
}); | |
/// Encodes [body] to JSON and then to UTF8 | |
@protected | |
@visibleForTesting | |
List<int> encodeBody(Map<String, Object?> body) { | |
try { | |
return _jsonUTF8.encode(body); | |
} on Object catch (e, stackTrace) { | |
Error.throwWithStackTrace( | |
ClientException(message: 'Error occurred during encoding', cause: e), | |
stackTrace, | |
); | |
} | |
} | |
/// Builds [Uri] from [path], [queryParams] and [baseURL] | |
@protected | |
@visibleForTesting | |
Uri buildUri({ | |
required String path, | |
Map<String, String?>? queryParams, | |
}) { | |
return Uri.parse(baseURL).resolve(path).replace(queryParameters: queryParams); | |
} | |
/// Decodes [body] from JSON \ UTF8 | |
@protected | |
@visibleForTesting | |
Future<Map<String, Object?>?> decodeResponse( | |
Object? body, { | |
int? statusCode, | |
}) async { | |
if (statusCode != null) { | |
raiseForStatus(statusCode); | |
} | |
if (body == null || (body is String && body.isEmpty) || (body is List<int> && body.isEmpty)) { | |
return null; | |
} | |
try { | |
Map<String, dynamic> result; | |
if (body is String) { | |
if (body.length > 1000) { | |
result = (await compute(json.decode, body)) as Map<String, Object?>; | |
} else { | |
result = json.decode(body) as Map<String, dynamic>; | |
} | |
} else if (body is Map<String, dynamic>) { | |
result = body; | |
} else if (body is List<int>) { | |
if (body.length > 1000) { | |
result = (await compute(_jsonUTF8.decode, body))! as Map<String, Object?>; | |
} else { | |
result = _jsonUTF8.decode(body)! as Map<String, Object?>; | |
} | |
} else { | |
Error.throwWithStackTrace( | |
ClientException( | |
message: 'Unexpected response body type: ${body.runtimeType}', | |
statusCode: statusCode, | |
), | |
StackTrace.current, | |
); | |
} | |
return result; | |
} on ClientException { | |
rethrow; | |
} on Object catch (e, stackTrace) { | |
Error.throwWithStackTrace( | |
ClientException( | |
message: 'Error occured during decoding', | |
statusCode: statusCode, | |
cause: e, | |
), | |
stackTrace, | |
); | |
} | |
} | |
void raiseForStatus(int statusCode) { | |
if (statusCode < 200 || statusCode >= 300) { | |
final error = switch (statusCode) { | |
400 => const BadRequestException(), | |
403 => const ForbiddenException(), | |
404 => const NotFoundException(), | |
405 => const MethodNotAllowedException(), | |
406 => const NotAcceptableException(), | |
408 => const RequestTimeout(), | |
409 => const ConflictException(), | |
422 => const UnprocessableEntityException(), | |
426 => const UpgradeRequired(), | |
_ => InvalidResponseException(statusCode: statusCode), | |
}; | |
Error.throwWithStackTrace(error, StackTrace.current); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'package:cronet_http/cronet_http.dart' show CronetClient; | |
import 'package:cupertino_http/cupertino_http.dart' show CupertinoClient; | |
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; | |
import 'package:http/http.dart' as http; | |
import 'package:intercepted_client/intercepted_client.dart'; | |
import 'package:mira/core/api/core/base.dart'; | |
import 'package:mira/core/api/core/exceptions.dart'; | |
/// Creates an [http.Client] based on the current platform. | |
/// | |
/// For Android, it returns a [CronetClient] with the default Cronet engine. | |
/// For iOS, it returns a [CupertinoClient] with the default session configuration. | |
http.Client createDefaultHttpClient() { | |
http.Client? client; | |
final platform = defaultTargetPlatform; | |
try { | |
client = switch (platform) { | |
TargetPlatform.android => CronetClient.defaultCronetEngine(), | |
TargetPlatform.iOS => CupertinoClient.defaultSessionConfiguration(), | |
_ => null, | |
}; | |
} on Object catch (e, stackTrace) { | |
Zone.current.print('Failed to create a default http client for platform $platform $e $stackTrace'); | |
} | |
return client ?? http.Client(); | |
} | |
/// Rest client that uses [http] as HTTP library. | |
final class HttpClient extends AbstractHttpClient { | |
HttpClient({ | |
required super.baseURL, | |
required http.Client client, | |
this.timeout = const Duration(seconds: 30), | |
List<HttpInterceptor>? interceptors, | |
}) : _client = InterceptedClient(inner: client, interceptors: interceptors); | |
final Duration timeout; | |
final http.Client _client; | |
@override | |
Future<Map<String, Object?>> send( | |
String path, { | |
required String method, | |
Map<String, Object?>? body, | |
Map<String, String>? headers, | |
Map<String, String?>? queryParams, | |
}) async { | |
try { | |
final uri = buildUri(path: path, queryParams: queryParams); | |
final request = http.Request(method, uri); | |
if (headers != null) { | |
request.headers.addAll(headers); | |
} | |
if (body != null) { | |
request.bodyBytes = encodeBody(body); | |
request.headers['content-type'] = 'application/json;charset=utf-8'; | |
} | |
final response = await _client.send(request).timeout( | |
timeout, | |
onTimeout: () { | |
Error.throwWithStackTrace( | |
const ConnectionException(message: 'Connection timed out'), | |
StackTrace.current, | |
); | |
}, | |
).then(http.Response.fromStream); | |
final result = await decodeResponse( | |
response.bodyBytes, | |
statusCode: response.statusCode, | |
); | |
if (result == null) { | |
// If the response is empty, we override it with a success message in order to avoid returning null. | |
return {'success': true}; | |
} | |
return result; | |
} on HttpClientException { | |
rethrow; | |
} on http.ClientException catch (e, stack) { | |
Error.throwWithStackTrace(ClientException(message: e.message, cause: e), stack); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/foundation.dart'; | |
/// Base class for all rest client exceptions | |
@immutable | |
abstract base class HttpClientException implements Exception { | |
const HttpClientException({required this.message, this.statusCode, this.cause}); | |
/// Message of the exception | |
final String message; | |
/// The status code of the response (if any) | |
final int? statusCode; | |
/// The cause of the exception | |
/// | |
/// It is the inner exception that caused this exception to be thrown | |
final Object? cause; | |
@override | |
String toString() => message; | |
} | |
/// [ClientException] is thrown if something went wrong on client side | |
final class ClientException extends HttpClientException { | |
const ClientException({ | |
required super.message, | |
super.statusCode, | |
super.cause, | |
}); | |
@override | |
String toString() { | |
return 'ClientException(message: $message, statusCode: $statusCode, cause: $cause)'; | |
} | |
} | |
/// [ConnectionException] is thrown if there are problems with the connection | |
final class ConnectionException extends HttpClientException { | |
const ConnectionException({ | |
required super.message, | |
super.statusCode, | |
super.cause, | |
}); | |
@override | |
String toString() { | |
return 'ConnectionException(message: $message, statusCode: $statusCode, cause: $cause)'; | |
} | |
} | |
/// Generic exception raised when the server returns an error response code. | |
final class InvalidResponseException extends HttpClientException { | |
const InvalidResponseException({ | |
required super.statusCode, | |
}) : super(message: 'Invalid response with status code $statusCode'); | |
} | |
/// Raised when the server returns a 400 response code. | |
final class BadRequestException extends HttpClientException { | |
const BadRequestException({super.message = 'Bad request'}) : super(statusCode: 400); | |
} | |
/// Raised when the server returns a 401 response code. | |
final class UnauthorizedException extends HttpClientException { | |
const UnauthorizedException({super.message = 'Unauthorized'}) : super(statusCode: 401); | |
} | |
/// Raised when the server returns a 403 response code. | |
final class ForbiddenException extends HttpClientException { | |
const ForbiddenException({super.message = 'Forbidden'}) : super(statusCode: 403); | |
} | |
/// Raised when the server returns a 404 response code. | |
final class NotFoundException extends HttpClientException { | |
const NotFoundException({super.message = 'Ressource Not Found'}) : super(statusCode: 404); | |
} | |
/// Raised when the server returns a 405 response code. | |
base class MethodNotAllowedException extends HttpClientException { | |
const MethodNotAllowedException({super.message = 'Method Not Allowed'}) : super(statusCode: 405); | |
} | |
/// Raised when the server returns a 406 response code. | |
final class NotAcceptableException extends HttpClientException { | |
const NotAcceptableException({super.message = 'Not Acceptable'}) : super(statusCode: 406); | |
} | |
/// Raised when the server returns a 408 response code. | |
final class RequestTimeout extends HttpClientException { | |
const RequestTimeout({super.message = 'Request Timeout'}) : super(statusCode: 408); | |
} | |
/// Raised when the server returns a 409 response code. | |
final class ConflictException extends HttpClientException { | |
const ConflictException({super.message = 'Conflict'}) : super(statusCode: 409); | |
} | |
/// Raised when the server returns a 422 response code. | |
final class UnprocessableEntityException extends HttpClientException { | |
const UnprocessableEntityException({super.message = 'Unprocessable Entity'}) : super(statusCode: 422); | |
} | |
/// Raised when the server returns a 426 response code. | |
final class UpgradeRequired extends HttpClientException { | |
const UpgradeRequired({super.message = 'Upgrade Required'}) : super(statusCode: 426); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'package:http/http.dart'; | |
import 'package:intercepted_client/intercepted_client.dart'; | |
abstract class TokensStorageInterface<Tokens> { | |
/// Returns a stream of the Auth token. | |
Stream<Tokens?> get stream; | |
/// Load the Auth token from the storage. | |
Future<Tokens?> load(); | |
/// Save the Auth token to the storage. | |
Future<void> save(Tokens tokens); | |
/// Clears the Auth token. | |
Future<void> clear(); | |
} | |
/// The client that refreshes the Auth token using the refresh token. | |
abstract interface class AuthorizationClientInterface<Tokens> { | |
/// Check if refresh token is valid | |
Future<bool> isRefreshTokenValid(Tokens tokens); | |
/// Check if access token is valid | |
Future<bool> isAccessTokenValid(Tokens tokens); | |
/// Refreshes the token. | |
/// | |
/// This method should throw the [RevokeTokenException] if token | |
/// cannot be refreshed. | |
Future<Tokens> refresh(Tokens tokens); | |
Map<String, String> buildHeaders(Tokens tokens); | |
} | |
class AuthInterceptor<T> extends SequentialHttpInterceptor { | |
AuthInterceptor({ | |
required TokensStorageInterface<T> storage, | |
required AuthorizationClientInterface<T> authorizationClient, | |
Client? retryClient, | |
}) : _storage = storage, | |
_authorizationClient = authorizationClient, | |
_retryClient = retryClient ?? Client(); | |
/// [TokensStorageInterface] to store and retrieve the token | |
final TokensStorageInterface<T> _storage; | |
/// [AuthorizationClientInterface] to refresh the token | |
final AuthorizationClientInterface<T> _authorizationClient; | |
/// [Client] to retry the request | |
final Client _retryClient; | |
@override | |
Future<void> interceptRequest( | |
BaseRequest request, | |
RequestHandler handler, | |
) async { | |
final tokens = await _storage.load(); | |
// If the token is null, the request is made without authorization header | |
if (tokens == null) { | |
return handler.next(request); | |
} | |
// If token is valid, then the request is made with the token | |
if (await _authorizationClient.isAccessTokenValid(tokens)) { | |
final headers = _authorizationClient.buildHeaders(tokens); | |
request.headers.addAll(headers); | |
return handler.next(request); | |
} | |
// Refresh the token | |
if (await _authorizationClient.isRefreshTokenValid(tokens)) { | |
try { | |
// Even if refresh token seems to be valid from the client side, | |
// it may be revoked / banned / deleted on the server side, so | |
// the following method can throw the error. | |
final $tokens = await _authorizationClient.refresh(tokens); | |
await _storage.save($tokens); | |
final headers = _authorizationClient.buildHeaders($tokens); | |
request.headers.addAll(headers); | |
return handler.next(request); | |
// If authorization client decides that the token is no longer | |
// valid, it throws [RevokeTokenException] and user should be logged out | |
} on RevokeTokenException catch (e) { | |
// If token cannot be refreshed, then user should be logged out | |
await _storage.clear(); | |
return handler.rejectRequest(e); | |
// However, if another error occurs, like internet connection error, | |
// then we should not log out the user, but just reject the request | |
} on Object catch (e) { | |
return handler.rejectRequest(e); | |
} | |
} | |
// If token is not valid and cannot be refreshed, | |
// then user should be logged out | |
await _storage.clear(); | |
return handler.rejectRequest(const RevokeTokenException('Token is not valid and cannot be refreshed')); | |
} | |
@override | |
Future<void> interceptResponse( | |
StreamedResponse response, | |
ResponseHandler handler, | |
) async { | |
// If response is 401 (Unauthorized), then Access token is expired | |
// and, if possible, should be refreshed | |
if (response.statusCode != 401) { | |
return handler.resolveResponse(response); | |
} | |
final tokens = await _storage.load(); | |
// If token is null, then reject the response | |
if (tokens == null) { | |
return handler.rejectResponse(const RevokeTokenException('Token is not valid and cannot be refreshed')); | |
} | |
final tokenFromHeaders = _extractTokenFromHeaders(response.request?.headers ?? const {}); | |
// If request does not have the token, then return the response | |
if (tokenFromHeaders == null) { | |
return handler.resolveResponse(response); | |
} | |
// Refresh the token | |
if (await _authorizationClient.isRefreshTokenValid(tokens)) { | |
try { | |
// Even if refresh token seems to be valid from the client side, | |
// it may be revoked / banned / deleted on the server side, so | |
// the following method can throw the error. | |
final $tokens = await _authorizationClient.refresh(tokens); | |
await _storage.save($tokens); | |
// If authorization client decides that the token is no longer | |
// valid, it throws [RevokeTokenException] and user should be logged | |
// out | |
} on RevokeTokenException catch (e) { | |
// If token cannot be refreshed, then user should be logged out | |
await _storage.clear(); | |
return handler.rejectResponse(e); | |
// However, if another error occurs, like internet connection error, | |
// then we should not log out the user, but just reject the response | |
} on Object catch (e) { | |
return handler.rejectResponse(e); | |
} | |
} else { | |
// If token cannot be refreshed, then user should be logged out | |
await _storage.clear(); | |
return handler.rejectResponse(const RevokeTokenException('Token is not valid and cannot be refreshed')); | |
} | |
// If token is different, then the token is already refreshed | |
// and the request should be made again | |
final newResponse = await _retryRequest(response); | |
return handler.resolveResponse(newResponse); | |
} | |
String? _extractTokenFromHeaders(Map<String, String> headers) { | |
final authHeader = headers['Authorization']; | |
if (authHeader == null || !authHeader.startsWith('Bearer ')) { | |
return null; | |
} | |
return authHeader.substring(7); | |
} | |
Future<StreamedResponse> _retryRequest(StreamedResponse response) async { | |
final oldRequest = response.request; | |
if (oldRequest is Request) { | |
final newRequest = Request(oldRequest.method, oldRequest.url); | |
newRequest.headers.addAll(oldRequest.headers); | |
newRequest | |
..followRedirects = oldRequest.followRedirects | |
..maxRedirects = oldRequest.maxRedirects | |
..persistentConnection = oldRequest.persistentConnection | |
..bodyBytes = oldRequest.bodyBytes; | |
final response = await _retryClient.send(newRequest); | |
return response; | |
} | |
if (oldRequest is MultipartRequest) { | |
final newRequest = MultipartRequest(oldRequest.method, oldRequest.url); | |
newRequest.headers.addAll(oldRequest.headers); | |
newRequest | |
..followRedirects = oldRequest.followRedirects | |
..maxRedirects = oldRequest.maxRedirects | |
..persistentConnection = oldRequest.persistentConnection; | |
for (final field in oldRequest.fields.entries) { | |
newRequest.fields[field.key] = field.value; | |
} | |
newRequest.files.addAll(oldRequest.files); | |
return _retryClient.send(newRequest); | |
} | |
throw ArgumentError('Unknown request type: ${oldRequest.runtimeType}'); | |
} | |
/// Dispose the [AuthInterceptor] | |
void dispose() { | |
_retryClient.close(); | |
} | |
} | |
/// This exception is thrown when the token is not valid and cannot be refreshed | |
class RevokeTokenException implements Exception { | |
const RevokeTokenException(this.error); | |
/// The message of the exception | |
final Object error; | |
@override | |
String toString() => 'RevokeTokenException: $error'; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment