chore: API 오류 매핑과 Failure 파서 고도화

This commit is contained in:
JiWoong Sul
2025-10-14 18:06:25 +09:00
parent 67fc319c3c
commit 9f61b305d4
7 changed files with 1000 additions and 71 deletions

View File

@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
enum ApiErrorCode {
badRequest,
unauthorized,
forbidden,
notFound,
conflict,
unprocessableEntity,
@@ -92,6 +93,14 @@ class ApiErrorMapper {
statusCode: status,
cause: error,
);
case 403:
return ApiException(
code: ApiErrorCode.forbidden,
message: message,
statusCode: status,
details: _extractDetails(data),
cause: error,
);
case 404:
return ApiException(
code: ApiErrorCode.notFound,
@@ -131,24 +140,196 @@ class ApiErrorMapper {
/// 응답 바디 혹은 Dio 예외 객체에서 사용자용 메시지를 추출한다.
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;
final candidates = [
data['message'],
data['error_message'],
data['errorMessage'],
data['detail'],
];
for (final candidate in candidates) {
if (candidate is String && candidate.trim().isNotEmpty) {
return candidate.trim();
}
}
} else if (data is String && data.isNotEmpty) {
return data;
final errorNode = data['error'];
if (errorNode is String && errorNode.trim().isNotEmpty) {
return errorNode.trim();
}
if (errorNode is Map<String, dynamic>) {
final nested = [
errorNode['message'],
errorNode['title'],
errorNode['detail'],
errorNode['error'],
];
for (final candidate in nested) {
if (candidate is String && candidate.trim().isNotEmpty) {
return candidate.trim();
}
}
}
final messagesNode = data['messages'];
if (messagesNode is Iterable) {
final flattened = _flattenMessages(messagesNode);
if (flattened != null) {
return flattened;
}
}
final errorsNode = data['errors'];
final flattenedErrors = _flattenMessages(errorsNode);
if (flattenedErrors != null) {
return flattenedErrors;
}
} else if (data is String && data.trim().isNotEmpty) {
return data.trim();
}
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
return error.message?.trim() ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
}
/// 422/409 등에서 제공되는 필드별 오류 정보를 추출한다.
Map<String, dynamic>? _extractDetails(dynamic data) {
if (data is Map<String, dynamic>) {
final errors = data['errors'];
if (errors is Map<String, dynamic>) {
return errors;
if (data is! Map) {
return null;
}
Map<String, dynamic>? result;
void merge(dynamic node) {
if (node == null) {
return;
}
if (node is Map) {
if (node.isEmpty) {
return;
}
result ??= <String, dynamic>{};
node.forEach((rawKey, rawValue) {
final key = rawKey?.toString() ?? '';
if (key.isEmpty) {
return;
}
final values = _asList(rawValue);
if (values.isEmpty) {
return;
}
final existing = result![key];
if (existing == null) {
result![key] = values.length == 1 ? values.first : values;
return;
}
final merged = _asList(existing)..addAll(values);
result![key] = merged.length == 1 ? merged.first : merged;
});
return;
}
if (node is Iterable) {
final mapped = _mapFromErrors(node);
if (mapped != null) {
merge(mapped);
}
return;
}
final mapped = _mapFromErrors([node]);
if (mapped != null) {
merge(mapped);
}
}
return null;
final map = data.cast<dynamic, dynamic>();
merge(map['errors']);
merge(map['details']);
merge(map['context']);
final errorNode = map['error'];
if (errorNode is Map) {
merge(errorNode['errors']);
merge(errorNode['details']);
merge(errorNode['context']);
}
return result;
}
String? _flattenMessages(dynamic source) {
if (source == null) {
return null;
}
if (source is String) {
final trimmed = source.trim();
return trimmed.isEmpty ? null : trimmed;
}
if (source is Iterable) {
final buffer = <String>[];
for (final item in source) {
final value = _flattenMessages(item);
if (value != null && value.isNotEmpty) {
buffer.add(value);
}
}
if (buffer.isEmpty) {
return null;
}
return buffer.join('\n');
}
if (source is Map) {
final message =
source['message'] ??
source['detail'] ??
source['error'] ??
source['description'];
final flattened = _flattenMessages(message);
if (flattened != null) {
return flattened;
}
return _flattenMessages(source.values);
}
if (source is num || source is bool) {
return source.toString();
}
return source.toString();
}
Map<String, dynamic>? _mapFromErrors(Iterable<dynamic> errors) {
final result = <String, dynamic>{};
for (final entry in errors) {
if (entry is Map) {
final field = entry['field'] ?? entry['name'] ?? entry['key'];
final messages =
entry['messages'] ??
entry['message'] ??
entry['detail'] ??
entry['error'];
if (field is String) {
final bucket = result[field];
final nextMessages = _asList(messages);
if (bucket is List) {
bucket.addAll(nextMessages);
} else if (bucket != null) {
result[field] = _asList(bucket)..addAll(nextMessages);
} else if (nextMessages.length == 1) {
result[field] = nextMessages.first;
} else {
result[field] = nextMessages;
}
continue;
}
}
final list =
result.putIfAbsent('general', () => <dynamic>[]) as List<dynamic>;
list.add(entry);
}
return result.isEmpty ? null : result;
}
List<dynamic> _asList(dynamic value) {
if (value == null) {
return <dynamic>[];
}
if (value is List) {
return value;
}
if (value is Iterable) {
return value.toList();
}
return <dynamic>[value];
}
}

View File

@@ -0,0 +1,448 @@
import 'package:dio/dio.dart';
import 'api_error.dart';
/// 서버/네트워크 예외를 사용자 메시지와 필드 오류 형태로 정규화한 객체.
class Failure {
const Failure({
required this.message,
this.code,
this.statusCode,
this.reasons = const [],
this.fieldErrors = const {},
this.raw,
});
/// 사용자에게 우선 노출할 기본 메시지.
final String message;
/// [ApiErrorCode] 또는 유사 코드.
final ApiErrorCode? code;
/// HTTP 상태 코드(존재하는 경우).
final int? statusCode;
/// 추가 설명 메시지 목록.
final List<String> reasons;
/// 필드별 오류 메시지 묶음.
final Map<String, List<String>> fieldErrors;
/// 원본 예외 객체.
final Object? raw;
/// 필드 오류가 존재하는지 여부.
bool get hasFieldErrors => fieldErrors.isNotEmpty;
/// 추가 설명이 존재하는지 여부.
bool get hasReasons => reasons.isNotEmpty;
/// 필드명에 해당하는 첫 번째 오류 메시지를 반환한다.
String? firstFieldError(String field) {
final messages = fieldErrors[field];
if (messages == null) {
return null;
}
for (final message in messages) {
final trimmed = message.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
}
return null;
}
/// 필드 오류를 줄바꿈으로 합친 문자열 맵을 반환한다.
Map<String, String> get fieldErrorMessages {
final result = <String, String>{};
fieldErrors.forEach((key, value) {
final messages = _dedupeStrings(value);
if (messages.isEmpty) {
return;
}
result[key] = messages.join('\n');
});
return result;
}
/// 기본 메시지와 추가 설명 및 필드 오류를 단일 문자열로 합친다.
String describe({String separator = '\n', bool includeFieldErrors = true}) {
final lines = <String>[];
final base = message.trim();
if (base.isNotEmpty) {
lines.add(base);
}
for (final reason in reasons) {
final trimmed = reason.trim();
if (trimmed.isEmpty) {
continue;
}
final duplicate = lines.any(
(existing) => existing.toLowerCase() == trimmed.toLowerCase(),
);
if (!duplicate) {
lines.add(trimmed);
}
}
if (includeFieldErrors && fieldErrors.isNotEmpty) {
fieldErrors.forEach((key, value) {
final first = value.firstWhere(
(message) => message.trim().isNotEmpty,
orElse: () => '',
);
final trimmed = first.trim();
if (trimmed.isEmpty) {
return;
}
final line = '$key: $trimmed';
final duplicate = lines.any(
(existing) => existing.toLowerCase() == line.toLowerCase(),
);
if (!duplicate) {
lines.add(line);
}
});
}
return lines.join(separator).trim();
}
/// 임의 객체에서 [Failure]를 생성한다.
static Failure from(Object error) => const FailureParser().parse(error);
}
/// [Failure] 변환을 담당하는 파서.
class FailureParser {
const FailureParser();
Failure parse(Object error) {
if (error is Failure) {
return error;
}
if (error is ApiException) {
return _fromApiException(error);
}
if (error is DioException) {
final mapped = const ApiErrorMapper().map(error);
return _fromApiException(mapped);
}
if (error is Exception || error is Error) {
final message = _exceptionMessage(error);
return Failure(message: message, raw: error);
}
final fallback = error.toString().trim();
return Failure(
message: fallback.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : fallback,
raw: error,
);
}
Failure _fromApiException(ApiException exception) {
final payload = _FailurePayload();
payload.consumeDetails(exception.details);
payload.consumeResponseData(exception.cause?.response?.data);
final message =
_firstNonEmpty([payload.message, exception.message]) ??
'요청 처리 중 오류가 발생했습니다.';
final reasons = payload.reasons
.where(
(reason) =>
reason.trim().toLowerCase() != message.trim().toLowerCase(),
)
.toList(growable: false);
return Failure(
message: message,
code: exception.code,
statusCode: exception.statusCode,
reasons: reasons,
fieldErrors: payload.fieldErrors.map(
(key, value) => MapEntry(key, _dedupeStrings(value)),
),
raw: exception,
);
}
String _exceptionMessage(Object error) {
final raw = error.toString();
final trimmed = raw.trim();
if (trimmed.startsWith('Exception: ')) {
final stripped = trimmed.substring('Exception: '.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
if (trimmed.startsWith('Error: ')) {
final stripped = trimmed.substring('Error: '.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
return trimmed.isEmpty ? '요청 처리 중 오류가 발생했습니다.' : trimmed;
}
}
class _FailurePayload {
String? message;
final List<String> reasons = [];
final Map<String, List<String>> fieldErrors = {};
void consumeDetails(Map<String, dynamic>? details) {
if (details == null || details.isEmpty) {
return;
}
details.forEach((rawKey, rawValue) {
final key = rawKey.toString();
final values = _flattenMessages(rawValue);
if (values.isEmpty) {
return;
}
if (_isGeneralKey(key)) {
reasons.addAll(values);
} else {
fieldErrors.putIfAbsent(key, () => <String>[]).addAll(values);
}
});
}
void consumeResponseData(dynamic data) {
if (data == null) {
return;
}
if (data is Map<String, dynamic>) {
_consumeMap(data);
return;
}
if (data is Iterable) {
reasons.addAll(_flattenIterable(data));
return;
}
final text = _toMessage(data);
if (text != null) {
reasons.add(text);
}
}
void _consumeMap(Map<String, dynamic> map) {
final messageCandidate = _firstNonEmpty([
_toMessage(map['message']),
_toMessage(map['error_message']),
_toMessage(map['errorMessage']),
_toMessage(map['detail']),
]);
if (messageCandidate != null) {
message = messageCandidate;
}
_consumeErrorsNode(map['errors']);
_consumeErrorsNode(map['details']);
_consumeErrorsNode(map['reason']);
_consumeErrorsNode(map['reasons']);
_consumeErrorsNode(map['context']);
final errorNode = map['error'];
if (errorNode is Map<String, dynamic>) {
final nodeMessage = _firstNonEmpty([
_toMessage(errorNode['message']),
_toMessage(errorNode['title']),
_toMessage(errorNode['detail']),
]);
message ??= nodeMessage;
_consumeErrorsNode(errorNode['errors']);
_consumeErrorsNode(errorNode['details']);
_consumeErrorsNode(errorNode['reason']);
_consumeErrorsNode(errorNode['reasons']);
_consumeErrorsNode(errorNode['context']);
final extra = _flattenIterable(_ensureIterable(errorNode['messages']));
reasons.addAll(extra);
} else if (errorNode != null) {
final text = _toMessage(errorNode);
if (text != null) {
reasons.add(text);
}
}
final messagesNode = map['messages'];
reasons.addAll(_flattenIterable(_ensureIterable(messagesNode)));
}
void _consumeErrorsNode(dynamic node) {
if (node == null) {
return;
}
if (node is Map<String, dynamic>) {
node.forEach((rawKey, rawValue) {
final key = rawKey.toString();
final values = _flattenMessages(rawValue);
if (values.isEmpty) {
return;
}
if (_isGeneralKey(key)) {
reasons.addAll(values);
} else {
fieldErrors.putIfAbsent(key, () => <String>[]).addAll(values);
}
});
return;
}
if (node is Iterable) {
for (final item in node) {
if (item is Map<String, dynamic>) {
final field = _firstNonEmpty([
_toMessage(item['field']),
_toMessage(item['name']),
_toMessage(item['key']),
]);
final messages = _flattenMessages(
item['messages'] ??
item['message'] ??
item['detail'] ??
item['error'],
);
if (field != null && field.trim().isNotEmpty) {
if (messages.isEmpty) {
final fallback = _flattenMessages(item);
fieldErrors.putIfAbsent(field, () => <String>[]).addAll(fallback);
} else {
fieldErrors.putIfAbsent(field, () => <String>[]).addAll(messages);
}
} else if (messages.isNotEmpty) {
reasons.addAll(messages);
} else {
reasons.addAll(_flattenMessages(item.values));
}
} else {
reasons.addAll(_flattenMessages(item));
}
}
return;
}
final values = _flattenMessages(node);
if (values.isNotEmpty) {
reasons.addAll(values);
}
}
}
const Set<String> _generalErrorKeys = {
'general',
'base',
'common',
'global',
'_global',
'_common',
'_general',
'summary',
'message',
'messages',
'detail',
'details',
'reason',
'reasons',
'non_field_errors',
'nonfielderrors',
};
bool _isGeneralKey(String key) {
final normalized = key.trim().toLowerCase();
return _generalErrorKeys.contains(normalized);
}
Iterable<dynamic> _ensureIterable(dynamic value) {
if (value is Iterable) {
return value;
}
if (value == null) {
return const [];
}
return [value];
}
String? _firstNonEmpty(Iterable<String?> candidates) {
for (final candidate in candidates) {
final text = candidate?.trim();
if (text != null && text.isNotEmpty) {
return text;
}
}
return null;
}
String? _toMessage(dynamic value) {
if (value == null) {
return null;
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
if (value is num || value is bool) {
return value.toString();
}
return null;
}
List<String> _flattenIterable(Iterable<dynamic> values) {
final aggregated = <String>[];
for (final value in values) {
aggregated.addAll(_flattenMessages(value));
}
return aggregated;
}
List<String> _flattenMessages(dynamic value) {
if (value == null) {
return const [];
}
if (value is String) {
final trimmed = value.trim();
return trimmed.isEmpty ? const [] : [trimmed];
}
if (value is num || value is bool) {
return [value.toString()];
}
if (value is Iterable) {
return _flattenIterable(value);
}
if (value is Map) {
final map = value.cast<dynamic, dynamic>();
final candidates = <String>[];
final directMessage = _firstNonEmpty([
_toMessage(map['message']),
_toMessage(map['detail']),
_toMessage(map['error']),
_toMessage(map['description']),
_toMessage(map['reason']),
_toMessage(map['title']),
]);
if (directMessage != null) {
candidates.add(directMessage);
}
final messagesNode = map['messages'];
if (messagesNode is Iterable) {
candidates.addAll(_flattenIterable(messagesNode));
}
if (candidates.isNotEmpty) {
return candidates;
}
return _flattenIterable(map.values);
}
return [_toMessage(value) ?? value.toString()];
}
List<String> _dedupeStrings(Iterable<String> values) {
final result = <String>[];
final seen = <String>{};
for (final value in values) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
continue;
}
final normalized = trimmed.toLowerCase();
if (seen.add(normalized)) {
result.add(trimmed);
}
}
return result;
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import '../config/environment.dart';
import 'permission_resources.dart';
/// 권한 체크를 위한 액션 종류.
enum PermissionAction { view, create, edit, delete, restore, approve }
@@ -9,16 +10,20 @@ enum PermissionAction { view, create, edit, delete, restore, approve }
class PermissionManager extends ChangeNotifier {
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
if (overrides != null) {
_overrides.addAll(overrides);
updateOverrides(overrides);
}
}
/// 리소스별 임시 권한 집합을 보관한다.
final Map<String, Set<PermissionAction>> _overrides = {};
/// 서버에서 내려받은 실제 권한 집합.
final Map<String, Set<PermissionAction>> _serverPermissions = {};
/// 지정한 리소스/행동이 허용되는지 여부를 반환한다.
bool can(String resource, PermissionAction action) {
final override = _overrides[resource];
final key = _normalize(resource);
final override = _overrides[key];
if (override != null) {
// View 권한은 최소 접근을 허용하기 위해 별도로 처리한다.
if (override.contains(PermissionAction.view) &&
@@ -27,16 +32,50 @@ class PermissionManager extends ChangeNotifier {
}
return override.contains(action);
}
return Environment.hasPermission(resource, action.name);
final server = _serverPermissions[key];
if (server != null) {
if (action == PermissionAction.view) {
return server.contains(PermissionAction.view);
}
return server.contains(action);
}
return Environment.hasPermission(key, action.name);
}
/// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다.
void updateOverrides(Map<String, Set<PermissionAction>> overrides) {
_overrides
..clear()
..addAll(overrides);
..addAll(
overrides.map((key, value) => MapEntry(_normalize(key), value.toSet())),
);
notifyListeners();
}
/// 서버에서 내려온 권한 정보를 적용한다.
void applyServerPermissions(Map<String, Set<PermissionAction>> permissions) {
_serverPermissions
..clear()
..addAll(
permissions.map(
(key, value) => MapEntry(_normalize(key), value.toSet()),
),
);
notifyListeners();
}
/// 서버 권한 정보를 초기화한다.
void clearServerPermissions() {
if (_serverPermissions.isEmpty) {
return;
}
_serverPermissions.clear();
notifyListeners();
}
String _normalize(String resource) => PermissionResources.normalize(resource);
}
/// 위젯 트리에 [PermissionManager]를 전달하는 Inherited 위젯.