661 lines
22 KiB
Dart
661 lines
22 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
|
import '../config/api_config.dart';
|
|
|
|
/// Callback invoked when the access token has aged out (or, as a fallback, when
|
|
/// a request comes back 401). Should hit the refresh endpoint and update the
|
|
/// ApiService's access token, returning true if the new token is ready and the
|
|
/// original request can be sent.
|
|
typedef RefreshTokenCallback = Future<bool> Function();
|
|
|
|
/// API response wrapper
|
|
class ApiResponse<T> {
|
|
final bool success;
|
|
final T? data;
|
|
final String? message;
|
|
final int statusCode;
|
|
|
|
const ApiResponse({
|
|
required this.success,
|
|
this.data,
|
|
this.message,
|
|
required this.statusCode,
|
|
});
|
|
|
|
@override
|
|
String toString() =>
|
|
'ApiResponse(success: $success, statusCode: $statusCode, message: $message)';
|
|
}
|
|
|
|
/// HTTP client for BottleCRM API
|
|
///
|
|
/// Handles authentication headers, organization context,
|
|
/// and provides typed request methods.
|
|
class ApiService {
|
|
static final ApiService _instance = ApiService._internal();
|
|
factory ApiService() => _instance;
|
|
ApiService._internal();
|
|
|
|
http.Client _client = http.Client();
|
|
|
|
/// Swap the HTTP client. Tests only: the app shares one client for the
|
|
/// process lifetime, and `ApiService` is a singleton.
|
|
@visibleForTesting
|
|
void setClientForTesting(http.Client client) {
|
|
_client = client;
|
|
}
|
|
|
|
void Function(String module)? _moduleOffCallback;
|
|
|
|
/// Called with the module key when any request is answered 404 or 409 with
|
|
/// `code: module_off`, so the app can reload its module list.
|
|
void setModuleOffCallback(void Function(String module)? callback) {
|
|
_moduleOffCallback = callback;
|
|
}
|
|
|
|
@visibleForTesting
|
|
bool get hasModuleOffCallback => _moduleOffCallback != null;
|
|
|
|
void _notifyIfModuleOff(http.Response response) {
|
|
final callback = _moduleOffCallback;
|
|
if (callback == null ||
|
|
(response.statusCode != 404 && response.statusCode != 409)) {
|
|
return;
|
|
}
|
|
// The modules route itself never answers module_off; never reload from it.
|
|
final path = response.request?.url.path;
|
|
if (path != null && path == Uri.parse(ApiConfig.orgModules).path) return;
|
|
try {
|
|
final body = jsonDecode(response.body);
|
|
if (body is Map && body['code'] == 'module_off') {
|
|
callback(body['module']?.toString() ?? '');
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Token and org getters - will be set by AuthService
|
|
String? _accessToken;
|
|
String? _organizationId;
|
|
|
|
// Refresh wiring. AuthService registers `refreshAccessToken` here during
|
|
// initialize(); `_refreshInFlight` coalesces concurrent refresh attempts so
|
|
// a burst of expired-token requests only fires one network call to the
|
|
// refresh endpoint.
|
|
RefreshTokenCallback? _refreshCallback;
|
|
Future<bool>? _refreshInFlight;
|
|
|
|
/// Set the access token (called by AuthService)
|
|
void setAccessToken(String? token) {
|
|
_accessToken = token;
|
|
}
|
|
|
|
/// Set the organization ID (called by AuthService)
|
|
void setOrganizationId(String? orgId) {
|
|
_organizationId = orgId;
|
|
}
|
|
|
|
/// Register the refresh-token callback (called once by AuthService.initialize).
|
|
/// Passing null disables auto-refresh.
|
|
void setRefreshCallback(RefreshTokenCallback? callback) {
|
|
_refreshCallback = callback;
|
|
}
|
|
|
|
/// Clear authentication state
|
|
void clearAuth() {
|
|
_accessToken = null;
|
|
_organizationId = null;
|
|
}
|
|
|
|
/// Run the registered refresh callback at most once per concurrent burst.
|
|
/// Returns true if the access token was refreshed and the caller should
|
|
/// retry the original request.
|
|
Future<bool> _refreshAccessToken() async {
|
|
final cb = _refreshCallback;
|
|
if (cb == null) return false;
|
|
final existing = _refreshInFlight;
|
|
if (existing != null) return existing;
|
|
final fut = cb();
|
|
_refreshInFlight = fut;
|
|
try {
|
|
return await fut;
|
|
} finally {
|
|
_refreshInFlight = null;
|
|
}
|
|
}
|
|
|
|
/// How close to expiry counts as expired.
|
|
///
|
|
/// Covers a token that would age out while the request is in flight, and
|
|
/// small clock differences between the phone and the server.
|
|
static const Duration _expiryGrace = Duration(seconds: 30);
|
|
|
|
/// True when the access token is at or near its `exp`.
|
|
///
|
|
/// A token that will not decode returns false: refreshing cannot repair a
|
|
/// malformed credential, so the request carries it and the server answers.
|
|
bool get _accessTokenNeedsRefresh {
|
|
final token = _accessToken;
|
|
if (token == null) return false;
|
|
try {
|
|
return JwtDecoder.getExpirationDate(
|
|
token,
|
|
).isBefore(DateTime.now().add(_expiryGrace));
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Send an HTTP request, refreshing the access token first when it has aged
|
|
/// out. The `send` closure must be safe to invoke twice, every caller below
|
|
/// builds a fresh request inside it.
|
|
Future<http.Response> _sendWithRetry({
|
|
required bool requiresAuth,
|
|
required Future<http.Response> Function(Map<String, String> headers) send,
|
|
Duration? timeout,
|
|
}) async {
|
|
final deadline = timeout ?? ApiConfig.connectTimeout;
|
|
// Refresh BEFORE sending, not after a rejection.
|
|
//
|
|
// This API does not answer 401 for an expired credential. `HasOrgContext`
|
|
// (backend `common/permissions.py`) denies with 403 before DRF's auth layer
|
|
// would emit a 401, and it answers the same way for a missing, malformed or
|
|
// expired token. So the 401 branch below never fires on expiry, and a
|
|
// session that aged out while the app was open stayed broken until the next
|
|
// cold start, where `AuthService.initialize()` does this same check once.
|
|
//
|
|
// The web client refreshes the same way, off the `exp` claim on every
|
|
// request (`frontend/src/hooks.server.js`), rather than off a status code.
|
|
if (requiresAuth && _accessTokenNeedsRefresh && _refreshCallback != null) {
|
|
await _refreshAccessToken();
|
|
}
|
|
|
|
var response = await send(
|
|
_buildHeaders(requiresAuth: requiresAuth),
|
|
).timeout(deadline);
|
|
|
|
// Kept as a fallback for a token revoked or rotated out from under us
|
|
// before its `exp`, which the check above cannot see.
|
|
if (requiresAuth &&
|
|
response.statusCode == 401 &&
|
|
_accessToken != null &&
|
|
_refreshCallback != null) {
|
|
final refreshed = await _refreshAccessToken();
|
|
if (refreshed) {
|
|
response = await send(
|
|
_buildHeaders(requiresAuth: requiresAuth),
|
|
).timeout(deadline);
|
|
}
|
|
}
|
|
_notifyIfModuleOff(response);
|
|
return response;
|
|
}
|
|
|
|
/// Build request headers
|
|
Map<String, String> _buildHeaders({bool requiresAuth = true}) {
|
|
final headers = Map<String, String>.from(ApiConfig.defaultHeaders);
|
|
|
|
if (requiresAuth && _accessToken != null) {
|
|
headers['Authorization'] = 'Bearer $_accessToken';
|
|
}
|
|
|
|
if (_organizationId != null) {
|
|
headers['X-Organization-ID'] = _organizationId!;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
/// Parse response body
|
|
dynamic _parseResponse(http.Response response) {
|
|
if (response.body.isEmpty) return null;
|
|
|
|
try {
|
|
return jsonDecode(response.body);
|
|
} catch (e) {
|
|
debugPrint('Failed to parse response: ${response.body}');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Check if response is successful
|
|
bool _isSuccess(int statusCode) {
|
|
return statusCode >= 200 && statusCode < 300;
|
|
}
|
|
|
|
/// Extract error message from response
|
|
/// A sentence a user can act on, instead of the exception's own text.
|
|
///
|
|
/// `SocketException` and `ClientException` stringify with the host and the
|
|
/// full URI in them, so an offline screen was printing the API hostname at
|
|
/// the user. It is also not information they can use, and every failure that
|
|
/// reaches here has the same answer. A malformed body does not arrive here:
|
|
/// `_parseResponse` swallows that and returns null. The original exception
|
|
/// still goes to `debugPrint` for whoever is debugging.
|
|
String _networkErrorMessage(Object error) =>
|
|
'Cannot reach the server. Check your connection and try again.';
|
|
|
|
String _extractErrorMessage(dynamic data, int statusCode) {
|
|
if (data == null) return 'Request failed with status $statusCode';
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
// Check common error field names
|
|
if (data.containsKey('detail')) return data['detail'].toString();
|
|
if (data.containsKey('message') && data['message'] is String) {
|
|
return data['message'].toString();
|
|
}
|
|
|
|
// Handle backend-shaped errors. The Django views use two flavors:
|
|
// {"errors": "plain string"}: top-level error message
|
|
// {"errors": {"field": ["msg", ...]}}: DRF field-level errors
|
|
if (data.containsKey('errors')) {
|
|
final errors = data['errors'];
|
|
if (errors is String && errors.isNotEmpty) return errors;
|
|
if (errors is Map) {
|
|
final fromErrors = _fieldErrorSentences(errors);
|
|
if (fromErrors != null) return fromErrors;
|
|
}
|
|
}
|
|
|
|
if (data.containsKey('non_field_errors')) {
|
|
final errors = data['non_field_errors'];
|
|
if (errors is List && errors.isNotEmpty) {
|
|
return errors.first.toString();
|
|
}
|
|
}
|
|
|
|
// DRF's own shape, `{"field": ["message"]}`, with no wrapper.
|
|
//
|
|
// That is what a `serializers.ValidationError` raised outside a
|
|
// serializer produces, and it had no branch here: the attachment size
|
|
// limit answered "Files must be 25 MB or smaller." and the phone showed
|
|
// "Request failed with status 400". Last, so the wrapped flavours above
|
|
// still win where a view uses one.
|
|
final fromFields = _fieldErrorSentences(data);
|
|
if (fromFields != null) return fromFields;
|
|
}
|
|
|
|
return 'Request failed with status $statusCode';
|
|
}
|
|
|
|
/// Turn `{"field_name": ["message"]}` into `Field name: message`, one line
|
|
/// per field. Null when nothing in the map has that shape.
|
|
String? _fieldErrorSentences(Map errors) {
|
|
final sentences = <String>[];
|
|
for (final entry in errors.entries) {
|
|
final label = entry.key.toString().replaceAll('_', ' ');
|
|
final capitalised = label.isEmpty
|
|
? label
|
|
: '${label[0].toUpperCase()}${label.substring(1)}';
|
|
final messages = entry.value;
|
|
if (messages is List && messages.isNotEmpty) {
|
|
sentences.add('$capitalised: ${messages.first}');
|
|
} else if (messages is String && messages.isNotEmpty) {
|
|
sentences.add('$capitalised: $messages');
|
|
}
|
|
}
|
|
return sentences.isEmpty ? null : sentences.join('\n');
|
|
}
|
|
|
|
/// Perform GET request
|
|
Future<ApiResponse<Map<String, dynamic>>> get(
|
|
String url, {
|
|
bool requiresAuth = true,
|
|
Map<String, String>? queryParams,
|
|
}) async {
|
|
try {
|
|
final uri = queryParams != null
|
|
? Uri.parse(url).replace(queryParameters: queryParams)
|
|
: Uri.parse(url);
|
|
|
|
debugPrint('GET $uri');
|
|
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.get(uri, headers: headers),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
data: success ? data as Map<String, dynamic>? : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('GET error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform an authenticated GET without trying to parse the response as JSON.
|
|
/// Used for private attachments, whose URL must never carry a JWT.
|
|
Future<ApiResponse<Uint8List>> getBytes(
|
|
String url, {
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
try {
|
|
final uri = Uri.parse(url);
|
|
debugPrint('GET (bytes) $uri');
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.get(uri, headers: headers),
|
|
timeout: ApiConfig.uploadTimeout,
|
|
);
|
|
final success = _isSuccess(response.statusCode);
|
|
final errorData = success ? null : _parseResponse(response);
|
|
return ApiResponse(
|
|
success: success,
|
|
data: success ? response.bodyBytes : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(errorData, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (error) {
|
|
debugPrint('GET (bytes) error: $error');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(error),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform GET request returning a list
|
|
Future<ApiResponse<List<dynamic>>> getList(
|
|
String url, {
|
|
bool requiresAuth = true,
|
|
Map<String, String>? queryParams,
|
|
}) async {
|
|
try {
|
|
final uri = queryParams != null
|
|
? Uri.parse(url).replace(queryParameters: queryParams)
|
|
: Uri.parse(url);
|
|
|
|
debugPrint('GET (list) $uri');
|
|
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.get(uri, headers: headers),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
data: success ? (data as List<dynamic>?) : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('GET (list) error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform POST request
|
|
Future<ApiResponse<Map<String, dynamic>>> post(
|
|
String url,
|
|
Map<String, dynamic> body, {
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
try {
|
|
// The URL only. A request body is never safe to log here: the refresh
|
|
// call carries the refresh token, sign-in carries the Google id token,
|
|
// and an ordinary create carries a customer's name, email and phone.
|
|
debugPrint('POST $url');
|
|
|
|
final uri = Uri.parse(url);
|
|
final encoded = jsonEncode(body);
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.post(uri, headers: headers, body: encoded),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
debugPrint('Response status: ${response.statusCode}');
|
|
if (!success) {
|
|
debugPrint('Response body: ${response.body}');
|
|
}
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
// Preserve the parsed body on failure too so callers can read
|
|
// structured error fields (e.g. validation `errors` map, 409 conflict
|
|
// payloads like {running_case_id}). Callers must still check
|
|
// `success` before treating the data as a happy-path payload.
|
|
data: data is Map<String, dynamic> ? data : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('POST error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// POST one file as `multipart/form-data`.
|
|
///
|
|
/// Separate from [post] rather than a flag on it: that one JSON-encodes a
|
|
/// `Map` body, which is not a shape a file fits into.
|
|
///
|
|
/// `_buildHeaders` supplies `Content-Type: application/json` and it is left
|
|
/// alone here on purpose. `MultipartRequest.finalize()` overwrites the header
|
|
/// with its own boundary type, and `BaseRequest.headers` compares keys
|
|
/// case-insensitively, so removing it first changes nothing. A test pins the
|
|
/// header that actually goes out, since this is a detail of package:http
|
|
/// rather than a guarantee.
|
|
///
|
|
/// The request is rebuilt inside the closure because a `MultipartRequest`
|
|
/// streams its body exactly once and cannot be re-sent after a token refresh.
|
|
Future<ApiResponse<Map<String, dynamic>>> postMultipart(
|
|
String url, {
|
|
required String fileField,
|
|
String? filePath,
|
|
// The file's content instead of its path, for a caller that sends one file
|
|
// twice and needs both requests to carry the same bytes (the CSV import
|
|
// previews, then commits). Give exactly one of the two.
|
|
List<int>? fileBytes,
|
|
String? fileName,
|
|
Map<String, String> fields = const {},
|
|
bool requiresAuth = true,
|
|
// PUT or PATCH for an edit that replaces the file. The encoding is the
|
|
// same whatever the verb; which fields go is the caller's decision.
|
|
String method = 'POST',
|
|
}) async {
|
|
assert(
|
|
(filePath == null) != (fileBytes == null),
|
|
'Pass filePath or fileBytes, not both and not neither',
|
|
);
|
|
try {
|
|
// The URL and the field, never the path: a file path on a phone contains
|
|
// the account name, and on iOS the app's container UUID.
|
|
debugPrint('$method (multipart) $url [$fileField]');
|
|
|
|
final uri = Uri.parse(url);
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
// A slow connection uploading megabytes needs more than the 30 seconds
|
|
// a JSON call gets.
|
|
timeout: ApiConfig.uploadTimeout,
|
|
send: (headers) async {
|
|
final request = http.MultipartRequest(method, uri);
|
|
request.headers.addAll(headers);
|
|
request.fields.addAll(fields);
|
|
request.files.add(
|
|
fileBytes != null
|
|
? http.MultipartFile.fromBytes(
|
|
fileField,
|
|
fileBytes,
|
|
filename: fileName,
|
|
)
|
|
: await http.MultipartFile.fromPath(
|
|
fileField,
|
|
filePath!,
|
|
filename: fileName,
|
|
),
|
|
);
|
|
return http.Response.fromStream(await _client.send(request));
|
|
},
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
debugPrint('Response status: ${response.statusCode}');
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
data: data is Map<String, dynamic> ? data : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('POST (multipart) error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform PUT request
|
|
Future<ApiResponse<Map<String, dynamic>>> put(
|
|
String url,
|
|
Map<String, dynamic> body, {
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
try {
|
|
debugPrint('PUT $url');
|
|
|
|
final uri = Uri.parse(url);
|
|
final encoded = jsonEncode(body);
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.put(uri, headers: headers, body: encoded),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
// Preserve the parsed body on failure too. See POST for rationale.
|
|
data: data is Map<String, dynamic> ? data : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('PUT error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform PATCH request
|
|
Future<ApiResponse<Map<String, dynamic>>> patch(
|
|
String url,
|
|
Map<String, dynamic> body, {
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
try {
|
|
debugPrint('PATCH $url');
|
|
|
|
final uri = Uri.parse(url);
|
|
final encoded = jsonEncode(body);
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.patch(uri, headers: headers, body: encoded),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
// Preserve the parsed body on failure too. See POST for rationale.
|
|
data: data is Map<String, dynamic> ? data : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('PATCH error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform DELETE request
|
|
Future<ApiResponse<Map<String, dynamic>>> delete(
|
|
String url, {
|
|
bool requiresAuth = true,
|
|
}) async {
|
|
try {
|
|
debugPrint('DELETE $url');
|
|
|
|
final uri = Uri.parse(url);
|
|
final response = await _sendWithRetry(
|
|
requiresAuth: requiresAuth,
|
|
send: (headers) => _client.delete(uri, headers: headers),
|
|
);
|
|
|
|
final data = _parseResponse(response);
|
|
final success = _isSuccess(response.statusCode);
|
|
|
|
return ApiResponse(
|
|
success: success,
|
|
// Preserve the parsed body on failure too. See POST for rationale.
|
|
data: data is Map<String, dynamic> ? data : null,
|
|
message: success
|
|
? null
|
|
: _extractErrorMessage(data, response.statusCode),
|
|
statusCode: response.statusCode,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('DELETE error: $e');
|
|
return ApiResponse(
|
|
success: false,
|
|
message: _networkErrorMessage(e),
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
}
|