Integração é uma fronteira
Uma API externa não deve ditar o formato do domínio nem vazar detalhes de transporte para a interface. Organize o fluxo:
Widget -> Controller -> Use case -> Repository -> Data source -> HTTP
O caminho de volta converte JSON em DTO, DTO em entidade e exceções em falhas conhecidas.
Cliente Dio centralizado
Dio buildDio(AppEnvironment environment) {
return Dio(
BaseOptions(
baseUrl: environment.apiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
headers: const {'Accept': 'application/json'},
),
);
}
Não crie um Dio por requisição. A instância central permite interceptores, cancelamento, métricas e políticas consistentes.
DTO e conversão defensiva
final class ProductDto {
const ProductDto({required this.id, required this.name, required this.price});
factory ProductDto.fromJson(Map<String, Object?> json) {
final id = json['id'];
final name = json['name'];
final price = json['price'];
if (id is! String || name is! String || price is! num) {
throw const FormatException('Produto inválido');
}
return ProductDto(id: id, name: name, price: price.toDouble());
}
final String id;
final String name;
final double price;
}
Geradores reduzem repetição, mas não eliminam a necessidade de validar contratos e casos de compatibilidade.
Data source e repositório
final class DioProductRemoteDataSource {
const DioProductRemoteDataSource(this._dio);
final Dio _dio;
Future<List<ProductDto>> getProducts() async {
final response = await _dio.get<List<dynamic>>('/products');
final data = response.data ?? const [];
return data
.map((item) => ProductDto.fromJson(item as Map<String, Object?>))
.toList(growable: false);
}
}
O repositório decide cache, fallback e conversão para entidade. A camada de apresentação não deve conhecer Response, status code ou cabeçalhos.
Autenticação sem vazamento
Um interceptor pode obter o token de uma abstração segura e adicionar o cabeçalho. Nunca escreva token em logs, mensagens de erro ou analytics.
final class AuthInterceptor extends Interceptor {
AuthInterceptor(this._tokenStore);
final TokenStore _tokenStore;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokenStore.read();
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
}
Refresh de token precisa de exclusão mútua: várias respostas 401 não podem iniciar renovações paralelas. Se a renovação falhar, encerre a sessão de forma clara.
Erro, retry e cancelamento
Classifique falhas por causa e possibilidade de recuperação. Retry automático só é seguro para operações idempotentes e falhas transitórias. Não repita indefinidamente um POST sem idempotency key.
Use CancelToken quando a tela puder ser descartada ou uma busca substituída. Diferencie cancelamento voluntário de falha real.
Logging responsável
Registre método, rota normalizada, duração, status e correlation ID. Remova Authorization, cookies, payloads sensíveis e dados pessoais. Logs de desenvolvimento não devem virar configuração de produção por acidente.
Prática guiada: substitua o fake por HTTP sem contaminar o domínio
Continue com a arquitetura do Módulo 3. A interface não mudará; você trocará somente a implementação composta em AppDependencies.
Checkpoint 1 — instale e configure um único cliente
flutter pub add dio
Crie lib/core/network/dio_factory.dart:
import 'package:dio/dio.dart';
Dio buildDio({required String baseUrl}) {
return Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
headers: const {'Accept': 'application/json'},
),
);
}
Não escreva a URL diretamente no widget. Leia configuração pública na composição:
const apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'http://localhost:8080',
);
final dio = buildDio(baseUrl: apiUrl);
Execute com flutter run --dart-define=API_URL=https://seu-ambiente. Um dart-define incluído no binário não é segredo.
Checkpoint 2 — adapte Dio a uma interface pequena
Crie core/network/json_client.dart:
import 'package:dio/dio.dart';
abstract interface class JsonClient {
Future<Object?> get(String path, {Object? cancelToken});
}
final class DioJsonClient implements JsonClient {
DioJsonClient(this._dio);
final Dio _dio;
@override
Future<Object?> get(String path, {Object? cancelToken}) async {
final response = await _dio.get<Object?>(
path,
cancelToken: cancelToken as CancelToken?,
);
return response.data;
}
}
O cast fica na borda do adapter. O data source passa a depender de uma operação necessária, não da API inteira de Dio.
Checkpoint 3 — implemente o data source defensivamente
Substitua o fake por HttpProductRemoteDataSource:
final class HttpProductRemoteDataSource implements ProductRemoteDataSource {
const HttpProductRemoteDataSource(this._client);
final JsonClient _client;
@override
Future<List<ProductDto>> getProducts() async {
final payload = await _client.get('/products');
if (payload is! List) {
throw const FormatException('A resposta deveria ser uma lista');
}
return payload.map((item) {
if (item is! Map) {
throw const FormatException('Item de produto inválido');
}
return ProductDto.fromJson(Map<String, Object?>.from(item));
}).toList(growable: false);
}
}
Não use as Map<String, dynamic> sem verificação: um payload incompatível precisa virar uma falha controlada na fronteira.
Checkpoint 4 — prove HTTP com um servidor local descartável
Crie um teste VM em test/features/products/data/http_product_remote_data_source_test.dart:
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sales_management/core/network/json_client.dart';
import 'package:sales_management/features/products/data/data_sources/product_remote_data_source.dart';
void main() {
test('busca e converte produtos por HTTP', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(server.close);
server.listen((request) async {
request.response.headers.contentType = ContentType.json;
request.response.write(jsonEncode([
{'id': 'p1', 'title': 'Mouse', 'price': 199},
]));
await request.response.close();
});
final dio = Dio(BaseOptions(
baseUrl: 'http://${server.address.host}:${server.port}',
));
final dataSource = HttpProductRemoteDataSource(DioJsonClient(dio));
final result = await dataSource.getProducts();
expect(result.single.title, 'Mouse');
expect(result.single.price, 199);
});
}
Esse teste atravessa HTTP real no loopback, mas não depende de internet nem de um servidor compartilhado. Adicione casos com JSON inválido, status 500 e timeout.
Checkpoint 5 — traduza DioException uma única vez
Crie uma taxonomia em core/error/app_failure.dart e um mapper:
sealed class AppFailure implements Exception {
const AppFailure(this.message);
final String message;
}
final class NetworkFailure extends AppFailure { const NetworkFailure(super.message); }
final class UnauthorizedFailure extends AppFailure { const UnauthorizedFailure(super.message); }
final class ContractFailure extends AppFailure { const ContractFailure(super.message); }
final class UnexpectedFailure extends AppFailure { const UnexpectedFailure(super.message); }
AppFailure mapDioException(DioException error) => switch (error.response?.statusCode) {
401 => const UnauthorizedFailure('Sessão expirada'),
>= 400 && < 500 => const ContractFailure('Requisição rejeitada'),
_ when error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout =>
const NetworkFailure('Tempo de resposta excedido'),
_ => const UnexpectedFailure('Falha inesperada na comunicação'),
};
Capture Dio na camada data e lance AppFailure. A UI decide a mensagem final e a ação; ela não importa Dio.
Checkpoint 6 — autenticação, redação e cancelamento
Adicione um interceptor que depende de TokenStore, nunca de uma variável global. Antes de registrar requisições, remova Authorization, cookies e campos pessoais. Não use LogInterceptor(requestBody: true) em produção.
Para busca substituível, crie um CancelToken por operação, cancele o anterior e diferencie CancelToken.isCancel(error) de falha. Retry automático fica limitado a GET idempotente e falha transitória; nunca repita POST indefinidamente.
Componha:
final dio = buildDio(baseUrl: apiUrl);
final remote = HttpProductRemoteDataSource(DioJsonClient(dio));
final repository = ProductRepositoryImpl(remote);
productsController = ProductsController(GetProducts(repository));
Execute dart format, flutter analyze e flutter test.
Matriz de falhas obrigatória
| Cenário | Estado/apresentação esperada | Retry automático? |
|---|---|---|
| timeout em GET | mensagem de conexão e tentar novamente | limitado |
| 400 | dados rejeitados; orientar correção | não |
| 401 | renovar sessão uma vez ou sair | não como request comum |
| 403 | acesso insuficiente | não |
| 500 em GET | indisponibilidade temporária | limitado |
| JSON inválido | incompatibilidade de contrato | não |
| cancelamento voluntário | nenhuma mensagem de erro | não |
Prática autônoma: implemente POST de produto com idempotency key e um teste provando que timeout não é tratado como confirmação de que o servidor não processou a operação.
Aprofundamento: semântica antes da biblioteca
Dio organiza transporte; ele não define o contrato. Antes do código, descreva método, rota, autenticação, timeout, corpo, status de sucesso, erros e idempotência. GET repetido deveria observar o mesmo recurso sem criar efeito; POST pode duplicar trabalho se houver retry sem chave de idempotência.
Separe falha de transporte, resposta HTTP válida com erro de negócio e payload inválido. Um timeout não prova que o servidor não processou a operação. Um 400 contratual não deve entrar em retry infinito. Um 401 pode exigir refresh; um 403 normalmente representa autorização insuficiente, não sessão expirada.
Teste a matriz de falhas e confirme a mensagem e ação oferecidas ao usuário. “Erro inesperado” é fallback, não estratégia principal.
Termos para revisar: idempotência, timeout, cancellation, DTO, status code, interceptor, correlation ID e redaction. Consulte a referência do curso.