180 lines
5.5 KiB
Dart
180 lines
5.5 KiB
Dart
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<T> 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<String, String>? get queryParameters => null;
|
|
|
|
/// Returns the body to send for POST requests. Should be JSON-encodable.
|
|
Object? get body => null;
|
|
|
|
T assemble(String raw);
|
|
|
|
Future<T> 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<http.Response> _runOnce({required bool forceTokenRefresh}) async {
|
|
final uri = ConnectEndpoint.resolve(subpath).replace(
|
|
queryParameters: _normaliseQuery(queryParameters),
|
|
);
|
|
|
|
final headers = <String, String>{
|
|
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<String, String>? _normaliseQuery(Map<String, String>? raw) {
|
|
if (raw == null) return null;
|
|
final cleaned = <String, String>{};
|
|
raw.forEach((key, value) {
|
|
if (value.isNotEmpty) cleaned[key] = value;
|
|
});
|
|
return cleaned.isEmpty ? null : cleaned;
|
|
}
|
|
}
|