diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index f88a387..043fec0 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ C4A47D672B6FF83A00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D662B6FF83A00ADC637 /* ApiCall.swift */; }; C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */; }; C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D6C2B71364600ADC637 /* ModelObject.swift */; }; + C4A47D6F2B7154F600ADC637 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = C4A47D6E2B7154F600ADC637 /* README.md */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -49,6 +50,7 @@ C4A47D662B6FF83A00ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = ""; }; C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Extension.swift"; sourceTree = ""; }; C4A47D6C2B71364600ADC637 /* ModelObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelObject.swift; sourceTree = ""; }; + C4A47D6E2B7154F600ADC637 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -91,6 +93,7 @@ C425D4362B6D24E1002A7B48 /* LeStorage */ = { isa = PBXGroup; children = ( + C4A47D6E2B7154F600ADC637 /* README.md */, C425D4372B6D24E1002A7B48 /* LeStorage.h */, C425D4382B6D24E1002A7B48 /* LeStorage.docc */, C4A47D602B6D3C1300ADC637 /* Services.swift */, @@ -215,6 +218,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C4A47D6F2B7154F600ADC637 /* README.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LeStorage/ApiCall.swift b/LeStorage/ApiCall.swift index 98a95b8..ab0214c 100644 --- a/LeStorage/ApiCall.swift +++ b/LeStorage/ApiCall.swift @@ -14,33 +14,38 @@ protocol SomeCall : Storable { class ApiCall : ModelObject, Storable, SomeCall { - static func resourceName() -> String { return "apicalls" } + static func resourceName() -> String { return "apicalls_" + T.resourceName() } + var id: String = Store.randomId() + /// The http URL of the call var url: String /// The HTTP method of the call: post... var method: String + /// The id of the underlying data + var dataId: String + /// The content of the call var body: Data /// The number of times the call has been executed - var attemptsCount: Int = 1 + var attemptsCount: Int = 0 /// The date of the last execution var lastAttemptDate: Date = Date() - init(url: String, method: String, body: Data) { + init(url: String, method: String, dataId: String, body: Data) { self.url = url self.method = method + self.dataId = dataId self.body = body } /// Executes the api call func execute() throws { Task { - self.lastAttemptDate = Date() try await Store.main.execute(apiCall: self) } } diff --git a/LeStorage/ModelObject.swift b/LeStorage/ModelObject.swift index 0ecde27..9f1939b 100644 --- a/LeStorage/ModelObject.swift +++ b/LeStorage/ModelObject.swift @@ -9,8 +9,6 @@ import Foundation open class ModelObject { - public var id: String = Store.randomId() - public init() { } open func deleteDependencies() throws { diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index e9874a6..8c0f843 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -1,8 +1,8 @@ // -// ChatService.swift -// Chat +// Services.swift +// LeStorage // -// Created by Laurent Morvillier on 11/12/2023. +// Created by Laurent Morvillier on 02/02/2024. // import Foundation @@ -37,10 +37,11 @@ class Services { // MARK: - Base fileprivate func runRequest(_ request: URLRequest, apiCallId: String? = nil) async throws -> T { + Logger.log("Run request...") let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) - if let response = task.1 as? HTTPURLResponse { let statusCode = response.statusCode + Logger.log("request ended with status code = \(statusCode)") switch statusCode { case 200...300: if let apiCallId, @@ -48,12 +49,13 @@ class Services { try Store.main.deleteApiCallById(apiCallId, collectionName: collectionName) } default: - Store.main.startCallsRescheduling() + if let apiCallId, let type = (T.self as? any Storable.Type) { + try Store.main.startCallsRescheduling(apiCallId: apiCallId, type: type) + } } - Logger.log("status code = \(statusCode)") } - Logger.log("response = \(String(data: task.0, encoding: .utf8))") + Logger.log("response = \(String(describing: String(data: task.0, encoding: .utf8)))") return try jsonDecoder.decode(T.self, from: task.0) } @@ -61,17 +63,17 @@ 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 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 @@ -109,11 +111,15 @@ class Services { fileprivate func _createCall(method: Method, instance: T) throws -> ApiCall { let data = try instance.jsonData() let url = self._baseURL + T.resourceName() + "/" - return ApiCall(url: url, method: method.rawValue, body: data) + return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: data) } 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) } @@ -124,8 +130,9 @@ class Services { } var request = URLRequest(url: url) request.httpMethod = apiCall.method + request.httpBody = apiCall.body request.setValue("application/json", forHTTPHeaderField: "Content-Type") return request } - + } diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index b418e42..ef1f024 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -34,20 +34,22 @@ public class Store { fileprivate var _collections: [String : any SomeCollection] = [:] fileprivate var _apiCallsCollections: [String : any SomeCollection] = [:] - fileprivate var _apiCallsTimer: Timer? = nil - - fileprivate var _reschedulingCount: Int = 0 +// fileprivate var _apiCallTimers: [String: Timer] = [:] public init() { } public func registerCollection(synchronized: Bool) -> StoredCollection { // register collection - let collection = StoredCollection(synchronized: synchronized, store: self) + let collection = StoredCollection(synchronized: synchronized, store: self, loadCompletion: { _ in + + }) self._collections[T.resourceName()] = collection if synchronized { // register additional collection for api calls - let apiCallCollection = StoredCollection>(synchronized: false, store: self) + let apiCallCollection = StoredCollection>(synchronized: false, store: self, loadCompletion: { apiCallCollection in + self._reloadTimers(collection: apiCallCollection) + }) self._apiCallsCollections[T.resourceName()] = apiCallCollection } @@ -87,6 +89,12 @@ public class Store { // MARK: - Api call rescheduling + fileprivate func _reloadTimers(collection: StoredCollection>) { + for apiCall in collection { + self.startCallsRescheduling(apiCall: apiCall) + } + } + func apiCallCollection() throws -> StoredCollection> { if let apiCallCollection = self._apiCallsCollections[T.resourceName()] as? StoredCollection> { return apiCallCollection @@ -96,15 +104,25 @@ public class Store { func registerApiCall(_ apiCall: ApiCall) throws { let collection: StoredCollection> = try self.apiCallCollection() - collection.addOrUpdate(instance: apiCall) - } - - func deleteApiCallById (_ id: String, type: T.Type) throws { - let collection: StoredCollection> = try self.apiCallCollection() - try collection.deleteById(id) + + 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) + } + } func deleteApiCallById(_ id: String, collectionName: String) throws { + if let collection = self._apiCallsCollections[collectionName] { try collection.deleteById(id) return @@ -112,43 +130,29 @@ public class Store { throw StoreError.collectionNotRegistered(type: collectionName) } - func deleteApiCall (_ apiCall: ApiCall) throws { - let collection: StoredCollection> = try self.apiCallCollection() - try collection.delete(instance: apiCall) - } - - func startCallsRescheduling() { + func startCallsRescheduling(apiCall: ApiCall) { + let delay = pow(2, 0 + apiCall.attemptsCount) + let seconds = NSDecimalNumber(decimal: delay).intValue + Logger.log("Rerun request in \(seconds) seconds...") - self._reschedulingCount += 1 + 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) + } + } + } - let delay = pow(2, 1 + self._reschedulingCount) - let seconds = NSDecimalNumber(decimal: delay).doubleValue - self._apiCallsTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { timer in - self._executeApiCalls() - }) - } - fileprivate func _executeApiCalls() { - - DispatchQueue(label: "lestorage.queue.network").async { - - do { - for collection in self._apiCallsCollections.values { - if let apiCalls = collection.allItems() as? [any SomeCall] { - - let sortedCalls = apiCalls.sorted(keyPath: \.lastAttemptDate, ascending: true) - - for apiCall in sortedCalls { - try apiCall.execute() - } - } else { - Logger.w("_apiCallsCollections item not castable to [any SomeCall] ") - } - } - } catch { - Logger.error(error) - } + func startCallsRescheduling(apiCallId: String, type: T.Type) throws { + let apiCallCollection: StoredCollection> = try self.apiCallCollection() + if let apiCall = apiCallCollection.findById(apiCallId) { + self.startCallsRescheduling(apiCall: apiCall) } } diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index d6b3a5f..78bd42f 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -12,7 +12,12 @@ protocol SomeCollection : Identifiable { func deleteById(_ id: String) throws } -public class StoredCollection : RandomAccessCollection, SomeCollection, ObservableObject { +extension Notification.Name { + public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad") + public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange") +} + +public class StoredCollection : RandomAccessCollection, SomeCollection { /// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL let synchronized: Bool @@ -23,25 +28,65 @@ public class StoredCollection : RandomAccessCollection, SomeCollec /// The reference to the Store fileprivate var _store: Store + fileprivate var loadCompletion: (StoredCollection) -> () + + /// Returns the default filename for the collection + fileprivate var _fileName: String { + return T.resourceName() + ".json" + } + /// Indicates whether the collection has changed, thus requiring a write operation fileprivate var _hasChanged: Bool = false { didSet { if self._hasChanged == true { self._scheduleWrite() + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self) + } self._hasChanged = false } } } - init(synchronized: Bool, store: Store) { + init(synchronized: Bool, store: Store, loadCompletion: @escaping (StoredCollection) -> ()) { self.synchronized = synchronized self._store = store + self.loadCompletion = loadCompletion self._load() } - /// Returns the default filename for the collection - fileprivate var _fileName: String { - return T.resourceName() + ".json" + /// Launches a load operation if the file exists + fileprivate func _load() { + do { + let url = try FileUtils.directoryURLForFileName(self._fileName) + if FileManager.default.fileExists(atPath: url.path()) { + self._loadAsync() + } + } catch { + Logger.log(error) + } + + } + + /// Loads asynchronously into memory the objects contained inside the collection file + fileprivate func _loadAsync() { + DispatchQueue(label: "lestorage.queue.read", qos: .background).async { + do { + let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) + if let decoded: [T] = try jsonString.decodeArray() { + DispatchQueue.main.sync { + Logger.log("loaded \(self._fileName) with \(decoded.count) items") + self.items = decoded + self.loadCompletion(self) + + NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) + } + } + } catch { + Logger.error(error) // TODO how to notify the main project + } + } + } /// Adds or updates the provided instance inside the collection @@ -120,44 +165,10 @@ public class StoredCollection : RandomAccessCollection, SomeCollec } } - /// Launches a load operation if the file exists - fileprivate func _load() { - do { - let url = try FileUtils.directoryURLForFileName(self._fileName) - if FileManager.default.fileExists(atPath: url.path()) { - self._loadAsync() - } - } catch { - Logger.log(error) - } - - } - - /// Loads asynchronously into memory the objects contained inside the collection file - fileprivate func _loadAsync() { - DispatchQueue(label: "lestorage.queue.read", qos: .background).async { - - do { - let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) - if let decoded: [T] = try jsonString.decodeArray() { - DispatchQueue.main.sync { - Logger.log("loaded \(self._fileName) with \(decoded.count) items") - self.items = decoded - } - } - - } catch { - Logger.error(error) // TODO how to notify the main project - } - } - - } - // MARK: - Synchronization /// Sends an insert api call for the provided [instance] fileprivate func _sendInsertionIfNecessary(_ instance: T) { - Logger.log("_sendInsertionIfNecessary...") guard self.synchronized else { return } diff --git a/README.md b/README.md index a450172..a32a49d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,22 @@ # LeStorage +**1. RULES** + + - To store data in the json format inside files, + you first need to create some model class, for example `Car` + - You make `Car` inherit `ModelObject`, and implement `Storable` + - To get the `StoredCollection` that manages all your cars and stores them for you, you do + `Store.main.registerCollection()` to retrieve a collection. + +**2. Sync** + + - When registering your collection, you can choose to have it synchronized. To do that: + - Set `Store.main.synchronizationApiURL` + - Pass `synchronized: true` when registering the collection + - For each of your `ModelObject`, make sure that `resourceName()` returns the resource path of the endpoint, for example "cars" + - Synchronization is expected to be done with a rest_framework API on a django server + - On Django, when using cascading delete foreign, you'll want to avoid sending useless delete API calls to django, so override the `deleteDependencies` function of your ModelObject and call `Store.main.deleteDependencies` for the objects you also want to delete to reproduce the cascading effect + - On your Django serializers, you want to define the following on your foreign keys to avoid having a URL instead of just the id: + `car_id = serializers.PrimaryKeyRelatedField(queryset=Car.objects.all())` + +