smart_api_errors_parser 0.1.0
smart_api_errors_parser: ^0.1.0 copied to clipboard
Maps inconsistent API error payloads into typed exceptions, form errors, and localized messages.
example/lib/main.dart
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.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('Nested map'),
detailsList('Details list');
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),
),
);
_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(24),
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),
SegmentedButton<BackendErrorShape>(
segments: [
for (final shape in BackendErrorShape.values)
ButtonSegment(
value: shape,
label: Text(shape.label),
),
],
selected: {_shape},
onSelectionChanged: _loading
? null
: (selection) {
setState(() => _shape = selection.first);
},
),
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,
};
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 nested = data['shape'] == BackendErrorShape.nestedMap.name;
return nested
? _jsonResponse({
'message': 'Validation failed',
'errors': {
'email': [
{'message': 'Already used', 'code': 'email_taken'},
],
'phone': [
{'message': 'Invalid phone number', 'code': 'invalid_phone'},
],
},
}, 422)
: _jsonResponse({
'message': 'Validation failed',
'details': [
{
'field': 'email',
'message': 'Already used',
'code': 'email_taken',
},
{
'field': 'phone',
'message': 'Invalid phone number',
'code': 'invalid_phone',
},
],
}, 422);
}
ResponseBody _jsonResponse(Object body, int statusCode) {
return ResponseBody.fromString(
jsonEncode(body),
statusCode,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}