import 'dart:convert'; import 'package:http/http.dart' as http; import 'api_exception.dart'; /// Platform-neutral HTTP client. /// API base URL is injected at build time via --dart-define=API_BASE_URL. /// Default is same-origin '/api' to avoid mixed-content on HTTPS sites. class ApiClient { final String baseUrl; final http.Client _client; ApiClient({http.Client? client}) : baseUrl = const String.fromEnvironment( 'API_BASE_URL', defaultValue: '/api', ), _client = client ?? http.Client(); Map _headers({String? token}) => { 'Content-Type': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }; Future getJson(String path, {String? token}) async { final response = await _client.get( Uri.parse('$baseUrl$path'), headers: _headers(token: token), ); return _decodeOrNull(_guardResponse(response)); } Future postJson(String path, {Object? body, String? token}) async { final response = await _client.post( Uri.parse('$baseUrl$path'), headers: _headers(token: token), body: body == null ? null : jsonEncode(body), ); return _decodeOrNull(_guardResponse(response)); } Future putJson(String path, {Object? body, String? token}) async { final response = await _client.put( Uri.parse('$baseUrl$path'), headers: _headers(token: token), body: body == null ? null : jsonEncode(body), ); return _decodeOrNull(_guardResponse(response)); } Future patchJson(String path, {Object? body, String? token}) async { final response = await _client.patch( Uri.parse('$baseUrl$path'), headers: _headers(token: token), body: body == null ? null : jsonEncode(body), ); return _decodeOrNull(_guardResponse(response)); } Future deleteJson(String path, {String? token}) async { final response = await _client.delete( Uri.parse('$baseUrl$path'), headers: _headers(token: token), ); return _decodeOrNull(_guardResponse(response)); } http.Response _guardResponse(http.Response response) { if (response.statusCode >= 200 && response.statusCode < 300) { return response; } final parsedBody = _decodeOrNull(response); final serverMessage = _extractMessage(parsedBody); if (response.statusCode == 401) { throw ApiException( type: ApiErrorType.unauthorized, statusCode: 401, message: serverMessage ?? 'Unauthorized', ); } if (response.statusCode == 403) { throw ApiException( type: ApiErrorType.forbidden, statusCode: 403, message: serverMessage ?? 'Forbidden', ); } if (response.statusCode >= 500) { throw ApiException( type: ApiErrorType.server, statusCode: response.statusCode, message: serverMessage ?? 'Server error', ); } throw ApiException( type: ApiErrorType.unknown, statusCode: response.statusCode, message: serverMessage ?? 'Request failed', ); } dynamic _decodeOrNull(http.Response response) { final body = response.body.trim(); if (body.isEmpty) return null; try { return jsonDecode(body); } catch (_) { return body; } } String? _extractMessage(dynamic parsedBody) { if (parsedBody is Map) { final message = parsedBody['message']; if (message is String && message.trim().isNotEmpty) return message; if (message is List && message.isNotEmpty) { return message.first.toString(); } final error = parsedBody['error']; if (error is String && error.trim().isNotEmpty) return error; } if (parsedBody is String && parsedBody.trim().isNotEmpty) return parsedBody; return null; } }