Skip to main content

smart_api_errors_parser 1.0.0 copy "smart_api_errors_parser: ^1.0.0" to clipboard
smart_api_errors_parser: ^1.0.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_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}) {}
}
0
likes
160
points
48
downloads

Documentation

API reference

Publisher

verified publisherpinz.dev

Weekly Downloads

Maps inconsistent API error payloads into typed exceptions, form errors, and localized messages.

Repository (GitHub)
View/report issues

Topics

#api #dio #error-handling #localization #validation

License

MIT (license)

Dependencies

dio

More

Packages that depend on smart_api_errors_parser