diff --git a/LeStorage/ApiCall.swift b/LeStorage/ApiCall.swift index 1853a2b..c98b182 100644 --- a/LeStorage/ApiCall.swift +++ b/LeStorage/ApiCall.swift @@ -8,7 +8,7 @@ import Foundation protocol SomeCall : Storable { - func execute() throws +// func execute() throws var lastAttemptDate: Date { get } } @@ -44,10 +44,10 @@ class ApiCall : ModelObject, Storable, SomeCall { } /// Executes the api call - func execute() throws { - Task { - try await Store.main.execute(apiCall: self) - } - } +// func execute() throws { +// Task { +// try await Store.main.execute(apiCall: self) +// } +// } } diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index c6e95fa..88cc9dc 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -21,10 +21,10 @@ enum ServiceError: Error { class Services { init(url: String) { - self._baseURL = url + self.baseURL = url } - fileprivate var _baseURL: String + fileprivate(set) var baseURL: String fileprivate var jsonDecoder: JSONDecoder = { let decoder = JSONDecoder() @@ -50,7 +50,7 @@ class Services { } default: if let apiCallId, let type = (T.self as? any Storable.Type) { - try Store.main.startCallsRescheduling(apiCallId: apiCallId, type: type) + try Store.main.rescheduleApiCall(id: apiCallId, type: type) } } } @@ -63,20 +63,8 @@ class Services { return try self._baseRequest(servicePath: servicePath, method: .get) } -// fileprivate func postRequest(servicePath: String) throws -> URLRequest { -// return try self._baseRequest(servicePath: servicePath, method: .post) -// } -// -// fileprivate func putRequest(servicePath: String) throws -> URLRequest { -// return try self._baseRequest(servicePath: servicePath, method: .put) -// } -// -// fileprivate func deleteRequest(servicePath: String) throws -> URLRequest { -// return try self._baseRequest(servicePath: servicePath, method: .delete) -// } - fileprivate func _baseRequest(servicePath: String, method: Method) throws -> URLRequest { - let urlString = _baseURL + servicePath + let urlString = baseURL + servicePath guard let url = URL(string: urlString) else { throw ServiceError.urlCreationError(url: urlString) } @@ -93,33 +81,7 @@ class Services { return try await self.runRequest(getRequest) } - func insert(_ instance: T) async throws -> T { - let apiCall = try self._createCall(method: Method.post, instance: instance) - return try await self.runApiCall(apiCall) - } - - func update(_ instance: T) async throws -> T { - let apiCall = try self._createCall(method: Method.put, instance: instance) - return try await self.runApiCall(apiCall) - } - - func delete(_ instance: T) async throws -> T { - let apiCall = try self._createCall(method: Method.delete, instance: instance) - return try await self.runApiCall(apiCall) - } - - fileprivate func _createCall(method: Method, instance: T) throws -> ApiCall { - let jsonString = try instance.jsonString() - let url = self._baseURL + T.resourceName() + "/" - return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: jsonString) - } - func runApiCall(_ apiCall: ApiCall) async throws -> T { - - apiCall.lastAttemptDate = Date() - apiCall.attemptsCount += 1 - try Store.main.registerApiCall(apiCall) - let request = try self._request(from: apiCall) return try await self.runRequest(request, apiCallId: apiCall.id) } diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 1832cf8..b467d04 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -40,8 +40,8 @@ public class Store { /// The dictionary of registered StoredCollections fileprivate var _collections: [String : any SomeCollection] = [:] - /// The dictionary of ApiCall StoredCollections corresponding to the synchronized registered collections - fileprivate var _apiCallsCollections: [String : any SomeCollection] = [:] +// /// The dictionary of ApiCall StoredCollections corresponding to the synchronized registered collections +// fileprivate var _apiCallsCollections: [String : any SomeCollection] = [:] /// The list of migrations to apply fileprivate var _migrations: [SomeMigration] = [] @@ -60,12 +60,12 @@ public class Store { let collection = StoredCollection(synchronized: synchronized, store: Store.main, loadCompletion: nil) self._collections[T.resourceName()] = collection - if synchronized { // register additional collection for api calls - let apiCallCollection = StoredCollection>(synchronized: false, store: Store.main, loadCompletion: { apiCallCollection in - self._rescheduleCalls(collection: apiCallCollection) - }) - self._apiCallsCollections[T.resourceName()] = apiCallCollection - } +// if synchronized { // register additional collection for api calls +// let apiCallCollection = StoredCollection>(synchronized: false, store: Store.main, loadCompletion: { apiCallCollection in +// self._rescheduleCalls(collection: apiCallCollection) +// }) +// self._apiCallsCollections[T.resourceName()] = apiCallCollection +// } return collection } @@ -148,78 +148,19 @@ public class Store { // MARK: - Api call rescheduling - /// Schedules all stored api calls from all collections - fileprivate func _rescheduleCalls(collection: StoredCollection>) { - for apiCall in collection { - self.startCallsRescheduling(apiCall: apiCall) - } - } - - /// Returns an API call collection corresponding to a type T - func apiCallCollection() throws -> StoredCollection> { - if let apiCallCollection = self._apiCallsCollections[T.resourceName()] as? StoredCollection> { - return apiCallCollection - } - throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) - } - - /// Registers an api call into its collection - func registerApiCall(_ apiCall: ApiCall) throws { - - let collection: StoredCollection> = try self.apiCallCollection() - - if let existingDataCall = collection.first(where: { $0.dataId == apiCall.dataId }) { - switch apiCall.method { - case Method.put.rawValue: - existingDataCall.body = apiCall.body - collection.addOrUpdate(instance: existingDataCall) - case Method.delete.rawValue: - try self.deleteApiCallById(existingDataCall.id, collectionName: T.resourceName()) - default: - collection.addOrUpdate(instance: apiCall) // rewrite new attempt values - } - } else { - collection.addOrUpdate(instance: apiCall) - } - - } - /// Deletes an ApiCall by [id] and [collectionName] func deleteApiCallById(_ id: String, collectionName: String) throws { - - if let collection = self._apiCallsCollections[collectionName] { - try collection.deleteById(id) - return - } - throw StoreError.collectionNotRegistered(type: collectionName) - } - - /// Schedule an ApiCall for its execution in the future - func startCallsRescheduling(apiCall: ApiCall) { - let delay = pow(2, 0 + apiCall.attemptsCount) - let seconds = NSDecimalNumber(decimal: delay).intValue - Logger.log("Rerun request in \(seconds) seconds...") - - DispatchQueue(label: "queue.scheduling", qos: .utility) - .asyncAfter(deadline: .now() + .seconds(seconds)) { - Logger.log("Try to execute api call...") - Task { - do { - _ = try await self._executeApiCall(apiCall) - } catch { - Logger.error(error) - } - } + if let collection = self._collections[collectionName] { + try collection.deleteApiCallById(id) + } else { + throw StoreError.collectionNotRegistered(type: collectionName) } - } /// Reschedule an ApiCall by id - func startCallsRescheduling(apiCallId: String, type: T.Type) throws { - let apiCallCollection: StoredCollection> = try self.apiCallCollection() - if let apiCall = apiCallCollection.findById(apiCallId) { - self.startCallsRescheduling(apiCall: apiCall) - } + func rescheduleApiCall(id: String, type: T.Type) throws { + let collection: StoredCollection = try self.collection() + collection.rescheduleApiCallsIfNecessary() } /// Executes an ApiCall @@ -231,8 +172,12 @@ public class Store { } /// Executes an ApiCall - func execute(apiCall: ApiCall) async throws { - _ = try await self._executeApiCall(apiCall) +// func execute(apiCall: ApiCall) async throws { +// _ = try await self._executeApiCall(apiCall) +// } + + func execute(apiCall: ApiCall) async throws -> T { + return try await self._executeApiCall(apiCall) } /// Retrieves all the items on the server diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index 500459d..f90ab3c 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -7,9 +7,14 @@ import Foundation +enum StoredCollectionError : Error { + case unmanagedHTTPMethod(method: String) +} + protocol SomeCollection : Identifiable { func allItems() -> [any Storable] func deleteById(_ id: String) throws + func deleteApiCallById(_ id: String) throws } extension Notification.Name { @@ -34,6 +39,8 @@ public class StoredCollection : RandomAccessCollection, SomeCollec /// Provides fast access for instances if the collection has been instanced with [indexed] = true fileprivate var _index: [String : T]? = nil + fileprivate var apiCallsCollection: StoredCollection>? = nil + /// Indicates whether the collection has changed, thus requiring a write operation fileprivate var _hasChanged: Bool = false { didSet { @@ -58,9 +65,16 @@ public class StoredCollection : RandomAccessCollection, SomeCollec } self._store = store self.loadCompletion = loadCompletion + + if synchronized { + self.apiCallsCollection = StoredCollection>(synchronized: false, store: store, loadCompletion: { apiCallCollection in + self._rescheduleApiCalls() + }) + } + self._load() } - + // MARK: - Loading /// Migrates if necessary and asynchronously decodes the json file @@ -77,17 +91,17 @@ public class StoredCollection : RandomAccessCollection, SomeCollec } else { try self._decodeJSONFile() } - + } -// else { -// try? self.loadDataFromServer() -// } + // else { + // try? self.loadDataFromServer() + // } } catch { Logger.log(error) } } - + /// Decodes the json file into the items array fileprivate func _decodeJSONFile() throws { let jsonString = try FileUtils.readDocumentFile(fileName: T.fileName()) @@ -131,36 +145,33 @@ public class StoredCollection : RandomAccessCollection, SomeCollec /// Adds it if its id is not found, and otherwise updates it public func addOrUpdate(instance: T) { -// DispatchQueue(label: "lestorage.queue.items").sync { + defer { + self._hasChanged = true + } - defer { - self._hasChanged = true - } - - // update - if let index = self.items.firstIndex(where: { $0.id == instance.id }) { - self.items[index] = instance - self._sendUpdateIfNecessary(instance) - } else { // insert - self.items.append(instance) - self._index?[instance.stringId] = instance - self._sendInsertionIfNecessary(instance) - } -// } + // update + if let index = self.items.firstIndex(where: { $0.id == instance.id }) { + self.items[index] = instance + self._sendUpdateIfNecessary(instance) + } else { // insert + self.items.append(instance) + self._index?[instance.stringId] = instance + self._sendInsertionIfNecessary(instance) + } } /// Deletes the instance in the collection by id public func delete(instance: T) throws { - - defer { - self._hasChanged = true - } - - try instance.deleteDependencies() - self.items.removeAll { $0.id == instance.id } - self._index?.removeValue(forKey: instance.stringId) - self._sendDeletionIfNecessary(instance) + + defer { + self._hasChanged = true + } + + try instance.deleteDependencies() + self.items.removeAll { $0.id == instance.id } + self._index?.removeValue(forKey: instance.stringId) + self._sendDeletionIfNecessary(instance) } @@ -232,16 +243,56 @@ public class StoredCollection : RandomAccessCollection, SomeCollec // MARK: - Synchronization + fileprivate func _callForInstance(_ instance: T, method: Method) throws -> ApiCall? { + guard let apiCallCollection = self.apiCallsCollection else { + throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) + } + + if let existingCall = apiCallCollection.first(where: { $0.dataId == instance.id }) { + switch existingCall.method { + case Method.post.rawValue, Method.put.rawValue: + existingCall.body = try instance.jsonString() + return existingCall + case Method.delete.rawValue: + try self.deleteApiCallById(existingCall.id) + return nil + default: + throw StoredCollectionError.unmanagedHTTPMethod(method: existingCall.method) + } + + } else { + return try self._createCall(instance, method: method) + } + } + + fileprivate func _createCall(_ instance: T, method: Method) throws -> ApiCall { + guard let baseURL = _store.service?.baseURL else { + throw StoreError.missingService + } + let jsonString = try instance.jsonString() + let url = baseURL + T.resourceName() + "/" + return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: jsonString) + } + + fileprivate func _prepareCall(apiCall: ApiCall) { + apiCall.lastAttemptDate = Date() + apiCall.attemptsCount += 1 + self.apiCallsCollection?.addOrUpdate(instance: apiCall) + } + /// Sends an insert api call for the provided [instance] fileprivate func _sendInsertionIfNecessary(_ instance: T) { guard self.synchronized else { return } - Logger.log("Call service...") Task { do { - let _ = try await self._store.service?.insert(instance) + if let apiCall = try self._callForInstance(instance, method: Method.post) { + self._prepareCall(apiCall: apiCall) + _ = try await self._store.execute(apiCall: apiCall) + } } catch { + self.rescheduleApiCallsIfNecessary() Logger.error(error) } } @@ -256,9 +307,15 @@ public class StoredCollection : RandomAccessCollection, SomeCollec Task { do { - let _ = try await self._store.service?.update(instance) + if let apiCall = try self._callForInstance(instance, method: Method.put) { + self._prepareCall(apiCall: apiCall) + _ = try await self._store.execute(apiCall: apiCall) + } + +// let _ = try await self._store.service?.update(instance) } catch { Logger.error(error) + self.rescheduleApiCallsIfNecessary() } } @@ -272,14 +329,79 @@ public class StoredCollection : RandomAccessCollection, SomeCollec Task { do { - let _ = try await self._store.service?.delete(instance) + + if let apiCall = try self._callForInstance(instance, method: Method.delete) { + self._prepareCall(apiCall: apiCall) + _ = try await self._store.execute(apiCall: apiCall) + } + +// let _ = try await self._store.service?.delete(instance) } catch { Logger.error(error) + self.rescheduleApiCallsIfNecessary() } } } + // MARK: - Reschedule calls + + /// number of time an execution loop has been called + fileprivate var _attemptLoops: Int = 0 + + /// Indicates if the collection is currently retrying ApiCalls + fileprivate var _isRetryingCalls: Bool = false + + func rescheduleApiCallsIfNecessary() { + if !self._isRetryingCalls { + self._rescheduleApiCalls() + } + } + + fileprivate func _rescheduleApiCalls() { + + guard let apiCallsCollection, apiCallsCollection.isNotEmpty else { + return + } + + self._isRetryingCalls = true + self._attemptLoops += 1 + + Task { + + let delay = pow(2, self._attemptLoops) + let seconds = NSDecimalNumber(decimal: delay).intValue + Logger.log("wait for \(seconds) sec") + try await Task.sleep(until: .now + .seconds(seconds)) + + let apiCallsCopy = apiCallsCollection.items + for apiCall in apiCallsCopy { + apiCall.attemptsCount += 1 + apiCall.lastAttemptDate = Date() + do { + let _ = try await Store.main.execute(apiCall: apiCall) + } catch { + Logger.error(error) + } + } + + if apiCallsCollection.isEmpty { + self._isRetryingCalls = false + } else { + self._rescheduleApiCalls() + } + + } + + } + + func deleteApiCallById(_ id: String) throws { + guard let apiCallsCollection else { + throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) + } + try apiCallsCollection.deleteById(id) + } + // MARK: - RandomAccessCollection public var startIndex: Int { return self.items.startIndex } diff --git a/LeStorage/Utils/Collection+Extension.swift b/LeStorage/Utils/Collection+Extension.swift index 357b104..b4cc8f6 100644 --- a/LeStorage/Utils/Collection+Extension.swift +++ b/LeStorage/Utils/Collection+Extension.swift @@ -26,3 +26,7 @@ extension Array { } } + +extension RandomAccessCollection { + var isNotEmpty: Bool { return !self.isEmpty } +}