Dio con interceptores en Flutter: ejercicio resuelto

Dio con interceptores en Flutter: ejercicio resuelto

El paquete http cubre casos simples, pero en apps reales necesitas interceptores: lógica que se ejecuta automáticamente en cada petición o respuesta sin repetir código. Dio los implementa con una API limpia y es el cliente HTTP más usado en Flutter para producción.

Enunciado

Configura un cliente Dio que:

  1. Añada automáticamente el header Authorization: Bearer <token> a todas las peticiones.
  2. Logee método, URL y código de respuesta en consola.
  3. Capture errores 401 (sesión expirada) de forma centralizada y los convierta en una excepción tipada.

Implementa una pantalla que use ese cliente para cargar un post y muestre loading, resultado y error.

Dependencias

1
2
dependencies:
  dio: ^5.7.0

Solución en Flutter

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

// ── Excepción tipada ──────────────────────────────────────────────────────────
class UnauthorizedException implements Exception {
  const UnauthorizedException();
  @override
  String toString() => 'Sesión expirada. Vuelve a iniciar sesión.';
}

// ── Interceptor de autenticación ──────────────────────────────────────────────
class AuthInterceptor extends Interceptor {
  final String token;
  AuthInterceptor(this.token);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    if (err.response?.statusCode == 401) {
      handler.reject(
        DioException(
          requestOptions: err.requestOptions,
          error: const UnauthorizedException(),
          type: DioExceptionType.badResponse,
        ),
      );
      return;
    }
    handler.next(err);
  }
}

// ── Interceptor de logging ────────────────────────────────────────────────────
class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    debugPrint('→ ${options.method} ${options.uri}');
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    debugPrint('✗ ${err.response?.statusCode} ${err.requestOptions.uri}');
    handler.next(err);
  }
}

// ── Cliente HTTP configurado ──────────────────────────────────────────────────
Dio buildDioClient({required String token}) {
  return Dio(
    BaseOptions(
      baseUrl: 'https://jsonplaceholder.typicode.com',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
    ),
  )
    ..interceptors.add(AuthInterceptor(token))
    ..interceptors.add(LoggingInterceptor());
}

// ── App ──────────────────────────────────────────────────────────────────────
void main() => runApp(const MaterialApp(home: DioPage()));

// ── UI ───────────────────────────────────────────────────────────────────────
class DioPage extends StatefulWidget {
  const DioPage({super.key});

  @override
  State<DioPage> createState() => _DioPageState();
}

class _DioPageState extends State<DioPage> {
  final _dio = buildDioClient(token: 'mi-token-de-prueba');

  String? _title;
  String? _error;
  bool _loading = false;

  Future<void> _fetchPost() async {
    setState(() {
      _loading = true;
      _title = null;
      _error = null;
    });

    try {
      final response = await _dio.get<Map<String, dynamic>>('/posts/1');
      setState(() => _title = response.data?['title'] as String?);
    } on DioException catch (e) {
      final err = e.error;
      setState(() =>
          _error = err is UnauthorizedException ? err.toString() : 'Error: ${e.message}');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dio + Interceptores')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              if (_loading)
                const CircularProgressIndicator()
              else if (_error != null)
                Column(
                  children: [
                    const Icon(Icons.error_outline, color: Colors.red, size: 40),
                    const SizedBox(height: 8),
                    Text(_error!, textAlign: TextAlign.center),
                  ],
                )
              else if (_title != null)
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Text(_title!),
                  ),
                )
              else
                const Text('Pulsa el botón para cargar'),
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: _loading ? null : _fetchPost,
                icon: const Icon(Icons.download),
                label: const Text('Cargar post'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Resultado esperado

  • Al pulsar el botón, aparece el loader y en consola se imprime → GET https://jsonplaceholder.typicode.com/posts/1.
  • Tras la respuesta: ← 200 https://... y el título del post en pantalla.
  • El header Authorization: Bearer mi-token-de-prueba se añade automáticamente a todas las peticiones sin escribirlo manualmente.

Errores frecuentes

  • Añadir interceptores después de la primera petición: los interceptores se evalúan en el orden en que se añaden; añádelos al construir el cliente, no en el initState.
  • No llamar a handler.next(): si olvidas llamar a next en onRequest, la petición queda colgada indefinidamente.
  • Capturar Exception genérica en lugar de DioException: DioException contiene response, requestOptions y type; usa esos campos para diagnósticos precisos.

Cuándo usar Dio vs http

NecesidadhttpDio
GET sencillo sin auth
Interceptores globales
Cancelación de peticiones
Subida de archivos (multipart)tedioso
Retry automáticomanual✅ (con plugin)

Aplicación práctica

En cualquier app con backend autenticado, este patrón elimina el copy-paste del header en cada petición y centraliza el manejo de sesión expirada, que de otro modo se dispersa por toda la app.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Puedo tener varios interceptores activos a la vez?

Sí. Dio los ejecuta en cadena en el orden en que los registras. Los de respuesta y error se ejecutan en orden inverso.

¿Cómo cancelo una petición en curso?

Pasa un CancelToken a la petición y llama a cancelToken.cancel() cuando necesites cancelarla (ej. al desmontar el widget).

¿Dio es compatible con null safety?

Sí. Desde la versión 5.x es null-safe y soporta tipos genéricos: _dio.get<Map<String, dynamic>>(...) para tipado fuerte.