184 lines
5.7 KiB
Dart
184 lines
5.7 KiB
Dart
// lib/core/api/api_client.dart
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../storage/token_storage.dart';
|
|
|
|
class ApiClient {
|
|
static const Duration _timeout = Duration(seconds: 30);
|
|
|
|
/// POST request
|
|
///
|
|
/// - `url` should be a fully qualified endpoint (ApiEndpoints.*)
|
|
/// - `body` is the JSON object to send (Map)
|
|
/// - when `requiresAuth == true` token & username are added to the request body
|
|
Future<Map<String, dynamic>> post(
|
|
String url, {
|
|
Map<String, dynamic>? body,
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
final headers = <String, String>{
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
final Map<String, dynamic> finalBody = await _buildAuthBody(body, requiresAuth);
|
|
|
|
late http.Response response;
|
|
try {
|
|
response = await http
|
|
.post(
|
|
Uri.parse(url),
|
|
headers: headers,
|
|
body: jsonEncode(finalBody),
|
|
)
|
|
.timeout(_timeout);
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
|
|
throw Exception('Network error: $e');
|
|
}
|
|
|
|
return _handleResponse(url, response, finalBody);
|
|
}
|
|
|
|
/// GET request
|
|
///
|
|
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
|
/// - `params` will be appended as query parameters.
|
|
Future<Map<String, dynamic>> get(
|
|
String url, {
|
|
Map<String, dynamic>? params,
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
// build final query params including auth if needed
|
|
final Map<String, dynamic> finalParams = {};
|
|
|
|
if (requiresAuth) {
|
|
final token = await TokenStorage.getToken();
|
|
final username = await TokenStorage.getUsername();
|
|
if (token == null || username == null) {
|
|
throw Exception('Authentication required');
|
|
}
|
|
finalParams['token'] = token;
|
|
finalParams['username'] = username;
|
|
}
|
|
|
|
if (params != null) finalParams.addAll(params);
|
|
|
|
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
|
|
|
late http.Response response;
|
|
try {
|
|
response = await http.get(uri).timeout(_timeout);
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('ApiClient.get network error: $e');
|
|
throw Exception('Network error: $e');
|
|
}
|
|
|
|
return _handleResponse(url, response, finalParams);
|
|
}
|
|
|
|
/// Build request body and attach token + username if required
|
|
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
|
final Map<String, dynamic> finalBody = {};
|
|
|
|
if (requiresAuth) {
|
|
final token = await TokenStorage.getToken();
|
|
final username = await TokenStorage.getUsername();
|
|
|
|
if (token == null || username == null) {
|
|
throw Exception('Authentication required');
|
|
}
|
|
|
|
finalBody['token'] = token;
|
|
finalBody['username'] = username;
|
|
}
|
|
|
|
if (body != null) finalBody.addAll(body);
|
|
|
|
return finalBody;
|
|
}
|
|
|
|
/// Centralized response handling and error parsing
|
|
Map<String, dynamic> _handleResponse(String url, http.Response response, Map<String, dynamic> requestBody) {
|
|
dynamic decoded;
|
|
try {
|
|
decoded = jsonDecode(response.body);
|
|
} catch (e) {
|
|
decoded = response.body;
|
|
}
|
|
|
|
if (kDebugMode) {
|
|
debugPrint('API -> $url');
|
|
debugPrint('Status: ${response.statusCode}');
|
|
debugPrint('Request body: ${jsonEncode(requestBody)}');
|
|
debugPrint('Response body: ${response.body}');
|
|
}
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
return {'data': decoded};
|
|
}
|
|
|
|
// Build human-friendly message from common server patterns
|
|
String errorMessage = 'Request failed (status ${response.statusCode})';
|
|
|
|
if (decoded is Map) {
|
|
// 1) If there's an explicit top-level 'message' (string), prefer it
|
|
if (decoded.containsKey('message') && decoded['message'] is String) {
|
|
errorMessage = decoded['message'] as String;
|
|
}
|
|
// 2) If 'errors' exists and is a map, collect inner messages
|
|
else if (decoded.containsKey('errors')) {
|
|
final errs = decoded['errors'];
|
|
final messages = <String>[];
|
|
if (errs is String) {
|
|
messages.add(errs);
|
|
} else if (errs is List) {
|
|
for (final e in errs) messages.add(e.toString());
|
|
} else if (errs is Map) {
|
|
// collect first-level messages (prefer the text, not the key)
|
|
errs.forEach((k, v) {
|
|
if (v is List && v.isNotEmpty) {
|
|
messages.add(v.first.toString());
|
|
} else if (v is String) {
|
|
messages.add(v);
|
|
} else {
|
|
messages.add(v.toString());
|
|
}
|
|
});
|
|
} else {
|
|
messages.add(errs.toString());
|
|
}
|
|
if (messages.isNotEmpty) {
|
|
errorMessage = messages.join(' | ');
|
|
}
|
|
}
|
|
// 3) If '__all__' present (DRF default), show it
|
|
else if (decoded.containsKey('__all__')) {
|
|
final all = decoded['__all__'];
|
|
if (all is List) {
|
|
errorMessage = all.join(' | ');
|
|
} else {
|
|
errorMessage = all.toString();
|
|
}
|
|
}
|
|
// 4) fallback - join map values' messages (prefer strings inside lists)
|
|
else {
|
|
final messages = <String>[];
|
|
decoded.forEach((k, v) {
|
|
if (v is List && v.isNotEmpty) {
|
|
messages.add(v.first.toString());
|
|
} else {
|
|
messages.add(v.toString());
|
|
}
|
|
});
|
|
errorMessage = messages.isNotEmpty ? messages.join(' | ') : decoded.toString();
|
|
}
|
|
} else if (decoded is String) {
|
|
errorMessage = decoded;
|
|
}
|
|
|
|
throw Exception(errorMessage);
|
|
}
|
|
}
|