Skip to content

Instantly share code, notes, and snippets.

@herveGuigoz
Created April 4, 2025 08:29
Show Gist options
  • Save herveGuigoz/2bb775caee9856365d1f7ea54b1961e9 to your computer and use it in GitHub Desktop.
Save herveGuigoz/2bb775caee9856365d1f7ea54b1961e9 to your computer and use it in GitHub Desktop.
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;
}
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);
}
}
}
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);
}
}
}
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);
}
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