From 7a21f27550ac75b7f96e3907712ec1b1571bad99 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 30 Oct 2024 14:37:17 +0100 Subject: [PATCH] Add / update / delete sync obtained! --- LeStorage.xcodeproj/project.pbxproj | 4 + LeStorage/ApiCallCollection.swift | 65 +++++++++++---- LeStorage/Codables/ApiCall.swift | 42 +++++++++- LeStorage/Codables/GetSyncData.swift | 16 +++- LeStorage/Codables/SyncResponse.swift | 24 +++--- LeStorage/ModelObject.swift | 4 - LeStorage/Services.swift | 98 +++++++++++----------- LeStorage/Storable.swift | 3 - LeStorage/Store.swift | 8 +- LeStorage/StoreCenter.swift | 100 +++++++++++++++-------- LeStorage/StoredCollection+Sync.swift | 37 +++++++-- LeStorage/StoredCollection.swift | 70 +++++----------- LeStorage/SyncedStorable.swift | 4 + LeStorage/Utils/Codable+Extensions.swift | 42 +++++----- LeStorage/Utils/Errors.swift | 3 +- LeStorage/Utils/Formatter.swift | 12 +++ 16 files changed, 335 insertions(+), 197 deletions(-) create mode 100644 LeStorage/Utils/Formatter.swift diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index f30e894..77d7966 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; + C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.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 */; }; @@ -60,6 +61,7 @@ C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = ""; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = ""; }; + C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.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 = ""; }; @@ -156,6 +158,7 @@ C4A47D582B6D352900ADC637 /* Utils */ = { isa = PBXGroup; children = ( + C467AAE22CD2466400D76CD2 /* Formatter.swift */, C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, C4D477962CB66EEA0077713D /* Date+Extensions.swift */, @@ -317,6 +320,7 @@ C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, + C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */, diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index 43728e7..f61630a 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -51,7 +51,7 @@ actor ApiCallCollection: SomeCallCollection { /// Reschedule Api calls if not empty func loadFromFile() throws { try self._decodeJSONFile() - self.rescheduleApiCallsIfNecessary() +// self.rescheduleApiCallsIfNecessary() } /// Returns the file URL of the collection @@ -223,8 +223,21 @@ actor ApiCallCollection: SomeCallCollection { /// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// The method updates existing calls or creates a new one - fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall? { + fileprivate func _call(method: HTTPMethod, instance: T? = nil) throws -> ApiCall? { + if let instance { + return try self._callForInstance(method, instance: instance) + } else { + if self.items.contains(where: { $0.method == .get }) { + return nil + } else { + return try self._createCall(.get) + } + } + } + + fileprivate func _callForInstance(_ method: HTTPMethod, instance: T) throws -> ApiCall? { + if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) { switch method { case .delete: @@ -232,21 +245,25 @@ actor ApiCallCollection: SomeCallCollection { if existingCall.method == HTTPMethod.post { return nil // if the post has not been done, we can just stop here } else { - return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete + return try self._createCall(method, instance: instance) // otherwise it's a put and we want to send the delete } - default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values + default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values existingCall.body = try instance.jsonString() return existingCall } } else { - return try self._createCall(instance, method: method) + return try self._createCall(method, instance: instance) } } /// Creates an API call for the Storable [instance] and an HTTP [method] - fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall { - let jsonString = try instance.jsonString() - return ApiCall(method: method, dataId: instance.stringId, body: jsonString) + fileprivate func _createCall(_ method: HTTPMethod, instance: T? = nil) throws -> ApiCall { + if let instance { + let jsonString = try instance.jsonString() + return ApiCall(method: method, dataId: instance.stringId, body: jsonString) + } else { + return ApiCall(method: .get) + } } /// Prepares a call for execution by updating its properties and adding it to its collection for storage @@ -256,10 +273,22 @@ actor ApiCallCollection: SomeCallCollection { self.addOrUpdate(apiCall) } + /// Sends an insert api call for the provided [instance] + func sendGetIfNecessary(instance: T) async throws where T : URLParameterConvertible { + do { + let apiCall = ApiCall(method: .get) + apiCall.urlParameters = instance.queryParameters() + let _: Empty? = try await self._prepareAndSendCall(apiCall) + } catch { + self.rescheduleApiCallsIfNecessary() + Logger.error(error) + } + } + /// Sends an insert api call for the provided [instance] func sendInsertion(_ instance: T) async throws -> T? { do { - return try await self._synchronize(instance, method: HTTPMethod.post) + return try await self._sendServerRequest(HTTPMethod.post, instance: instance) } catch { self.rescheduleApiCallsIfNecessary() Logger.error(error) @@ -271,7 +300,7 @@ actor ApiCallCollection: SomeCallCollection { /// Sends an update api call for the provided [instance] func sendUpdate(_ instance: T) async throws -> T? { do { - return try await self._synchronize(instance, method: HTTPMethod.put) + return try await self._sendServerRequest(HTTPMethod.put, instance: instance) } catch { self.rescheduleApiCallsIfNecessary() Logger.error(error) @@ -282,7 +311,7 @@ actor ApiCallCollection: SomeCallCollection { /// Sends an delete api call for the provided [instance] func sendDeletion(_ instance: T) async throws { do { - let _: Empty? = try await self._synchronize(instance, method: HTTPMethod.delete) + let _: Empty? = try await self._sendServerRequest(HTTPMethod.delete, instance: instance) } catch { self.rescheduleApiCallsIfNecessary() Logger.error(error) @@ -291,15 +320,21 @@ actor ApiCallCollection: SomeCallCollection { } /// Initiates the process of sending the data with the server - fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> V? { - if let apiCall = try self._callForInstance(instance, method: method) { - try self._prepareCall(apiCall: apiCall) - return try await self._executeApiCall(apiCall) + fileprivate func _sendServerRequest(_ method: HTTPMethod, instance: T? = nil) async throws -> V? { + if let apiCall = try self._call(method: method, instance: instance) { + return try await self._prepareAndSendCall(apiCall) +// try self._prepareCall(apiCall: apiCall) +// return try await self._executeApiCall(apiCall) } else { return nil } } + fileprivate func _prepareAndSendCall(_ apiCall: ApiCall) async throws -> V? { + try self._prepareCall(apiCall: apiCall) + return try await self._executeApiCall(apiCall) + } + /// Executes an API call /// For POST requests, potentially copies additional data coming from the server during the insert // fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws -> T { diff --git a/LeStorage/Codables/ApiCall.swift b/LeStorage/Codables/ApiCall.swift index f48aee1..6f9dd13 100644 --- a/LeStorage/Codables/ApiCall.swift +++ b/LeStorage/Codables/ApiCall.swift @@ -24,7 +24,7 @@ class ApiCall: ModelObject, Storable, SomeCall { /// Creation date of the call var creationDate: Date? = Date() - /// The HTTP method of the call: post... + /// The HTTP method of the call var method: HTTPMethod /// The content of the call @@ -39,7 +39,10 @@ class ApiCall: ModelObject, Storable, SomeCall { /// The date of the last execution var lastAttemptDate: Date = Date() - init(method: HTTPMethod, dataId: String, body: String) { + /// The parameters to add in the URL to obtain : "?p1=v1&p2=v2" + var urlParameters: [String : String]? = nil + + init(method: HTTPMethod, dataId: String? = nil, body: String? = nil) { self.method = method self.dataId = dataId self.body = body @@ -49,4 +52,39 @@ class ApiCall: ModelObject, Storable, SomeCall { fatalError("should not happen") } + func formattedURLParameters() -> String? { + return self.urlParameters?.toQueryString() + } + + func urlExtension() -> String { + switch self.method { + case HTTPMethod.put, HTTPMethod.delete: + return T.path(id: self.dataId) + case HTTPMethod.post: + return T.path() + case HTTPMethod.get: + if let parameters = self.urlParameters?.toQueryString() { + return T.path() + parameters + } else { + return T.path() + } + } + } + +} + +fileprivate extension Dictionary where Key == String, Value == String { + func toQueryString() -> String? { + guard !self.isEmpty else { + return nil + } + + let pairs = self.map { key, value in + let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key + let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + return "\(escapedKey)=\(escapedValue)" + } + + return "?" + pairs.joined(separator: "&") + } } diff --git a/LeStorage/Codables/GetSyncData.swift b/LeStorage/Codables/GetSyncData.swift index d12a403..22c3c76 100644 --- a/LeStorage/Codables/GetSyncData.swift +++ b/LeStorage/Codables/GetSyncData.swift @@ -7,11 +7,12 @@ import Foundation -class GetSyncData: ModelObject, SyncedStorable { +class GetSyncData: ModelObject, SyncedStorable, URLParameterConvertible { static func filterByStoreIdentifier() -> Bool { return false } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } - var lastUpdate: Date = Date() + + var lastUpdate: Date = Date.distantPast static func resourceName() -> String { return "data" @@ -22,4 +23,15 @@ class GetSyncData: ModelObject, SyncedStorable { self.lastUpdate = getSyncData.lastUpdate } + func queryParameters() -> [String : String] { + return ["last_update" : self._formattedLastUpdate] + } + + fileprivate var _formattedLastUpdate: String { + let formattedDate = ISO8601DateFormatter().string(from: self.lastUpdate) + let encodedDate = + formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return encodedDate.replacingOccurrences(of: "+", with: "%2B") + } + } diff --git a/LeStorage/Codables/SyncResponse.swift b/LeStorage/Codables/SyncResponse.swift index 55f2d06..1403130 100644 --- a/LeStorage/Codables/SyncResponse.swift +++ b/LeStorage/Codables/SyncResponse.swift @@ -26,7 +26,7 @@ struct SyncResponse: Codable { var updatesDict = [String: [AnyCodable]]() for key in updatesContainer.allKeys { - let swiftClass = try SyncResponse._classFromClassName(key.stringValue) +// let swiftClass = try SyncResponse._classFromClassName(key.stringValue) let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key) let typedArray = decodedArray.compactMap { $0.value as? AnyCodable } updatesDict[key.stringValue] = typedArray @@ -59,17 +59,17 @@ struct SyncResponse: Codable { } } - fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type { - - let fullClassName = "PadelClub.\(className)" - let modelClass: AnyClass? = NSClassFromString(fullClassName) - if let type = modelClass as? Codable.Type { - return type - } else { - throw LeStorageError.cantFindClassFromName(name: className) - } - - } +// fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type { +// +// let fullClassName = "PadelClub.\(className)" +// let modelClass: AnyClass? = NSClassFromString(fullClassName) +// if let type = modelClass as? Codable.Type { +// return type +// } else { +// throw LeStorageError.cantFindClassFromName(name: className) +// } +// +// } } diff --git a/LeStorage/ModelObject.swift b/LeStorage/ModelObject.swift index 6b6bf9e..70555a1 100644 --- a/LeStorage/ModelObject.swift +++ b/LeStorage/ModelObject.swift @@ -27,10 +27,6 @@ open class ModelObject: NSObject { static var relationshipNames: [String] = [] - open func hasBeenDeleted() { - - } - // // MARK: - Codable // // enum CodingKeys: CodingKey { diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 879c5ff..3c28118 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -49,20 +49,20 @@ public class Services { /// The base API URL to send requests fileprivate(set) var baseURL: String - fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .iso8601 - return encoder - }() - - fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() +// fileprivate var jsonEncoder: JSONEncoder = { +// let encoder = JSONEncoder() +// encoder.keyEncodingStrategy = .convertToSnakeCase +// encoder.outputFormatting = .prettyPrinted +// encoder.dateEncodingStrategy = .iso8601 +// return encoder +// }() +// +// fileprivate var jsonDecoder: JSONDecoder = { +// let decoder = JSONDecoder() +// decoder.keyDecodingStrategy = .convertFromSnakeCase +// decoder.dateDecodingStrategy = .iso8601 +// return decoder +// }() // MARK: - Base @@ -75,7 +75,7 @@ public class Services { async throws -> U { var request = try self._baseRequest(call: serviceCall) - request.httpBody = try jsonEncoder.encode(payload) + request.httpBody = try JSON.encoder.encode(payload) return try await _runRequest(request) } @@ -99,7 +99,7 @@ public class Services { try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) if T.self == GetSyncData.self { - StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder) + StoreCenter.main.synchronizeContent(task.0) } default: // error @@ -131,9 +131,9 @@ public class Services { fileprivate func _decode(data: Data) throws -> V { if !(V.self is Empty?.Type || V.self is Empty.Type) { - return try jsonDecoder.decode(V.self, from: data) + return try JSON.decoder.decode(V.self, from: data) } else { - return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) + return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!) } } @@ -277,7 +277,7 @@ public class Services { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try jsonEncoder.encode(payload) + request.httpBody = try JSON.encoder.encode(payload) let token = try self.keychainStore.getValue() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") @@ -288,33 +288,41 @@ public class Services { /// Returns the URLRequest for an ApiCall /// - Parameters: /// - apiCall: An ApiCall instance to configure the returned request - fileprivate func _syncRequest(from apiCall: ApiCall) throws -> URLRequest - { + fileprivate func _syncRequest(from apiCall: ApiCall) throws -> URLRequest { - let urlString = baseURL + "data/" + var urlString = baseURL + "data/" + if let urlParameters = apiCall.formattedURLParameters() { + urlString.append(urlParameters) + } guard let url = URL(string: urlString) else { throw ServiceError.urlCreationError(url: urlString) } - guard let body = apiCall.body, let bodyData = body.data(using: .utf8) else { - throw ServiceError.cantDecodeData(content: apiCall.body) - } - + var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.post.rawValue + if apiCall.method == .get { + request.httpMethod = HTTPMethod.get.rawValue + } else { + request.httpMethod = HTTPMethod.post.rawValue + } + request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // moyennement fan de decoder pour recoder derriere - let data = try jsonDecoder.decode(T.self, from: bodyData) - let modelName = String(describing: T.self) - - let payload = SyncPayload( - operation: apiCall.method.rawValue, - modelName: modelName, - data: data, - storeId: data.getStoreId()) - - request.httpBody = try jsonEncoder.encode(payload) + if let body = apiCall.body { + if let data = body.data(using: .utf8) { + let object = try JSON.decoder.decode(T.self, from: data) + let modelName = String(describing: T.self) + let payload = SyncPayload( + operation: apiCall.method.rawValue, + modelName: modelName, + data: object, + storeId: object.getStoreId()) + request.httpBody = try JSON.encoder.encode(payload) + + } else { + throw ServiceError.cantDecodeData(resource: T.resourceName(), method: apiCall.method.rawValue, content: apiCall.body) + } + } if self._isTokenRequired(type: T.self, method: apiCall.method) { let token = try self.keychainStore.getValue() @@ -371,7 +379,7 @@ public class Services { print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success - StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder) + StoreCenter.main.synchronizeContent(task.0) default: // error Logger.log( "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") @@ -463,12 +471,8 @@ public class Services { /// - apiCall: an instance of ApiCall to build to URL fileprivate func _url(from apiCall: ApiCall) throws -> URL { var stringURL: String = self.baseURL - switch apiCall.method { - case HTTPMethod.put, HTTPMethod.delete: - stringURL += T.path(id: apiCall.dataId) - default: - stringURL += T.path() - } + stringURL += apiCall.urlExtension() + if let url = URL(string: stringURL) { return url } else { @@ -493,7 +497,7 @@ public class Services { var postRequest = try self._baseRequest(call: requestTokenCall) let deviceId = StoreCenter.main.deviceId() let credentials = Credentials(username: username, password: password, deviceId: deviceId) - postRequest.httpBody = try jsonEncoder.encode(credentials) + postRequest.httpBody = try JSON.encoder.encode(credentials) let response: AuthResponse = try await self._runRequest(postRequest) self._storeToken(username: username, token: response.token) return response.token @@ -580,7 +584,7 @@ public class Services { public func forgotPassword(email: String) async throws { var postRequest = try self._baseRequest( servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) - postRequest.httpBody = try jsonEncoder.encode(Email(email: email)) + postRequest.httpBody = try JSON.encoder.encode(Email(email: email)) let response: Email = try await self._runRequest(postRequest) Logger.log("response = \(response)") } diff --git a/LeStorage/Storable.swift b/LeStorage/Storable.swift index 73da501..d13cb37 100644 --- a/LeStorage/Storable.swift +++ b/LeStorage/Storable.swift @@ -30,9 +30,6 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol { // static var relationshipNames: [String] { get } - /// A method called after the instance has been deleted from its StoredCollection - func hasBeenDeleted() - func copy(from other: any Storable) } diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index c9025c3..df9a8cf 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -199,12 +199,18 @@ open class Store { func deleteNoSync(instance: T) { do { let collection: StoredCollection = try self.collection() - try collection.deleteById(instance.id) + collection.delete(instance: instance) } catch { Logger.error(error) } } + /// Calls deleteById from the collection corresponding to the instance + func deleteNoSync(type: T.Type, id: String) throws { + let collection: StoredCollection = try self.collection() + collection.deleteByStringIdNoSync(id) + } + // MARK: - Write /// Returns the directory URL of the store diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index a0d974d..552ed0d 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -41,6 +41,9 @@ public class StoreCenter { /// The dictionary of registered StoredCollections fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] + /// A collection of DataLog objects, used for the synchronization +// fileprivate var _syncGetRequests: ApiCallCollection + /// A collection of DataLog objects, used for the synchronization fileprivate var _dataLogs: StoredCollection @@ -54,8 +57,12 @@ public class StoreCenter { fileprivate var _blackListedUserName: [String] = [] init() { + +// self._syncGetRequests = ApiCallCollection() self._dataLogs = Store.main.registerCollection() self._setupNotifications() + + self.loadApiCallCollection(type: GetSyncData.self) NetworkMonitor.shared.onConnectionEstablished = { self._resumeApiCalls() @@ -212,7 +219,7 @@ public class StoreCenter { if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection { return collection } - throw StoreError.collectionNotRegistered(type: T.resourceName()) + throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) } /// Deletes an ApiCall, identifying it by dataId @@ -361,11 +368,22 @@ public class StoreCenter { } public func synchronizeLastUpdates() async throws { - let lastSync: Date? = self._settingsStorage.item.lastSynchronization - try await self._services?.synchronizeLastUpdates(since: lastSync) + + if let lastSync = self._settingsStorage.item.lastSynchronization { + let getSyncData = GetSyncData() + getSyncData.lastUpdate = lastSync + + let sync: ApiCallCollection = try self.apiCallCollection() + try await sync.sendGetIfNecessary(instance: getSyncData) + } else { + Logger.w("Can't sync due to missing saved date") + } + +// let lastSync: Date? = self._settingsStorage.item.lastSynchronization +// try await self._services?.synchronizeLastUpdates(since: lastSync) } - func synchronizeContent(_ data: Data, decoder: JSONDecoder) { + func synchronizeContent(_ data: Data) { do { guard @@ -378,7 +396,7 @@ public class StoreCenter { if let updates = json["updates"] as? [String: Any] { do { - try self._parseSyncUpdates(updates, decoder: decoder) + try self._parseSyncUpdates(updates) } catch { StoreCenter.main.log(message: error.localizedDescription) Logger.error(error) @@ -387,7 +405,7 @@ public class StoreCenter { if let deletions = json["deletions"] as? [String: Any] { do { - try self._parseSyncDeletions(deletions, decoder: decoder) + try self._parseSyncDeletions(deletions) } catch { StoreCenter.main.log(message: error.localizedDescription) Logger.error(error) @@ -406,21 +424,21 @@ public class StoreCenter { } } - fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws { + fileprivate func _parseSyncUpdates(_ updates: [String: Any]) throws { for (className, updateData) in updates { guard let updateArray = updateData as? [[String: Any]] else { Logger.w("Invalid update data for \(className)") continue } - let type = try self._classFromClassName(className) + let type = try StoreCenter.classFromName(className) for updateItem in updateArray { do { let jsonData = try JSONSerialization.data( withJSONObject: updateItem, options: []) - let decodedObject = try decoder.decode(type, from: jsonData) + let decodedObject = try JSON.decoder.decode(type, from: jsonData) let storeId: String? = decodedObject.getStoreId() StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId) @@ -431,38 +449,35 @@ public class StoreCenter { } } - fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws { + fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws { for (className, updateDeletions) in deletions { - guard let deletionArray = updateDeletions as? [[String: Any]] else { + guard let deletedItem = updateDeletions as? [Any] else { Logger.w("Invalid update data for \(className)") continue } - let type = try self._classFromClassName(className) - - for updateItem in deletionArray { - - if let object = updateItem["data"] { - do { - let jsonData = try JSONSerialization.data( - withJSONObject: object, options: []) - let decodedObject = try decoder.decode(type, from: jsonData) - - let storeId = updateItem["storeId"] as? String - StoreCenter.main.synchronizationDelete( - instance: decodedObject, storeId: storeId) - } catch { - Logger.error(error) - } + for deleted in deletedItem { + + do { + let data = try JSONSerialization.data(withJSONObject: deleted, options: []) + let deletedObject = try JSON.decoder.decode(DeletedObject.self, from: data) + + StoreCenter.main.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId) + } catch { + Logger.error(error) } + } } } - fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type { + static func classFromName(_ className: String) throws -> any SyncedStorable.Type { - let fullClassName = "PadelClub.\(className)" - let modelClass: AnyClass? = NSClassFromString(fullClassName) + guard let projectName = Bundle.main.infoDictionary?["CFBundleName"] as? String else { + throw LeStorageError.cantAccessCFBundleName + } + + let modelClass: AnyClass? = NSClassFromString("\(projectName).\(className)") if let type = modelClass as? any SyncedStorable.Type { return type } else { @@ -494,12 +509,26 @@ public class StoreCenter { } } - func synchronizationDelete(instance: T, storeId: String?) { + func synchronizationDelete(id: String, model: String, storeId: String?) { + + DispatchQueue.main.async { - self._store(id: storeId)?.deleteNoSync(instance: instance) - self._cleanupDataLog(dataId: instance.stringId) + do { + let type = try StoreCenter.classFromName(model) + try self._store(id: storeId)?.deleteNoSync(type: type, id: id) + } catch { + Logger.error(error) + } + self._cleanupDataLog(dataId: id) } } + +// func synchronizationDelete(instance: T, storeId: String?) { +// DispatchQueue.main.async { +// self._store(id: storeId)?.deleteNoSync(instance: instance) +// self._cleanupDataLog(dataId: instance.stringId) +// } +// } fileprivate func _cleanupDataLog(dataId: String) { let logs = self._dataLogs.filter { $0.dataId == dataId } @@ -712,3 +741,8 @@ public class StoreCenter { } } + +class DeletedObject: Codable { + var modelId: String + var storeId: String? +} diff --git a/LeStorage/StoredCollection+Sync.swift b/LeStorage/StoredCollection+Sync.swift index 01b56b1..7081c13 100644 --- a/LeStorage/StoredCollection+Sync.swift +++ b/LeStorage/StoredCollection+Sync.swift @@ -10,20 +10,16 @@ import Foundation extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { /// Migrates if necessary and asynchronously decodes the json file - func load() { - + func load() async { do { if self.inMemory { - Task { - try await self.loadDataFromServerIfAllowed() - } + try await self.loadDataFromServerIfAllowed() } else { try self.loadFromFile() } } catch { Logger.error(error) } - } /// Loads the collection using the server data only if the collection file doesn't exists @@ -86,6 +82,33 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { self.deleteItem(instance) } + /// Deletes the instance in the collection without synchronization + func deleteByStringIdNoSync(_ id: String) { + defer { + self.setChanged() + } + if let realId = self._buildRealId(id: id) { + if let instance = self.findById(realId) { + self.deleteItem(instance) + } + } else { + Logger.w("CRITICAL: collection \(T.resourceName()) could not build id from \(id)") + StoreCenter.main.log(message: "Could not build an id from \(id)") + } + } + + fileprivate func _buildRealId(id: String) -> T.ID? { + switch T.ID.self { + case is String.Type: + return id as? T.ID + case is Int64.Type: + return Formatter.number.number(from: id)?.int64Value as? T.ID + default: + print("ID is neither String nor Int") + return nil + } + } + public func addOrUpdate(instance: T) { defer { self.setChanged() @@ -119,7 +142,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { } - public func delete(instance: T) throws { + public func delete(instance: T) { defer { self.setChanged() } diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index b463992..45e80fa 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -69,15 +69,11 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } } - /// Denotes a collection that loads and writes asynchronously - fileprivate var asynchronousIO: Bool = true - /// Indicates if the collection has loaded locally, with or without a file fileprivate(set) public var hasLoaded: Bool = false - init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) { + init(store: Store, indexed: Bool = false, inMemory: Bool = false) { // self.synchronized = synchronized - self.asynchronousIO = asynchronousIO if indexed { self._indexes = [:] } @@ -121,17 +117,9 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Starts the JSON file decoding synchronously or asynchronously func loadFromFile() throws { - - if self.asynchronousIO { - Task(priority: .high) { - try self._decodeJSONFile() - } - } else { - try self._decodeJSONFile() - } - + try self._decodeJSONFile() } - + /// Decodes the json file into the items array fileprivate func _decodeJSONFile() throws { @@ -143,18 +131,9 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti for item in decoded { item.store = self.store } - if self.asynchronousIO { - DispatchQueue.main.async { - self._setItems(decoded) - self.setAsLoaded() - } - } else { - self._setItems(decoded) - self.setAsLoaded() - } - } else { - self.setAsLoaded() + self._setItems(decoded) } + self.setAsLoaded() } /// Sets the collection as loaded @@ -211,8 +190,14 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self.addItem(instance: instance) } + func deleteById(_ id: T.ID) { + if let instance = self.findById(id) { + self.delete(instance: instance) + } + } + /// Deletes the instance in the collection and sets the collection as changed to trigger a write - public func delete(instance: T) throws { + public func delete(instance: T) { defer { self._hasChanged = true } @@ -231,17 +216,6 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } } - /// Deletes an instance in the collection. Also: - /// - Removes its reference from the index - /// - Notifies the server of the deletion - /// - Calls `hasBeenDeleted` on the deleted instance - // fileprivate func _delete(_ instance: T) throws { - // instance.deleteDependencies() - // self.items.removeAll { $0.id == instance.id } - // self._indexes?.removeValue(forKey: instance.id) - // instance.hasBeenDeleted() - // } - /// Adds or update a sequence of elements public func addOrUpdate(contentOfs sequence: any Sequence) { self.addSequence(sequence) @@ -293,7 +267,6 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti instance.deleteDependencies() self.items.removeAll { $0.id == instance.id } self._indexes?.removeValue(forKey: instance.id) - instance.hasBeenDeleted() } /// Returns the instance corresponding to the provided [id] @@ -305,12 +278,12 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Deletes the instance corresponding to the provided [id] - public func deleteById(_ id: T.ID) throws { - if let instance = self.findById(id) { - self.deleteItem(instance) - // try self.delete(instance: instance) - } - } +// public func deleteById(_ id: T.ID) throws { +// if let instance = self.findById(id) { +// self.deleteItem(instance) +// self._hasChanged = true +// } +// } /// Proceeds to "hard" delete the items without synchronizing them /// Also removes related API calls @@ -323,7 +296,6 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti if let index = self.items.firstIndex(where: { $0.id == item.id }) { self.items.remove(at: index) } - item.hasBeenDeleted() // Task { // do { @@ -355,11 +327,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti guard !self.inMemory else { return } - if self.asynchronousIO { - DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time - self._write() - } - } else { + DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time self._write() } } diff --git a/LeStorage/SyncedStorable.swift b/LeStorage/SyncedStorable.swift index 6264854..e3e7140 100644 --- a/LeStorage/SyncedStorable.swift +++ b/LeStorage/SyncedStorable.swift @@ -22,6 +22,10 @@ public protocol SyncedStorable: Storable { } +protocol URLParameterConvertible { + func queryParameters() -> [String : String] +} + public protocol SideStorable { var storeId: String? { get set } } diff --git a/LeStorage/Utils/Codable+Extensions.swift b/LeStorage/Utils/Codable+Extensions.swift index 5bd1c8d..2aaa6cc 100644 --- a/LeStorage/Utils/Codable+Extensions.swift +++ b/LeStorage/Utils/Codable+Extensions.swift @@ -7,22 +7,26 @@ import Foundation -fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - #if DEBUG - encoder.outputFormatting = .prettyPrinted - #endif - encoder.dateEncodingStrategy = .iso8601 - return encoder -}() +class JSON { + + static var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + #if DEBUG + encoder.outputFormatting = .prettyPrinted + #endif + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() -fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder -}() + static var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + +} extension Encodable { @@ -32,11 +36,11 @@ extension Encodable { } public func jsonData() throws -> Data { - return try jsonEncoder.encode(self) + return try JSON.encoder.encode(self) } public func prettyJSONString() throws -> String { - let data = try jsonEncoder.encode(self) + let data = try JSON.encoder.encode(self) return String(data: data, encoding: .utf8) ?? "" } @@ -57,11 +61,11 @@ extension String { extension Data { public func decode() throws -> T { - return try jsonDecoder.decode(T.self, from: self) + return try JSON.decoder.decode(T.self, from: self) } public func decodeArray() throws -> [T] { - return try jsonDecoder.decode([T].self, from: self) + return try JSON.decoder.decode([T].self, from: self) } } diff --git a/LeStorage/Utils/Errors.swift b/LeStorage/Utils/Errors.swift index a195c94..81eab21 100644 --- a/LeStorage/Utils/Errors.swift +++ b/LeStorage/Utils/Errors.swift @@ -26,7 +26,7 @@ public enum ServiceError: Error { case missingUserName case missingUserId case responseError(response: String) - case cantDecodeData(content: String?) + case cantDecodeData(resource: String, method: String, content: String?) } public enum UUIDError: Error { @@ -35,4 +35,5 @@ public enum UUIDError: Error { public enum LeStorageError: Error { case cantFindClassFromName(name: String) + case cantAccessCFBundleName } diff --git a/LeStorage/Utils/Formatter.swift b/LeStorage/Utils/Formatter.swift new file mode 100644 index 0000000..938ccc1 --- /dev/null +++ b/LeStorage/Utils/Formatter.swift @@ -0,0 +1,12 @@ +// +// Formatter.swift +// LeStorage +// +// Created by Laurent Morvillier on 30/10/2024. +// + +class Formatter { + + static let number: NumberFormatter = NumberFormatter() + +}