feat(apps): add core API infrastructure

This commit is contained in:
qzl
2026-02-25 14:36:03 +08:00
parent 53c72e48e6
commit 75f4d2c3fb
6 changed files with 236 additions and 0 deletions
+59
View File
@@ -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);
}
}
}
+29
View File
@@ -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});
}
+40
View File
@@ -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);
}
}
+40
View File
@@ -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);
}
}