Files
rely/lib/integrations/backend/backend_gateway_rest.dart
2026-02-14 20:10:16 +01:00

214 lines
5.8 KiB
Dart

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/dio_error_mapper.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:relationship_saver/integrations/backend/sync_envelope_validator.dart';
import 'package:uuid/uuid.dart';
/// REST implementation of [BackendGateway] backed by Dio.
class BackendGatewayRest implements BackendGateway {
BackendGatewayRest({
required Dio dio,
required TokenStore tokenStore,
Uuid? uuid,
Clock? clock,
this.deviceId = 'device-local',
}) : _dio = dio,
_tokenStore = tokenStore,
_uuid = uuid ?? const Uuid(),
_clock = clock ?? const Clock();
final Dio _dio;
final TokenStore _tokenStore;
final Uuid _uuid;
final Clock _clock;
final String deviceId;
@override
Future<void> acknowledgeSignal({
required String signalId,
required SignalAction action,
DateTime? at,
}) {
return _guard(() async {
final SignalAckRequest body = SignalAckRequest(
action: action,
at: at ?? _clock.now(),
);
await _dio.post<dynamic>(
'/v1/signals/$signalId/ack',
data: body.toJson(),
);
});
}
@override
Future<DownloadUrl> getDownloadUrl({required String fileId}) {
return _guard(() async {
final Response<dynamic> response = await _dio.get<dynamic>(
'/v1/files/$fileId',
);
return DownloadUrl.fromJson(_asJsonMap(response.data));
});
}
@override
Future<SignalsFeed> getSignalsFeed({
String? cursor,
DateTime? since,
int limit = 50,
Set<String>? personIds,
}) {
return _guard(() async {
final Map<String, dynamic> query = <String, dynamic>{'limit': limit};
if (cursor != null && cursor.isNotEmpty) {
query['cursor'] = cursor;
}
if (since != null) {
query['since'] = since.toUtc().toIso8601String();
}
if (personIds != null && personIds.isNotEmpty) {
query['personId'] = personIds.toList(growable: false);
}
final Response<dynamic> response = await _dio.get<dynamic>(
'/v1/signals',
queryParameters: query,
);
return SignalsFeed.fromJson(_asJsonMap(response.data));
});
}
@override
Future<UploadInitResult> initUpload({
required UploadPurpose purpose,
required String contentType,
required int sizeBytes,
}) {
return _guard(() async {
final UploadInitRequest request = UploadInitRequest(
purpose: purpose,
contentType: contentType,
sizeBytes: sizeBytes,
);
final Response<dynamic> response = await _dio.post<dynamic>(
'/v1/uploads',
data: request.toJson(),
);
return UploadInitResult.fromJson(_asJsonMap(response.data));
});
}
@override
Future<UserProfile> me() {
return _guard(() async {
final Response<dynamic> response = await _dio.get<dynamic>('/v1/me');
return UserProfile.fromJson(_asJsonMap(response.data));
});
}
@override
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) {
return _guard(() async {
final Map<String, dynamic> query = <String, dynamic>{'limit': limit};
if (cursor != null && cursor.isNotEmpty) {
query['cursor'] = cursor;
}
final Response<dynamic> response = await _dio.get<dynamic>(
'/v1/sync/pull',
queryParameters: query,
);
return SyncPullResult.fromJson(_asJsonMap(response.data));
});
}
@override
Future<SyncPushResult> pushChanges({
required List<ChangeEnvelope> changes,
String? cursor,
}) {
return _guard(() async {
SyncEnvelopeValidator.validateAll(changes);
final String idempotencyKey = _uuid.v4();
final SyncPushRequest request = SyncPushRequest(
deviceId: deviceId,
cursor: cursor,
changes: changes,
idempotencyKey: idempotencyKey,
);
final Response<dynamic> response = await _dio.post<dynamic>(
'/v1/sync/push',
data: request.toJson(),
options: Options(
headers: <String, dynamic>{
NetworkConstants.idempotencyKeyHeader: idempotencyKey,
},
),
);
return SyncPushResult.fromJson(_asJsonMap(response.data));
});
}
@override
Future<AuthSession> signIn(SignInRequest request) {
return _guard(() async {
final Response<dynamic> response = await _dio.post<dynamic>(
'/v1/auth/sign-in',
data: request.toJson(),
options: Options(
extra: const <String, dynamic>{
NetworkConstants.extraSkipAuth: true,
NetworkConstants.extraSkipRefresh: true,
},
),
);
final AuthSession session = AuthSession.fromJson(
_asJsonMap(response.data),
);
await _tokenStore.write(session);
return session;
});
}
@override
Future<void> signOut() async {
try {
await _guard(() async {
await _dio.post<dynamic>('/v1/auth/sign-out');
});
} finally {
await _tokenStore.clear();
}
}
Future<T> _guard<T>(Future<T> Function() run) async {
try {
return await run();
} on DioException catch (error) {
throw mapDioException(error);
}
}
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 FormatException(
'Expected JSON object response, got ${value.runtimeType}',
);
}
}