Files
please-pay-me/mobile/lib/data/api/api_client.dart
T
2026-09-21 04:06:43 +03:00

125 lines
3.8 KiB
Dart

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<Map<String, dynamic>> getJson(String path, {Map<String, String>? query}) async {
return asMap(await _send('GET', path, query: query));
}
Future<Map<String, dynamic>> postJson(
String path, {
Map<String, dynamic>? body,
Map<String, String>? query,
}) async {
return asMap(await _send('POST', path, body: body, query: query));
}
Future<Map<String, dynamic>> putJson(String path, {Map<String, dynamic>? body}) async {
return asMap(await _send('PUT', path, body: body));
}
Future<Map<String, dynamic>> patchJson(String path, {Map<String, dynamic>? body}) async {
return asMap(await _send('PATCH', path, body: body));
}
Future<Map<String, dynamic>> deleteJson(String path, {Map<String, String>? query}) async {
return asMap(await _send('DELETE', path, query: query));
}
Future<Object?> _send(
String method,
String path, {
Map<String, dynamic>? body,
Map<String, String>? 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();
}