import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:http/http.dart' as http; import '../api_request.dart'; import '../errors/network_exception.dart'; import '../errors/parse_exception.dart'; import '../errors/server_exception.dart'; import 'connect_auth_store.dart'; import 'connect_endpoint.dart'; import 'errors/connect_exception.dart'; import 'errors/rmv_rate_limited_exception.dart'; import 'errors/rmv_upstream_exception.dart'; enum ConnectHttpMethod { get, post } /// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the /// subpath/query/body, and implements [assemble]. Handles bearer-token /// injection (via [ConnectAuthStore]), one transparent 401-retry after a /// fresh login, and turns the structured `RmvController.wrap` error strings /// into typed exceptions. abstract class ConnectApi extends ApiRequest { final String subpath; ConnectApi(this.subpath); /// Override to `false` for endpoints that must NOT receive a bearer token /// (currently only login itself, to avoid an infinite refresh loop). bool get requiresAuth => true; ConnectHttpMethod get method => ConnectHttpMethod.get; Map? get queryParameters => null; /// Returns the body to send for POST requests. Should be JSON-encodable. Object? get body => null; T assemble(String raw); Future run() async { final response = await _runOnce(forceTokenRefresh: false); if (response.statusCode == 401 && requiresAuth) { // Single transparent retry after a forced refresh, then bail. await ConnectAuthStore.instance.invalidate(); final retry = await _runOnce(forceTokenRefresh: true); if (retry.statusCode == 401) { throw ConnectException.authFailed( technicalDetails: 'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}', ); } return _handleResponse(retry); } return _handleResponse(response); } Future _runOnce({required bool forceTokenRefresh}) async { final uri = ConnectEndpoint.resolve(subpath).replace( queryParameters: _normaliseQuery(queryParameters), ); final headers = { if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json', 'Accept': 'application/json', }; if (requiresAuth) { final token = await ConnectAuthStore.instance.getToken( forceRefresh: forceTokenRefresh, ); headers['Authorization'] = 'Bearer $token'; } try { switch (method) { case ConnectHttpMethod.get: return await http.get(uri, headers: headers); case ConnectHttpMethod.post: final payload = body; return await http.post( uri, headers: headers, body: payload == null ? null : jsonEncode(payload), ); } } on SocketException catch (e) { throw NetworkException( technicalDetails: 'connect $subpath: ${e.message}', ); } on TimeoutException catch (e) { throw NetworkException.timeout( technicalDetails: 'connect $subpath: $e', ); } on http.ClientException catch (e) { throw NetworkException( technicalDetails: 'connect $subpath: ${e.message}', ); } on HandshakeException catch (e) { throw NetworkException( technicalDetails: 'connect $subpath TLS: ${e.message}', ); } } T _handleResponse(http.Response response) { final status = response.statusCode; final bodyText = _safeBody(response); if (status == 503) { final retryAfter = _parseRetryAfter(bodyText); throw RmvRateLimitedException( retryAfter: retryAfter, technicalDetails: 'connect $subpath HTTP 503: $bodyText', ); } if (status == 502) { final code = _parseUpstreamErrorCode(bodyText); throw RmvUpstreamException( errorCode: code, technicalDetails: 'connect $subpath HTTP 502: $bodyText', ); } if (status > 299) { throw ServerException( statusCode: status, technicalDetails: 'connect $subpath HTTP $status: $bodyText', ); } try { return assemble(bodyText); } catch (e, st) { final preview = bodyText.length > 1024 ? '${bodyText.substring(0, 1024)}…' : bodyText; log( 'connect $subpath assemble failed: $e\nbody: $preview', stackTrace: st, ); throw ParseException( technicalDetails: 'connect $subpath assemble: $e', ); } } String _safeBody(http.Response response) { try { return utf8.decode(response.bodyBytes); } catch (_) { return response.body; } } /// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`. Duration _parseRetryAfter(String body) { final match = RegExp(r'retryAfter=(\d+)').firstMatch(body); final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60; return Duration(seconds: seconds); } /// Body format: `upstream_error|H390` — the segment after the pipe is the /// RMV/HaFAS error code. String? _parseUpstreamErrorCode(String body) { final idx = body.indexOf('|'); if (idx < 0 || idx >= body.length - 1) return null; return body.substring(idx + 1).trim(); } Map? _normaliseQuery(Map? raw) { if (raw == null) return null; final cleaned = {}; raw.forEach((key, value) { if (value.isNotEmpty) cleaned[key] = value; }); return cleaned.isEmpty ? null : cleaned; } }