From f473ad0823f6038f9647a045e5345cafcc36d82b Mon Sep 17 00:00:00 2001 From: Yelaman Yelmuratov Date: Wed, 26 Jun 2024 23:51:47 +0500 Subject: [PATCH] @ R Rest client ref --- .../data/dio_rest_client/rest_client.dart | 2 - .../src/auth/auth_interceptor.dart | 281 ---------- .../src/auth/refresh_client.dart | 12 - .../src/auth/token_storage.dart | 26 - .../dio_rest_client/src/rest_client_dio.dart | 57 +- .../core/resource/domain/api/rest_client.dart | 17 +- .../resource/domain/api/rest_client_base.dart | 32 +- .../core/theme/presentation/themes/light.dart | 2 +- .../resource/data/data_auth_repository.dart | 12 +- .../logic/initialization_steps.dart | 5 +- .../widget/initialization_failed_app.dart | 6 +- .../components/auth_interceptor_test.dart | 523 ------------------ test/core/components/rest_client_test.dart | 11 +- 13 files changed, 87 insertions(+), 899 deletions(-) delete mode 100644 lib/src/core/resource/data/dio_rest_client/src/auth/auth_interceptor.dart delete mode 100644 lib/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart delete mode 100644 lib/src/core/resource/data/dio_rest_client/src/auth/token_storage.dart delete mode 100644 test/core/components/auth_interceptor_test.dart diff --git a/lib/src/core/resource/data/dio_rest_client/rest_client.dart b/lib/src/core/resource/data/dio_rest_client/rest_client.dart index e55bf90..0569563 100644 --- a/lib/src/core/resource/data/dio_rest_client/rest_client.dart +++ b/lib/src/core/resource/data/dio_rest_client/rest_client.dart @@ -3,5 +3,3 @@ library rest_client; export '../../../exceptions/network/rest_client_exception.dart'; export '../../domain/api/rest_client.dart'; export '../../domain/api/rest_client_base.dart'; -export 'src/auth/auth_interceptor.dart'; -export 'src/auth/token_storage.dart'; diff --git a/lib/src/core/resource/data/dio_rest_client/src/auth/auth_interceptor.dart b/lib/src/core/resource/data/dio_rest_client/src/auth/auth_interceptor.dart deleted file mode 100644 index 26d642f..0000000 --- a/lib/src/core/resource/data/dio_rest_client/src/auth/auth_interceptor.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:async'; - -import 'package:async/async.dart'; -import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client.dart'; -import 'package:base_starter/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart'; -import 'package:dio/dio.dart'; -import 'package:ispect/ispect.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/subjects.dart'; - -// coverage:ignore-start -/// Throw this exception when refresh token fails -class RevokeTokenException implements Exception { - /// Create a [RevokeTokenException] - const RevokeTokenException(); - - @override - String toString() => 'RevokedTokenException'; -} -// coverage:ignore-end - -/// [AuthenticationStatus] is used to determine the authentication state -/// of the user. -/// -/// This should be consumed by the business logic. -enum AuthenticationStatus { - /// The initial state of the authentication status - initial, - - /// The user is authenticated - authenticated, - - /// The user is unauthenticated - unauthenticated; -} - -/// AuthSource provides valuable information about the authentication state -abstract interface class AuthStatusDataSource { - /// Stream of token pairs - /// - /// This stream should be listened from repository and bloc, - /// if it emits null, it means the token pair is revoked - /// and the user should be logged out. - Stream getAuthenticationStatusStream(); -} - -/// Interceptor for Auth -/// -/// This interceptor adds the Auth token to the request header -/// and clears the token if the request fails with a 401 -class AuthInterceptor extends QueuedInterceptor - implements AuthStatusDataSource { - /// Create an Auth interceptor - AuthInterceptor({ - required this.storage, - required this.refreshClient, - required this.buildHeaders, - @visibleForTesting Dio? retryClient, - }) : retryClient = retryClient ?? Dio() { - _storageSubscription = storage.getTokenPairStream().listen( - _updateAuthenticationStatus, - ); - - // Preload the token pair - getTokenPair().then(_updateAuthenticationStatus).ignore(); - } - - /// [Dio] client used to retry the request. - final Dio retryClient; - - /// The token storage - /// - /// This is used to store and retrieve the Auth token. - final TokenStorage storage; - - /// Refresh client that refreshes the Auth token pair - /// - /// This is used to refresh the Auth token - /// pair when the request fails with a 401. - final RefreshClient refreshClient; - - /// Async cache that ensures that only one request is made to the storage - /// simultaneously. - final AsyncCache _tokenCache = AsyncCache.ephemeral(); - - StreamSubscription? _storageSubscription; - - /// The current token model - T? _token; - - /// The current authentication status - var _authenticationStatus = AuthenticationStatus.initial; - - /// The authentication status controller - final _authController = BehaviorSubject.seeded(AuthenticationStatus.initial); - - /// Get the token pair - /// - /// Returns the cached token pair if it exists, - /// otherwise loads from the storage. - Future getTokenPair() { - if (_token != null) { - return Future.value(_token); - } - - return _tokenCache.fetch( - () async => _token = await storage.loadTokenPair(), - ); - } - - @override - Stream getAuthenticationStatusStream() => - _authController.stream; - - /// Clear the token pair - /// Invalidates cache and clears storage - @visibleForTesting - Future clearTokenPair() => storage.clearTokenPair(); - - /// Save the token pair - /// Invalidates cache and saves to storage - @visibleForTesting - Future saveTokenPair(T pair) => storage.saveTokenPair(pair); - - @override - Future onRequest( - RequestOptions options, - RequestInterceptorHandler handler, - ) async { - try { - // Load the token pair - final tokenPair = await getTokenPair(); - - // Build the headers based on the token pair - final headers = tokenPair != null - ? buildHeaders(tokenPair) - : const {}; - - // Add the headers to the request - options.headers.addAll(headers); - - // Continue the request - handler.next(options); - } on Object catch (e) { - talkerWrapper.warning('Clearing token pair due to error: $e'); - - // We don't create a new exception here, just rethrow the original - rethrow; - } - } - - @override - Future onResponse( - Response response, - ResponseInterceptorHandler handler, - ) async { - final token = await getTokenPair(); - - if (token == null || !shouldRefresh(response)) { - return handler.next(response); - } - - final newResponse = await _refresh(response, token); - handler.resolve(newResponse); - } - - @override - Future onError( - DioException err, - ErrorInterceptorHandler handler, - ) async { - final response = err.response; - final token = await getTokenPair(); - if (response == null || - token == null || - err.error is RevokeTokenException || - !shouldRefresh(response)) { - return handler.next(err); - } - - try { - final refreshResponse = await _refresh(response, token); - handler.resolve(refreshResponse); - } on DioException catch (error) { - handler.next(error); - } - } - - // coverage:ignore-start - /// Close the interceptor - Future close() async { - await _storageSubscription?.cancel(); - await _authController.close(); - } - // coverage:ignore-end - - /// Build the headers - /// - /// This is used to build the headers for the request. - @visibleForTesting - @pragma('vm:prefer-inline') - final Map Function(T token) buildHeaders; - - /// Check if the token pair should be refreshed - @visibleForTesting - @pragma('vm:prefer-inline') - bool shouldRefresh(Response response) => response.statusCode == 401; - - /// Update the authentication status based on the token pair - void _updateAuthenticationStatus(T? token) { - final oldStatus = _authenticationStatus; - if (token == null) { - _authenticationStatus = AuthenticationStatus.unauthenticated; - } else { - _authenticationStatus = AuthenticationStatus.authenticated; - } - - _token = token; - if (oldStatus != _authenticationStatus) { - _authController.add(_authenticationStatus); - } - } - - Future> _refresh(Response response, T token) async { - final T newTokenPair; - - try { - // Refresh the token pair - newTokenPair = await refreshClient.refreshToken(token); - } on RevokeTokenException { - // Clear the token pair - talkerWrapper.info('Revoking token pair'); - await clearTokenPair(); - rethrow; - } on Object catch (_) { - rethrow; - } - - // Save the new token pair - await saveTokenPair(newTokenPair); - - final headers = buildHeaders(newTokenPair); - - // Retry the request - final newResponse = await retryRequest(response, headers); - - return newResponse; - } - - /// Retry the request - @visibleForTesting - Future> retryRequest( - Response response, - Map headers, - ) => - retryClient.request( - response.requestOptions.path, - cancelToken: response.requestOptions.cancelToken, - data: response.requestOptions.data, - onReceiveProgress: response.requestOptions.onReceiveProgress, - onSendProgress: response.requestOptions.onSendProgress, - queryParameters: response.requestOptions.queryParameters, - options: Options( - method: response.requestOptions.method, - sendTimeout: response.requestOptions.sendTimeout, - receiveTimeout: response.requestOptions.receiveTimeout, - extra: response.requestOptions.extra, - headers: response.requestOptions.headers..addAll(headers), - responseType: response.requestOptions.responseType, - contentType: response.requestOptions.contentType, - validateStatus: response.requestOptions.validateStatus, - receiveDataWhenStatusError: - response.requestOptions.receiveDataWhenStatusError, - followRedirects: response.requestOptions.followRedirects, - maxRedirects: response.requestOptions.maxRedirects, - requestEncoder: response.requestOptions.requestEncoder, - responseDecoder: response.requestOptions.responseDecoder, - listFormat: response.requestOptions.listFormat, - ), - ); -} diff --git a/lib/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart b/lib/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart deleted file mode 100644 index a6a386c..0000000 --- a/lib/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client.dart'; - -/// The client that refreshes the Auth token using the refresh token. -/// -/// This client is used by the [AuthInterceptor] to refresh the Auth token. -abstract interface class RefreshClient { - /// Refresh the Auth token. - /// - /// This method is called by the [AuthInterceptor] - /// when the request fails with a 401. - Future refreshToken(T token); -} diff --git a/lib/src/core/resource/data/dio_rest_client/src/auth/token_storage.dart b/lib/src/core/resource/data/dio_rest_client/src/auth/token_storage.dart deleted file mode 100644 index 028b6a7..0000000 --- a/lib/src/core/resource/data/dio_rest_client/src/auth/token_storage.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:async'; - -import 'package:base_starter/src/core/resource/data/dio_rest_client/src/auth/auth_interceptor.dart'; - -/// The interface for token storage. -/// -/// This interface is used by the [AuthInterceptor] -/// to store and retrieve the Auth token pair. -abstract interface class TokenStorage { - /// Load the Auth token pair. - Future loadTokenPair(); - - /// Save the Auth token pair. - Future saveTokenPair(T tokenPair); - - /// Clear the Auth token pair. - /// - /// This is used to clear the token pair when the request fails with a 401. - Future clearTokenPair(); - - /// A stream of token pairs. - Stream getTokenPairStream(); - - /// Close the token storage. - Future close(); -} diff --git a/lib/src/core/resource/data/dio_rest_client/src/rest_client_dio.dart b/lib/src/core/resource/data/dio_rest_client/src/rest_client_dio.dart index f8a95d7..a66dae6 100644 --- a/lib/src/core/resource/data/dio_rest_client/src/rest_client_dio.dart +++ b/lib/src/core/resource/data/dio_rest_client/src/rest_client_dio.dart @@ -2,7 +2,6 @@ import 'package:base_starter/bootstrap.dart'; import 'package:base_starter/src/common/configs/preferences/secure_storage_manager.dart'; -import 'package:base_starter/src/common/constants/app_constants.dart'; import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client.dart'; import 'package:base_starter/src/core/resource/data/dio_rest_client/src/interceptor/dio_interceptor.dart'; import 'package:base_starter/src/core/resource/domain/token/token_pair.dart'; @@ -15,33 +14,37 @@ import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; /// Rest client that uses `Dio` as HTTP library. final class RestClientDio extends RestClientBase { final Dio? dio; - final String? baseUrl; + final String baseUrl; RestClientDio({ + required this.baseUrl, this.dio, - this.baseUrl, - }); + }) : super(baseUrl: baseUrl); /// Send [Dio] request @protected @visibleForTesting - Future?> sendRequest({ + Future> sendRequest({ required String path, required String method, - Map? body, + dynamic body, Map? headers, Map? queryParams, + bool returnFullData = false, }) async { try { final uri = buildUri(path: path, queryParams: queryParams); + final options = Options( headers: headers, method: method, contentType: 'application/json', responseType: ResponseType.json, ); + final dioClient = (dio != null) + ? DioClient(baseUrl: dio!.options.baseUrl, initialDio: dio).dio + : DioClient(baseUrl: baseUrl).dio; - final response = - await (dio ?? DioClient(baseUrl: baseUrl).dio).request( + final response = await dioClient.request( uri.toString(), data: body, options: options, @@ -50,8 +53,15 @@ final class RestClientDio extends RestClientBase { final resp = await decodeResponse( response.data, statusCode: response.statusCode, + returnFullData: returnFullData, ); + if (resp == null) { + throw WrongResponseTypeException( + message: 'Unexpected response body type: ${body.runtimeType}', + statusCode: response.statusCode, + ); + } return resp; } on RestClientException { rethrow; @@ -72,6 +82,7 @@ final class RestClientDio extends RestClientBase { final result = await decodeResponse( e.response?.data, statusCode: e.response?.statusCode, + returnFullData: returnFullData, ); throw CustomBackendException( @@ -97,37 +108,42 @@ final class RestClientDio extends RestClientBase { } @override - Future?> delete( + Future> delete( String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }) => sendRequest( path: path, method: 'DELETE', headers: headers, queryParams: queryParams, + returnFullData: returnFullData, ); @override - Future?> get( + Future> get( String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }) => sendRequest( path: path, method: 'GET', headers: headers, queryParams: queryParams, + returnFullData: returnFullData, ); @override - Future?> patch( + Future> patch( String path, { required Map body, Map? headers, Map? queryParams, + bool returnFullData = false, }) => sendRequest( path: path, @@ -135,14 +151,16 @@ final class RestClientDio extends RestClientBase { body: body, headers: headers, queryParams: queryParams, + returnFullData: returnFullData, ); @override - Future?> post( + Future> post( String path, { - required Map body, + required dynamic body, Map? headers, Map? queryParams, + bool returnFullData = false, }) => sendRequest( path: path, @@ -150,14 +168,16 @@ final class RestClientDio extends RestClientBase { body: body, headers: headers, queryParams: queryParams, + returnFullData: returnFullData, ); @override - Future?> put( + Future> put( String path, { required Map body, Map? headers, Map? queryParams, + bool returnFullData = false, }) => sendRequest( path: path, @@ -165,6 +185,7 @@ final class RestClientDio extends RestClientBase { body: body, headers: headers, queryParams: queryParams, + returnFullData: returnFullData, ); } @@ -172,13 +193,13 @@ class DioClient { static DioClient? _instance; final Dio dio; - factory DioClient({String? baseUrl}) { - _instance ??= DioClient._internal(baseUrl ?? AppConstants.baseUrl); + factory DioClient({required String baseUrl, Dio? initialDio}) { + _instance ??= DioClient._internal(baseUrl: baseUrl, initialDio: initialDio); return _instance!; } - DioClient._internal(String baseUrl) - : dio = Dio(BaseOptions(baseUrl: baseUrl)) { + DioClient._internal({required String baseUrl, Dio? initialDio}) + : dio = initialDio ?? Dio(BaseOptions(baseUrl: baseUrl)) { _initInterceptors(); } diff --git a/lib/src/core/resource/domain/api/rest_client.dart b/lib/src/core/resource/domain/api/rest_client.dart index a76515a..45bdead 100644 --- a/lib/src/core/resource/domain/api/rest_client.dart +++ b/lib/src/core/resource/domain/api/rest_client.dart @@ -1,40 +1,45 @@ /// A REST client for making HTTP requests. abstract class RestClient { /// Sends a GET request to the given [path]. - Future?> get( + Future> get( String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }); /// Sends a POST request to the given [path]. - Future?> post( + Future> post( String path, { - required Map body, + required dynamic body, Map? headers, Map? queryParams, + bool returnFullData = false, }); /// Sends a PUT request to the given [path]. - Future?> put( + Future> put( String path, { required Map body, Map? headers, Map? queryParams, + bool returnFullData = false, }); /// Sends a DELETE request to the given [path]. - Future?> delete( + Future> delete( String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }); /// Sends a PATCH request to the given [path]. - Future?> patch( + Future> patch( String path, { required Map body, Map? headers, Map? queryParams, + bool returnFullData = false, }); } diff --git a/lib/src/core/resource/domain/api/rest_client_base.dart b/lib/src/core/resource/domain/api/rest_client_base.dart index 9829a98..0f29a17 100644 --- a/lib/src/core/resource/domain/api/rest_client_base.dart +++ b/lib/src/core/resource/domain/api/rest_client_base.dart @@ -2,15 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; -import 'package:base_starter/src/common/constants/app_constants.dart'; import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; @immutable abstract base class RestClientBase implements RestClient { - RestClientBase({String baseUrl = AppConstants.baseUrl}) - : baseUri = Uri.parse(baseUrl); + RestClientBase({required String baseUrl}) : baseUri = Uri.parse(baseUrl); /// The base url for the client final Uri baseUri; @@ -35,7 +33,10 @@ abstract base class RestClientBase implements RestClient { @protected @visibleForTesting Uri buildUri({required String path, Map? queryParams}) { - final finalPath = p.canonicalize(p.join(baseUri.path, path)); + final finalPath = p.canonicalize( + p.join(baseUri.path, path.startsWith('/') ? path.substring(1) : path), + ); + return baseUri.replace( path: finalPath, queryParameters: { @@ -51,17 +52,24 @@ abstract base class RestClientBase implements RestClient { FutureOr?> decodeResponse( Object? body, { int? statusCode, + bool returnFullData = false, }) async { if (body == null) return null; try { Map result; if (body is String) { - if (body.length > 1000) { - result = await Isolate.run( - () => json.decode(body) as Map, - ); + if (body.contains('DOCTYPE html')) { + result = { + 'HTML error': body.toString(), + }; } else { - result = json.decode(body) as Map; + if (body.length > 1000) { + result = await Isolate.run( + () => json.decode(body) as Map, + ); + } else { + result = json.decode(body) as Map; + } } } else if (body is Map) { result = body; @@ -88,8 +96,10 @@ abstract base class RestClientBase implements RestClient { ); } - if (result case {'data': final Map data}) { - return data; + if (returnFullData == false) { + if (result case {'data': final Map data}) { + return data; + } } /// return null if in your response you have no data key (data key is required) diff --git a/lib/src/core/theme/presentation/themes/light.dart b/lib/src/core/theme/presentation/themes/light.dart index ff49214..e7843a5 100644 --- a/lib/src/core/theme/presentation/themes/light.dart +++ b/lib/src/core/theme/presentation/themes/light.dart @@ -1,6 +1,6 @@ +import 'package:base_starter/src/common/utils/utils.dart'; import 'package:base_starter/src/core/theme/presentation/theme_colors.dart'; import 'package:base_starter/src/core/theme/presentation/theme_text_style.dart'; -import 'package:base_starter/src/common/utils/utils.dart'; import 'package:flutter/material.dart'; final class LightThemeData { diff --git a/lib/src/features/auth/resource/data/data_auth_repository.dart b/lib/src/features/auth/resource/data/data_auth_repository.dart index af18108..69cae90 100644 --- a/lib/src/features/auth/resource/data/data_auth_repository.dart +++ b/lib/src/features/auth/resource/data/data_auth_repository.dart @@ -15,11 +15,7 @@ final class AuthRepository implements IAuthRepository { Future getCurrentUser() async { try { final response = await restClient.get("api/v1/auth/profile"); - if (response != null) { - return UserModel.fromJson(response); - } else { - return null; - } + return UserModel.fromJson(response); } catch (e, st) { talkerWrapper.handle( exception: e, @@ -43,11 +39,7 @@ final class AuthRepository implements IAuthRepository { "password": password, }, ); - if (response != null) { - return TokenPair.fromJson(response); - } else { - return null; - } + return TokenPair.fromJson(response); } catch (e, st) { talkerWrapper.handle( exception: e, diff --git a/lib/src/features/initialization/logic/initialization_steps.dart b/lib/src/features/initialization/logic/initialization_steps.dart index 4e192ca..4e2bb5a 100644 --- a/lib/src/features/initialization/logic/initialization_steps.dart +++ b/lib/src/features/initialization/logic/initialization_steps.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:base_starter/flavors.dart'; import 'package:base_starter/src/common/configs/preferences/app_config_manager.dart'; +import 'package:base_starter/src/common/constants/app_constants.dart'; import 'package:base_starter/src/common/constants/preferences.dart'; import 'package:base_starter/src/core/localization/generated/l10n.dart'; import 'package:base_starter/src/core/localization/localization.dart'; @@ -16,6 +17,7 @@ import 'package:base_starter/src/features/settings/data/locale/locale_repository import 'package:base_starter/src/features/settings/data/theme/theme_datasource.dart'; import 'package:base_starter/src/features/settings/data/theme/theme_mode_codec.dart'; import 'package:base_starter/src/features/settings/data/theme/theme_repository.dart'; +import 'package:dio/dio.dart'; import 'package:ispect/ispect.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -89,7 +91,8 @@ mixin InitializationSteps { progress.dependencies.settingsBloc = settingsBloc; }, 'Rest Client': (progress) async { - final restClient = RestClientDio(); + final dio = Dio(); + final restClient = RestClientDio(baseUrl: AppConstants.baseUrl, dio: dio); progress.dependencies.restClient = restClient; }, 'Auth Repository, BLoC': (progress) async { diff --git a/lib/src/features/initialization/presentation/widget/initialization_failed_app.dart b/lib/src/features/initialization/presentation/widget/initialization_failed_app.dart index 5b11fca..e772d6c 100644 --- a/lib/src/features/initialization/presentation/widget/initialization_failed_app.dart +++ b/lib/src/features/initialization/presentation/widget/initialization_failed_app.dart @@ -1,10 +1,10 @@ import 'package:base_starter/flavors.dart'; -import 'package:base_starter/src/core/di/containers/dependencies.dart'; -import 'package:base_starter/src/core/di/containers/repositories.dart'; -import 'package:base_starter/src/core/di/dependencies_scope.dart'; import 'package:base_starter/src/common/utils/extensions/context_extension.dart'; import 'package:base_starter/src/common/utils/extensions/talker.dart'; import 'package:base_starter/src/core/assets/generated/assets.gen.dart'; +import 'package:base_starter/src/core/di/containers/dependencies.dart'; +import 'package:base_starter/src/core/di/containers/repositories.dart'; +import 'package:base_starter/src/core/di/dependencies_scope.dart'; import 'package:base_starter/src/core/localization/generated/l10n.dart'; import 'package:base_starter/src/core/localization/localization.dart'; import 'package:base_starter/src/features/settings/bloc/settings_bloc.dart'; diff --git a/test/core/components/auth_interceptor_test.dart b/test/core/components/auth_interceptor_test.dart deleted file mode 100644 index 338b0be..0000000 --- a/test/core/components/auth_interceptor_test.dart +++ /dev/null @@ -1,523 +0,0 @@ -import 'dart:async'; - -import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client.dart'; -import 'package:base_starter/src/core/resource/data/dio_rest_client/src/auth/refresh_client.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'rest_client_test.dart'; - -/// A pair of Auth tokens. -/// -/// The **accessToken** is used to authenticate the request. -/// -/// The **refreshToken** is used to refresh the accessToken. -typedef TokenPair = ({String accessToken, String refreshToken}); - -/// InMemoryTokenStorage is an in-memory implementation of [TokenStorage]. -/// Generally, this should only be used for testing. -class InMemoryTokenStorage implements TokenStorage { - /// Create an in-memory token storage. - InMemoryTokenStorage({String? accessToken, String? refreshToken}) { - if (accessToken != null) { - _storage['accessToken'] = accessToken; - } - if (refreshToken != null) { - _storage['refreshToken'] = refreshToken; - } - } - - final _storage = {}; - final _controller = StreamController.broadcast(); - - @override - Future saveTokenPair(TokenPair tokenPair) async { - _storage['accessToken'] = tokenPair.accessToken; - _storage['refreshToken'] = tokenPair.refreshToken; - _controller.add(tokenPair); - } - - @override - Future loadTokenPair() async { - final accessToken = _storage['accessToken']; - final refreshToken = _storage['refreshToken']; - if (accessToken != null && refreshToken != null) { - return (accessToken: accessToken, refreshToken: refreshToken); - } - return null; - } - - @override - Future clearTokenPair() async { - _storage.remove('accessToken'); - _storage.remove('refreshToken'); - _controller.add(null); - } - - @override - Stream getTokenPairStream() => _controller.stream; - - @override - Future close() => _controller.close(); -} - -const TokenPair mockTokenPair = ( - accessToken: 'Access Token', - refreshToken: 'RefreshToken', -); - -class MockRefreshClient extends Mock implements RefreshClient {} - -class MockRequestInterceptorHandler extends Mock - implements RequestInterceptorHandler {} - -class MockResponseInterceptorHandler extends Mock - implements ResponseInterceptorHandler {} - -class MockErrorInterceptorHandler extends Mock - implements ErrorInterceptorHandler {} - -class MockTokenStorage extends Mock implements TokenStorage {} - -Map buildHeaders(TokenPair pair) => - {'Authorization': 'Bearer ${pair.accessToken}'}; - -void main() { - group('Auth Interceptor', () { - late InMemoryTokenStorage memStorageWithToken; - late InMemoryTokenStorage memStorageWithoutToken; - late MockRefreshClient refreshClientSuccess; - late MockRefreshClient refreshClientError; - - setUp(() { - memStorageWithToken = InMemoryTokenStorage( - accessToken: mockTokenPair.accessToken, - refreshToken: mockTokenPair.refreshToken, - ); - memStorageWithoutToken = InMemoryTokenStorage(); - refreshClientSuccess = MockRefreshClient(); - when(() => refreshClientSuccess.refreshToken(any())).thenAnswer( - (_) => Future.value(mockTokenPair), - ); - refreshClientError = MockRefreshClient(); - when(() => refreshClientError.refreshToken(any())).thenThrow( - const RevokeTokenException(), - ); - }); - - tearDown(() { - memStorageWithToken.close(); - memStorageWithoutToken.close(); - resetMocktailState(); - }); - - setUpAll(() { - registerFallbackValue(RequestOptions(path: '/test')); - registerFallbackValue( - Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 200, - data: const {}, - ), - ); - registerFallbackValue(mockTokenPair); - }); - group('On Request >', () { - test('Adds AccessToken to Request Headers if Available', () async { - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - final options = RequestOptions(path: '/test'); - - final handler = MockRequestInterceptorHandler(); - - await interceptor.onRequest(options, handler); - verify(() => handler.next(options)).called(1); - - expect( - options.headers, - {'Authorization': 'Bearer ${mockTokenPair.accessToken}'}, - ); - }); - - test('Proceeds Without Error When No Tokens Are Present', () async { - final interceptor = AuthInterceptor( - storage: memStorageWithoutToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - final options = RequestOptions(path: '/test'); - - final handler = MockRequestInterceptorHandler(); - - await expectLater( - interceptor.onRequest(options, handler), - completes, - ); - - verify(() => handler.next(options)); - }); - - // test('Rethrows Exception When Token Storage Access Fails', () async { - // final storage = MockTokenStorage(); - // when(() => storage.getTokenPairStream()).thenAnswer( - // (_) => const Stream.empty(), - // ); - - // when(() => storage.loadTokenPair()).thenThrow(Exception('Test Error')); - // when(() => storage.clearTokenPair()).thenAnswer((_) => Future.value()); - - // final interceptor = AuthInterceptor( - // storage: storage, - // refreshClient: refreshClientSuccess, - // buildHeaders: buildHeaders, - // ); - // final options = RequestOptions(path: '/test'); - - // final handler = MockRequestInterceptorHandler(); - - // await expectLater( - // () => interceptor.onRequest(options, handler), - // throwsA( - // isA().having( - // (e) => e.toString(), - // 'toString()', - // 'Exception: Test Error', - // ), - // ), - // ); - - // verifyNever(() => handler.next(options)); - // }); - }); - group('On Response >', () { - test('Calls Next Handler on Successful API Response', () async { - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - final response = Response( - requestOptions: RequestOptions(), - statusCode: 200, - data: const {}, - ); - - final handler = MockResponseInterceptorHandler(); - - await expectLater( - interceptor.onResponse(response, handler), - completes, - ); - - verify(() => handler.next(response)).called(1); - }); - test( - 'Preloads TokenPair from Storage on Initial Setup', - () async { - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - - await expectLater( - interceptor.getAuthenticationStatusStream(), - emitsInOrder([ - AuthenticationStatus.initial, - AuthenticationStatus.authenticated, - ]), - ); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - test( - 'Emits Unauthenticated Status When TokenPair is Empty', - () async { - final interceptor = AuthInterceptor( - storage: memStorageWithoutToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - - await expectLater( - interceptor.getAuthenticationStatusStream(), - emitsInOrder([ - AuthenticationStatus.initial, - AuthenticationStatus.unauthenticated, - ]), - ); - }, - timeout: const Timeout(Duration(seconds: 1)), - ); - - test('Caches Token Storage Access on Consecutive Calls', () { - final storage = MockTokenStorage(); - when(() => storage.getTokenPairStream()) - .thenAnswer((invocation) => const Stream.empty()); - final refreshClient = MockRefreshClient(); - when(() => storage.loadTokenPair()).thenAnswer( - (_) => Future.value(mockTokenPair), - ); - - when(() => storage.getTokenPairStream()).thenAnswer( - (_) => Stream.value(mockTokenPair), - ); - - final interceptor = AuthInterceptor( - storage: storage, - refreshClient: refreshClient, - buildHeaders: buildHeaders, - ); - - interceptor.getTokenPair(); - interceptor.getTokenPair(); - - verify(() => storage.loadTokenPair()).called(1); - }); - - test('Caches token pair value', () async { - final storage = MockTokenStorage(); - when(() => storage.getTokenPairStream()).thenAnswer( - (invocation) => const Stream.empty(), - ); - - when(() => storage.loadTokenPair()).thenAnswer( - (_) => Future.value(mockTokenPair), - ); - - when(() => storage.clearTokenPair()).thenAnswer( - (_) => Future.value(), - ); - - final interceptor = AuthInterceptor( - storage: storage, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - - await interceptor.getTokenPair(); - await interceptor.clearTokenPair(); - await interceptor.getTokenPair(); - - verify(() => storage.loadTokenPair()).called(1); - }); - - test('Emits Unauthenticated Status After Clearing TokenPair', () async { - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - buildHeaders: buildHeaders, - ); - - await expectLater( - interceptor.getAuthenticationStatusStream(), - emitsInOrder([ - AuthenticationStatus.initial, - AuthenticationStatus.authenticated, - ]), - ); - - interceptor.clearTokenPair().ignore(); - - await expectLater( - interceptor.getAuthenticationStatusStream(), - emitsInOrder([ - AuthenticationStatus.authenticated, - AuthenticationStatus.unauthenticated, - ]), - ); - }); - test('Refreshes Token Upon Receiving 401 Status Code', () async { - final mockAdapter = MockHttpAdapter() - ..registerResponse( - '/test', - (options) => ResponseBody.fromString('{"test": "test"}', 200), - ); - final retryClient = Dio()..httpClientAdapter = mockAdapter; - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - retryClient: retryClient, - buildHeaders: buildHeaders, - ); - final response = Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 401, - data: const {}, - ); - - final handler = MockResponseInterceptorHandler(); - - await expectLater( - interceptor.onResponse(response, handler), - completes, - ); - - verify(() => handler.resolve(any())).called(1); - }); - // test('Clears Tokens and Emits Status on RevokeTokenException', () async { - // final mockAdapter = MockHttpAdapter() - // ..registerResponse( - // '/test', - // (options) => ResponseBody.fromString('{"test": "test"}', 200), - // ); - // final baseClient = Dio()..httpClientAdapter = mockAdapter; - // final interceptor = AuthInterceptor( - // storage: memStorageWithToken, - // refreshClient: refreshClientError, - // retryClient: baseClient, - // buildHeaders: buildHeaders, - // ); - // final response = Response( - // requestOptions: RequestOptions(path: '/test'), - // statusCode: 401, - // data: const {}, - // ); - - // final handler = MockResponseInterceptorHandler(); - - // await expectLater( - // interceptor.onResponse(response, handler), - // throwsA(isA()), - // ); - - // await expectLater( - // interceptor.getAuthenticationStatusStream(), - // emits(AuthenticationStatus.unauthenticated), - // ); - // }); - test('Throws Exception on Token Refresh Failure During Response', () { - final refreshClient = MockRefreshClient(); - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - buildHeaders: buildHeaders, - refreshClient: refreshClient, - ); - final response = Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 401, - data: const {}, - ); - - final handler = MockResponseInterceptorHandler(); - - when(() => refreshClient.refreshToken(any())).thenThrow( - Exception('Test Error'), - ); - - expectLater( - () => interceptor.onResponse(response, handler), - throwsA( - isA().having( - (e) => e.toString(), - 'toString()', - 'Exception: Test Error', - ), - ), - ); - - verifyNever(() => handler.next(response)); - }); - }); - group('On Error', () { - test('Should refresh on error', () async { - final mockAdapter = MockHttpAdapter() - ..registerResponse( - '/test', - (options) => ResponseBody.fromString('{"test": "test"}', 200), - ); - final baseClient = Dio()..httpClientAdapter = mockAdapter; - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - retryClient: baseClient, - buildHeaders: buildHeaders, - ); - final error = DioException( - requestOptions: RequestOptions(path: '/test'), - error: 'Test Error', - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 401, - data: const {}, - ), - ); - - final handler = MockErrorInterceptorHandler(); - - await expectLater(interceptor.onError(error, handler), completes); - - verify(() => handler.resolve(any())).called(1); - }); - - test('Handler next is called on refresh if DioException', () async { - final refreshClient = MockRefreshClient(); - final mockAdapter = MockHttpAdapter() - ..registerResponse( - '/test', - (options) => ResponseBody.fromString('{"test": "test"}', 200), - ); - final baseClient = Dio()..httpClientAdapter = mockAdapter; - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClient, - retryClient: baseClient, - buildHeaders: buildHeaders, - ); - final error = DioException( - requestOptions: RequestOptions(path: '/test'), - error: 'Test Error', - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 401, - data: const {}, - ), - ); - - final handler = MockErrorInterceptorHandler(); - - when(() => refreshClient.refreshToken(any())).thenAnswer( - (_) => throw error, - ); - - await expectLater(interceptor.onError(error, handler), completes); - - verify(() => handler.next(error)); - }); - - test("If error code is not 401, it doesn't refresh", () async { - final mockAdapter = MockHttpAdapter() - ..registerResponse( - '/test', - (options) => ResponseBody.fromString('{"test": "test"}', 200), - ); - final baseClient = Dio()..httpClientAdapter = mockAdapter; - final interceptor = AuthInterceptor( - storage: memStorageWithToken, - refreshClient: refreshClientSuccess, - retryClient: baseClient, - buildHeaders: buildHeaders, - ); - final error = DioException( - requestOptions: RequestOptions(path: '/test'), - error: 'Test Error', - response: Response( - requestOptions: RequestOptions(path: '/test'), - statusCode: 400, - data: const {}, - ), - ); - - final handler = MockErrorInterceptorHandler(); - - await expectLater(interceptor.onError(error, handler), completes); - - verify(() => handler.next(error)).called(1); - }); - }); - }); -} diff --git a/test/core/components/rest_client_test.dart b/test/core/components/rest_client_test.dart index 8d8f3d1..46d6718 100644 --- a/test/core/components/rest_client_test.dart +++ b/test/core/components/rest_client_test.dart @@ -7,8 +7,6 @@ import 'package:base_starter/src/core/resource/data/dio_rest_client/rest_client. import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'auth_interceptor_test.dart' as auth_interceptor_test; - Map _generateJsonData(int length) => { 'data': { 'list': List.generate(length, (index) => {'test': 'test'}), @@ -17,8 +15,6 @@ Map _generateJsonData(int length) => { void main() { group('RestClient >', () { - auth_interceptor_test.main(); - group('encodeBody >', () { test('Should encode body', () { final restClient = _RestClientBase(); @@ -349,6 +345,7 @@ final class _RestClientBase extends RestClientBase { String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }) async { throw UnimplementedError(); } @@ -358,6 +355,7 @@ final class _RestClientBase extends RestClientBase { String path, { Map? headers, Map? queryParams, + bool returnFullData = false, }) async { throw UnimplementedError(); } @@ -368,6 +366,7 @@ final class _RestClientBase extends RestClientBase { Map? body, Map? headers, Map? queryParams, + bool returnFullData = false, }) async { throw UnimplementedError(); } @@ -375,9 +374,10 @@ final class _RestClientBase extends RestClientBase { @override Future> post( String path, { - Map? body, + dynamic body, Map? headers, Map? queryParams, + bool returnFullData = false, }) async { throw UnimplementedError(); } @@ -388,6 +388,7 @@ final class _RestClientBase extends RestClientBase { Map? body, Map? headers, Map? queryParams, + bool returnFullData = false, }) async { throw UnimplementedError(); }