feat: enhance API error handling and response structure; introduce ApiException for V2 error codes
This commit is contained in:
@@ -5,7 +5,9 @@ import 'feature_gate.dart';
|
||||
|
||||
/// A service that handles HTTP communication using the [Dio] client.
|
||||
///
|
||||
/// Integrates [FeatureGate] to validate endpoint scopes before each request.
|
||||
/// Integrates [FeatureGate] for scope validation and throws typed domain
|
||||
/// exceptions ([ApiException], [NetworkException], [ServerException]) on
|
||||
/// error responses so repositories never receive silent failures.
|
||||
class ApiService implements BaseApiService {
|
||||
/// Creates an [ApiService] with the given [Dio] instance.
|
||||
ApiService(this._dio);
|
||||
@@ -27,7 +29,7 @@ class ApiService implements BaseApiService {
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ class ApiService implements BaseApiService {
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ class ApiService implements BaseApiService {
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ class ApiService implements BaseApiService {
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +109,7 @@ class ApiService implements BaseApiService {
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,43 +119,63 @@ class ApiService implements BaseApiService {
|
||||
|
||||
/// Extracts [ApiResponse] from a successful [Response].
|
||||
ApiResponse _handleResponse(Response<dynamic> response) {
|
||||
final dynamic body = response.data;
|
||||
final String message = body is Map<String, dynamic>
|
||||
? body['message']?.toString() ?? 'Success'
|
||||
: 'Success';
|
||||
|
||||
return ApiResponse(
|
||||
code: response.statusCode?.toString() ?? '200',
|
||||
message: response.data['message']?.toString() ?? 'Success',
|
||||
data: response.data,
|
||||
message: message,
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts [ApiResponse] from a [DioException].
|
||||
/// Maps a [DioException] to a typed domain exception.
|
||||
///
|
||||
/// Supports both legacy error format and V2 API error envelope
|
||||
/// (`{ code, message, details, requestId }`).
|
||||
ApiResponse _handleError(DioException e) {
|
||||
/// The V2 API error envelope is `{ code, message, details, requestId }`.
|
||||
/// This method parses it and throws the appropriate [AppException] subclass
|
||||
/// so that `BlocErrorHandler` can translate it for the user.
|
||||
AppException _mapDioException(DioException e) {
|
||||
// Network-level failures (no response from server).
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.connectionError) {
|
||||
return NetworkException(technicalMessage: e.message);
|
||||
}
|
||||
|
||||
final int? statusCode = e.response?.statusCode;
|
||||
|
||||
// Parse V2 error envelope if available.
|
||||
if (e.response?.data is Map<String, dynamic>) {
|
||||
final Map<String, dynamic> body =
|
||||
e.response!.data as Map<String, dynamic>;
|
||||
return ApiResponse(
|
||||
code:
|
||||
body['code']?.toString() ??
|
||||
e.response?.statusCode?.toString() ??
|
||||
'error',
|
||||
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
|
||||
data: body['data'] ?? body['details'],
|
||||
errors: _parseErrors(body['errors']),
|
||||
|
||||
final String apiCode =
|
||||
body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
|
||||
final String apiMessage =
|
||||
body['message']?.toString() ?? e.message ?? 'An error occurred';
|
||||
|
||||
// Map well-known codes to specific exceptions.
|
||||
if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
|
||||
return NotAuthenticatedException(technicalMessage: apiMessage);
|
||||
}
|
||||
|
||||
return ApiException(
|
||||
apiCode: apiCode,
|
||||
apiMessage: apiMessage,
|
||||
statusCode: statusCode,
|
||||
details: body['details'],
|
||||
technicalMessage: '$apiCode: $apiMessage',
|
||||
);
|
||||
}
|
||||
return ApiResponse(
|
||||
code: e.response?.statusCode?.toString() ?? 'error',
|
||||
message: e.message ?? 'Unknown error',
|
||||
errors: <String, dynamic>{'exception': e.type.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to parse the errors map from various possible formats.
|
||||
Map<String, dynamic> _parseErrors(dynamic errors) {
|
||||
if (errors is Map) {
|
||||
return Map<String, dynamic>.from(errors);
|
||||
// Server error without a parseable body.
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return ServerException(technicalMessage: e.message);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
|
||||
return UnknownException(technicalMessage: e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class ApiResponse {
|
||||
this.errors = const <String, dynamic>{},
|
||||
});
|
||||
|
||||
/// The response code (e.g., '200', '404', or custom error code).
|
||||
/// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR').
|
||||
final String code;
|
||||
|
||||
/// A descriptive message about the response.
|
||||
@@ -19,4 +19,13 @@ class ApiResponse {
|
||||
|
||||
/// A map of field-specific error messages, if any.
|
||||
final Map<String, dynamic> errors;
|
||||
|
||||
/// Whether the response indicates success (HTTP 2xx).
|
||||
bool get isSuccess {
|
||||
final int? statusCode = int.tryParse(code);
|
||||
return statusCode != null && statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
/// Whether the response indicates failure.
|
||||
bool get isFailure => !isSuccess;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../exceptions/app_exception.dart';
|
||||
import 'api_response.dart';
|
||||
import 'base_api_service.dart';
|
||||
|
||||
@@ -14,10 +15,13 @@ abstract class BaseCoreService {
|
||||
|
||||
/// Standardized wrapper to execute API actions.
|
||||
///
|
||||
/// This handles generic error normalization for unexpected non-HTTP errors.
|
||||
/// Rethrows [AppException] subclasses (domain errors) directly.
|
||||
/// Wraps unexpected non-HTTP errors into an error [ApiResponse].
|
||||
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
|
||||
try {
|
||||
return await execution();
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
return ApiResponse(
|
||||
code: 'CORE_INTERNAL_ERROR',
|
||||
|
||||
@@ -286,6 +286,54 @@ class NoActiveShiftException extends ShiftException {
|
||||
// NETWORK/GENERIC EXCEPTIONS
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// API EXCEPTIONS (mapped from V2 error envelope codes)
|
||||
// ============================================================
|
||||
|
||||
/// Thrown when the V2 API returns a non-success response.
|
||||
///
|
||||
/// Carries the full error envelope so callers can inspect details.
|
||||
class ApiException extends AppException {
|
||||
/// Creates an [ApiException].
|
||||
const ApiException({
|
||||
required String apiCode,
|
||||
required String apiMessage,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
super.technicalMessage,
|
||||
}) : super(code: apiCode);
|
||||
|
||||
/// The HTTP status code (e.g. 400, 404, 500).
|
||||
final int? statusCode;
|
||||
|
||||
/// The V2 API error code string (e.g. 'VALIDATION_ERROR').
|
||||
String get apiCode => code;
|
||||
|
||||
/// Optional details from the error envelope.
|
||||
final dynamic details;
|
||||
|
||||
@override
|
||||
String get messageKey {
|
||||
switch (code) {
|
||||
case 'VALIDATION_ERROR':
|
||||
return 'errors.generic.validation_error';
|
||||
case 'NOT_FOUND':
|
||||
return 'errors.generic.not_found';
|
||||
case 'FORBIDDEN':
|
||||
return 'errors.generic.forbidden';
|
||||
case 'UNAUTHENTICATED':
|
||||
return 'errors.auth.not_authenticated';
|
||||
case 'CONFLICT':
|
||||
return 'errors.generic.conflict';
|
||||
default:
|
||||
if (statusCode != null && statusCode! >= 500) {
|
||||
return 'errors.generic.server_error';
|
||||
}
|
||||
return 'errors.generic.unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when there is no network connection.
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException({super.technicalMessage})
|
||||
|
||||
@@ -55,20 +55,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
final Map<String, dynamic> body =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
// Check for V2 error responses.
|
||||
if (response.code != '200' && response.code != '201') {
|
||||
final String errorCode = body['code']?.toString() ?? response.code;
|
||||
if (errorCode == 'INVALID_CREDENTIALS' ||
|
||||
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
|
||||
throw InvalidCredentialsException(
|
||||
technicalMessage: response.message,
|
||||
);
|
||||
}
|
||||
throw SignInFailedException(
|
||||
technicalMessage: '$errorCode: ${response.message}',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||
// to subsequent requests. The V2 API already validated credentials, so
|
||||
// email/password sign-in establishes the local Firebase Auth state.
|
||||
@@ -115,12 +101,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
},
|
||||
);
|
||||
|
||||
// Check for V2 error responses.
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
if (response.code != '201' && response.code != '200') {
|
||||
final String errorCode = body['code']?.toString() ?? response.code;
|
||||
_throwSignUpError(errorCode, response.message);
|
||||
}
|
||||
|
||||
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||
// for subsequent requests. The V2 API already created the Firebase
|
||||
|
||||
Reference in New Issue
Block a user