Настройка API клиента с Dio

Настройка API клиента с Dio

Чтобы код масштабировался, тестировался и был в целом надёжным, хорошим вариантом будет делать API клиент через паттерн репозиторий.

Архитектура и зависимости

Поделим код на три слоя:

  • Сетевой слой - Dio, AuthInterceptor, RemoteDataSources
  • Слой данных - Repositories (абстрация, работа с DTO, маппинг в доменные модели и обработка ошибок)
  • Доменный слой - чистые модели и интерфейсы репозиториев (без зависимостей от Dio)

Для начала, добавим зависимости:

  • dio - собственно, наш клиент для API
  • json_annotation - для удобного бингдинга полей JSON через декораторы
  • flutter_secure_storage - для безопасного хранения токенов
  • get_it - для DI (Dependency Injection)

И dev-зависимости:

  • build_runner - утилита для генерации кода, поможет нам с генерацией сериализуемых классов
  • json_serializable - декоратор для генерации сериализуемых в/из JSON классов

DTO и доменные модели

Тут сразу разделим: DTO (Data Transfer Object) - это класс, точно повторяющий структуру JSON из ответов API, а доменная модель - чистая модель бизнес-логики без привязки к JSON.

Представим, что у нас есть пользователь, а также определённый API эндпоинт может возвращать нам этого пользователя.

import 'package:json_annotation/json_annotation.dart';
part 'user_dto.g.dart';

// Доменная модель пользователя
class User {
	final String id;
	final String name;
	
	User({required this.id, required this.name});
}

// DTO пользователя
@JsonSerializable()
class UserDto {
	@JsonKey(name: 'user_id') // маппинг sneak_case из API
	final String id;
	final String name;
	
	UserDto({required this.id, required this.name});
	
	factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
	Map<String, dynamic> toJson() => _$UserDtoToJson(this);
	
	// Маппинг DTO в доменную модель
	User toDomain() => User(id: id, name: name);
}

Сетевой слой: настройка Dio и AuthInterceptor

Самая сложная часть нашей задумки - интерцептор авторизации. Он должен не только добавлять токен, но и обрабатывать его протухание, обновлять его и повторять запрос.

Для этого используем QueuedInterceptorsWrapper. Он ставит запросы в очередь, чтобы при 5 одновременных запросах с истёкшим токеном не произошло 5 попыток его обновить.

Хранилище токенов

abstract class TokenStorage {
	Future<String?> getAccessToken();
	Future<String?> getRefreshToken();
	Future<void> saveTokens({required String access, required String refresh});
	Future<void> clearTokens();
}

Auth Interceptor

import 'package:dio/dio.dart';

class AuthInterceptor extends QueuedInterceptorsWrapper {
	final TokenStorage _tokenStorage;
	final Dio _refreshDio; // отдельный клиент для запроса refresh, чтобы не зациклить интерцептор
	final VoidCallback onUnauthorized;
	
	AuthInterceptor({
		required TokenStorage tokenStorage,
		required Dio refreshDio,
		required this.onUnauthorized
	}) : _tokenStorage = tokenStorage,
		 _refreshDio = refreshDio;
		 
	@override
	void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
		final token = await _tokenStorage.getAccessToken();
		if (token != null) {
			options.headers['Authorization'] = 'Bearer $token';
		}
		
		handler.next(options);
	}
	
	@override
	void onError(DioException error, ErrorInterceptorHandler handler) async {
		if (err.response?.statusCode != 401 ||
			err.requestOptions.path.contains('/auth/refresh')) {
			return handler.next(error);
		}
		
		try {
			final refreshToken = await _tokenStorage.getRefreshToken();
			if (refreshToken == null) throw Exception('No refresh token');
			
			final resposne = await _refreshDio.posh('/auth/refresh', data: {'refresh_token': refreshToken});
			
			final newAccess = response.data['access_token'];
			final newRefresh = response.data['refresh_token'];
			
			await _tokenStorage.saveTokens(access: newAccess, refresh: newRefresh);
			
			err.requestOptions.headers['Authorization'] = 'Bearer $newAccess';
			final retryResponse = await _refreshDio.fetch(err.requestOptions);
			
			handler.resolve(retryResponse);
		} catch (e) {
			await _tokenStorage.clearTokens();
			onUnauthorized();
			handler.next(error);
		}
	}
}

Фабрика Dio

class DioFactory {
	static Dio createDio({String baseUrl = 'https://api.example.com', List<Interceptor>? interceptors}) {
		final dio = Dio(BaseOptions(
			baseUrl: baseUrl,
			connectTimeout: const Duration(seconds: 15),
			receiveTimeout: const Duration(seconds: 15),
			headers: {'Content-Type': 'application/json'},
		));
		
		dio.interceptors.addAll([
			LogInterceptor(requestBody: true, responseBody: true), // только для dev-режима
			if (interceptors != null) ...interceptors,
		]);
		
		return dio;
	}
}

Сетевой слой: RemoteDataSources

"Data Sources" отвечает только за поход в сеть и возврат DTO. Он не знает о доменных моделях.

