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.
|
/// 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 {
|
class ApiService implements BaseApiService {
|
||||||
/// Creates an [ApiService] with the given [Dio] instance.
|
/// Creates an [ApiService] with the given [Dio] instance.
|
||||||
ApiService(this._dio);
|
ApiService(this._dio);
|
||||||
@@ -27,7 +29,7 @@ class ApiService implements BaseApiService {
|
|||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ class ApiService implements BaseApiService {
|
|||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ class ApiService implements BaseApiService {
|
|||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ class ApiService implements BaseApiService {
|
|||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ class ApiService implements BaseApiService {
|
|||||||
);
|
);
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
return _handleError(e);
|
throw _mapDioException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,43 +119,63 @@ class ApiService implements BaseApiService {
|
|||||||
|
|
||||||
/// Extracts [ApiResponse] from a successful [Response].
|
/// Extracts [ApiResponse] from a successful [Response].
|
||||||
ApiResponse _handleResponse(Response<dynamic> 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(
|
return ApiResponse(
|
||||||
code: response.statusCode?.toString() ?? '200',
|
code: response.statusCode?.toString() ?? '200',
|
||||||
message: response.data['message']?.toString() ?? 'Success',
|
message: message,
|
||||||
data: response.data,
|
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
|
/// The V2 API error envelope is `{ code, message, details, requestId }`.
|
||||||
/// (`{ code, message, details, requestId }`).
|
/// This method parses it and throws the appropriate [AppException] subclass
|
||||||
ApiResponse _handleError(DioException e) {
|
/// 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>) {
|
if (e.response?.data is Map<String, dynamic>) {
|
||||||
final Map<String, dynamic> body =
|
final Map<String, dynamic> body =
|
||||||
e.response!.data as Map<String, dynamic>;
|
e.response!.data as Map<String, dynamic>;
|
||||||
return ApiResponse(
|
|
||||||
code:
|
final String apiCode =
|
||||||
body['code']?.toString() ??
|
body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
|
||||||
e.response?.statusCode?.toString() ??
|
final String apiMessage =
|
||||||
'error',
|
body['message']?.toString() ?? e.message ?? 'An error occurred';
|
||||||
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
|
|
||||||
data: body['data'] ?? body['details'],
|
// Map well-known codes to specific exceptions.
|
||||||
errors: _parseErrors(body['errors']),
|
if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
|
||||||
);
|
return NotAuthenticatedException(technicalMessage: apiMessage);
|
||||||
}
|
}
|
||||||
return ApiResponse(
|
|
||||||
code: e.response?.statusCode?.toString() ?? 'error',
|
return ApiException(
|
||||||
message: e.message ?? 'Unknown error',
|
apiCode: apiCode,
|
||||||
errors: <String, dynamic>{'exception': e.type.toString()},
|
apiMessage: apiMessage,
|
||||||
|
statusCode: statusCode,
|
||||||
|
details: body['details'],
|
||||||
|
technicalMessage: '$apiCode: $apiMessage',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to parse the errors map from various possible formats.
|
// Server error without a parseable body.
|
||||||
Map<String, dynamic> _parseErrors(dynamic errors) {
|
if (statusCode != null && statusCode >= 500) {
|
||||||
if (errors is Map) {
|
return ServerException(technicalMessage: e.message);
|
||||||
return Map<String, dynamic>.from(errors);
|
|
||||||
}
|
}
|
||||||
return const <String, dynamic>{};
|
|
||||||
|
return UnknownException(technicalMessage: e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class ApiResponse {
|
|||||||
this.errors = const <String, dynamic>{},
|
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;
|
final String code;
|
||||||
|
|
||||||
/// A descriptive message about the response.
|
/// A descriptive message about the response.
|
||||||
@@ -19,4 +19,13 @@ class ApiResponse {
|
|||||||
|
|
||||||
/// A map of field-specific error messages, if any.
|
/// A map of field-specific error messages, if any.
|
||||||
final Map<String, dynamic> errors;
|
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 'api_response.dart';
|
||||||
import 'base_api_service.dart';
|
import 'base_api_service.dart';
|
||||||
|
|
||||||
@@ -14,10 +15,13 @@ abstract class BaseCoreService {
|
|||||||
|
|
||||||
/// Standardized wrapper to execute API actions.
|
/// 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 {
|
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
|
||||||
try {
|
try {
|
||||||
return await execution();
|
return await execution();
|
||||||
|
} on AppException {
|
||||||
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return ApiResponse(
|
return ApiResponse(
|
||||||
code: 'CORE_INTERNAL_ERROR',
|
code: 'CORE_INTERNAL_ERROR',
|
||||||
|
|||||||
@@ -286,6 +286,54 @@ class NoActiveShiftException extends ShiftException {
|
|||||||
// NETWORK/GENERIC EXCEPTIONS
|
// 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.
|
/// Thrown when there is no network connection.
|
||||||
class NetworkException extends AppException {
|
class NetworkException extends AppException {
|
||||||
const NetworkException({super.technicalMessage})
|
const NetworkException({super.technicalMessage})
|
||||||
|
|||||||
@@ -55,20 +55,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
final Map<String, dynamic> body =
|
final Map<String, dynamic> body =
|
||||||
response.data as Map<String, dynamic>;
|
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
|
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||||
// to subsequent requests. The V2 API already validated credentials, so
|
// to subsequent requests. The V2 API already validated credentials, so
|
||||||
// email/password sign-in establishes the local Firebase Auth state.
|
// 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>;
|
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
|
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||||
// for subsequent requests. The V2 API already created the Firebase
|
// for subsequent requests. The V2 API already created the Firebase
|
||||||
|
|||||||
Reference in New Issue
Block a user