import 'dart:convert'; import 'package:shelf/shelf.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'auth_api.dart'; final class SyncApi { const SyncApi({ required this.pushSync, required this.pullSync, required this.exchangeSync, required this.authenticateRequest, }); final PushSyncUseCase pushSync; final PullSyncUseCase pullSync; final ExchangeSyncUseCase exchangeSync; final AuthenticateRequestUseCase authenticateRequest; Future push(Request request) async { final authenticated = authenticatedRequestFrom(request); if (authenticated == null) { return _errorResponse(401, 'Unauthorized.'); } try { final body = await _readJsonObject(request); final result = await pushSync.execute( ownerUserId: authenticated.user.id, deviceId: _optionalString(body, 'deviceId'), items: _pushItems(body['items']), ); return _jsonResponse(200, _pushResultJson(result)); } on FormatException catch (error) { return _errorResponse(400, error.message); } } Future pull(Request request) async { final authenticated = authenticatedRequestFrom(request); if (authenticated == null) { return _errorResponse(401, 'Unauthorized.'); } try { final since = _optionalDateTime(request.url.queryParameters['since']); final result = await pullSync.execute( ownerUserId: authenticated.user.id, since: since, ); return _jsonResponse(200, _pullResultJson(result)); } on FormatException catch (error) { return _errorResponse(400, error.message); } } Future exchange(Request request) async { final authenticated = authenticatedRequestFrom(request); if (authenticated == null) { return _errorResponse(401, 'Unauthorized.'); } try { final body = await _readJsonObject(request); final result = await exchangeSync.execute( ownerUserId: authenticated.user.id, deviceId: _optionalString(body, 'deviceId'), items: _pushItems(body['items']), since: _optionalDateTime(_optionalString(body, 'since')), ); return _jsonResponse(200, { 'serverCursor': result.pull.serverCursor.toIso8601String(), 'pushResults': _pushResultsJson(result.push.results), 'items': _resourcesJson(result.pull.items), }); } on FormatException catch (error) { return _errorResponse(400, error.message); } } } List _pushItems(Object? rawItems) { if (rawItems is! List) { throw const FormatException('items must be a JSON array.'); } return [ for (final rawItem in rawItems) if (rawItem is Map) _pushItem(Map.from(rawItem)) else const PushSyncItemInput.invalid( message: 'Sync item must be a JSON object.', ), ]; } PushSyncItemInput _pushItem(Map item) { try { final payload = item['payload']; if (payload is! Map) { return PushSyncItemInput.invalid( resourceType: _stringOrNull(item['resourceType']), clientId: _stringOrNull(item['clientId']), message: 'payload must be a JSON object.', ); } return PushSyncItemInput( resourceType: _stringOrNull(item['resourceType']), clientId: _stringOrNull(item['clientId']), schemaVersion: item['schemaVersion'] is int ? item['schemaVersion'] as int : null, clientUpdatedAt: _optionalDateTime( _stringOrNull(item['clientUpdatedAt']), ), deletedAt: _optionalDateTime(_stringOrNull(item['deletedAt'])), payloadJson: Map.from(payload), ); } on FormatException catch (error) { return PushSyncItemInput.invalid( resourceType: _stringOrNull(item['resourceType']), clientId: _stringOrNull(item['clientId']), message: error.message, ); } } Map _pushResultJson(PushSyncResult result) { return { 'serverCursor': result.serverCursor.toIso8601String(), 'results': _pushResultsJson(result.results), }; } List> _pushResultsJson(List results) { return [ for (final item in results) { 'resourceType': item.resourceType, 'clientId': item.clientId, 'serverId': item.serverId, 'status': item.status, 'serverUpdatedAt': item.serverUpdatedAt?.toIso8601String(), if (item.message != null) 'message': item.message, }, ]; } Map _pullResultJson(PullSyncResult result) { return { 'serverCursor': result.serverCursor.toIso8601String(), 'items': _resourcesJson(result.items), }; } List> _resourcesJson(List resources) { return [ for (final resource in resources) { 'resourceType': resource.resourceType.wireName, 'clientId': resource.clientId, 'serverId': resource.serverId, 'schemaVersion': resource.schemaVersion, 'clientUpdatedAt': resource.clientUpdatedAt.toIso8601String(), 'serverUpdatedAt': resource.serverUpdatedAt.toIso8601String(), 'deletedAt': resource.deletedAt?.toIso8601String(), 'payload': resource.payloadJson, }, ]; } Future> _readJsonObject(Request request) async { final raw = await request.readAsString(); final decoded = jsonDecode(raw); if (decoded is! Map) { throw const FormatException('Request body must be a JSON object.'); } return decoded; } String? _optionalString(Map body, String key) { final value = body[key]; if (value == null) { return null; } if (value is! String) { throw FormatException('$key must be a string.'); } final trimmed = value.trim(); return trimmed.isEmpty ? null : trimmed; } String? _stringOrNull(Object? value) { if (value == null) { return null; } return value is String ? value : null; } DateTime? _optionalDateTime(String? value) { if (value == null) { return null; } return DateTime.parse(value).toUtc(); } Response _jsonResponse(int statusCode, Map body) { return Response( statusCode, body: jsonEncode(body), headers: {'content-type': 'application/json'}, ); } Response _errorResponse(int statusCode, String message) { return _jsonResponse(statusCode, {'error': message}); }