Scaffold backend gateway and integration docs

This commit is contained in:
Rijad Zuzo
2026-02-14 20:10:16 +01:00
commit 577c4b33b7
166 changed files with 13382 additions and 0 deletions
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
/// Adds bearer access token to outgoing requests.
class AuthInterceptor extends Interceptor {
AuthInterceptor({required TokenStore tokenStore}) : _tokenStore = tokenStore;
final TokenStore _tokenStore;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (options.extra[NetworkConstants.extraSkipAuth] == true) {
handler.next(options);
return;
}
final session = await _tokenStore.read();
if (session?.accessToken case final String accessToken
when accessToken.isNotEmpty) {
options.headers[NetworkConstants.authorizationHeader] =
'Bearer $accessToken';
}
handler.next(options);
}
}
@@ -0,0 +1,154 @@
import 'package:clock/clock.dart';
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/core/network/backend_exception.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Handles access-token refresh on 401 responses and retries the original call.
class RefreshTokenInterceptor extends Interceptor {
RefreshTokenInterceptor({
required Dio dio,
required Dio refreshDio,
required TokenStore tokenStore,
Clock? clock,
}) : _dio = dio,
_refreshDio = refreshDio,
_tokenStore = tokenStore,
_clock = clock ?? const Clock();
final Dio _dio;
final Dio _refreshDio;
final TokenStore _tokenStore;
final Clock _clock;
Future<AuthRefreshResponse?>? _ongoingRefresh;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (!_shouldHandle(err.requestOptions, err.response?.statusCode)) {
handler.next(err);
return;
}
try {
final AuthRefreshResponse? refreshed = await _refreshOnce();
if (refreshed == null) {
await _tokenStore.clear();
handler.reject(_asAuthExpired(err));
return;
}
final RequestOptions retried = err.requestOptions.copyWith(
headers: <String, dynamic>{
...err.requestOptions.headers,
NetworkConstants.authorizationHeader:
'Bearer ${refreshed.accessToken}',
},
extra: <String, dynamic>{
...err.requestOptions.extra,
NetworkConstants.extraDidRefresh: true,
},
);
final Response<dynamic> response = await _dio.fetch<dynamic>(retried);
handler.resolve(response);
} on DioException catch (refreshFailure) {
await _tokenStore.clear();
handler.reject(
DioException(
requestOptions: err.requestOptions,
response: refreshFailure.response,
error: const AuthExpiredException('Authentication expired'),
type: DioExceptionType.badResponse,
),
);
} catch (_) {
await _tokenStore.clear();
handler.reject(_asAuthExpired(err));
}
}
bool _shouldHandle(RequestOptions options, int? statusCode) {
if (statusCode != 401) {
return false;
}
if (options.extra[NetworkConstants.extraSkipRefresh] == true ||
options.extra[NetworkConstants.extraDidRefresh] == true) {
return false;
}
return !options.path.endsWith('/v1/auth/refresh');
}
Future<AuthRefreshResponse?> _refreshOnce() async {
final Future<AuthRefreshResponse?>? inflight = _ongoingRefresh;
if (inflight != null) {
return inflight;
}
final Future<AuthRefreshResponse?> future = _refreshInternal();
_ongoingRefresh = future;
try {
return await future;
} finally {
_ongoingRefresh = null;
}
}
Future<AuthRefreshResponse?> _refreshInternal() async {
final AuthSession? current = await _tokenStore.read();
if (current == null || current.refreshToken.isEmpty) {
return null;
}
final Response<dynamic> response = await _refreshDio.post<dynamic>(
'/v1/auth/refresh',
data: AuthRefreshRequest(refreshToken: current.refreshToken).toJson(),
options: Options(
headers: <String, dynamic>{
NetworkConstants.requestIdHeader:
'refresh-${_clock.now().microsecondsSinceEpoch}',
},
extra: <String, dynamic>{
NetworkConstants.extraSkipAuth: true,
NetworkConstants.extraSkipRefresh: true,
},
),
);
final AuthRefreshResponse refreshed = AuthRefreshResponse.fromJson(
_asJsonMap(response.data),
);
final AuthSession updated = current.copyWith(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken ?? current.refreshToken,
expiresAt: refreshed.expiresAt,
);
await _tokenStore.write(updated);
return refreshed;
}
Map<String, dynamic> _asJsonMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map<Object?, Object?>) {
return value.map<String, dynamic>(
(Object? key, Object? val) => MapEntry(key.toString(), val),
);
}
throw const FormatException('Expected JSON object');
}
DioException _asAuthExpired(DioException source) {
return DioException(
requestOptions: source.requestOptions,
response: source.response,
type: DioExceptionType.badResponse,
error: const AuthExpiredException('Authentication expired'),
);
}
}
@@ -0,0 +1,19 @@
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import 'package:uuid/uuid.dart';
/// Adds per-request metadata headers such as request ID.
class RequestMetadataInterceptor extends Interceptor {
RequestMetadataInterceptor({Uuid? uuid}) : _uuid = uuid ?? const Uuid();
final Uuid _uuid;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers.putIfAbsent(
NetworkConstants.requestIdHeader,
() => _uuid.v4(),
);
handler.next(options);
}
}
@@ -0,0 +1,91 @@
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:dio/dio.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
/// Retries transient failures with exponential backoff.
class RetryInterceptor extends Interceptor {
RetryInterceptor({
required Dio dio,
this.maxRetries = 2,
this.baseDelay = const Duration(milliseconds: 200),
Clock? clock,
}) : _dio = dio,
_clock = clock ?? const Clock();
final Dio _dio;
final int maxRetries;
final Duration baseDelay;
final Clock _clock;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final RequestOptions request = err.requestOptions;
final int attempt =
(request.extra[NetworkConstants.extraRetryAttempt] as int?) ?? 0;
if (!_shouldRetry(err, request) || attempt >= maxRetries) {
handler.next(err);
return;
}
final int multiplier = math.pow(2, attempt).toInt();
final Duration delay = Duration(
milliseconds: baseDelay.inMilliseconds * multiplier,
);
final DateTime wakeAt = _clock.now().add(delay);
final Duration sleepFor = wakeAt.difference(_clock.now());
await Future<void>.delayed(sleepFor.isNegative ? Duration.zero : sleepFor);
final RequestOptions retried = request.copyWith(
extra: <String, dynamic>{
...request.extra,
NetworkConstants.extraRetryAttempt: attempt + 1,
},
);
try {
final Response<dynamic> response = await _dio.fetch<dynamic>(retried);
handler.resolve(response);
} on DioException catch (retryError) {
handler.next(retryError);
}
}
bool _shouldRetry(DioException error, RequestOptions request) {
if (!_isTransient(error)) {
return false;
}
final String method = request.method.toUpperCase();
if (method == 'GET' || method == 'HEAD' || method == 'OPTIONS') {
return true;
}
final Object? idempotency =
request.headers[NetworkConstants.idempotencyKeyHeader];
return idempotency is String && idempotency.trim().isNotEmpty;
}
bool _isTransient(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionError:
return true;
case DioExceptionType.badResponse:
final int? status = error.response?.statusCode;
return status != null && status >= 500 && status < 600;
case DioExceptionType.badCertificate:
case DioExceptionType.cancel:
case DioExceptionType.unknown:
return false;
}
}
}