You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
LeStorage/LeStorage/ApiCallCollection.swift

363 lines
12 KiB

//
// ApiCallCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/06/2024.
//
import Foundation
protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async
func hasPendingCalls() async -> Bool
func contentOfFile() async -> String?
func reset() async
func resumeApiCalls() async
}
enum ApiCallError: Error, LocalizedError {
case encodingError(id: String, type: String)
var errorDescription: String? {
switch self {
case .encodingError(let id, let type):
return "Can't encode instance \(type) with id: \(id)"
}
}
}
/// ApiCallCollection is an object communicating with a server to synchronize data managed locally
/// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
/// The number of time an execution loop has been called
fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isRescheduling: Bool = false
fileprivate var _schedulingTask: Task<(), Never>? = nil
/// Indicates whether the collection content has changed
/// Initiates a write when true
fileprivate var _hasChanged: Bool = false {
didSet {
if self._hasChanged {
self._write()
self._hasChanged = false
}
}
}
/// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty
func loadFromFile() throws {
try self._decodeJSONFile()
}
/// Returns the file URL of the collection
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)
do {
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded
} catch {
let decoded: [OldApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded.compactMap { $0.toNewApiCall() }
}
}
}
/// Writes the content of the data
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")
}
}
/// Adds or update an API call instance
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 })
// Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true
}
/// Deletes a call by a data id
func deleteByDataId(_ dataId: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.data?.stringId == dataId }) {
self.items.remove(at: apiCallIndex)
self._hasChanged = true
}
}
/// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id })
}
/// Returns the Api call associated with the provided id
func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id)
}
/// Removes all objects in memory and deletes the JSON file
func reset() {
self._isRescheduling = false
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)
}
}
func resumeApiCalls() {
if self._schedulingTask != nil && self._attemptLoops > 2 {
self._schedulingTask?.cancel()
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
}
func rescheduleImmediately() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if self.items.isNotEmpty && !self._isRescheduling {
self._schedulingTask = Task {
await self._waitAndExecuteApiCalls()
}
}
}
/// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async {
// Logger.log("\(T.resourceName()) > RESCHED")
guard !self._isRescheduling, StoreCenter.main.collectionsCanSynchronize else { return }
guard self.items.isNotEmpty else { return }
self._isRescheduling = true
self._attemptLoops += 1
await self._wait()
let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
for batch in batches.values {
do {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
let _: Empty = try await self._executeGetCall(apiCall)
} else {
let results = try await self._executeApiCalls(batch)
if T.copyServerResponse {
StoreCenter.main.updateLocalInstances(results)
}
}
} catch {
Logger.error(error)
}
}
self._isRescheduling = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
/// Wait for an exponentionnaly long time depending on the number of attemps
fileprivate func _wait() async {
#if DEBUG
let seconds = self._attemptLoops
#else
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
#endif
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
do {
try await Task.sleep(until: .now + .seconds(seconds))
} catch {
Logger.w("*** WAITING CRASHED !!!")
Logger.error(error)
}
}
// 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 _call(method: HTTPMethod, instance: T? = nil) async throws -> ApiCall<T>? {
if let instance {
return try await self._callForInstance(instance, method: method)
} else {
if self.items.contains(where: { $0.method == .get }) {
return nil
} else {
return try self._createGetCall()
}
}
}
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) async throws -> ApiCall<T> {
// cleanup
let existingCalls = self.items.filter { $0.data?.id == instance.id }
self._deleteCalls(existingCalls)
// create
let call = try self._createCall(method, instance: instance, transactionId: transactionId)
self._prepareCall(apiCall: call)
return call
}
fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) {
for call in calls {
self.deleteById(call.id)
}
}
fileprivate func _createGetCall() throws -> ApiCall<T> {
return try self._createCall(.get, instance: nil)
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) throws -> ApiCall<T> {
if let instance {
return ApiCall(method: method, data: instance, transactionId: transactionId)
} else {
return ApiCall(method: .get, data: nil)
}
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
fileprivate func _prepareCall(apiCall: ApiCall<T>) {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
self.addOrUpdate(apiCall)
}
/// Sends an insert api call for the provided [instance]
func sendGetRequest(instance: T) async throws where T : URLParameterConvertible {
do {
let apiCall = ApiCall<T>(method: .get, data: nil)
apiCall.urlParameters = instance.queryParameters()
let _: Empty? = try await self._prepareAndSendCall(apiCall)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
func executeBatch(_ batch: OperationBatch<T>) async throws -> [T] {
var apiCalls: [ApiCall<T>] = []
let transactionId = Store.randomId()
for insert in batch.inserts {
let call = try await self._callForInstance(insert, method: .post, transactionId: transactionId)
apiCalls.append(call)
}
for update in batch.updates {
let call = try await self._callForInstance(update, method: .put, transactionId: transactionId)
apiCalls.append(call)
}
for delete in batch.deletes {
let call = try await self._callForInstance(delete, method: .delete, transactionId: transactionId)
apiCalls.append(call)
}
return try await self._executeApiCalls(apiCalls)
}
// /// Initiates the process of sending the data with the server
//<<<<<<< HEAD
// fileprivate func _sendServerRequest<V: Decodable>(_ method: HTTPMethod, instance: T? = nil) async throws -> V? {
// if let apiCall = try await self._call(method: method, instance: instance) {
// return try await self._prepareAndSendCall(apiCall)
//=======
// fileprivate func _synchronize<V: Decodable>(_ instance: T, method: HTTPMethod) async throws -> V? {
// if let apiCall = try await self._callForInstance(instance, method: method) {
// return try await self._executeApiCall(apiCall)
//>>>>>>> main
// } else {
// return nil
// }
// }
fileprivate func _prepareAndSendCall<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V? {
self._prepareCall(apiCall: apiCall)
return try await self._executeGetCall(apiCall)
}
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeGetCall<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
return try await StoreCenter.main.executeGet(apiCall: apiCall)
}
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [T] {
return try await StoreCenter.main.execute(apiCalls: apiCalls)
}
/// Returns the content of the API call file as a String
func contentOfFile() -> 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
}
/// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool {
return self.items.isNotEmpty
}
}