import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../../application/application.dart'; final class HttpApiClient { HttpApiClient({ required this.baseUrl, http.Client? client, this.timeout = const Duration(seconds: 10), }) : client = client ?? http.Client(); static const defaultBaseUrl = String.fromEnvironment( 'GAMETIME_API_BASE_URL', defaultValue: 'http://localhost:8080', ); final Uri baseUrl; final http.Client client; final Duration timeout; Future> postJson( String path, { Map? body, String? bearerToken, Set expectedStatuses = const {200, 201}, }) async { final response = await _send( () => client.post( _resolve(path), headers: _headers(bearerToken), body: body == null ? null : jsonEncode(body), ), ); if (!expectedStatuses.contains(response.statusCode)) { throw _exceptionForStatus(response.statusCode, response.body); } if (response.body.trim().isEmpty) { return const {}; } final Object? decoded; try { decoded = jsonDecode(response.body); } on FormatException catch (error) { throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); } if (decoded is Map) { return Map.from(decoded); } throw const RemoteAuthException( RemoteAuthFailure.unknown, 'Unexpected JSON response.', ); } Future postEmpty( String path, { String? bearerToken, Set expectedStatuses = const {204}, }) async { final response = await _send( () => client.post(_resolve(path), headers: _headers(bearerToken)), ); if (!expectedStatuses.contains(response.statusCode)) { throw _exceptionForStatus(response.statusCode, response.body); } } Future> getJson( String path, { Map queryParameters = const {}, String? bearerToken, Set expectedStatuses = const {200}, }) async { final response = await _send( () => client.get( _resolve(path, queryParameters: queryParameters), headers: _headers(bearerToken), ), ); if (!expectedStatuses.contains(response.statusCode)) { throw _exceptionForStatus(response.statusCode, response.body); } if (response.body.trim().isEmpty) { return const {}; } final Object? decoded; try { decoded = jsonDecode(response.body); } on FormatException catch (error) { throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); } if (decoded is Map) { return Map.from(decoded); } throw const RemoteAuthException( RemoteAuthFailure.unknown, 'Unexpected JSON response.', ); } Uri _resolve(String path, {Map queryParameters = const {}}) { final normalized = path.startsWith('/') ? path.substring(1) : path; final base = baseUrl.toString().endsWith('/') ? baseUrl : Uri.parse('${baseUrl.toString()}/'); final resolved = base.resolve(normalized); final cleanQuery = { for (final entry in queryParameters.entries) if (entry.value != null) entry.key: entry.value!, }; if (cleanQuery.isEmpty) { return resolved; } return resolved.replace( queryParameters: {...resolved.queryParameters, ...cleanQuery}, ); } Map _headers(String? bearerToken) => { 'accept': 'application/json', 'content-type': 'application/json', if (bearerToken != null) 'authorization': 'Bearer $bearerToken', }; Future _send(Future Function() send) async { try { return await send().timeout(timeout); } on TimeoutException { throw const RemoteAuthException(RemoteAuthFailure.network, 'Timeout.'); } on http.ClientException catch (error) { throw RemoteAuthException(RemoteAuthFailure.network, error.message); } on FormatException catch (error) { throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); } } RemoteAuthException _exceptionForStatus(int statusCode, String body) { final message = _messageFromBody(body); return switch (statusCode) { 401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message), 409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message), >= 500 => RemoteAuthException(RemoteAuthFailure.network, message), _ => RemoteAuthException(RemoteAuthFailure.unknown, message), }; } String? _messageFromBody(String body) { if (body.trim().isEmpty) { return null; } try { final decoded = jsonDecode(body); if (decoded is Map && decoded['error'] is String) { return decoded['error'] as String; } if (decoded is Map && decoded['message'] is String) { return decoded['message'] as String; } } on FormatException { return body; } return body; } }