Refactor asynchronicity for API calls collections

multistore
Laurent 1 year ago
parent a8133b697c
commit 2191733d97
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 260
      LeStorage/ApiCallCollection.swift
  3. 4
      LeStorage/Services.swift
  4. 16
      LeStorage/Storable.swift
  5. 43
      LeStorage/Store.swift
  6. 297
      LeStorage/StoredCollection.swift

@ -14,6 +14,7 @@
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; }; C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.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 */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; };
C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; };
@ -51,6 +52,7 @@
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; }; C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; }; C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
@ -113,6 +115,7 @@
C425D4372B6D24E1002A7B48 /* LeStorage.h */, C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */, C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D9D2B7CFFF500ADC637 /* Codables */, C4A47D9D2B7CFFF500ADC637 /* Codables */,
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */,
C4A47D6C2B71364600ADC637 /* ModelObject.swift */, C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */, C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */, C425D4572B6D2519002A7B48 /* Store.swift */,
@ -294,6 +297,7 @@
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */, C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */, C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

@ -0,0 +1,260 @@
//
// SafeCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/06/2024.
//
import Foundation
actor ApiCallCollection<T: Storable> {
/// The reference to the Store
fileprivate var _store: Store
fileprivate(set) var items: [ApiCall<T>] = []
/// 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
fileprivate var _hasChanged: Bool = false {
didSet {
self._write()
}
}
init(store: Store) {
self._store = store
}
/// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws {
try self._decodeJSONFile()
}
fileprivate func _urlForJSONFile() throws -> URL {
return try ApiCall<T>.urlForJSONFile()
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
Logger.log("loaded \(T.fileName()) with \(decoded.count) items")
self.items = decoded
self.rescheduleApiCallsIfNecessary()
}
}
fileprivate func _write() {
let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
Logger.log("Start write to \(fileName)...")
do {
let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
} catch {
Logger.error(error)
}
Logger.log("End write")
}
}
func addOrUpdate(_ instance: ApiCall<T>) {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
} else {
self.items.append(instance)
}
self._hasChanged = true
}
/// Deletes an API call by [id]
func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id })
self._hasChanged = true
}
func deleteByDataId(_ id: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == id }) {
self.items.remove(at: apiCallIndex)
self._hasChanged = true
}
}
func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id })
}
func reset() {
self.items.removeAll()
do {
let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) {
try FileManager.default.removeItem(at: url)
}
} catch {
Logger.error(error)
}
}
fileprivate func _rescheduleApiCalls() {
guard self.items.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 = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
do {
try await self._executeApiCall(apiCall)
// let _ = try await Store.main.execute(apiCall: apiCall)
} catch {
Logger.error(error)
}
}
if self.items.isEmpty {
self._isRetryingCalls = false
} else {
self._rescheduleApiCalls()
}
}
}
// MARK: - Synchronization
/// 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<T>? {
if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
switch method {
case .delete:
self.deleteById(existingCall.id) // delete the existing call as we don't need it
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
}
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)
}
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
fileprivate func _prepareCall(apiCall: ApiCall<T>) throws {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
self.addOrUpdate(apiCall)
}
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
}
}
/// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) {
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.post)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
/// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) {
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
/// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) {
Task {
do {
try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
try await self._executeApiCall(apiCall)
}
}
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws {
let result = try await self._store.execute(apiCall: apiCall)
switch apiCall.method {
case .post:
if let instance = self.findById(result.stringId) {
self._hasChanged = instance.copyFromServerInstance(result)
}
default:
break
}
Logger.log("")
}
func contentOfApiCallFile() -> String? {
guard let fileURL = try? self._urlForJSONFile() else { return nil }
if FileManager.default.fileExists(atPath: fileURL.path()) {
return try? FileUtils.readFile(fileURL: fileURL)
}
return nil
}
}

