smart_api_errors_parser 1.0.0
smart_api_errors_parser: ^1.0.0 copied to clipboard
Maps inconsistent API error payloads into typed exceptions, form errors, and localized messages.
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:smart_api_errors_parser/smart_api_errors_parser_dio.dart';
void main() => runApp(const ApiErrorExampleApp());
class ApiErrorExampleApp extends StatelessWidget {
const ApiErrorExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
title: 'API Error Mapper Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xff6750a4),
),
useMaterial3: true,
),
home: const RegistrationPage(),
);
}
enum BackendErrorShape {
nestedMap('Laravel / nested map'),
detailsList('Details list'),
problemDetails('ASP.NET Problem Details'),
jsonApi('JSON:API'),
django('Django REST Framework'),
graphql('GraphQL'),
oauth2('OAuth 2.0'),
springBoot('Spring Boot'),
rails('Rails ActiveModel'),
firebase('Firebase callable');
const BackendErrorShape(this.label);
final String label;
}
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@override
State<RegistrationPage> createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State<RegistrationPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'used@example.com');
final _phoneController = TextEditingController(text: '+123456789');
final _emailFocus = FocusNode();
final _phoneFocus = FocusNode();
late final RegistrationRepository _repository;
BackendErrorShape _shape = BackendErrorShape.detailsList;
Map<String, String> _fieldErrors = const {};
String? _globalError;
bool _loading = false;
@override
void initState() {
super.initState();
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.test'))
..httpClientAdapter = ExampleBackendAdapter()
..interceptors.add(
ApiErrorInterceptor(
mapper: ApiErrorMapper(
localizer: localizeError,
fieldPathNormalizer: normalizeFieldPath,
),
),
);
_repository = RegistrationRepository(dio);
}
@override
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_emailFocus.dispose();
_phoneFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_fieldErrors = const {};
_globalError = null;
});
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _loading = true);
try {
await _repository.register(
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
shape: _shape,
);
if (!mounted) return;
_showMessage('Account created successfully.');
} on ApiValidationException catch (error) {
if (!mounted) return;
setState(() {
_fieldErrors = error.fields;
_globalError =
error.globalErrors.isEmpty ? null : error.globalErrors.first;
});
_formKey.currentState?.validate();
WidgetsBinding.instance.addPostFrameCallback((_) => _focusFirstError());
} on ApiAuthenticationException {
if (!mounted) return;
_showMessage('Session expired. Please sign in again.');
} on ApiException catch (error) {
if (!mounted) return;
_showMessage(error.message);
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _focusFirstError() {
if (_fieldErrors.containsKey('email')) {
_emailFocus.requestFocus();
} else if (_fieldErrors.containsKey('phone')) {
_phoneFocus.requestFocus();
}
}
void _clearFieldError(String field) {
if (!_fieldErrors.containsKey(field)) return;
setState(() {
_fieldErrors = Map<String, String>.of(_fieldErrors)..remove(field);
});
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('API error mapper')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Card(
elevation: 0,
color: colors.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(28),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.person_add_alt_1_rounded,
size: 44,
color: colors.primary,
),
const SizedBox(height: 16),
Text(
'Create an account',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Submit defaults to see mapped server errors. '
'Change email to see success.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 28),
Text(
'Backend response shape',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
DropdownButtonFormField<BackendErrorShape>(
initialValue: _shape,
isExpanded: true,
decoration: const InputDecoration(
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.data_object),
),
items: [
for (final shape in BackendErrorShape.values)
DropdownMenuItem(
value: shape,
child: Text(shape.label,
overflow: TextOverflow.ellipsis),
),
],
onChanged: _loading
? null
: (shape) {
if (shape != null) {
setState(() => _shape = shape);
}
},
),
const SizedBox(height: 24),
if (_globalError case final message?) ...[
Semantics(
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colors.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(
color: colors.onErrorContainer,
),
),
),
],
),
),
),
const SizedBox(height: 16),
],
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
enabled: !_loading,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
onChanged: (_) => _clearFieldError('email'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Enter a valid email';
}
return _fieldErrors['email'];
},
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
focusNode: _phoneFocus,
enabled: !_loading,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.telephoneNumber],
decoration: const InputDecoration(
labelText: 'Phone',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
onChanged: (_) => _clearFieldError('phone'),
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Phone is required';
}
return _fieldErrors['phone'];
},
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _loading ? null : _submit,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.app_registration),
label: Text(
_loading ? 'Creating account…' : 'Register',
),
),
],
),
),
),
),
),
),
),
),
);
}
}
String localizeError(ApiErrorMessage error) => switch (error.code) {
'email_taken' => 'This email is already in use',
'invalid_phone' => 'Enter a valid phone number',
_ => error.message,
};
String? normalizeFieldPath(String? field) {
final segment = ApiFieldPathNormalizers.compose([
ApiFieldPathNormalizers.jsonPointerToDot,
ApiFieldPathNormalizers.lastSegment,
ApiFieldPathNormalizers.snakeToCamel,
ApiFieldPathNormalizers.aliases({'userEmail': 'email'}),
])(field);
return switch (segment?.toLowerCase()) {
'email' => 'email',
'phone' => 'phone',
_ => segment,
};
}
class RegistrationRepository {
RegistrationRepository(this._dio);
final Dio _dio;
Future<void> register({
required String email,
required String phone,
required BackendErrorShape shape,
}) async {
await _dio.post<void>(
'/register',
data: {'email': email, 'phone': phone, 'shape': shape.name},
).unwrapApiErrors();
}
}
/// Offline fake backend. Replace with Dio's default adapter for a real API.
class ExampleBackendAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
await Future<void>.delayed(const Duration(milliseconds: 350));
final data = Map<String, Object?>.from(options.data as Map);
if (data['email'] != 'used@example.com') {
return _jsonResponse({'id': 'user_123'}, 201);
}
final shape = BackendErrorShape.values.byName(data['shape'].toString());
return switch (shape) {
BackendErrorShape.nestedMap => _jsonResponse({
'message': 'Validation failed',
'errors': {
'email': [
{'message': 'Already used', 'code': 'email_taken'},
],
'phone': [
{'message': 'Invalid phone number', 'code': 'invalid_phone'},
],
},
}, 422),
BackendErrorShape.detailsList => _jsonResponse({
'message': 'Validation failed',
'details': [
{
'field': 'email',
'message': 'Already used',
'code': 'email_taken',
},
{
'field': 'phone',
'message': 'Invalid phone number',
'code': 'invalid_phone',
},
],
}, 422),
BackendErrorShape.problemDetails => _jsonResponse({
'type': 'https://example.test/problems/validation',
'title': 'Validation failed',
'status': 422,
'errors': {
'Email': [
{'message': 'Already used', 'code': 'email_taken'},
],
'Phone': [
{'message': 'Invalid phone number', 'code': 'invalid_phone'},
],
},
}, 422),
BackendErrorShape.jsonApi => _jsonResponse({
'errors': [
{
'status': '422',
'code': 'email_taken',
'detail': 'Already used',
'source': {'pointer': '/data/attributes/email'},
},
{
'status': '422',
'code': 'invalid_phone',
'detail': 'Invalid phone number',
'source': {'pointer': '/data/attributes/phone'},
},
],
}, 422),
BackendErrorShape.django => _jsonResponse({
'email': [
{'message': 'Already used', 'code': 'email_taken'},
],
'phone': [
{'message': 'Invalid phone number', 'code': 'invalid_phone'},
],
'non_field_errors': ['Validation failed'],
}, 422),
BackendErrorShape.graphql => _jsonResponse({
'errors': [
{
'message': 'Already used',
'path': ['email'],
'extensions': {'code': 'email_taken'},
},
{
'message': 'Invalid phone number',
'path': ['phone'],
'extensions': {'code': 'invalid_phone'},
},
],
}, 422),
BackendErrorShape.oauth2 => _jsonResponse({
'error': 'invalid_request',
'error_description': 'Validation failed',
'error_uri': 'https://example.test/errors/invalid_request',
}, 422),
BackendErrorShape.springBoot => _jsonResponse({
'timestamp': '2026-08-05T10:00:00Z',
'status': 422,
'error': 'Unprocessable Entity',
'message': 'Validation failed',
'path': '/register',
'errors': [
{
'field': 'email',
'defaultMessage': 'Already used',
'code': 'email_taken',
},
{
'field': 'phone',
'defaultMessage': 'Invalid phone number',
'code': 'invalid_phone',
},
],
}, 422),
BackendErrorShape.rails => _jsonResponse({
'errors': {
'email': ['Already used'],
'phone': ['Invalid phone number'],
},
}, 422),
BackendErrorShape.firebase => _jsonResponse({
'error': {
'status': 'INVALID_ARGUMENT',
'message': 'Validation failed',
'details': {'field': 'email'},
},
}, 422),
};
}
ResponseBody _jsonResponse(Object body, int statusCode) {
return ResponseBody.fromString(
jsonEncode(body),
statusCode,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}