diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index d94a09a..1ad3e38 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; }; C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; }; + C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */; }; @@ -77,6 +78,7 @@ C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = ""; }; C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = ""; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = ""; }; @@ -219,6 +221,7 @@ C400D7222CC2AF560092237C /* GetSyncData.swift */, C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */, C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */, + C49774DE2DC4B3D7005CD239 /* SyncData.swift */, ); path = Codables; sourceTree = ""; @@ -363,6 +366,7 @@ C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */, C4A47D4F2B6D280200ADC637 /* BaseCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, + C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */, C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */, diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index 3f38b18..d415cf3 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -236,9 +236,9 @@ actor ApiCallCollection: SomeCallCollection { if batch.count == 1, let apiCall = batch.first, apiCall.method == .get { try await self._executeGetCall(apiCall: apiCall) } else { - let results = try await self._executeApiCalls(batch) + let results: [OperationResult] = try await self._executeApiCalls(batch) if T.copyServerResponse { - let instances = results.compactMap { $0.data } + let instances: [T] = results.compactMap { $0.data } self.storeCenter.updateLocalInstances(instances) } } @@ -248,15 +248,28 @@ actor ApiCallCollection: SomeCallCollection { } } - fileprivate func _executeGetCall(apiCall: ApiCall) async throws { + @discardableResult func _executeGetCall(apiCall: ApiCall) async throws -> Data { + + let data = try await self.storeCenter.executeGet(apiCall: apiCall) + if T.self == GetSyncData.self { - let _: Empty = try await self.storeCenter.executeGet(apiCall: apiCall) + let syncData = try SyncData(data: data, storeCenter: self.storeCenter) + await self.storeCenter.synchronizeContent(syncData) } else { - let results: [T] = try await self.storeCenter.executeGet(apiCall: apiCall) + let results: [T] = try self._decode(data: data) await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive) } + return data } + fileprivate func _decode(data: Data) throws -> V { + if !(V.self is Empty?.Type || V.self is Empty.Type) { + return try JSON.decoder.decode(V.self, from: data) + } else { + return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!) + } + } + /// Wait for an exponentionnaly long time depending on the number of attemps fileprivate func _wait() async { @@ -376,11 +389,11 @@ actor ApiCallCollection: SomeCallCollection { await self._batchExecution() } - func executeSingleGet(instance: T) async where T : URLParameterConvertible { + func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible { let call = self._createCall(.get, instance: instance, option: .none) call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter) self._addCallToWaitingList(call) - await self._batchExecution() + return try await self._executeGetCall(apiCall: call) } fileprivate func _prepareCalls(batch: OperationBatch) { diff --git a/LeStorage/Codables/SyncData.swift b/LeStorage/Codables/SyncData.swift new file mode 100644 index 0000000..26ab696 --- /dev/null +++ b/LeStorage/Codables/SyncData.swift @@ -0,0 +1,79 @@ +// +// SyncData.swift +// LeStorage +// +// Created by Laurent Morvillier on 02/05/2025. +// + +import Foundation + +enum SyncDataError: Error { + case invalidFormat +} + +struct SyncedStorableArray { + var type: any SyncedStorable.Type + var items: [any SyncedStorable] +} + +struct ObjectIdentifierArray { + var type: any SyncedStorable.Type + var items: [ObjectIdentifier] +} + +class SyncData { + + var updates: [SyncedStorableArray] = [] + var deletions: [ObjectIdentifierArray] = [] + var grants: [SyncedStorableArray] = [] + var revocations: [ObjectIdentifierArray] = [] + var revocationParents: [[ObjectIdentifierArray]] = [] + var relationshipSets: [SyncedStorableArray] = [] + var relationshipRemovals: [ObjectIdentifierArray] = [] + var sharedRelationshipSets: [SyncedStorableArray] = [] + var sharedRelationshipRemovals: [ObjectIdentifierArray] = [] + var date: String? + + init(data: Data, storeCenter: StoreCenter) throws { + guard let json = try JSONSerialization.jsonObject(with: data, options: []) + as? [String : Any] + else { + throw SyncDataError.invalidFormat + } + + if let updates = json["updates"] as? [String: Any] { + self.updates = try storeCenter.decodeDictionary(updates) + } + if let deletions = json["deletions"] as? [String: Any] { + self.deletions = try storeCenter.decodeObjectIdentifierDictionary(deletions) + } + if let grants = json["grants"] as? [String: Any] { + self.grants = try storeCenter.decodeDictionary(grants) + } + if let revocations = json["revocations"] as? [String: Any] { + self.revocations = try storeCenter.decodeObjectIdentifierDictionary(revocations) + } + if let revocationParents = json["revocation_parents"] as? [[String: Any]] { + for level in revocationParents { + let decodedLevel = try storeCenter.decodeObjectIdentifierDictionary(level) + self.revocationParents.append(decodedLevel) + } + } + + if let relationshipSets = json["relationship_sets"] as? [String: Any] { + self.relationshipSets = try storeCenter.decodeDictionary(relationshipSets) + } + if let relationshipRemovals = json["relationship_removals"] as? [String: Any] { + self.relationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(relationshipRemovals) + } + if let sharedRelationshipSets = json["shared_relationship_sets"] as? [String: Any] { + self.sharedRelationshipSets = try storeCenter.decodeDictionary(sharedRelationshipSets) + } + if let sharedRelationshipRemovals = json["shared_relationship_removals"] as? [String: Any] { + self.sharedRelationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(sharedRelationshipRemovals) + } + + self.date = json["date"] as? String + } + +} diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index b663751..270f87c 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -81,9 +81,9 @@ public class Services { /// - Parameters: /// - request: the URLRequest to run /// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure - fileprivate func _runGetApiCallRequest( + fileprivate func _runGetApiCallRequest( _ request: URLRequest, apiCall: ApiCall - ) async throws -> V { + ) async throws -> Data { let debugURL = request.url?.absoluteString ?? "" // print("Run \(request.httpMethod ?? "") \(debugURL)") let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) @@ -95,11 +95,6 @@ public class Services { switch statusCode { case 200..<300: // success try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id) - - if T.self == GetSyncData.self { - await self.storeCenter.synchronizeContent(task.0) - } - default: // error Logger.log( "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") @@ -123,7 +118,7 @@ public class Services { Logger.w(message) } - return try self._decode(data: task.0) + return task.0 //try self._decode(data: task.0) } @@ -420,15 +415,15 @@ public class Services { return request } - /// Starts a request to retrieve the synchronization updates - /// - Parameters: - /// - since: The date from which updates are retrieved - func synchronizeLastUpdates(since: Date?) async throws { - let request = try self._getSyncLogRequest(since: since) - if let data = try await self._runRequest(request) { - await self.storeCenter.synchronizeContent(data) - } - } +// /// Starts a request to retrieve the synchronization updates +// /// - Parameters: +// /// - since: The date from which updates are retrieved +// func synchronizeLastUpdates(since: Date?) async throws { +// let request = try self._getSyncLogRequest(since: since) +// if let data = try await self._runRequest(request) { +// await self.storeCenter.synchronizeContent(data) +// } +// } /// Returns the URLRequest for an ApiCall /// - Parameters: @@ -520,7 +515,7 @@ public class Services { } /// Executes an ApiCall - func runGetApiCall(_ apiCall: ApiCall) async throws -> V { + func runGetApiCall(_ apiCall: ApiCall) async throws -> Data { let request = try self._syncGetRequest(from: apiCall) return try await self._runGetApiCallRequest(request, apiCall: apiCall) } diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index c1beb2e..d026cec 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -19,6 +19,7 @@ public class StoreCenter { /// A dictionary of Stores associated to their id fileprivate var _stores: [String: Store] = [:] + /// Returns a default Store instance lazy var mainStore: Store = { Store(storeCenter: self) }() /// A KeychainStore object used to store the user's token @@ -61,6 +62,7 @@ public class StoreCenter { /// The URL manager fileprivate var _urlManager: URLManager? = nil + /// Used for testing, gives the project name to retrieve classes from names var classProject: String? = nil init(directoryName: String? = nil) { @@ -72,6 +74,11 @@ public class StoreCenter { self.loadApiCallCollection(type: GetSyncData.self) + if let directoryName { + self._settingsStorage = MicroStorage( + fileName: "\(directoryName)/settings.json") + } + NetworkMonitor.shared.onConnectionEstablished = { self._resumeApiCalls() // self._configureWebSocket() @@ -442,7 +449,7 @@ public class StoreCenter { // } /// Executes an API call - func executeGet(apiCall: ApiCall) async throws -> V { + func executeGet(apiCall: ApiCall) async throws -> Data { return try await self.service().runGetApiCall(apiCall) } @@ -563,7 +570,7 @@ public class StoreCenter { } - func testSynchronizeOnceAsync() async throws { + func testSynchronizeOnceAsync() async throws -> Data { guard self.isAuthenticated else { throw StoreError.missingToken } @@ -572,7 +579,7 @@ public class StoreCenter { let getSyncData = GetSyncData() getSyncData.date = lastSync - await syncGetCollection.executeSingleGet(instance: getSyncData) + return try await syncGetCollection.executeSingleGet(instance: getSyncData) } func sendGetRequest(_ type: T.Type, storeId: String?, clear: Bool) async throws { @@ -599,69 +606,33 @@ public class StoreCenter { Logger.w("data unrecognized: \(string)") return } - try await self._parseSyncUpdates(json, shared: true) + + let array = try self.decodeDictionary(json) + await self._syncAddOrUpdate(array, shared: true) } catch { Logger.error(error) } } /// Processes the data coming from a sync request - @MainActor func synchronizeContent(_ data: Data) { - - do { - guard - let json = try JSONSerialization.jsonObject(with: data, options: []) - as? [String: Any] - else { - Logger.w("data unrecognized") - return - } - - if let updates = json["updates"] as? [String: Any] { - try self._parseSyncUpdates(updates) - } - - if let deletions = json["deletions"] as? [String: Any] { - try self._parseSyncDeletions(deletions) - } - - if let updates = json["grants"] as? [String: Any] { - try self._parseSyncUpdates(updates, shared: true) - } - - if let revocations = json["revocations"] as? [String: Any] { - try self._parseSyncRevocations(revocations, parents: json["revocation_parents"] as? [[String: Any]]) - } - - // Data access events - if let rs = json["relationship_sets"] as? [String: Any] { - try self._parseSyncUpdates(rs) - } - - if let rr = json["relationship_removals"] as? [String: Any] { - try self._parseSyncDeletions(rr) - } - - if let srs = json["shared_relationship_sets"] as? [String: Any] { - try self._parseSyncUpdates(srs, shared: true) - } - - if let srm = json["shared_relationship_removals"] as? [String: Any] { - self._synchronizationRevoke(items: srm) + @MainActor func synchronizeContent(_ syncData: SyncData) { + + self._syncAddOrUpdate(syncData.updates) + self._syncDelete(syncData.deletions) + self._syncAddOrUpdate(syncData.grants, shared: true) + self.syncRevoke(syncData.revocations, parents: syncData.revocationParents) + self._syncAddOrUpdate(syncData.relationshipSets) + self._syncDelete(syncData.relationshipRemovals) + self._syncAddOrUpdate(syncData.sharedRelationshipSets) + self._syncRevoke(syncData.sharedRelationshipRemovals) + + if let dateString = syncData.date { + Logger.log("Sets sync date = \(dateString)") + self._settingsStorage.update { settings in + settings.lastSynchronization = dateString } - - if let dateString = json["date"] as? String { - Logger.log("Sets sync date = \(dateString)") - self._settingsStorage.update { settings in - settings.lastSynchronization = dateString - } - } - - } catch { - self.log(message: error.localizedDescription) - Logger.error(error) } - + NotificationCenter.default.post( name: NSNotification.Name.LeStorageDidSynchronize, object: self) @@ -669,99 +640,50 @@ public class StoreCenter { /// Processes data that should be inserted or updated inside the app /// - Parameters: - /// - updates: the server updates + /// - updateArrays: the server updates /// - shared: indicates if the content should be flagged as shared - @MainActor func _parseSyncUpdates(_ updates: [String: Any], shared: Bool = false) throws { - for (className, updateData) in updates { - - guard let updateArray = updateData as? [[String: Any]] else { - Logger.w("Invalid update data for \(className)") - continue - } - Logger.log(">>> UPDATE \(updateArray.count) \(className)") - - let type = try self.classFromName(className) - - for updateItem in updateArray { - - do { - let jsonData = try JSONSerialization.data( - withJSONObject: updateItem, options: []) - let decodedObject = try JSON.decoder.decode(type, from: jsonData) -// Logger.log(">>> \(decodedObject.lastUpdate.timeIntervalSince1970) : \(decodedObject.id)") - - let storeId: String? = decodedObject.getStoreId() - self.synchronizationAddOrUpdate(decodedObject, storeId: storeId, shared: shared) - } catch { - Logger.w("Issue with json decoding: \(updateItem)") - Logger.error(error) - } + @MainActor func _syncAddOrUpdate(_ updateArrays: [SyncedStorableArray], shared: Bool = false) { + + for updateArray in updateArrays { + for item in updateArray.items { + let storeId: String? = item.getStoreId() + self.synchronizationAddOrUpdate(item, storeId: storeId, shared: shared) } } + } /// Processes data that should be deleted inside the app - fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws { - for (className, deleteData) in deletions { - guard let deletedItems = deleteData as? [Any] else { - Logger.w("Invalid update data for \(className)") - continue - } - - for deleted in deletedItems { - - do { - let data = try JSONSerialization.data(withJSONObject: deleted, options: []) - let deletedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data) - - self.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId) - } catch { - Logger.error(error) - } - + fileprivate func _syncDelete(_ deletionArrays: [ObjectIdentifierArray]) { + + for deletionArray in deletionArrays { + for deletedObject in deletionArray.items { + self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId) } } + } /// Processes data that has been revoked - fileprivate func _parseSyncRevocations(_ deletions: [String: Any], parents: [[String: Any]]?) throws { - for (className, revocationData) in deletions { - guard let revokedItems = revocationData as? [Any] else { - Logger.w("Invalid update data for \(className)") - continue - } - for revoked in revokedItems { - do { - let data = try JSONSerialization.data(withJSONObject: revoked, options: []) - let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data) - self.synchronizationDelete(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId) - } catch { - Logger.error(error) - } + fileprivate func syncRevoke(_ revokedArrays: [ObjectIdentifierArray], parents: [[ObjectIdentifierArray]]) { + + self._syncRevoke(revokedArrays) + for revokedArray in revokedArrays { + for revoked in revokedArray.items { + self.synchronizationDelete(id: revoked.modelId, type: revokedArray.type, storeId: revoked.storeId) // or synchronizationRevoke ? } } - if let parents { - for level in parents { - self._synchronizationRevoke(items: level) - } + for level in parents { + self._syncRevoke(level) } } - fileprivate func _synchronizationRevoke(items: [String: Any]) { - for (className, parentData) in items { - guard let parentItems = parentData as? [Any] else { - Logger.w("Invalid update data for \(className): \(parentData)") - continue - } - for parentItem in parentItems { - do { - let data = try JSONSerialization.data(withJSONObject: parentItem, options: []) - let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data) - self.synchronizationRevoke(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId) - } catch { - Logger.error(error) - } + fileprivate func _syncRevoke(_ revokeArrays: [ObjectIdentifierArray]) { + + for revokeArray in revokeArrays { + for revoked in revokeArray.items { + self.synchronizationRevoke(id: revoked.modelId, type: revokeArray.type, storeId: revoked.storeId) } } @@ -814,11 +736,11 @@ public class StoreCenter { } /// Deletes an instance with the given parameters - func synchronizationDelete(id: String, model: String, storeId: String?) { + func synchronizationDelete(id: String, type: T.Type, storeId: String?) { DispatchQueue.main.async { do { - let type = try self.classFromName(model) +// let type = try self.classFromName(model) try self._store(id: storeId).deleteNoSync(type: type, id: id) } catch { Logger.error(error) @@ -828,11 +750,11 @@ public class StoreCenter { } /// Revokes a data that has been shared with the user - func synchronizationRevoke(id: String, model: String, storeId: String?) { + func synchronizationRevoke(id: String, type: T.Type, storeId: String?) { DispatchQueue.main.async { do { - let type = try self.classFromName(model) +// let type = try self.classFromName(model) if self._instanceShared(id: id, type: type) { let count = self.mainStore.referenceCount(type: type, id: id) if count == 0 { @@ -870,6 +792,49 @@ public class StoreCenter { self._deleteLogs.addOrUpdate(instance: dataLog) } + // MARK: - Sync data conversion + + func decodeObjectIdentifierDictionary(_ dictionary: [String: Any]) throws -> [ObjectIdentifierArray] { + + var objectIdentifierArray: [ObjectIdentifierArray] = [] + + for (className, dataArray) in dictionary { + + guard let array = dataArray as? [[String: Any]] else { + Logger.w("Invalid update data for \(className)") + continue + } + let type = try self.classFromName(className) + let decodedArray = try self._decodeArray(type: ObjectIdentifier.self, array: array) + objectIdentifierArray.append(ObjectIdentifierArray(type: type, items: decodedArray)) + } + return objectIdentifierArray + } + + func decodeDictionary(_ dictionary: [String: Any]) throws -> [SyncedStorableArray] { + + var syncedStorableArray: [SyncedStorableArray] = [] + + for (className, dataArray) in dictionary { + + guard let array = dataArray as? [[String: Any]] else { + Logger.w("Invalid update data for \(className)") + continue + } + Logger.log(">>> UPDATE \(array.count) \(className)") + + let type = try self.classFromName(className) + let decodedArray = try self._decodeArray(type: type, array: array) + syncedStorableArray.append(SyncedStorableArray(type: type, items: decodedArray)) + } + return syncedStorableArray + } + + fileprivate func _decodeArray(type: T.Type, array: [[String : Any]]) throws -> [T] { + let jsonData = try JSONSerialization.data(withJSONObject: array, options: []) + return try JSON.decoder.decode([T].self, from: jsonData) + } + // MARK: - Miscellanous /// Returns the count of api calls for a Type