class ServerException implements Exception {
	final String message;
	ServerException(this.message);
}

abstract class UserRemoteDataSource {
	Future<UserDto> getUser(String id);
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
	final Dio _dio;
	
	UserRemoteDataSourceImpl(this._dio);
	
	@override
	Future<UserDto> getUser(String id) async {
		try {
			final response = await _dio.get('/users/$id');
			return UserDto.fromJson(response.data);
		} on DioException catch (e) {
			if (e.type == DioExceptionType.connectionTimeout ||
				e.type == DioExceptionType.receiveTimeout) {
				throw NetworkException();
			}
			
			throw ServerException(e.response?.data['message'] ?? 'Server error');
		} catch (e) {
			throw ServerException(e.toString());
		}
	}
}

Слой данных: репозитории

Репозиторий - это мост между сетевым, локальным и доменным слоями. Он возвращает доменные модели и обрабатывает бизнес-исключения.

// Доменная ошибка
sealed class Failure {
	final String message;
	Failure(this.message);
}

class ServerFailure extends Failure {
	ServerFailure(super.message);
}

class NetworkFailure extends Failure {
	NetworkFailure() : super('Нет подключения к сети');
}

// Интерфейс репозитория (в доменном слое)
abstract class UserRepository {
	Future<Either<Failure, User>> getUser(String id);
	// Either<T> можно заменить на свой класс Result<T>
}

// Реализация в слое данных
class UserRepositoryImpl implements UserRepository {
	final UserRemoteDataSource _remoteDataSource;
	
	UserRepositoryImpl(this._remoteDataSource);
	
	@override
	Future<Either<Failure, User>> getUser(String id) async {
		try {
			final userDto = await _remoteDataSource.getUser(id);
			return Right(userDto.toDomain()); // маппим DTO в доменную модель
		} on ServerException catch (e) {
			return Left(ServerFailure(e.message));
		} on NetworkException catch (e) {
			return Left(NetworkFailure());
		}
	}
}

Примечание: Either используется из пакета dartz или fpdart. Если не хочется использовать функциональщину, можно использовать стандартный try/catch с кастомным исключением или Result<T>.

DI (сборка через get_it)

Собираем всё воедино. Важный момент: AuthInterceptor требует Dio для обновления токена, но этот Dio не должен иметь AuthInterceptor, иначе будет рекурсия.

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

Future<void> setupDI() async {
	// Хранилище токенов
	getIt.registerLazySingleton<TokenStorage>(() => SecureTokenStorage());
	
	// Dio для refresh (без интерцептора!)
	getIt.registerLazySingleton<Dio>(
		() => DioFactory.createDio(baseUrl: 'https://api.example.com/auth'),
		instanceName: 'refreshDio',
	);
	
	// Главный Dio
	getIt.registerLazySingleton<Dio>(() {
		final authInterceptor = AuthInterceptor(
			tokenStorage: getIt<TokenStorage>(),
			refreshDio: getIt<Dio>(instanceName: 'refresh_dio'),
			onUnauthorized: () {
				print('User logged out!');
			},
		);
		
		return DioFactory.createDio(interceptors: [authInterceptor]);
	});
	
	// Data Sources
	getIt.registerLazySingleton<UserRemoteDataSource>(
		() => UserRemoteDataSourceImpl(getIt<Dio>()),
	);
	
	// Репозитории
	getIt.registerLazySingleton<UserRepository>(
		() => UserRepositoryImpl(getIt<UserRemoteDataSource>()),
	);
}

Использование в BLoC или ViewModel

Теперь UI вообще не знает о существовании Dio, JSON или DTO. Он работает только с репозиторием пользователя (UserRepository) и доменной моделью пользователя (User).

class UserCubit extends Cubit<UserState> {
	final UserRepository _userRepository;
	
	UserCubit(this._userRepository) : super(UserInitial());
	
	Future<void> loadUser(String id) async {
		emit(UserLoading());
		final result = await _userRepository.getUser(id);
		
		result.fold(
			(failure) => emit(UserError(failure.message)),
			(user) => emit(UserLoaded(user)),
		);
	}
}

На что обратить внимание

  1. Два экземпляра Dio - никогда не использовать единый экземпляр Dio для основного API и для запроса refresh токена. Иначе AuthInterceptor перехватит 401 от рефреша и уйдёт в бесконечный цикл.
  2. QueuedInterceptorsWrapper - обязательная штука для авторизации. Если на экране есть 3 виджета, которые делают запрос в API с протухшим токеном, то Queue гарантирует, что рефреш вызовется один раз, а остальные 2 запроса дождутся обновления и снова уйдут в onRequest.
  3. Генерация кода - всегда использовать json_serializable или freezed для DTO. Ручной парсинг json['key'] - источник опечаток и багов.
  4. Отмена запросов (CancelToken) - при уничтожении экрана обязательно нужно вызывать cancelToken.cancel(), чтобы отменить "висящие" запросы и избежать утечек памяти или обновления несуществующего UI.