diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart new file mode 100644 index 0000000..d01f0d9 --- /dev/null +++ b/apps/lib/core/api/api_client.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; +import 'api_exception.dart'; +import 'api_interceptor.dart'; +import '../storage/token_storage.dart'; + +class ApiClient { + final Dio _dio; + final TokenStorage _tokenStorage; + final Future Function(String)? _refreshToken; + + ApiClient({ + required String baseUrl, + required TokenStorage tokenStorage, + Dio? dio, + Future Function(String)? refreshToken, + }) : _tokenStorage = tokenStorage, + _refreshToken = refreshToken, + _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { + _dio.interceptors.add( + ApiInterceptor( + tokenStorage: _tokenStorage, + onTokenRefresh: _handleTokenRefresh, + ), + ); + } + + Dio get dio => _dio; + + Future _handleTokenRefresh() async { + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken == null || _refreshToken == null) return false; + try { + final success = await _refreshToken!(refreshToken); + return success; + } catch (_) { + return false; + } + } + + Future> get(String path, {Options? options}) async { + try { + return await _dio.get(path, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } + + Future> post( + String path, { + dynamic data, + Options? options, + }) async { + try { + return await _dio.post(path, data: data, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } +} diff --git a/apps/lib/core/api/api_exception.dart b/apps/lib/core/api/api_exception.dart new file mode 100644 index 0000000..231b933 --- /dev/null +++ b/apps/lib/core/api/api_exception.dart @@ -0,0 +1,29 @@ +abstract class ApiException implements Exception { + final String message; + final int? statusCode; + + const ApiException(this.message, {this.statusCode}); + + factory ApiException.fromDioError(Object error) { + if (error is ApiException) return error; + return ServerException('Request failed: ${error.toString()}'); + } +} + +class NetworkException extends ApiException { + const NetworkException(super.message); +} + +class ServerException extends ApiException { + const ServerException(super.message, {super.statusCode}); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException([super.message = 'Authentication required']) + : super(statusCode: 401); +} + +class ValidationException extends ApiException { + final Map? errors; + const ValidationException(super.message, {this.errors, super.statusCode}); +} diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart new file mode 100644 index 0000000..74550fe --- /dev/null +++ b/apps/lib/core/api/api_interceptor.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; +import '../storage/token_storage.dart'; + +class ApiInterceptor extends Interceptor { + final TokenStorage tokenStorage; + final Future Function()? onTokenRefresh; + + ApiInterceptor({required this.tokenStorage, this.onTokenRefresh}); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401 && onTokenRefresh != null) { + final refreshed = await onTokenRefresh!(); + if (refreshed) { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + err.requestOptions.headers['Authorization'] = 'Bearer $token'; + try { + final response = await Dio().fetch(err.requestOptions); + handler.resolve(response); + return; + } catch (_) {} + } + } + } + handler.next(err); + } +} diff --git a/apps/lib/core/storage/token_storage.dart b/apps/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..9015357 --- /dev/null +++ b/apps/lib/core/storage/token_storage.dart @@ -0,0 +1,40 @@ +abstract class TokenStorage { + Future getAccessToken(); + Future getRefreshToken(); + Future saveTokens({required String access, required String refresh}); + Future clear(); +} + +class SecureTokenStorage implements TokenStorage { + static const _accessTokenKey = 'access_token'; + static const _refreshTokenKey = 'refresh_token'; + + final dynamic _storage; + + SecureTokenStorage([this._storage]); + + @override + Future getAccessToken() async { + return _storage?.read(key: _accessTokenKey); + } + + @override + Future getRefreshToken() async { + return _storage?.read(key: _refreshTokenKey); + } + + @override + Future saveTokens({ + required String access, + required String refresh, + }) async { + await _storage?.write(key: _accessTokenKey, value: access); + await _storage?.write(key: _refreshTokenKey, value: refresh); + } + + @override + Future clear() async { + await _storage?.delete(key: _accessTokenKey); + await _storage?.delete(key: _refreshTokenKey); + } +} diff --git a/apps/test/core/api/api_exception_test.dart b/apps/test/core/api/api_exception_test.dart new file mode 100644 index 0000000..e42825c --- /dev/null +++ b/apps/test/core/api/api_exception_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/api/api_exception.dart'; + +void main() { + group('ApiException', () { + test('creates from DioException with 400 status', () { + final dioException = Exception('Bad request'); + final apiException = ApiException.fromDioError(dioException); + + expect(apiException, isA()); + expect(apiException.message, contains('Request failed')); + }); + + test('NetworkException has correct message', () { + const exception = NetworkException('No internet'); + expect(exception.message, 'No internet'); + }); + + test('UnauthorizedException has default message', () { + const exception = UnauthorizedException(); + expect(exception.message, 'Authentication required'); + }); + }); +} diff --git a/apps/test/core/storage/token_storage_test.dart b/apps/test/core/storage/token_storage_test.dart new file mode 100644 index 0000000..ea79f96 --- /dev/null +++ b/apps/test/core/storage/token_storage_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +void main() { + late TokenStorage storage; + + setUp(() { + storage = MockTokenStorage(); + }); + + group('TokenStorage', () { + test('saves and retrieves access token', () async { + when( + () => storage.getAccessToken(), + ).thenAnswer((_) async => 'test_access'); + when( + () => + storage.saveTokens(access: 'test_access', refresh: 'test_refresh'), + ).thenAnswer((_) async {}); + + await storage.saveTokens(access: 'test_access', refresh: 'test_refresh'); + final token = await storage.getAccessToken(); + + expect(token, 'test_access'); + verify( + () => + storage.saveTokens(access: 'test_access', refresh: 'test_refresh'), + ).called(1); + }); + + test('clear removes all tokens', () async { + when(() => storage.clear()).thenAnswer((_) async {}); + when(() => storage.getAccessToken()).thenAnswer((_) async => null); + + await storage.clear(); + final token = await storage.getAccessToken(); + + expect(token, isNull); + }); + }); +}