// // 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 SomeCallCollection] = [:] /// A collection storing FailedAPICall objects fileprivate var _failedAPICallsCollection: StoredCollection? = nil /// A list of username that cannot synchronize with the server 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 } } /// Registers a store into the list of stores /// - Parameters: /// - store: A store to save fileprivate func _registerStore(store: Store) { guard let identifier = store.identifier?.value else { fatalError("The store has no identifier") } self._stores[identifier] = store } /// Returns a store using its identifier, and registers it if it does not exists /// - Parameters: /// - identifier: The store identifer /// - parameter: The parameter name used to filter data on the server 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 } } // 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.resetApiCalls() self._settingsStorage.update { settings in settings.username = nil settings.userId = nil } } /// Returns whether the system has a user token public func hasToken() -> Bool { do { _ = try self.service().keychainStore.getToken() return true } catch { return false } } func loadApiCallCollection(type: T.Type) { let apiCallCollection = ApiCallCollection() self._apiCallCollections[T.resourceName()] = apiCallCollection Task { do { try await apiCallCollection.loadFromFile() } catch { Logger.error(error) } } } /// Returns or create the ApiCall collection matching the provided T type // func getOrCreateApiCallCollection() -> ApiCallCollection { // if let apiCallCollection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection { // return apiCallCollection // } // let apiCallCollection = ApiCallCollection() // self._apiCallCollections[T.resourceName()] = apiCallCollection // return apiCallCollection // } /// Returns the ApiCall collection using the resource name of the provided T type func apiCallCollection() throws -> ApiCallCollection { if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection { return collection } throw StoreError.collectionNotRegistered(type: T.resourceName()) } /// Deletes an ApiCall, identifying it by dataId /// - Parameters: /// - type: the subsequent type of the ApiCall /// - id: the id of the data stored inside the ApiCall func deleteApiCallByDataId(type: T.Type, id: String) async throws { let apiCallCollection: ApiCallCollection = try self.apiCallCollection() await apiCallCollection.deleteByDataId(id) } /// Deletes an ApiCall by its id /// - Parameters: /// - type: the subsequent type of the ApiCall /// - id: the id of the ApiCall func deleteApiCallById(type: T.Type, id: String) async throws { let apiCallCollection: ApiCallCollection = try self.apiCallCollection() await apiCallCollection.deleteById(id) } /// Deletes an ApiCall by its id /// - Parameters: /// - id: the id of the ApiCall /// - collectionName: the name of the collection of ApiCall 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() } } } /// Resets the ApiCall whose type identifies with the provided collection /// - Parameters: /// - collection: The collection identifying the Storable type 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.hasPendingCalls() { return true } } return false } /// Returns the content of the api call file public func apiCallsFileContent(resourceName: String) async -> String { return await self._apiCallCollections[resourceName]?.contentOfFile() ?? "" } /// 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) } } /// Adds a userName to the black list /// Black listed username cannot send data to the server /// - Parameters: /// - collection: The collection identifying the Storable type public func blackListUserName(_ userName: String) { self._blackListedUserName.append(userName) } /// Returns whether the current userName is allowed to sync with the server func userIsAllowed() -> Bool { guard let userName = self.userName() else { return true } return !self._blackListedUserName.contains(where: { $0 == userName } ) } /// Deletes the directory using its identifier /// - Parameters: /// - identifier: The name of the directory public func destroyStore(identifier: String) { let directory = "\(Store.storageDirectory)/\(identifier)" FileManager.default.deleteDirectoryInDocuments(directoryName: directory) self._stores.removeValue(forKey: identifier) } /// Returns whether the collection can synchronize fileprivate func _canSynchronise() -> Bool { return self.collectionsCanSynchronize && self.userIsAllowed() } /// Transmit the insertion request to the ApiCall collection /// - Parameters: /// - instance: an object to insert func sendInsertion(_ instance: T) async throws { guard self._canSynchronise() else { return } try await self.apiCallCollection().sendInsertion(instance) } /// Transmit the update request to the ApiCall collection /// - Parameters: /// - instance: an object to update func sendUpdate(_ instance: T) async throws { guard self._canSynchronise() else { return } try await self.apiCallCollection().sendUpdate(instance) } /// Transmit the deletion request to the ApiCall collection /// - Parameters: /// - instance: an object to delete func sendDeletion(_ instance: T) async throws { guard self._canSynchronise() else { return } try await self.apiCallCollection().sendDeletion(instance) } }