feat(apps): add core API infrastructure
This commit is contained in:
@@ -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<bool> Function(String)? _refreshToken;
|
||||||
|
|
||||||
|
ApiClient({
|
||||||
|
required String baseUrl,
|
||||||
|
required TokenStorage tokenStorage,
|
||||||
|
Dio? dio,
|
||||||
|
Future<bool> 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<bool> _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<Response<T>> get<T>(String path, {Options? options}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.get<T>(path, options: options);
|
||||||
|
} catch (e) {
|
||||||
|
throw ApiException.fromDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<T>> post<T>(
|
||||||
|
String path, {
|
||||||
|
dynamic data,
|
||||||
|
Options? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _dio.post<T>(path, data: data, options: options);
|
||||||
|
} catch (e) {
|
||||||
|
throw ApiException.fromDioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic>? errors;
|
||||||
|
const ValidationException(super.message, {this.errors, super.statusCode});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../storage/token_storage.dart';
|
||||||
|
|
||||||
|
class ApiInterceptor extends Interceptor {
|
||||||
|
final TokenStorage tokenStorage;
|
||||||
|
final Future<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
abstract class TokenStorage {
|
||||||
|
Future<String?> getAccessToken();
|
||||||
|
Future<String?> getRefreshToken();
|
||||||
|
Future<void> saveTokens({required String access, required String refresh});
|
||||||
|
Future<void> clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecureTokenStorage implements TokenStorage {
|
||||||
|
static const _accessTokenKey = 'access_token';
|
||||||
|
static const _refreshTokenKey = 'refresh_token';
|
||||||
|
|
||||||
|
final dynamic _storage;
|
||||||
|
|
||||||
|
SecureTokenStorage([this._storage]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getAccessToken() async {
|
||||||
|
return _storage?.read(key: _accessTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getRefreshToken() async {
|
||||||
|
return _storage?.read(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveTokens({
|
||||||
|
required String access,
|
||||||
|
required String refresh,
|
||||||
|
}) async {
|
||||||
|
await _storage?.write(key: _accessTokenKey, value: access);
|
||||||
|
await _storage?.write(key: _refreshTokenKey, value: refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
await _storage?.delete(key: _accessTokenKey);
|
||||||
|
await _storage?.delete(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiException>());
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user