import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:please_pay_me/data/models/json.dart'; class ApiException implements Exception { const ApiException(this.message, {this.statusCode}); final String message; final int? statusCode; bool get isUnauthorized => statusCode == 401; @override String toString() => message; } /// Thin JSON transport over the PleasePayMe REST API. /// /// Keeps auth concerns out of repositories: the token is supplied lazily so a /// re-login does not require rebuilding the whole object graph. class ApiClient { ApiClient({ required String baseUrl, required String? Function() tokenProvider, http.Client? httpClient, this.onUnauthorized, this.timeout = const Duration(seconds: 15), }) : _baseUrl = baseUrl.replaceAll(RegExp(r'/$'), ''), _tokenProvider = tokenProvider, _http = httpClient ?? http.Client(); final String _baseUrl; final String? Function() _tokenProvider; final http.Client _http; final void Function()? onUnauthorized; final Duration timeout; Future> getJson(String path, {Map? query}) async { return asMap(await _send('GET', path, query: query)); } Future> postJson( String path, { Map? body, Map? query, }) async { return asMap(await _send('POST', path, body: body, query: query)); } Future> putJson(String path, {Map? body}) async { return asMap(await _send('PUT', path, body: body)); } Future> patchJson(String path, {Map? body}) async { return asMap(await _send('PATCH', path, body: body)); } Future> deleteJson(String path, {Map? query}) async { return asMap(await _send('DELETE', path, query: query)); } Future _send( String method, String path, { Map? body, Map? query, }) async { final uri = Uri.parse('$_baseUrl$path').replace( queryParameters: query?.isEmpty ?? true ? null : query, ); final request = http.Request(method, uri); request.headers['Accept'] = 'application/json'; final token = _tokenProvider(); if (token != null && token.isNotEmpty) { request.headers['Authorization'] = 'Bearer $token'; } if (body != null) { request.headers['Content-Type'] = 'application/json'; request.body = jsonEncode(body); } late final http.Response response; try { final streamed = await _http.send(request).timeout(timeout); response = await http.Response.fromStream(streamed); } on Exception catch (error) { throw ApiException('Нет связи с сервером: $error'); } if (response.statusCode == 401) { onUnauthorized?.call(); throw const ApiException('Сессия истекла, войдите заново', statusCode: 401); } final raw = utf8.decode(response.bodyBytes); if (response.statusCode >= 400) { throw ApiException(_extractError(raw, response.statusCode), statusCode: response.statusCode); } if (response.statusCode == 204 || raw.trim().isEmpty) return null; try { return jsonDecode(raw); } on FormatException { throw ApiException('Сервер вернул не JSON (HTTP ${response.statusCode})'); } } String _extractError(String raw, int statusCode) { try { final parsed = asMap(jsonDecode(raw)); final detail = asStringOrNull(parsed['detail']) ?? asStringOrNull(parsed['title']); if (detail != null) return detail; } on FormatException { // Fall through to the raw payload. } return raw.trim().isEmpty ? 'Ошибка запроса (HTTP $statusCode)' : raw.trim(); } void close() => _http.close(); }