diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 1c6055f..7580f1e 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; }; C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; }; C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; }; + C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -69,6 +70,7 @@ C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = ""; }; C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; + C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -119,6 +121,7 @@ C4A47D6C2B71364600ADC637 /* ModelObject.swift */, C4A47D602B6D3C1300ADC637 /* Services.swift */, C425D4572B6D2519002A7B48 /* Store.swift */, + C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */, C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */, @@ -141,12 +144,12 @@ isa = PBXGroup; children = ( C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, + C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */, + C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, C4A47D832B7B97F000ADC637 /* KeychainStore.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */, - C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, - C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, ); path = Utils; sourceTree = ""; @@ -291,6 +294,7 @@ C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, + C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */, C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */, C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */, diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index 0f4958d..ecf6b5f 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -7,13 +7,25 @@ import Foundation + +protocol ACCollection { + func findCallById(_ id: String) async -> (any SomeCall)? + func deleteById(_ id: String) async + + func hasPendingAPICalls() async -> Bool + func contentOfApiCallFile() async -> String? + + func reset() async + +} + /// 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 { +actor ApiCallCollection: ACCollection { /// The reference to the Store - fileprivate var _store: Store +// fileprivate var _store: Store /// The list of api calls fileprivate(set) var items: [ApiCall] = [] @@ -35,9 +47,8 @@ actor ApiCallCollection { } } - init(store: Store) { - self._store = store - } +// init() { +// } /// Starts the JSON file decoding synchronously or asynchronously /// Reschedule Api calls if not empty @@ -58,7 +69,7 @@ actor ApiCallCollection { if FileManager.default.fileExists(atPath: fileURL.path()) { let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let decoded: [ApiCall] = try jsonString.decodeArray() ?? [] - Logger.log("loaded \(T.fileName()) with \(decoded.count) items") + Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items") self.items = decoded } } @@ -101,7 +112,10 @@ actor ApiCallCollection { self._hasChanged = true } } - + func findCallById(_ id: String) async -> (any SomeCall)? { + return self.findById(id) + } + /// Returns the Api call associated with the provided id func findById(_ id: String) -> ApiCall? { return self.items.first(where: { $0.id == id }) @@ -255,7 +269,7 @@ actor ApiCallCollection { /// Executes an API call /// For POST requests, potentially copies additional data coming from the server during the insert fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws { - let result = try await self._store.execute(apiCall: apiCall) + let result = try await StoreCenter.main.execute(apiCall: apiCall) switch apiCall.method { case .post: if let instance = self.findById(result.stringId) { @@ -275,5 +289,10 @@ actor ApiCallCollection { } return nil } - + + /// Returns if the API call collection is not empty + func hasPendingAPICalls() -> Bool { + return self.items.isNotEmpty + } + } diff --git a/LeStorage/Codables/ApiCall.swift b/LeStorage/Codables/ApiCall.swift index 90397cc..6666c96 100644 --- a/LeStorage/Codables/ApiCall.swift +++ b/LeStorage/Codables/ApiCall.swift @@ -17,6 +17,7 @@ class ApiCall: ModelObject, Storable, SomeCall { static func resourceName() -> String { return "apicalls_" + T.resourceName() } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + static func filterByStoreIdentifier() -> Bool { return false } var id: String = Store.randomId() diff --git a/LeStorage/Codables/FailedAPICall.swift b/LeStorage/Codables/FailedAPICall.swift index a4ab422..1cdfad1 100644 --- a/LeStorage/Codables/FailedAPICall.swift +++ b/LeStorage/Codables/FailedAPICall.swift @@ -11,7 +11,8 @@ class FailedAPICall: ModelObject, Storable { static func resourceName() -> String { return "failed-api-calls" } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } - + static func filterByStoreIdentifier() -> Bool { return false } + var id: String = Store.randomId() /// The creation date of the call diff --git a/LeStorage/ModelObject.swift b/LeStorage/ModelObject.swift index 74a9b83..e0ee0e0 100644 --- a/LeStorage/ModelObject.swift +++ b/LeStorage/ModelObject.swift @@ -10,6 +10,8 @@ import Foundation /// A class used as the root class for Storable objects open class ModelObject { + public var store: Store? = nil + public init() { } open func deleteDependencies() throws { diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 50b18d8..fc3e7be 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -50,9 +50,12 @@ public class Services { /// A KeychainStore object used to store the user's token let keychainStore: KeychainStore +// fileprivate var _storeIdentifier: StoreIdentifier? + public init(url: String) { self.baseURL = url self.keychainStore = KeychainStore(serverId: url) +// self._storeIdentifier = storeId Logger.log("create keystore with id: \(url)") } @@ -103,7 +106,7 @@ public class Services { case 200..<300: if let apiCallId, let collectionName = (T.self as? any Storable.Type)?.resourceName() { - try await Store.main.deleteApiCallById(apiCallId, collectionName: collectionName) + try await StoreCenter.main.deleteApiCallById(apiCallId, collectionName: collectionName) } default: /* @@ -119,10 +122,10 @@ public class Services { } if let apiCallId, let type = (T.self as? any Storable.Type) { - try Store.main.rescheduleApiCalls(id: apiCallId, type: type) - Store.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message) + try await StoreCenter.main.rescheduleApiCalls(id: apiCallId, type: type) + StoreCenter.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message) } else { - Store.main.logFailedAPICall(request: request, error: errorMessage.message) + StoreCenter.main.logFailedAPICall(request: request, error: errorMessage.message) } throw ServiceError.responseError(response: errorMessage.error) @@ -147,9 +150,9 @@ public class Services { /// Returns a GET request for the resource /// - Parameters: /// - type: the type of the request resource - fileprivate func _getRequest(type: T.Type) throws -> URLRequest { + fileprivate func _getRequest(type: T.Type, identifier: StoreIdentifier?) throws -> URLRequest { let requiresToken = self._isTokenRequired(type: T.self, method: .get) - return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken) + return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier) } /// Returns a POST request for the resource @@ -188,11 +191,14 @@ public class Services { /// - servicePath: the path to add to the API base URL /// - method: the HTTP method to execute /// - requiresToken: An optional boolean to indicate if the token is required - fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil) throws -> URLRequest { + fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest { let urlString = baseURL + servicePath - guard let url = URL(string: urlString) else { + guard var url = URL(string: urlString) else { throw ServiceError.urlCreationError(url: urlString) } + if let identifier { + url.append(path: identifier.urlComponent) + } var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -207,8 +213,8 @@ public class Services { // MARK: - Services /// Executes a GET request - public func get() async throws -> [T] { - let getRequest = try _getRequest(type: T.self) + public func get(identifier: StoreIdentifier?) async throws -> [T] { + let getRequest = try _getRequest(type: T.self, identifier: identifier) return try await self._runRequest(getRequest) } @@ -315,8 +321,8 @@ public class Services { _ = try await requestToken(username: username, password: password) let postRequest = try self._baseRequest(conf: .getUser) let user: U = try await self._runRequest(postRequest) - Store.main.setUserUUID(uuidString: user.id) - Store.main.setUserName(user.username) + StoreCenter.main.setUserUUID(uuidString: user.id) + StoreCenter.main.setUserName(user.username) return user } @@ -327,7 +333,7 @@ public class Services { /// - password2: a repeat of the new password public func changePassword(oldPassword: String, password1: String, password2: String) async throws { - guard let username = Store.main.userName() else { + guard let username = StoreCenter.main.userName() else { throw ServiceError.missingUserName } diff --git a/LeStorage/Storable.swift b/LeStorage/Storable.swift index cf4c635..73babe0 100644 --- a/LeStorage/Storable.swift +++ b/LeStorage/Storable.swift @@ -10,6 +10,9 @@ import Foundation /// A protocol describing classes that can be stored locally in JSON and synchronized on our django server public protocol Storable: Codable, Identifiable where ID : StringProtocol { + /// The store containing a reference to the instance + var store: Store? { get set } + /// The resource name corresponding to the resource path on the API /// Also used as the name of the local file static func resourceName() -> String @@ -17,6 +20,11 @@ public protocol Storable: Codable, Identifiable where ID : StringProtocol { /// Returns HTTP methods that do not need to pass the token to the request static func tokenExemptedMethods() -> [HTTPMethod] + /// This method is only used if the instance store uses an identifier + /// This method should return true if the resources need to get filtered using the store identifier when performing a GET + /// Returning false won't filter the resources when performing a GET + static func filterByStoreIdentifier() -> Bool + /// A method that deletes the local dependencies of the resource /// Mimics the behavior the cascading delete on the django server /// Typically when we delete a resource, we automatically delete items that depends on it, diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index b2a8d51..7db2737 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -8,10 +8,10 @@ import Foundation import UIKit -public enum ResetOption { - case all - case synchronizedOnly -} +//public enum ResetOption { +// case all +// case synchronizedOnly +//} public enum StoreError: Error { case missingService @@ -22,59 +22,45 @@ public enum StoreError: Error { case unSynchronizedCollection } -public class Store { - - /// The Store singleton - public static let main = Store() - - /// A method to provide ids corresponding to the django storage - public static func randomId() -> String { - return UUID().uuidString.lowercased() - } +public struct StoreIdentifier { + var value: String + var parameterName: String - /// The URL of the django API - public var synchronizationApiURL: String? { - didSet { - if let url = synchronizationApiURL { - self._services = Services(url: url) - } - } + var urlComponent: String { + return "?\(self.parameterName)=\(self.value)" } +} + +open class Store { - /// The services performing the API calls - fileprivate var _services: Services? - - /// Returns the service instance - public func service() throws -> Services { - if let service = self._services { - return service - } else { - throw StoreError.missingService - } - } + /// The Store singleton + public static let main = Store() /// The dictionary of registered StoredCollections fileprivate var _collections: [String : any SomeCollection] = [:] - /// A store for the Settings object - fileprivate var _settingsStorage: MicroStorage = MicroStorage(fileName: "settings.json") - /// The name of the directory to store the json files static let storageDirectory = "storage" - /// Indicates to Stored Collection if they can synchronize - public var collectionsCanSynchronize: Bool = true { - didSet { - Logger.log(">>> collectionsCanSynchronize = \(self.collectionsCanSynchronize)") - } + fileprivate(set) var identifier: StoreIdentifier? = nil + + public init() { + self._createDirectory(directory: Store.storageDirectory) } - fileprivate var _failedAPICallsCollection: StoredCollection? = nil + public required init(identifier: String, parameter: String) { + self.identifier = StoreIdentifier(value: identifier, parameterName: parameter) + let directory = "\(Store.storageDirectory)/\(identifier)" + self._createDirectory(directory: directory) + } - fileprivate var blackListedUserName: [String] = [] + fileprivate func _createDirectory(directory: String) { + FileManager.default.createDirectoryInDocuments(directoryName: directory) + } - public init() { - FileManager.default.createDirectoryInDocuments(directoryName: Store.storageDirectory) + /// A method to provide ids corresponding to the django storage + public static func randomId() -> String { + return UUID().uuidString.lowercased() } /// Registers a collection @@ -82,7 +68,7 @@ public class Store { public func registerCollection(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection { // register collection - let collection = StoredCollection(synchronized: synchronized, store: Store.main, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate, loadCompletion: nil) + let collection = StoredCollection(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate) self._collections[T.resourceName()] = collection return collection @@ -92,78 +78,12 @@ public class Store { public func registerObject(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton { // register collection - let storedObject = StoredSingleton(synchronized: synchronized, store: Store.main, inMemory: inMemory, sendsUpdate: sendsUpdate, loadCompletion: nil) + let storedObject = StoredSingleton(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate) self._collections[T.resourceName()] = storedObject return storedObject } - // MARK: - Settings - - /// Stores the user UUID - func setUserUUID(uuidString: String) { - self._settingsStorage.update { settings in - settings.userId = uuidString - } - } - - /// Returns the stored user Id - public var userId: String? { - return self._settingsStorage.item.userId - } - - /// Returns the username - public func userName() -> String? { - return self._settingsStorage.item.username - } - - /// Sets the username - func setUserName(_ username: String) { - self._settingsStorage.update { settings in - settings.username = username - } - } - - /// Returns the stored token - public func token() -> String? { - return try? self.service().keychainStore.getToken() - } - - /// Disconnect the user from the storage and resets collection - public func disconnect(resetOption: ResetOption? = nil) { - try? self.service().deleteToken() - self._settingsStorage.update { settings in - settings.username = nil - settings.userId = nil - } - - switch resetOption { - case .all: - for collection in self._collections.values { - collection.reset() - } - case .synchronizedOnly: - for collection in self._collections.values { - if collection.synchronized { - collection.reset() - } - } - default: - break - } - - } - - /// Returns whether the system has a user token - public func hasToken() -> Bool { - do { - _ = try self.service().keychainStore.getToken() - return true - } catch { - return false - } - } - // MARK: - Convenience /// Looks for an instance by id @@ -192,57 +112,6 @@ public class Store { throw StoreError.collectionNotRegistered(type: T.resourceName()) } - // MARK: - Api call rescheduling - - /// Deletes an ApiCall by [id] and [collectionName] - func deleteApiCallById(_ id: String, collectionName: String) async throws { - if let collection = self._collections[collectionName] { - try await collection.deleteApiCallById(id) - } else { - throw StoreError.collectionNotRegistered(type: collectionName) - } - } - - /// Reschedule an ApiCall by id - func rescheduleApiCalls(id: String, type: T.Type) throws { - guard self.collectionsCanSynchronize else { - return - } - let collection: StoredCollection = try self.collection() - collection.rescheduleApiCallsIfNecessary() - } - - /// Executes an ApiCall - fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws -> T { - return try await self.service().runApiCall(apiCall) - } - - /// Executes an API call - func execute(apiCall: ApiCall) async throws -> T { - return try await self._executeApiCall(apiCall) - } - - // MARK: - - - /// Retrieves all the items on the server - func getItems() async throws -> [T] { - return try await self.service().get() - } - - /// Resets all registered collection - public func reset() { - for collection in self._collections.values { - collection.reset() - } - } - - /// Resets all the api call collections - public func resetApiCalls() { - for collection in self._collections.values { - collection.resetApiCalls() - } - } - /// Loads all collection with the data from the server public func loadCollectionFromServer() { for collection in self._collections.values { @@ -252,14 +121,11 @@ public class Store { } } - /// Returns whether any collection has pending API calls - public func hasPendingAPICalls() async -> Bool { + /// Resets all registered collection + public func reset() { for collection in self._collections.values { - if await collection.hasPendingAPICalls() { - return true - } + collection.reset() } - return false } /// Returns the names of all collections @@ -267,77 +133,57 @@ public class Store { return self._collections.values.map { $0.resourceName } } - /// Returns the content of the api call file - public func apiCallsFileContent(resourceName: String) async -> String { - return await self._collections[resourceName]?.contentOfApiCallFile() ?? "" + // MARK: - Write + + fileprivate func _directoryPath() throws -> URL { + var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) + if let identifier = self.identifier?.value { + url.append(component: identifier) + } + return url } - /// This method triggers the framework to save and send failed api calls - public func logsFailedAPICalls() { - self._failedAPICallsCollection = self.registerCollection(synchronized: true) + func write(content: String, fileName: String) throws { + var fileURL = try self._directoryPath() + fileURL.append(component: fileName) + try content.write(to: fileURL, atomically: false, encoding: .utf8) } - /// If configured for, logs and send to the server a failed API call - /// Logs a failed API call that has failed at least 5 times - func logFailedAPICall(_ apiCallId: String, request: URLRequest, collectionName: String, error: String) { - - guard let failedAPICallsCollection = self._failedAPICallsCollection, - let collection = self._collections[collectionName], - collectionName != FailedAPICall.resourceName() - else { - return - } - - Task { - if let apiCall = await collection.apiCallById(apiCallId) { - - if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 { - - do { - let authValue = request.allHTTPHeaderFields?["Authorization"] - let string = try apiCall.jsonString() - let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue) - try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) - } catch { - Logger.error(error) - } - } - } - } - - + func fileURL(type: T.Type) throws -> URL { + let fileURL = try self._directoryPath() + return fileURL.appending(component: T.fileName()) } - /// Logs a failed Api call with its request and error message - func logFailedAPICall(request: URLRequest, error: String) { - - guard let failedAPICallsCollection = self._failedAPICallsCollection, - let body: Data = request.httpBody, - let bodyString = String(data: body, encoding: .utf8), - let url = request.url?.absoluteString else { - return - } - - let authValue = request.allHTTPHeaderFields?["Authorization"] - + func removeFile(type: T.Type) { do { - let failedAPICall = FailedAPICall(callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error, authentication: authValue) - try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) + let url: URL = try self.fileURL(type: type) + if FileManager.default.fileExists(atPath: url.path()) { + try FileManager.default.removeItem(at: url) + } } catch { Logger.error(error) } + } + + /// Retrieves all the items on the server + public func getItems() async throws -> [T] { + if T.filterByStoreIdentifier() { + return try await StoreCenter.main.getItems(identifier: self.identifier) + } else { + return try await StoreCenter.main.getItems() + } + } + func sendInsertion(_ instance: T) async throws { + try await StoreCenter.main.sendInsertion(instance) } - public func blackListUserName(_ userName: String) { - self.blackListedUserName.append(userName) + func sendUpdate(_ instance: T) async throws { + try await StoreCenter.main.sendUpdate(instance) } - func userIsAllowed() -> Bool { - guard let userName = self.userName() else { - return true - } - return !self.blackListedUserName.contains(where: { $0 == userName } ) + func sendDeletion(_ instance: T) async throws { + try await StoreCenter.main.sendDeletion(instance) } } diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift new file mode 100644 index 0000000..d4dde07 --- /dev/null +++ b/LeStorage/StoreCenter.swift @@ -0,0 +1,361 @@ +// +// StoreCenter.swift +// LeStorage +// +// Created by Laurent Morvillier on 25/06/2024. +// + +import Foundation + +public class StoreCenter { + + public static let main: StoreCenter = StoreCenter() + + fileprivate var _stores: [String : Store] = [:] + + /// The URL of the django API + public var synchronizationApiURL: String? { + didSet { + if let url = synchronizationApiURL { + self._services = Services(url: url) + } + } + } + + /// Indicates to Stored Collection if they can synchronize + public var collectionsCanSynchronize: Bool = true + + /// A store for the Settings object + fileprivate var _settingsStorage: MicroStorage = MicroStorage(fileName: "settings.json") + + /// The services performing the API calls + fileprivate var _services: Services? + + /// The dictionary of registered StoredCollections + fileprivate var _apiCallCollections: [String : any ACCollection] = [:] + + fileprivate var _failedAPICallsCollection: StoredCollection? = nil + + fileprivate var blackListedUserName: [String] = [] + + init() { + self._loadExistingApiCollections() + } + + /// Returns the service instance + public func service() throws -> Services { + if let service = self._services { + return service + } else { + throw StoreError.missingService + } + } + + fileprivate func _registerStore(store: Store) { + guard let identifier = store.identifier?.value else { + fatalError("The store has no identifier") + } + self._stores[identifier] = store + } + + public func store(identifier: String, parameter: String) -> T { + if let store = self._stores[identifier] as? T { + return store + } else { + let store = T(identifier: identifier, parameter: parameter) + self._registerStore(store: store) + return store + } + } + + fileprivate func _loadExistingApiCollections() { + let string = "clubs" + + + } + + // MARK: - Settings + + /// Stores the user UUID + func setUserUUID(uuidString: String) { + self._settingsStorage.update { settings in + settings.userId = uuidString + } + } + + /// Returns the stored user Id + public var userId: String? { + return self._settingsStorage.item.userId + } + + /// Returns the username + public func userName() -> String? { + return self._settingsStorage.item.username + } + + /// Sets the username + func setUserName(_ username: String) { + self._settingsStorage.update { settings in + settings.username = username + } + } + + /// Returns the stored token + public func token() -> String? { + return try? self.service().keychainStore.getToken() + } + + /// Disconnect the user from the storage and resets collection + public func disconnect() { + try? self.service().deleteToken() + self._settingsStorage.update { settings in + settings.username = nil + settings.userId = nil + } + +// switch resetOption { +// case .all: +// for collection in self._collections.values { +// collection.reset() +// } +// case .synchronizedOnly: +// for collection in self._collections.values { +// if collection.synchronized { +// collection.reset() +// } +// } +// default: +// break +// } + + } + + /// Returns whether the system has a user token + public func hasToken() -> Bool { + do { + _ = try self.service().keychainStore.getToken() + return true + } catch { + return false + } + } + + func getOrCreateApiCallCollection() -> ApiCallCollection { + if let apiCallCollection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection { + return apiCallCollection + } + let apiCallCollection = ApiCallCollection() + self._apiCallCollections[T.resourceName()] = apiCallCollection + return apiCallCollection +// apiCallCollection.loadFromFile() + } + + func apiCallCollection() throws -> ApiCallCollection { + if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection { + return collection + } + throw StoreError.collectionNotRegistered(type: T.resourceName()) + } + + func deleteApiCallByDataId(type: T.Type, id: String) async throws { + let apiCallCollection: ApiCallCollection = try self.apiCallCollection() + await apiCallCollection.deleteByDataId(id) + } + + func deleteApiCallById(type: T.Type, id: String) async throws { + let apiCallCollection: ApiCallCollection = try self.apiCallCollection() + await apiCallCollection.deleteById(id) + } + + func deleteApiCallById(_ id: String, collectionName: String) async throws { + if let apiCallCollection = self._apiCallCollections[collectionName] { + await apiCallCollection.deleteById(id) + } else { + throw StoreError.collectionNotRegistered(type: collectionName) + } + } + + // MARK: - Api call rescheduling + + /// Reschedule an ApiCall by id + func rescheduleApiCalls(id: String, type: T.Type) async throws { + guard self.collectionsCanSynchronize else { + return + } + let collection: ApiCallCollection = try self.apiCallCollection() + await collection.rescheduleApiCallsIfNecessary() + } + + /// Executes an ApiCall + fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws -> T { + return try await self.service().runApiCall(apiCall) + } + + /// Executes an API call + func execute(apiCall: ApiCall) async throws -> T { + return try await self._executeApiCall(apiCall) + } + + // MARK: - + + /// Retrieves all the items on the server + func getItems(identifier: StoreIdentifier? = nil) async throws -> [T] { + return try await self.service().get(identifier: identifier) + } + + /// Resets all registered collection + public func reset() { + Store.main.reset() + for store in self._stores.values { + store.reset() + } + } + + /// Resets all the api call collections + public func resetApiCalls() { + Task { + for collection in self._apiCallCollections.values { + await collection.reset() + } + } + } + + public func resetApiCalls(collection: StoredCollection) { + do { + let apiCallCollection: ApiCallCollection = try self.apiCallCollection() + Task { + await apiCallCollection.reset() + } + } catch { + Logger.error(error) + } + } + + /// Returns whether any collection has pending API calls + public func hasPendingAPICalls() async -> Bool { + for collection in self._apiCallCollections.values { + if await collection.hasPendingAPICalls() { + return true + } + } + return false + } + + /// Returns the content of the api call file + public func apiCallsFileContent(resourceName: String) async -> String { + return await self._apiCallCollections[resourceName]?.contentOfApiCallFile() ?? "" + } + + /// This method triggers the framework to save and send failed api calls + public func logsFailedAPICalls() { + self._failedAPICallsCollection = Store.main.registerCollection(synchronized: true) + } + + /// If configured for, logs and send to the server a failed API call + /// Logs a failed API call that has failed at least 5 times + func logFailedAPICall(_ apiCallId: String, request: URLRequest, collectionName: String, error: String) { + + guard let failedAPICallsCollection = self._failedAPICallsCollection, + let collection = self._apiCallCollections[collectionName], + collectionName != FailedAPICall.resourceName() + else { + return + } + + Task { + if let apiCall = await collection.findCallById(apiCallId) { + + if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 { + + do { + let authValue = request.allHTTPHeaderFields?["Authorization"] + let string = try apiCall.jsonString() + let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue) + try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) + } catch { + Logger.error(error) + } + } + } + } + + + } + + /// Logs a failed Api call with its request and error message + func logFailedAPICall(request: URLRequest, error: String) { + + guard let failedAPICallsCollection = self._failedAPICallsCollection, + let body: Data = request.httpBody, + let bodyString = String(data: body, encoding: .utf8), + let url = request.url?.absoluteString else { + return + } + + let authValue = request.allHTTPHeaderFields?["Authorization"] + + do { + let failedAPICall = FailedAPICall(callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error, authentication: authValue) + try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) + } catch { + Logger.error(error) + } + + } + + public func blackListUserName(_ userName: String) { + self.blackListedUserName.append(userName) + } + + func userIsAllowed() -> Bool { + guard let userName = self.userName() else { + return true + } + return !self.blackListedUserName.contains(where: { $0 == userName } ) + } + +// fileprivate func _registerStore(identifier: String, parameter: String) -> Store { +// let store = Store(identifier: identifier, parameter: parameter) +// self._stores[identifier] = store +// return store +// } +// +// public func store(identifier: String, parameter: String) -> Store { +// if let store = self._stores[identifier] { +// return store +// } +// return self._registerStore(identifier: identifier, parameter: parameter) +// } + + public func destroyStore(identifier: String) { + let directory = "\(Store.storageDirectory)/\(identifier)" + FileManager.default.deleteDirectoryInDocuments(directoryName: directory) + } + + /// Returns whether the collection can synchronize + fileprivate func _canSynchronise() -> Bool { + return self.collectionsCanSynchronize && self.userIsAllowed() + } + + func sendInsertion(_ instance: T) async throws { + guard self._canSynchronise() else { + return + } + try await self.apiCallCollection().sendInsertion(instance) + } + + func sendUpdate(_ instance: T) async throws { + guard self._canSynchronise() else { + return + } + try await self.apiCallCollection().sendUpdate(instance) + } + + func sendDeletion(_ instance: T) async throws { + guard self._canSynchronise() else { + return + } + try await self.apiCallCollection().sendDeletion(instance) + } + +} diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index 74b4ac3..9366c79 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -17,25 +17,24 @@ protocol CollectionHolder { associatedtype Item var items: [Item] { get } + func reset() } -protocol SomeCollection: Identifiable { +protocol SomeCollection: CollectionHolder, Identifiable { var resourceName: String { get } var synchronized: Bool { get } func allItems() -> [any Storable] - func deleteById(_ id: String) throws func loadDataFromServerIfAllowed() async throws - func reset() - func resetApiCalls() +// func resetApiCalls() - func deleteApiCallById(_ id: String) async throws - func apiCallById(_ id: String) async -> (any SomeCall)? +// func deleteApiCallById(_ id: String) async throws +// func apiCallById(_ id: String) async -> (any SomeCall)? - func hasPendingAPICalls() async -> Bool - func contentOfApiCallFile() async -> String? +// func hasPendingAPICalls() async -> Bool +// func contentOfApiCallFile() async -> String? } @@ -62,13 +61,13 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti fileprivate var _store: Store /// Notifies the closure when the loading is done - fileprivate var loadCompletion: ((StoredCollection) -> ())? = nil +// fileprivate var loadCompletion: ((StoredCollection) -> ())? = nil /// Provides fast access for instances if the collection has been instanced with [indexed] = true fileprivate var _indexes: [String : T]? = nil /// Collection of API calls used to store HTTP calls - fileprivate var apiCallsCollection: ApiCallCollection? = nil +// fileprivate var apiCallsCollection: ApiCallCollection? = nil /// Indicates whether the collection has changed, thus requiring a write operation fileprivate var _hasChanged: Bool = false { @@ -90,7 +89,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Indicates if the collection has loaded objects from the server fileprivate(set) public var hasLoadedFromServer: Bool = false - init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true, loadCompletion: ((StoredCollection) -> ())? = nil) { + init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) { self.synchronized = synchronized self.asynchronousIO = asynchronousIO if indexed { @@ -99,24 +98,33 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._inMemory = inMemory self._sendsUpdate = sendsUpdate self._store = store - self.loadCompletion = loadCompletion +// self.loadCompletion = loadCompletion - if synchronized { - let apiCallCollection = ApiCallCollection(store: store) - self.apiCallsCollection = apiCallCollection - Task { - do { - try await apiCallCollection.loadFromFile() - } catch { - Logger.error(error) - } - } - - } +// if synchronized { +// let apiCallCollection = ApiCallCollection() +// self.apiCallsCollection = apiCallCollection +// Task { +// do { +// try await apiCallCollection.loadFromFile() +// } catch { +// Logger.error(error) +// } +// } +// +// } self._load() } + fileprivate init() { + self.synchronized = false + self._store = Store.main + } + + public static func placeholder() -> StoredCollection { + return StoredCollection() + } + var resourceName: String { return T.resourceName() } @@ -155,24 +163,31 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Decodes the json file into the items array fileprivate func _decodeJSONFile() throws { - let fileURL = try T.urlForJSONFile() + let fileURL = try self._store.fileURL(type: T.self) if FileManager.default.fileExists(atPath: fileURL.path()) { let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let decoded: [T] = try jsonString.decodeArray() ?? [] + for var item in decoded { + item.store = self._store + } DispatchQueue.main.async { Logger.log("loaded \(T.fileName()) with \(decoded.count) items") self.items = decoded self._updateIndexIfNecessary() - self.loadCompletion?(self) +// self.loadCompletion?(self) NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) } - } else { - DispatchQueue.main.async { - self.loadCompletion?(self) - NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) - } - } + } +// else { +// Task { +// do { +// try await self.loadDataFromServerIfAllowed() +// } catch { +// Logger.error(error) +// } +// } +// } } @@ -192,7 +207,9 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti let items: [T] = try await self._store.getItems() try self._addOrUpdate(contentOfs: items, shouldSync: false) self.hasLoadedFromServer = true - NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) + } } catch { Logger.error(error) } @@ -208,6 +225,9 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._hasChanged = true } + var item = instance + item.store = self._store + // update if let index = self.items.firstIndex(where: { $0.id == instance.id }) { self.items[index] = instance @@ -276,7 +296,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._hasChanged = true } - for instance in sequence { + for var instance in sequence { if let index = self.items.firstIndex(where: { $0.id == instance.id }) { self.items[index] = instance if shouldSync { @@ -288,6 +308,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._sendInsertionIfNecessary(instance) } } + instance.store = self._store self._indexes?[instance.stringId] = instance } @@ -315,11 +336,20 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._hasChanged = true } for item in items { - self.items.removeAll(where: { $0.id == item.id }) + if let index = self.items.firstIndex(where: { $0.id == item.id }) { + self.items.remove(at: index) + } + +// self.items.removeAll(where: { $0.id == item.id }) Task { + do { + try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId) + } catch { + Logger.error(error) + } /// Remove related API call if existing - await self.apiCallsCollection?.deleteByDataId(item.stringId) +// await self.apiCallsCollection?.deleteByDataId(item.stringId) } } @@ -335,16 +365,16 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Deletes an API Call by its id /// - Parameters: /// - id: the id of the API Call - func deleteApiCallById(_ id: String) async throws { - await self.apiCallsCollection?.deleteById(id) - } - - /// Returns an API Call by its id - /// - Parameters: - /// - id: the id of the API Call - func apiCallById(_ id: String) async -> (any SomeCall)? { - return await self.apiCallsCollection?.findById(id) - } +// func deleteApiCallById(_ id: String) async throws { +// await self.apiCallsCollection?.deleteById(id) +// } +// +// /// Returns an API Call by its id +// /// - Parameters: +// /// - id: the id of the API Call +// func apiCallById(_ id: String) async -> (any SomeCall)? { +// return await self.apiCallsCollection?.findById(id) +// } // MARK: - SomeCall @@ -374,7 +404,8 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti Logger.log("Start write to \(T.fileName())...") do { let jsonString: String = try self.items.jsonString() - try T.writeToStorageDirectory(content: jsonString, fileName: T.fileName()) + try self._store.write(content: jsonString, fileName: T.fileName()) +// try T.writeToStorageDirectory(content: jsonString, fileName: T.fileName()) } catch { Logger.error(error) // TODO how to notify the main project } @@ -389,27 +420,27 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Removes the items of the collection, deletes the corresponding file, and also reset the related API calls collection public func reset() { self.items.removeAll() + self._store.removeFile(type: T.self) +// do { +// let url: URL = try T.urlForJSONFile() +// if FileManager.default.fileExists(atPath: url.path()) { +// try FileManager.default.removeItem(at: url) +// } +// } catch { +// Logger.error(error) +// } - do { - let url: URL = try T.urlForJSONFile() - if FileManager.default.fileExists(atPath: url.path()) { - try FileManager.default.removeItem(at: url) - } - } catch { - Logger.error(error) - } - - self.resetApiCalls() +// self.resetApiCalls() } - /// Removes the collection related API calls collection - public func resetApiCalls() { - if let apiCallsCollection = self.apiCallsCollection { - Task { - await apiCallsCollection.reset() - } - } - } +// /// Removes the collection related API calls collection +// public func resetApiCalls() { +// if let apiCallsCollection = self.apiCallsCollection { +// Task { +// await apiCallsCollection.reset() +// } +// } +// } // MARK: - Reschedule calls @@ -417,11 +448,11 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// - Parameters: /// - instance: the object to POST fileprivate func _sendInsertionIfNecessary(_ instance: T) { - guard self.synchronized, self._canSynchronise() else { + guard self.synchronized else { return } Task { - await self.apiCallsCollection?.sendInsertion(instance) + try await self._store.sendInsertion(instance) } } @@ -429,11 +460,11 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// - Parameters: /// - instance: the object to PUT fileprivate func _sendUpdateIfNecessary(_ instance: T) { - guard self.synchronized, self._sendsUpdate, self._canSynchronise() else { + guard self.synchronized, self._sendsUpdate else { return } Task { - await self.apiCallsCollection?.sendUpdate(instance) + try await self._store.sendUpdate(instance) } } @@ -441,36 +472,20 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// - Parameters: /// - instance: the object to DELETE fileprivate func _sendDeletionIfNecessary(_ instance: T) { - guard self.synchronized, self._canSynchronise() else { + guard self.synchronized else { return } Task { - await self.apiCallsCollection?.sendDeletion(instance) + try await self._store.sendDeletion(instance) } } - /// Returns whether the collection can synchronize - fileprivate func _canSynchronise() -> Bool { - return Store.main.collectionsCanSynchronize && Store.main.userIsAllowed() - } - /// Reschedule the api calls if possible - func rescheduleApiCallsIfNecessary() { - Task { - await self.apiCallsCollection?.rescheduleApiCallsIfNecessary() - } - } - - /// Returns the content of the API call file as a String - func contentOfApiCallFile() async -> String? { - return await self.apiCallsCollection?.contentOfApiCallFile() - } - - /// Returns if the API call collection is not empty - func hasPendingAPICalls() async -> Bool { - guard let apiCallsCollection else { return false } - return await apiCallsCollection.items.isNotEmpty - } +// func rescheduleApiCallsIfNecessary() { +// Task { +// await self.apiCallsCollection?.rescheduleApiCallsIfNecessary() +// } +// } // MARK: - RandomAccessCollection diff --git a/LeStorage/Utils/FileManager+Extensions.swift b/LeStorage/Utils/FileManager+Extensions.swift index 7b81750..44579fc 100644 --- a/LeStorage/Utils/FileManager+Extensions.swift +++ b/LeStorage/Utils/FileManager+Extensions.swift @@ -22,4 +22,16 @@ extension FileManager { } } + func deleteDirectoryInDocuments(directoryName: String) { + let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first! + let directoryURL = documentsDirectory.appendingPathComponent(directoryName) + if self.fileExists(atPath: directoryURL.path) { + do { + try self.removeItem(at: directoryURL) + } catch { + Logger.error(error) + } + } + } + }