전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
@@ -2,18 +2,23 @@
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_error.dart';
|
||||
|
||||
/// 공통 API 클라이언트 (Dio 래퍼)
|
||||
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
|
||||
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
|
||||
class ApiClient {
|
||||
ApiClient({required Dio dio, ApiErrorMapper? errorMapper})
|
||||
: _dio = dio,
|
||||
_errorMapper = errorMapper ?? const ApiErrorMapper();
|
||||
|
||||
final Dio _dio;
|
||||
final ApiErrorMapper _errorMapper;
|
||||
|
||||
/// 내부에서 사용하는 Dio 인스턴스
|
||||
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
|
||||
Dio get dio => _dio;
|
||||
|
||||
ApiClient({required Dio dio}) : _dio = dio;
|
||||
|
||||
/// GET 요청 헬퍼
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
@@ -21,7 +26,14 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.get<T>(
|
||||
path,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// POST 요청 헬퍼
|
||||
@@ -32,7 +44,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// PATCH 요청 헬퍼
|
||||
@@ -43,7 +63,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.patch<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE 요청 헬퍼
|
||||
@@ -54,7 +82,22 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response<T>> _wrap<T>(Future<Response<T>> Function() request) async {
|
||||
try {
|
||||
return await request();
|
||||
} on DioException catch (error) {
|
||||
throw _errorMapper.map(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
lib/core/network/api_error.dart
Normal file
148
lib/core/network/api_error.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
enum ApiErrorCode {
|
||||
badRequest,
|
||||
unauthorized,
|
||||
notFound,
|
||||
conflict,
|
||||
unprocessableEntity,
|
||||
network,
|
||||
timeout,
|
||||
cancel,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiErrorCode code;
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final Map<String, dynamic>? details;
|
||||
final DioException? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException(code: $code, statusCode: $statusCode, message: $message)';
|
||||
}
|
||||
|
||||
class ApiErrorMapper {
|
||||
const ApiErrorMapper();
|
||||
|
||||
ApiException map(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
final data = error.response?.data;
|
||||
final message = _resolveMessage(error, data);
|
||||
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.timeout,
|
||||
message: '서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.badCertificate) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.network,
|
||||
message: '네트워크 연결에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.cancel,
|
||||
message: '요청이 취소되었습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
final details = _extractDetails(data);
|
||||
switch (status) {
|
||||
case 400:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 401:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unauthorized,
|
||||
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 404:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.notFound,
|
||||
message: '요청한 리소스를 찾을 수 없습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 409:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.conflict,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 422:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unprocessableEntity,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unknown,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveMessage(DioException error, dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'] ?? data['error'];
|
||||
if (message is String && message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _extractDetails(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final errors = data['errors'];
|
||||
if (errors is Map<String, dynamic>) {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,124 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 인증 인터셉터(스켈레톤)
|
||||
import '../../services/token_storage.dart';
|
||||
|
||||
typedef RefreshTokenCallback = Future<TokenPair?> Function();
|
||||
|
||||
class TokenPair {
|
||||
const TokenPair({required this.accessToken, required this.refreshToken});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
}
|
||||
|
||||
/// 인증 인터셉터
|
||||
/// - 요청 전에 Authorization 헤더 주입
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도
|
||||
class AuthInterceptor extends Interceptor {
|
||||
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
|
||||
AuthInterceptor();
|
||||
AuthInterceptor({
|
||||
required TokenStorage tokenStorage,
|
||||
required Dio dio,
|
||||
this.onRefresh,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio = dio;
|
||||
|
||||
final TokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
final RefreshTokenCallback? onRefresh;
|
||||
|
||||
final List<Completer<void>> _refreshQueue = [];
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
|
||||
// final token = await _authRepository.getToken();
|
||||
// if (token != null && token.isNotEmpty) {
|
||||
// options.headers['Authorization'] = 'Bearer $token';
|
||||
// }
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
|
||||
// if (err.response?.statusCode == 401) { ... }
|
||||
handler.next(err);
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_shouldAttemptRefresh(err)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _refreshToken();
|
||||
final response = await _retry(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} on _RefreshFailedException {
|
||||
await _tokenStorage.clear();
|
||||
handler.next(err);
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
} catch (_) {
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAttemptRefresh(DioException err) {
|
||||
return onRefresh != null &&
|
||||
err.response?.statusCode == 401 &&
|
||||
err.requestOptions.extra['__retry'] != true;
|
||||
}
|
||||
|
||||
Future<void> _refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
final completer = Completer<void>();
|
||||
_refreshQueue.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final callback = onRefresh;
|
||||
if (callback == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
final pair = await callback();
|
||||
if (pair == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
await _tokenStorage.writeAccessToken(pair.accessToken);
|
||||
await _tokenStorage.writeRefreshToken(pair.refreshToken);
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
for (final completer in _refreshQueue) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
_refreshQueue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
} else {
|
||||
requestOptions.headers.remove('Authorization');
|
||||
}
|
||||
requestOptions.extra['__retry'] = true;
|
||||
return _dio.fetch(requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshFailedException implements Exception {
|
||||
const _RefreshFailedException();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user