@ -102,9 +102,7 @@ public class Services {
case 200..<300: case 200..<300:
if let apiCallId, if let apiCallId,
let collectionName = (T.self as? any Storable.Type)?.resourceName() { let collectionName = (T.self as? any Storable.Type)?.resourceName() {
try await MainActor.run { try await Store.main.deleteApiCallById(apiCallId, collectionName: collectionName)
try Store.main.deleteApiCallById(apiCallId, collectionName: collectionName)
}
} }
default: default:
/* /*

@ -47,4 +47,20 @@ extension Storable {
return path return path
} }
static func storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
static func writeToStorageDirectory(content: String, fileName: String) throws {
var fileURL = try self.storageDirectoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
static func urlForJSONFile() throws -> URL {
var storageDirectory = try self.storageDirectoryPath()
storageDirectory.append(component: self.fileName())
return storageDirectory
}
} }

@ -199,9 +199,9 @@ public class Store {
// MARK: - Api call rescheduling // MARK: - Api call rescheduling
/// Deletes an ApiCall by [id] and [collectionName] /// Deletes an ApiCall by [id] and [collectionName]
func deleteApiCallById(_ id: String, collectionName: String) throws { func deleteApiCallById(_ id: String, collectionName: String) async throws {
if let collection = self._collections[collectionName] { if let collection = self._collections[collectionName] {
try collection.deleteApiCallById(id) try await collection.deleteApiCallById(id)
} else { } else {
throw StoreError.collectionNotRegistered(type: collectionName) throw StoreError.collectionNotRegistered(type: collectionName)
} }
@ -257,8 +257,13 @@ public class Store {
} }
/// Returns whether any collection has pending API calls /// Returns whether any collection has pending API calls
public func hasPendingAPICalls() -> Bool { public func hasPendingAPICalls() async -> Bool {
return self._collections.values.contains(where: { $0.hasPendingAPICalls() }) for collection in self._collections.values {
if await collection.hasPendingAPICalls() {
return true
}
}
return false
} }
/// Returns the names of all collections /// Returns the names of all collections
@ -267,8 +272,8 @@ public class Store {
} }
/// Returns the content of the api call file /// Returns the content of the api call file
public func apiCallsFile(resourceName: String) -> String { public func apiCallsFileContent(resourceName: String) async -> String {
return self._collections[resourceName]?.contentOfApiCallFile() ?? "" return await self._collections[resourceName]?.contentOfApiCallFile() ?? ""
} }
/// This method triggers the framework to save and send failed api calls /// This method triggers the framework to save and send failed api calls
@ -282,23 +287,29 @@ public class Store {
guard let failedAPICallsCollection = self._failedAPICallsCollection, guard let failedAPICallsCollection = self._failedAPICallsCollection,
let collection = self._collections[collectionName], let collection = self._collections[collectionName],
collectionName != FailedAPICall.resourceName(), collectionName != FailedAPICall.resourceName()
let apiCall = try? collection.apiCallById(apiCallId) else { else {
return return
} }
if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 { Task {
if let apiCall = await collection.apiCallById(apiCallId) {
do { if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 {
let authValue = request.allHTTPHeaderFields?["Authorization"]
let string = try apiCall.jsonString() do {
let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue) let authValue = request.allHTTPHeaderFields?["Authorization"]
try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) let string = try apiCall.jsonString()
} catch { let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue)
Logger.error(error) try failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} catch {
Logger.error(error)
}
}
} }
} }
} }
func logFailedAPICall(request: URLRequest, error: String) { func logFailedAPICall(request: URLRequest, error: String) {

@ -13,22 +13,34 @@ enum StoredCollectionError: Error {
case missingInstance case missingInstance
} }
protocol CollectionHolder {
associatedtype Item
var items: [Item] { get }
}
extension CollectionHolder {
}
protocol SomeCollection: Identifiable { protocol SomeCollection: Identifiable {
var resourceName: String { get } var resourceName: String { get }
var synchronized: Bool { get } var synchronized: Bool { get }
func allItems() -> [any Storable] func allItems() -> [any Storable]
func deleteById(_ id: String) throws func deleteById(_ id: String) throws
func deleteApiCallById(_ id: String) throws
func loadDataFromServerIfAllowed() async throws func loadDataFromServerIfAllowed() async throws
func hasPendingAPICalls() -> Bool
func contentOfApiCallFile() -> String?
func reset() func reset()
func resetApiCalls() func resetApiCalls()
func apiCallById(_ id: String) throws -> (any SomeCall)?
func deleteApiCallById(_ id: String) async throws
func apiCallById(_ id: String) async -> (any SomeCall)?
func hasPendingAPICalls() async -> Bool
func contentOfApiCallFile() async -> String?
} }
extension Notification.Name { extension Notification.Name {
@ -36,7 +48,7 @@ extension Notification.Name {
public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange") public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
} }
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection { public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder {
/// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL /// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL
let synchronized: Bool let synchronized: Bool
@ -60,7 +72,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
fileprivate var _indexes: [String : T]? = nil fileprivate var _indexes: [String : T]? = nil
/// Collection of API calls used to store HTTP calls /// Collection of API calls used to store HTTP calls
fileprivate var apiCallsCollection: StoredCollection<ApiCall<T>>? = nil fileprivate var apiCallsCollection: ApiCallCollection<T>? = nil
/// Indicates whether the collection has changed, thus requiring a write operation /// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
@ -94,9 +106,20 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.loadCompletion = loadCompletion self.loadCompletion = loadCompletion
if synchronized { if synchronized {
self.apiCallsCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: store, loadCompletion: { apiCallCollection in let apiCallCollection = ApiCallCollection<T>(store: store)
self._rescheduleApiCalls() self.apiCallsCollection = apiCallCollection
}) Task {
do {
try await apiCallCollection.loadFromFile()
} catch {
Logger.error(error)
}
}
// self.apiCallsCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: store, loadCompletion: { apiCallCollection in
// self._rescheduleApiCalls()
// })
} }
self._load() self._load()
@ -216,20 +239,20 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
// update // update
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance self.items[index] = instance
try self._sendUpdateIfNecessary(instance) self._sendUpdateIfNecessary(instance)
} else { // insert } else { // insert
self.items.append(instance) self.items.append(instance)
try self._sendInsertionIfNecessary(instance) self._sendInsertionIfNecessary(instance)
} }
self._indexes?[instance.stringId] = instance self._indexes?[instance.stringId] = instance
} }
public func writeChangeAndInsertOnServer(instance: T) throws { public func writeChangeAndInsertOnServer(instance: T) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
try self._sendInsertionIfNecessary(instance) self._sendInsertionIfNecessary(instance)
} }
/// A method the treat the collection as a single instance holder /// A method the treat the collection as a single instance holder
@ -251,8 +274,8 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
try instance.deleteDependencies() try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id } self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId) self._indexes?.removeValue(forKey: instance.stringId)
try self._sendDeletionIfNecessary(instance)
self._sendDeletionIfNecessary(instance)
} }
/// Deletes all items of the sequence by id /// Deletes all items of the sequence by id
@ -266,7 +289,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
try instance.deleteDependencies() try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id } self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId) self._indexes?.removeValue(forKey: instance.stringId)
try self._sendDeletionIfNecessary(instance) self._sendDeletionIfNecessary(instance)
} }
} }
@ -285,12 +308,12 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance self.items[index] = instance
if shouldSync { if shouldSync {
try self._sendUpdateIfNecessary(instance) self._sendUpdateIfNecessary(instance)
} }
} else { // insert } else { // insert
self.items.append(instance) self.items.append(instance)
if shouldSync { if shouldSync {
try self._sendInsertionIfNecessary(instance) self._sendInsertionIfNecessary(instance)
} }
} }
self._indexes?[instance.stringId] = instance self._indexes?[instance.stringId] = instance
@ -322,9 +345,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
for item in items { for item in items {
self.items.removeAll(where: { $0.id == item.id }) self.items.removeAll(where: { $0.id == item.id })
/// remove related API call if existing Task {
if let apiCallIndex = self.apiCallsCollection?.firstIndex(where: { $0.dataId == item.id }) { /// Remove related API call if existing
self.apiCallsCollection?.items.remove(at: apiCallIndex) await self.apiCallsCollection?.deleteByDataId(item.stringId)
} }
} }
@ -335,6 +358,16 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
try self.delete(contentOfs: self.items) try self.delete(contentOfs: self.items)
} }
// MARK: - Some Collection
func deleteApiCallById(_ id: String) async throws {
await self.apiCallsCollection?.deleteById(id)
}
func apiCallById(_ id: String) async -> (any SomeCall)? {
return await self.apiCallsCollection?.findById(id)
}
// MARK: - SomeCall // MARK: - SomeCall
/// Returns the collection items as [any Storable] /// Returns the collection items as [any Storable]
@ -395,202 +428,110 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Removes the collection related API calls collection /// Removes the collection related API calls collection
public func resetApiCalls() { public func resetApiCalls() {
if let apiCallsCollection = self.apiCallsCollection { if let apiCallsCollection = self.apiCallsCollection {
apiCallsCollection.reset() Task {
} await apiCallsCollection.reset()
}
// MARK: - Synchronization
/// 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<T>? {
guard let apiCallCollection = self.apiCallsCollection else {
throw StoredCollectionError.missingApiCallCollection
}
if let existingCall = apiCallCollection.first(where: { $0.dataId == instance.id }) {
switch method {
case .delete:
try self.deleteApiCallById(existingCall.id) // delete the existing call as we don't need it
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
}
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)
} }
} }
/// Creates an API call for the Storable [instance] and an HTTP [method] // MARK: - Reschedule calls
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
fileprivate func _prepareCall(apiCall: ApiCall<T>) throws {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
try self.apiCallsCollection?.addOrUpdate(instance: apiCall)
}
/// Sends an insert api call for the provided [instance] /// Sends an insert api call for the provided [instance]
fileprivate func _sendInsertionIfNecessary(_ instance: T) throws { fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized, Store.main.collectionsCanSynchronize else { guard self.synchronized, Store.main.collectionsCanSynchronize else {
return return
} }
Task { Task {
do { await self.apiCallsCollection?.sendInsertion(instance)
try await self._synchronize(instance, method: HTTPMethod.post)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
} }
} }
/// Sends an update api call for the provided [instance] /// Sends an update api call for the provided [instance]
fileprivate func _sendUpdateIfNecessary(_ instance: T) throws { fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized, self._sendsUpdate, Store.main.collectionsCanSynchronize else { guard self.synchronized, self._sendsUpdate, Store.main.collectionsCanSynchronize else {
return return
} }
Task { Task {
do { await self.apiCallsCollection?.sendUpdate(instance)
try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
} }
} }
/// Sends an delete api call for the provided [instance] /// Sends an delete api call for the provided [instance]
fileprivate func _sendDeletionIfNecessary(_ instance: T) throws { fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized, Store.main.collectionsCanSynchronize else { guard self.synchronized, Store.main.collectionsCanSynchronize else {
return return
} }
Task { Task {
do { await self.apiCallsCollection?.sendDeletion(instance)
try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
}
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
try await self._executeApiCall(apiCall)
}
}
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws {
let result = try await self._store.execute(apiCall: apiCall)
switch apiCall.method {
case .post:
// DispatchQueue.main.async {
if let instance = self.findById(result.stringId) {
self._hasChanged = instance.copyFromServerInstance(result)
}
// }
default:
break
} }
Logger.log("")
} }
// 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
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() { func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
}
}
/// Reschedule API calls
fileprivate func _rescheduleApiCalls() {
guard let apiCallsCollection, apiCallsCollection.isNotEmpty else {
return
}
self._isRetryingCalls = true
self._attemptLoops += 1
Task { Task {
await self.apiCallsCollection?.rescheduleApiCallsIfNecessary()
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 {
try await self._executeApiCall(apiCall)
// let _ = try await Store.main.execute(apiCall: apiCall)
} catch {
Logger.error(error)
}
}
if apiCallsCollection.isEmpty {
self._isRetryingCalls = false
} else {
self._rescheduleApiCalls()
}
} }
} }
/// Deletes an API call by [id] //
func deleteApiCallById(_ id: String) throws { // /// number of time an execution loop has been called
guard let apiCallsCollection else { // fileprivate var _attemptLoops: Int = 0
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) //
} // /// Indicates if the collection is currently retrying ApiCalls
try apiCallsCollection.deleteById(id) // fileprivate var _isRetryingCalls: Bool = false
} //
// /// Reschedule API calls
// 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 {
// try await self._executeApiCall(apiCall)
//// let _ = try await Store.main.execute(apiCall: apiCall)
// } catch {
// Logger.error(error)
// }
// }
//
// if apiCallsCollection.isEmpty {
// self._isRetryingCalls = false
// } else {
// self._rescheduleApiCalls()
// }
//
// }
//
// }
func apiCallById(_ id: String) throws -> (any SomeCall)? { func contentOfApiCallFile() async -> String? {
guard let apiCallsCollection else { return await self.apiCallsCollection?.contentOfApiCallFile()
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) // guard let fileURL = try? self.apiCallsCollection?._urlForJSONFile() else { return nil }
} // if FileManager.default.fileExists(atPath: fileURL.path()) {
return apiCallsCollection.findById(id) // return try? FileUtils.readFile(fileURL: fileURL)
// }
// return nil
} }
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingAPICalls() -> Bool { func hasPendingAPICalls() async -> Bool {
guard let apiCallsCollection else { return false } guard let apiCallsCollection else { return false }
return apiCallsCollection.isNotEmpty return await apiCallsCollection.items.isNotEmpty
}
func contentOfApiCallFile() -> String? {
guard let fileURL = try? self.apiCallsCollection?._urlForJSONFile() else { return nil }
if FileManager.default.fileExists(atPath: fileURL.path()) {
return try? FileUtils.readFile(fileURL: fileURL)
}
return nil
} }
// MARK: - RandomAccessCollection // MARK: - RandomAccessCollection

Loading…
Cancel
Save