333 lines
8.8 KiB
Dart
333 lines
8.8 KiB
Dart
import 'dart:convert';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
typedef TokenProvider = String? Function();
|
|
typedef UnauthorizedHandler = Future<bool> 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, {
|
|
bool includeAuth = true,
|
|
}) {
|
|
final token = _getToken?.call();
|
|
final headers = {'accept': 'application/json', ...?extra};
|
|
if (includeAuth && token != null && token.isNotEmpty) {
|
|
headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
Future<dynamic> get(
|
|
String endpoint, {
|
|
Map<String, String>? headers,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final response = await _sendWithRetry(
|
|
() => _client.get(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(headers, includeAuth: includeAuth),
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
return _processResponse(response);
|
|
}
|
|
|
|
Future<ApiBinaryResponse> getBytes(
|
|
String endpoint, {
|
|
Map<String, String>? headers,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final response = await _sendWithRetry(
|
|
() => _client.get(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(headers, includeAuth: includeAuth),
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
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,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final hasBody = data != null;
|
|
final response = await _sendWithRetry(
|
|
() => _client.post(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(
|
|
hasBody ? _jsonHeaders(headers) : headers,
|
|
includeAuth: includeAuth,
|
|
),
|
|
body: hasBody ? jsonEncode(data) : null,
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
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,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
Future<http.Response> send() async {
|
|
final request = http.MultipartRequest(
|
|
'POST',
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
);
|
|
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
|
|
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);
|
|
return http.Response.fromStream(streamed);
|
|
}
|
|
|
|
final response = await _sendWithRetry(send, allowRetry: allowRetry);
|
|
return _processResponse(response);
|
|
}
|
|
|
|
Future<dynamic> postForm(
|
|
String endpoint,
|
|
Map<String, String> data, {
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final response = await _sendWithRetry(
|
|
() => _client.post(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(
|
|
{
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'accept': 'application/json',
|
|
},
|
|
includeAuth: includeAuth,
|
|
),
|
|
body: data, // http package handles form-encoding for Map<String, String>
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
return _processResponse(response);
|
|
}
|
|
|
|
Future<dynamic> put(
|
|
String endpoint,
|
|
dynamic data, {
|
|
Map<String, String>? headers,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final hasBody = data != null;
|
|
final response = await _sendWithRetry(
|
|
() => _client.put(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(
|
|
hasBody ? _jsonHeaders(headers) : headers,
|
|
includeAuth: includeAuth,
|
|
),
|
|
body: hasBody ? jsonEncode(data) : null,
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
return _processResponse(response);
|
|
}
|
|
|
|
Future<dynamic> delete(
|
|
String endpoint, {
|
|
Map<String, String>? headers,
|
|
bool includeAuth = true,
|
|
bool allowRetry = true,
|
|
}) async {
|
|
final response = await _sendWithRetry(
|
|
() => _client.delete(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: _buildHeaders(headers, includeAuth: includeAuth),
|
|
),
|
|
allowRetry: allowRetry,
|
|
);
|
|
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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
Future<http.Response> _sendWithRetry(
|
|
Future<http.Response> Function() send, {
|
|
required bool allowRetry,
|
|
}) async {
|
|
var response = await send().timeout(timeout);
|
|
if (response.statusCode == 401 && allowRetry && _onUnauthorized != null) {
|
|
final refreshed = await _onUnauthorized!();
|
|
if (refreshed) {
|
|
response = await send().timeout(timeout);
|
|
}
|
|
}
|
|
return response;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|