import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'api_exception.dart'; import 'app_config.dart'; import 'session_store.dart'; /// One decoded response: the `data` payload plus the envelope's metadata. class ApiResponse { const ApiResponse({ this.data, this.etag, this.total, this.nextCursor, this.notModified = false, this.requestId, }); /// The `data` member of the envelope. `null` on a 304 or a 204. final dynamic data; /// `ETag` from the response, to be replayed as `If-None-Match`. final String? etag; /// Total rows behind a filtered list, when the endpoint sends one. final int? total; /// Keyset cursor for the next page. Null on the last page. final String? nextCursor; /// True when the server answered `304` — the caller keeps what it has. final bool notModified; final String? requestId; Map get map => data is Map ? data as Map : const {}; /// The rows of a list endpoint. /// /// `data` is the list itself on most of this surface, but a paged endpoint /// wraps it — `data: { items: [...], nextCursor }` — and the contract does /// not say which of the two spellings a given route uses. Reading through the /// wrapper costs one lookup; guessing wrong shows the customer an empty /// Orders tab and no error at all, which is the failure that cannot be /// diagnosed from a screenshot. List> get rows => (_list ?? const []).whereType>().toList(); List? get _list { final payload = data; if (payload is List) return payload; if (payload is Map) { for (final key in _listKeys) { final value = payload[key]; if (value is List) return value; } } return null; } static const List _listKeys = [ 'items', 'results', 'rows', 'data', 'bookings', 'orders', 'states', 'districts', 'slots', 'places', 'locations', ]; } /// The app's only HTTP caller. /// /// Everything the contract asks for on the wire lives here and nowhere else: /// the `data` envelope, the nine error codes, bearer auth with silent refresh, /// the client identity headers, idempotency keys, `ETag` revalidation, keyset /// paging and the retry policy. A screen that wanted to add a header would have /// to come through this class, which is the point. class ApiClient { ApiClient({ http.Client? httpClient, SessionStore? sessions, this.onSessionLost, }) : _http = httpClient ?? http.Client(), _sessions = sessions ?? SecureSessionStore(); final http.Client _http; final SessionStore _sessions; /// Called when a refresh fails and the session is dropped, so [AppState] can /// return the customer to sign-in rather than leaving them on a dead screen. /// Settable because the state that handles it is built after this client. void Function()? onSessionLost; SessionStore get sessions => _sessions; Session? _session; bool _restored = false; /// Single-flight refresh: ten calls hitting 401 together must produce one /// refresh, not ten — the token rotates, so the other nine would be replaying /// a token the server has already revoked and would kill the whole chain. Future? _refreshing; final Random _rng = Random(); // ------------------------------------------------------------------ session Future currentSession() async { if (!_restored) { _session = await _sessions.read(); _restored = true; } return _session; } Future adoptSession(Session session) async { _session = session; _restored = true; await _sessions.write(session); } Future dropSession() async { _session = null; _restored = true; await _sessions.clear(); } // ------------------------------------------------------------------ requests Future get( String path, { Map? query, String? ifNoneMatch, bool authenticated = true, Duration? timeout, }) => _send( 'GET', path, query: query, ifNoneMatch: ifNoneMatch, authenticated: authenticated, timeout: timeout, // A GET carries no side effect, so replaying one is always safe. retries: 2, ); Future post( String path, { Object? body, Map? query, String? idempotencyKey, bool authenticated = true, Duration? timeout, }) => _send( 'POST', path, body: body, query: query, idempotencyKey: idempotencyKey, authenticated: authenticated, timeout: timeout ?? AppConfig.writeTimeout, ); Future patch( String path, { Object? body, bool authenticated = true, }) => _send('PATCH', path, body: body, authenticated: authenticated); Future put( String path, { Object? body, bool authenticated = true, }) => _send('PUT', path, body: body, authenticated: authenticated); Future delete(String path, {bool authenticated = true}) => _send('DELETE', path, authenticated: authenticated); /// A fresh idempotency key. Held by the caller across retries of the *same* /// intent — a new key means a new booking. String newIdempotencyKey() { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36); final salt = List.generate( 10, (_) => alphabet[_rng.nextInt(alphabet.length)], ).join(); return '$now-$salt'; } // ------------------------------------------------------------------ internals Future _send( String method, String path, { Object? body, Map? query, String? ifNoneMatch, String? idempotencyKey, bool authenticated = true, Duration? timeout, int retries = 0, bool allowRefresh = true, }) async { final uri = Uri.parse( AppConfig.url(path), ).replace(queryParameters: (query?.isEmpty ?? true) ? null : query); ApiException? lastTransient; for (var attempt = 0; attempt <= retries; attempt++) { if (attempt > 0) { await Future.delayed(Duration(milliseconds: 300 * attempt * attempt)); } try { final response = await _once( method, uri, body: body, ifNoneMatch: ifNoneMatch, idempotencyKey: idempotencyKey, authenticated: authenticated, timeout: timeout, ); return response; } on ApiException catch (e) { // 401 once, then refresh and try again — but never on the refresh call // itself, which would recurse. if (e.isAuthFailure && authenticated && allowRefresh) { final refreshed = await _refreshSession(); if (refreshed != null) { return _send( method, path, body: body, query: query, ifNoneMatch: ifNoneMatch, idempotencyKey: idempotencyKey, authenticated: authenticated, timeout: timeout, retries: 0, allowRefresh: false, ); } await dropSession(); onSessionLost?.call(); rethrow; } if (!e.isTransient || attempt == retries) rethrow; lastTransient = e; } } throw lastTransient ?? ApiException(ApiException.network, 'We could not reach Doormile'); } Future _once( String method, Uri uri, { Object? body, String? ifNoneMatch, String? idempotencyKey, required bool authenticated, Duration? timeout, }) async { final headers = { 'Accept': 'application/json', 'X-Client': AppConfig.clientHeader, 'X-Platform': AppConfig.platformHeader, if (body != null) 'Content-Type': 'application/json', 'If-None-Match': ?ifNoneMatch, 'Idempotency-Key': ?idempotencyKey, }; if (authenticated) { final session = await currentSession(); if (session != null) { // Refresh proactively rather than spending a 401 to discover it. final usable = session.isExpired ? (await _refreshSession() ?? session) : session; headers['Authorization'] = 'Bearer ${usable.accessToken}'; } } final request = http.Request(method, uri)..headers.addAll(headers); if (body != null) request.body = jsonEncode(body); http.Response response; try { final streamed = await _http .send(request) .timeout(timeout ?? AppConfig.requestTimeout); response = await http.Response.fromStream(streamed); } on TimeoutException { throw ApiException( ApiException.network, 'Doormile took too long to answer', ); } on SocketException { throw ApiException(ApiException.network, 'We could not reach Doormile'); } on http.ClientException { throw ApiException(ApiException.network, 'We could not reach Doormile'); } return _decode(response, uri); } ApiResponse _decode(http.Response response, Uri uri) { final requestId = response.headers['x-request-id']; final status = response.statusCode; if (status == 304) { return ApiResponse( notModified: true, etag: response.headers['etag'], requestId: requestId, ); } dynamic decoded; if (response.body.isNotEmpty) { try { decoded = jsonDecode(response.body); } catch (_) { // An HTML error page or a truncated body. The contract forbids both, // so treat it as the server being unreachable rather than guessing. _log(uri, status, requestId, 'unparseable body'); throw ApiException( status >= 500 ? ApiException.serverError : ApiException.network, 'Something went wrong', status, requestId, ); } } final envelope = decoded is Map ? decoded : const {}; if (status >= 200 && status < 300) { // `success: false` with a 200 would be a contract violation, but reading // it costs nothing and beats rendering a failure as data. if (envelope['success'] == false) { throw _errorFrom(envelope, status, requestId, uri); } final payload = envelope.containsKey('data') ? envelope['data'] : decoded; // Keyset metadata sits beside `data` on some routes and inside it on the // paged ones. Both are read, envelope first. final inner = payload is Map ? payload : const {}; return ApiResponse( data: payload, etag: response.headers['etag'], total: (envelope['total'] ?? inner['total']) is num ? ((envelope['total'] ?? inner['total']) as num).toInt() : null, nextCursor: (envelope['nextCursor'] ?? inner['nextCursor']) as String?, requestId: requestId, ); } throw _errorFrom(envelope, status, requestId, uri); } ApiException _errorFrom( Map envelope, int status, String? requestId, Uri uri, ) { final error = envelope['error']; final serverCode = (error is Map && error['code'] is String) ? error['code'] as String : ''; final code = ApiException.normalise( serverCode, () => _codeForStatus(status), ); final message = envelope['message'] is String && (envelope['message'] as String).trim().isNotEmpty ? envelope['message'] as String : (error is Map && error['message'] is String ? error['message'] as String : _messageForStatus(status)); _log(uri, status, requestId, serverCode.isEmpty ? code : serverCode); return ApiException(code, message, status, requestId, serverCode); } static String _codeForStatus(int status) => switch (status) { 400 => ApiException.invalid, 401 => ApiException.unauthorized, 403 => ApiException.forbidden, 404 => ApiException.notFound, 409 => ApiException.conflict, 422 => ApiException.unserviceable, 429 => ApiException.rateLimited, _ => status >= 500 ? ApiException.serverError : ApiException.invalid, }; static String _messageForStatus(int status) => switch (status) { 401 => 'Please sign in again', 403 => 'You do not have access to this', 404 => 'We could not find that', 409 => 'That is no longer available', 429 => 'Too many attempts. Try again in a minute', _ => 'Something went wrong', }; void _log(Uri uri, int status, String? requestId, String detail) { debugPrint( '[API] $status ${uri.path} · $detail' '${requestId == null ? '' : ' · req $requestId'}', ); } // ------------------------------------------------------------------ refresh /// Rotates the session. Returns null when the refresh itself failed, which /// means the chain is dead and the customer must sign in again. Future _refreshSession() { final inFlight = _refreshing; if (inFlight != null) return inFlight; final future = _doRefresh(); _refreshing = future; return future.whenComplete(() => _refreshing = null); } Future _doRefresh() async { final session = await currentSession(); if (session == null) return null; try { final response = await _send( 'POST', '/auth/refresh', body: {'refreshToken': session.refreshToken}, authenticated: false, allowRefresh: false, ); final data = response.map; final access = data['accessToken'] as String?; if (access == null || access.isEmpty) return null; final rotated = Session( accessToken: access, // A server that does not rotate returns no new refresh token; keeping // the old one is then correct. If it does rotate, this is the only // copy that still works, so it is persisted before anything uses it. refreshToken: (data['refreshToken'] as String?) ?? session.refreshToken, expiresAt: DateTime.now().add( Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600), ), customer: session.customer, ); await adoptSession(rotated); return rotated; } on ApiException catch (e) { debugPrint('[API] refresh failed: $e'); return null; } } void close() => _http.close(); }