import 'dart:async'; import 'dart:convert'; import 'package:ac_web/Api/ApiConfig.dart'; import 'package:ac_web/Api/ApiException.dart'; import 'package:http/http.dart' as http; /// Thin, transport only wrapper around `package:http`. /// /// It knows nothing about the concrete controllers of the backend, it only /// performs requests, maps failures onto [ApiException]s and decodes json. /// Everything endpoint specific lives in the services under `Api/Services`. class ApiClient { ApiClient({ApiConfig config = ApiConfig.development, http.Client? httpClient}) : mConfig = config, mHttpClient = httpClient ?? http.Client(), mOwnsHttpClient = httpClient == null; final ApiConfig mConfig; final http.Client mHttpClient; final bool mOwnsHttpClient; ApiConfig get config => mConfig; /// Performs a GET request and returns the decoded json body. Future getJson(String path, {Map? query, Map? headers}) { return _send('GET', path, query: query, headers: headers); } /// Performs a GET request and maps a json array onto a list of models. Future> getList( String path, T Function(Map json) fromJson, { Map? query, Map? headers, }) async { final uri = mConfig.buildUri(path, query); final body = await _send('GET', path, query: query, headers: headers); if (body == null) { return []; } if (body is! List) { throw ApiParsingException('Expected a json array but got ${body.runtimeType}.', uri: uri); } try { return body .whereType() .map((entry) => fromJson(Map.from(entry))) .toList(growable: false); } on ApiException { rethrow; } catch (error) { throw ApiParsingException('Failed to parse list of $T: $error', uri: uri); } } /// Performs a GET request and maps a json object onto a single model. Future getObject( String path, T Function(Map json) fromJson, { Map? query, Map? headers, }) async { final uri = mConfig.buildUri(path, query); final body = await _send('GET', path, query: query, headers: headers); if (body is! Map) { throw ApiParsingException('Expected a json object but got ${body.runtimeType}.', uri: uri); } try { return fromJson(Map.from(body)); } on ApiException { rethrow; } catch (error) { throw ApiParsingException('Failed to parse $T: $error', uri: uri); } } Future postJson( String path, { Object? body, Map? query, Map? headers, }) { return _send('POST', path, body: body, query: query, headers: headers); } Future putJson( String path, { Object? body, Map? query, Map? headers, }) { return _send('PUT', path, body: body, query: query, headers: headers); } Future deleteJson( String path, { Object? body, Map? query, Map? headers, }) { return _send('DELETE', path, body: body, query: query, headers: headers); } void dispose() { if (mOwnsHttpClient) { mHttpClient.close(); } } Future _send( String method, String path, { Object? body, Map? query, Map? headers, }) async { final uri = mConfig.buildUri(path, query); final request = http.Request(method, uri); request.headers.addAll({ 'Accept': 'application/json', ...mConfig.defaultHeaders, ...?headers, }); if (body != null) { request.headers['Content-Type'] = 'application/json; charset=utf-8'; request.body = jsonEncode(body); } http.Response response; try { final streamed = await mHttpClient.send(request).timeout(mConfig.timeout); response = await http.Response.fromStream(streamed); } on TimeoutException { throw ApiTimeoutException('Request timed out after ${mConfig.timeout.inSeconds}s.', uri: uri); } catch (error) { throw ApiNetworkException('Could not reach the api: $error', uri: uri); } if (response.statusCode < 200 || response.statusCode >= 300) { throw ApiHttpException( statusCode: response.statusCode, message: response.reasonPhrase ?? 'Request failed.', body: response.body, uri: uri, ); } if (response.statusCode == 204 || response.body.isEmpty) { return null; } try { return jsonDecode(utf8.decode(response.bodyBytes)); } catch (error) { throw ApiParsingException('Response was no valid json: $error', uri: uri, body: response.body); } } }