Files
mileograph_flutter/lib/services/api_service.dart
petegregoryy 9896b6f1f8
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s
add traction import export
2026-01-23 13:21:08 +00:00

285 lines
7.6 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
class ApiService {
String _baseUrl;
final http.Client _client;
final Duration timeout;
TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({
required String baseUrl,
http.Client? client,
this.timeout = const Duration(seconds: 30),
}) : _baseUrl = baseUrl,
_client = client ?? http.Client();
String get baseUrl => _baseUrl;
void setBaseUrl(String url) {
_baseUrl = url;
}
void setTokenProvider(TokenProvider provider) {
_getToken = provider;
}
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
void dispose() {
_client.close();
}
Map<String, String> _buildHeaders(Map<String, String>? extra) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await _client
.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
return _processResponse(response);
}
Future<ApiBinaryResponse> getBytes(
String endpoint, {
Map<String, String>? headers,
}) async {
final response = await _client
.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
if (response.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await _client
.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> postMultipartFile(
String endpoint, {
required List<int> bytes,
required String filename,
String fieldName = 'file',
Map<String, String>? fields,
Map<String, String>? headers,
}) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
);
request.headers.addAll(_buildHeaders(headers));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
request.files.add(
http.MultipartFile.fromBytes(
fieldName,
bytes,
filename: filename,
),
);
final streamed = await _client.send(request).timeout(timeout);
final response = await http.Response.fromStream(streamed);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await _client
.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
}),
body: data, // http package handles form-encoding for Map<String, String>
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> put(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await _client
.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
}) async {
final response = await _client
.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
return _processResponse(response);
}
Map<String, String> _jsonHeaders(Map<String, String>? extra) {
return {'Content-Type': 'application/json', if (extra != null) ...extra};
}
Future<dynamic> _processResponse(http.Response res) async {
final body = _decodeBody(res);
if (res.statusCode >= 200 && res.statusCode < 300) {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
message: message,
body: body,
);
}
dynamic _decodeBody(http.Response res) {
if (res.body.isEmpty) return null;
final contentType = res.headers['content-type'] ?? '';
final shouldTryJson = contentType.contains('application/json') ||
contentType.contains('+json') ||
res.body.trimLeft().startsWith('{') ||
res.body.trimLeft().startsWith('[');
if (!shouldTryJson) return res.body;
try {
return jsonDecode(res.body);
} catch (_) {
// Avoid turning a server-side error body into a client-side crash.
return res.body;
}
}
String _extractErrorMessage(dynamic body) {
if (body == null) return 'No response body';
if (body is String) return body;
if (body is Map<String, dynamic>) {
for (final key in ['message', 'error', 'detail', 'msg']) {
final val = body[key];
if (val is String && val.trim().isNotEmpty) return val;
}
return body.toString();
}
if (body is List) {
final parts = body
.map((e) => e is Map
? _extractErrorMessage(Map<String, dynamic>.from(e))
: e.toString())
.where((e) => e.trim().isNotEmpty)
.toList();
if (parts.isNotEmpty) return parts.join('; ');
}
return body.toString();
}
String? _extractFilename(String? contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty) return null;
final utf8Match =
RegExp(r"filename\\*=UTF-8''([^;]+)", caseSensitive: false)
.firstMatch(contentDisposition);
if (utf8Match != null) {
return Uri.decodeComponent(utf8Match.group(1) ?? '');
}
final match =
RegExp(r'filename="?([^\";]+)"?', caseSensitive: false)
.firstMatch(contentDisposition);
return match?.group(1);
}
}
class ApiException implements Exception {
final int statusCode;
final String message;
final dynamic body;
ApiException({
required this.statusCode,
required this.message,
this.body,
});
@override
String toString() => 'API error $statusCode: $message';
}
class ApiBinaryResponse {
final List<int> bytes;
final int statusCode;
final String? contentType;
final String? filename;
ApiBinaryResponse({
required this.bytes,
required this.statusCode,
this.contentType,
this.filename,
});
}