parent
a8133b697c
commit
2191733d97
@ -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 |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue