feat: API 통합을 위한 기초 인프라 구축
- 네트워크 레이어 구현 (Dio 기반 ApiClient) - 환경별 설정 관리 시스템 구축 - 의존성 주입 설정 (GetIt) - API 엔드포인트 상수 정의 - 인터셉터 구현 (Auth, Error, Logging) - 프로젝트 아키텍처 개선 (core, data, di 디렉토리 구조) - API 통합 계획서 및 요구사항 문서 작성 - 필요 패키지 추가 (dio, flutter_secure_storage, get_it 등)
This commit is contained in:
122
lib/data/datasources/remote/interceptors/auth_interceptor.dart
Normal file
122
lib/data/datasources/remote/interceptors/auth_interceptor.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
|
||||
/// 인증 인터셉터
|
||||
class AuthInterceptor extends Interceptor {
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
// 로그인, 토큰 갱신 요청은 토큰 없이 진행
|
||||
if (_isAuthEndpoint(options.path)) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// 저장된 액세스 토큰 가져오기
|
||||
final accessToken = await _storage.read(key: AppConstants.accessTokenKey);
|
||||
|
||||
if (accessToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $accessToken';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// 401 Unauthorized 에러 처리
|
||||
if (err.response?.statusCode == 401) {
|
||||
// 토큰 갱신 시도
|
||||
final refreshSuccess = await _refreshToken();
|
||||
|
||||
if (refreshSuccess) {
|
||||
// 새로운 토큰으로 원래 요청 재시도
|
||||
try {
|
||||
final newAccessToken = await _storage.read(key: AppConstants.accessTokenKey);
|
||||
|
||||
if (newAccessToken != null) {
|
||||
err.requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
|
||||
|
||||
final response = await Dio().fetch(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 재시도 실패
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신 실패 시 로그인 화면으로 이동
|
||||
await _clearTokens();
|
||||
// TODO: Navigate to login screen
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// 토큰 갱신
|
||||
Future<bool> _refreshToken() async {
|
||||
try {
|
||||
final refreshToken = await _storage.read(key: AppConstants.refreshTokenKey);
|
||||
|
||||
if (refreshToken == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final dio = Dio();
|
||||
final response = await dio.post(
|
||||
'${dio.options.baseUrl}${ApiEndpoints.refresh}',
|
||||
data: {
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data;
|
||||
|
||||
// 새로운 토큰 저장
|
||||
await _storage.write(
|
||||
key: AppConstants.accessTokenKey,
|
||||
value: data['access_token'],
|
||||
);
|
||||
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storage.write(
|
||||
key: AppConstants.refreshTokenKey,
|
||||
value: data['refresh_token'],
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 토큰 삭제
|
||||
Future<void> _clearTokens() async {
|
||||
await _storage.delete(key: AppConstants.accessTokenKey);
|
||||
await _storage.delete(key: AppConstants.refreshTokenKey);
|
||||
}
|
||||
|
||||
/// 인증 관련 엔드포인트 확인
|
||||
bool _isAuthEndpoint(String path) {
|
||||
return path.contains(ApiEndpoints.login) ||
|
||||
path.contains(ApiEndpoints.refresh) ||
|
||||
path.contains(ApiEndpoints.logout);
|
||||
}
|
||||
}
|
||||
253
lib/data/datasources/remote/interceptors/error_interceptor.dart
Normal file
253
lib/data/datasources/remote/interceptors/error_interceptor.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
/// 에러 처리 인터셉터
|
||||
class ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
// 에러 타입에 따른 처리
|
||||
switch (err.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: NetworkException(
|
||||
message: AppConstants.timeoutError,
|
||||
),
|
||||
type: err.type,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
if (err.error is SocketException) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: NetworkException(
|
||||
message: AppConstants.networkError,
|
||||
),
|
||||
type: err.type,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
handler.reject(err);
|
||||
}
|
||||
break;
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
_handleBadResponse(err, handler);
|
||||
break;
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
handler.reject(err);
|
||||
break;
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: NetworkException(
|
||||
message: '보안 인증서 오류가 발생했습니다.',
|
||||
),
|
||||
type: err.type,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ServerException(
|
||||
message: AppConstants.unknownError,
|
||||
),
|
||||
type: err.type,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 잘못된 응답 처리
|
||||
void _handleBadResponse(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
final statusCode = err.response?.statusCode;
|
||||
final data = err.response?.data;
|
||||
|
||||
String message = AppConstants.serverError;
|
||||
Map<String, dynamic>? errors;
|
||||
|
||||
// API 응답에서 에러 메시지 추출
|
||||
if (data != null) {
|
||||
if (data is Map) {
|
||||
message = data['message'] ?? data['error'] ?? message;
|
||||
errors = data['errors'] as Map<String, dynamic>?;
|
||||
} else if (data is String) {
|
||||
message = data;
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 코드별 예외 처리
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
// Bad Request - 유효성 검사 실패
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ValidationException(
|
||||
message: message,
|
||||
fieldErrors: _parseFieldErrors(errors),
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
// Unauthorized - 인증 실패
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: UnauthorizedException(
|
||||
message: AppConstants.unauthorizedError,
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 403:
|
||||
// Forbidden - 권한 부족
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ForbiddenException(
|
||||
message: '해당 작업을 수행할 권한이 없습니다.',
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 404:
|
||||
// Not Found - 리소스 없음
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: NotFoundException(
|
||||
message: message,
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 409:
|
||||
// Conflict - 중복 리소스
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: DuplicateException(
|
||||
message: message,
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// Unprocessable Entity - 비즈니스 로직 오류
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: BusinessException(
|
||||
message: message,
|
||||
code: data?['code'],
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 429:
|
||||
// Too Many Requests
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: NetworkException(
|
||||
message: '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요.',
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
// Server Error
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ServerException(
|
||||
message: AppConstants.serverError,
|
||||
statusCode: statusCode,
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ServerException(
|
||||
message: message,
|
||||
statusCode: statusCode,
|
||||
errors: errors,
|
||||
),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 필드 에러 파싱
|
||||
Map<String, List<String>>? _parseFieldErrors(Map<String, dynamic>? errors) {
|
||||
if (errors == null) return null;
|
||||
|
||||
final fieldErrors = <String, List<String>>{};
|
||||
|
||||
errors.forEach((key, value) {
|
||||
if (value is List) {
|
||||
fieldErrors[key] = value.map((e) => e.toString()).toList();
|
||||
} else if (value is String) {
|
||||
fieldErrors[key] = [value];
|
||||
}
|
||||
});
|
||||
|
||||
return fieldErrors.isNotEmpty ? fieldErrors : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 로깅 인터셉터
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
debugPrint('╔════════════════════════════════════════════════════════════');
|
||||
debugPrint('║ REQUEST');
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ ${options.method} ${options.uri}');
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Headers:');
|
||||
options.headers.forEach((key, value) {
|
||||
// 민감한 정보 마스킹
|
||||
if (key.toLowerCase() == 'authorization') {
|
||||
debugPrint('║ $key: ${_maskToken(value.toString())}');
|
||||
} else {
|
||||
debugPrint('║ $key: $value');
|
||||
}
|
||||
});
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Query Parameters:');
|
||||
options.queryParameters.forEach((key, value) {
|
||||
debugPrint('║ $key: $value');
|
||||
});
|
||||
}
|
||||
|
||||
if (options.data != null) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Request Body:');
|
||||
try {
|
||||
final formattedData = _formatJson(options.data);
|
||||
formattedData.split('\n').forEach((line) {
|
||||
debugPrint('║ $line');
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('║ ${options.data}');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('╚════════════════════════════════════════════════════════════');
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final requestTime = response.requestOptions.extra['requestTime'] as DateTime?;
|
||||
final responseTime = DateTime.now();
|
||||
final duration = requestTime != null
|
||||
? responseTime.difference(requestTime).inMilliseconds
|
||||
: null;
|
||||
|
||||
debugPrint('╔════════════════════════════════════════════════════════════');
|
||||
debugPrint('║ RESPONSE');
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ ${response.requestOptions.method} ${response.requestOptions.uri}');
|
||||
debugPrint('║ Status: ${response.statusCode} ${response.statusMessage}');
|
||||
if (duration != null) {
|
||||
debugPrint('║ Duration: ${duration}ms');
|
||||
}
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Headers:');
|
||||
response.headers.forEach((key, values) {
|
||||
debugPrint('║ $key: ${values.join(', ')}');
|
||||
});
|
||||
|
||||
if (response.data != null) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Response Body:');
|
||||
try {
|
||||
final formattedData = _formatJson(response.data);
|
||||
final lines = formattedData.split('\n');
|
||||
// 너무 긴 응답은 일부만 출력
|
||||
if (lines.length > 50) {
|
||||
lines.take(25).forEach((line) {
|
||||
debugPrint('║ $line');
|
||||
});
|
||||
debugPrint('║ ... (${lines.length - 50} lines omitted) ...');
|
||||
lines.skip(lines.length - 25).forEach((line) {
|
||||
debugPrint('║ $line');
|
||||
});
|
||||
} else {
|
||||
lines.forEach((line) {
|
||||
debugPrint('║ $line');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('║ ${response.data}');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('╚════════════════════════════════════════════════════════════');
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
debugPrint('╔════════════════════════════════════════════════════════════');
|
||||
debugPrint('║ ERROR');
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ ${err.requestOptions.method} ${err.requestOptions.uri}');
|
||||
debugPrint('║ Error Type: ${err.type}');
|
||||
debugPrint('║ Error Message: ${err.message}');
|
||||
|
||||
if (err.response != null) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Status: ${err.response!.statusCode} ${err.response!.statusMessage}');
|
||||
|
||||
if (err.response!.data != null) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Error Response:');
|
||||
try {
|
||||
final formattedData = _formatJson(err.response!.data);
|
||||
formattedData.split('\n').forEach((line) {
|
||||
debugPrint('║ $line');
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('║ ${err.response!.data}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (err.error != null) {
|
||||
debugPrint('╟────────────────────────────────────────────────────────────');
|
||||
debugPrint('║ Original Error: ${err.error}');
|
||||
}
|
||||
|
||||
debugPrint('╚════════════════════════════════════════════════════════════');
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// JSON 포맷팅
|
||||
String _formatJson(dynamic data) {
|
||||
try {
|
||||
if (data is String) {
|
||||
final parsed = json.decode(data);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} else {
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
} catch (e) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 토큰 마스킹
|
||||
String _maskToken(String token) {
|
||||
if (token.length <= 20) {
|
||||
return '***MASKED***';
|
||||
}
|
||||
|
||||
// Bearer 프리픽스 처리
|
||||
if (token.startsWith('Bearer ')) {
|
||||
final actualToken = token.substring(7);
|
||||
if (actualToken.length > 20) {
|
||||
return 'Bearer ${actualToken.substring(0, 10)}...${actualToken.substring(actualToken.length - 10)}';
|
||||
}
|
||||
return 'Bearer ***MASKED***';
|
||||
}
|
||||
|
||||
return '${token.substring(0, 10)}...${token.substring(token.length - 10)}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 요청 시간 측정을 위한 확장
|
||||
extension RequestOptionsExtension on RequestOptions {
|
||||
void setRequestTime() {
|
||||
extra['requestTime'] = DateTime.now();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user