From 0145072771d329feab1834a73361cc59b6d5907f Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 30 Apr 2025 10:18:47 +0200 Subject: [PATCH] De-singletonize StoreCenter and enable testing for multiple instances --- LeStorage.xcodeproj/project.pbxproj | 6 ++ LeStorage/ApiCallCollection.swift | 13 ++- LeStorage/BaseCollection.swift | 29 +++---- LeStorage/Storable.swift | 21 ----- LeStorage/Store.swift | 14 ++-- LeStorage/StoreCenter.swift | 101 +++++++++++++++++------- LeStorage/SyncedCollection.swift | 2 +- LeStorage/Utils/ClassLoader.swift | 12 ++- LeStorage/Utils/KeychainStore.swift | 9 ++- LeStorage/Utils/MockKeychainStore.swift | 44 +++++++++++ 10 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 LeStorage/Utils/MockKeychainStore.swift diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index e17f856..a97c46a 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */; }; C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; }; + C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; }; C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; @@ -73,6 +74,7 @@ C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = ""; }; C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; }; C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; + C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = ""; }; C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = ""; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; @@ -190,6 +192,7 @@ C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, C467AAE22CD2466400D76CD2 /* Formatter.swift */, C4A47D832B7B97F000ADC637 /* KeychainStore.swift */, + C471F2572DB10649006317F4 /* MockKeychainStore.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */, C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */, C4FAE69B2CEB8E9500790446 /* URLManager.swift */, @@ -372,6 +375,7 @@ C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */, C4D4779D2CB923720077713D /* DataLog.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, + C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */, C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */, C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */, ); @@ -523,6 +527,7 @@ C425D4492B6D24E1002A7B48 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -556,6 +561,7 @@ C425D44A2B6D24E1002A7B48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index f21fc96..3f38b18 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -75,7 +75,7 @@ actor ApiCallCollection: SomeCallCollection { /// Returns the file URL of the collection fileprivate func _urlForJSONFile() throws -> URL { - return try ApiCall.urlForJSONFile() + return try self.storeCenter.jsonFileURL(for: ApiCall.self) } /// Decodes the json file into the items array @@ -98,14 +98,12 @@ actor ApiCallCollection: SomeCallCollection { fileprivate func _write() { let fileName = ApiCall.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) + try self.storeCenter.write(content: jsonString, fileName: fileName) } catch { Logger.error(error) } - // Logger.log("End write") } } @@ -377,6 +375,13 @@ actor ApiCallCollection: SomeCallCollection { self._prepareCalls(batch: batch) await self._batchExecution() } + + func executeSingleGet(instance: T) async where T : URLParameterConvertible { + let call = self._createCall(.get, instance: instance, option: .none) + call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter) + self._addCallToWaitingList(call) + await self._batchExecution() + } fileprivate func _prepareCalls(batch: OperationBatch) { let transactionId = Store.randomId() diff --git a/LeStorage/BaseCollection.swift b/LeStorage/BaseCollection.swift index 21c0962..8d9b398 100644 --- a/LeStorage/BaseCollection.swift +++ b/LeStorage/BaseCollection.swift @@ -64,7 +64,7 @@ public class BaseCollection: SomeCollection, CollectionHolder { /// Sets a max number of items inside the collection fileprivate(set) var limit: Int? = nil - init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) { + init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) { if indexed { self._indexes = [:] } @@ -72,10 +72,16 @@ public class BaseCollection: SomeCollection, CollectionHolder { self.store = store self.limit = limit - Task(priority: .high) { - await self.load() + if synchronousLoading { + self.loadFromFile() + Task { + await self.setAsLoaded() + } + } else { + Task(priority: .high) { + await self.load() + } } - } init(store: Store) { @@ -103,25 +109,23 @@ public class BaseCollection: SomeCollection, CollectionHolder { /// Migrates if necessary and asynchronously decodes the json file func load() async { if !self.inMemory { - await self.loadFromFile() + self.loadFromFile() } else { - await MainActor.run { - self.setAsLoaded() - } + await self.setAsLoaded() } } /// Starts the JSON file decoding synchronously or asynchronously - func loadFromFile() async { + func loadFromFile() { do { - try await self._decodeJSONFile() + try self._decodeJSONFile() } catch { Logger.error(error) } } /// Decodes the json file into the items array - fileprivate func _decodeJSONFile() async throws { + fileprivate func _decodeJSONFile() throws { let fileURL = try self.store.fileURL(type: T.self) @@ -130,9 +134,6 @@ public class BaseCollection: SomeCollection, CollectionHolder { let decoded: [T] = try jsonString.decodeArray() ?? [] self.setItems(decoded) } - await MainActor.run { - self.setAsLoaded() - } } diff --git a/LeStorage/Storable.swift b/LeStorage/Storable.swift index eb87d65..0bfff97 100644 --- a/LeStorage/Storable.swift +++ b/LeStorage/Storable.swift @@ -61,27 +61,6 @@ extension Storable { return path } - /// Returns the local URL of the storage directory - public static func storageDirectoryPath() throws -> URL { - return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) - } - - /// Writes some content to a file inside the storage directory - /// - content: the string to write inside the file - /// - fileName: the name of the file inside the storage directory - static func writeToStorageDirectory(content: String, fileName: String) throws { - var fileURL = try self.storageDirectoryPath() - fileURL.append(component: fileName) - try content.write(to: fileURL, atomically: false, encoding: .utf8) - } - - /// Returns the URL of the Storable json file - static func urlForJSONFile() throws -> URL { - var storageDirectory = try self.storageDirectoryPath() - storageDirectory.append(component: self.fileName()) - return storageDirectory - } - static func buildRealId(id: String) -> ID { switch ID.self { case is String.Type: diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 24dbb2b..088474d 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -51,21 +51,21 @@ final public class Store { /// The dictionary of registered collections fileprivate var _collections: [String : any SomeCollection] = [:] - /// The name of the directory to store the json files - static let storageDirectory = "storage" +// /// The name of the directory to store the json files +// static let storageDirectory = "storage" /// The store identifier, used to name the store directory, and to perform filtering requests to the server public fileprivate(set) var identifier: String? = nil public init(storeCenter: StoreCenter) { self.storeCenter = storeCenter - self._createDirectory(directory: Store.storageDirectory) } public required init(storeCenter: StoreCenter, identifier: String) { self.storeCenter = storeCenter self.identifier = identifier - let directory = "\(Store.storageDirectory)/\(identifier)" + + let directory = "\(storeCenter.directoryName)/\(identifier)" self._createDirectory(directory: directory) } @@ -103,13 +103,13 @@ final public class Store { /// - Parameters: /// - indexed: Creates an index to quickly access the data /// - inMemory: Indicates if the collection should only live in memory, and not write into a file - public func registerSynchronizedCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> SyncedCollection { + public func registerSynchronizedCollection(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) -> SyncedCollection { if let collection: SyncedCollection = try? self.syncedCollection() { return collection } - let collection = SyncedCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit) + let collection = SyncedCollection(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading) self._collections[T.resourceName()] = collection self.storeCenter.loadApiCallCollection(type: T.self) return collection @@ -255,7 +255,7 @@ final public class Store { /// Returns the directory URL of the store fileprivate func _directoryPath() throws -> URL { - var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) + var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName) if let identifier { url.append(component: identifier) } diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index 9c31042..c1beb2e 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -13,13 +13,19 @@ public class StoreCenter { /// The main instance public static let main: StoreCenter = StoreCenter() + /// The name of the directory to store the json files + let directoryName: String + /// A dictionary of Stores associated to their id fileprivate var _stores: [String: Store] = [:] lazy var mainStore: Store = { Store(storeCenter: self) }() /// A KeychainStore object used to store the user's token - var keychainStore: KeychainStore? = nil + var tokenKeychain: KeychainService? = nil + + /// A KeychainStore object used to store the user's token + var deviceKeychain: KeychainService = KeychainStore(serverId: "lestorage.device") /// Force the absence of synchronization public var forceNoSynchronization: Bool = false @@ -55,10 +61,12 @@ public class StoreCenter { /// The URL manager fileprivate var _urlManager: URLManager? = nil - /// Memory only alternate device id for testing purpose - var alternateDeviceId: String? = nil + var classProject: String? = nil - init() { + init(directoryName: String? = nil) { + + self.directoryName = directoryName ?? "storage" + self._createDirectory() self._setupNotifications() @@ -71,17 +79,17 @@ public class StoreCenter { // Logger.log("device Id = \(self.deviceId())") } - public func configureURLs(secureScheme: Bool, domain: String) { + public func configureURLs(secureScheme: Bool, domain: String, webSockets: Bool = true) { let urlManager: URLManager = URLManager(secureScheme: secureScheme, domain: domain) self._urlManager = urlManager self._services = Services(storeCenter: self, url: urlManager.api) - self.keychainStore = KeychainStore(serverId: urlManager.api) + self.tokenKeychain = KeychainStore(serverId: urlManager.api) self._dataAccess = self.mainStore.registerSynchronizedCollection() Logger.log("Sync URL: \(urlManager.api)") - if self.userId != nil { + if webSockets && self.userId != nil { self._configureWebSocket() } } @@ -205,17 +213,17 @@ public class StoreCenter { /// Returns the stored token public func token() throws -> String { guard self.userName != nil else { throw StoreError.missingUsername } - guard let keychainStore else { throw StoreError.missingKeychainStore } - return try keychainStore.getValue() + guard let tokenKeychain else { throw StoreError.missingKeychainStore } + return try tokenKeychain.getValue() } public func rawTokenShouldNotBeUsed() throws -> String? { - return try self.keychainStore?.getValue() + return try self.tokenKeychain?.getValue() } /// Disconnect the user from the storage and resets collection public func disconnect() { - try? self.keychainStore?.deleteValue() + try? self.tokenKeychain?.deleteValue() self.resetApiCalls() self._failedAPICallsCollection?.reset() @@ -251,27 +259,23 @@ public class StoreCenter { /// - token: the token to store func storeToken(username: String, token: String) throws { self._settingsStorage.item.username = username - guard let keychainStore else { throw StoreError.missingKeychainStore } - try keychainStore.deleteValue() - try keychainStore.add(username: username, value: token) + guard let tokenKeychain else { throw StoreError.missingKeychainStore } + try tokenKeychain.deleteValue() + try tokenKeychain.add(username: username, value: token) } /// Returns a generated device id /// If created, stores it inside the keychain to get a consistent value even if the app is deleted /// as UIDevice.current.identifierForVendor value changes when the app is deleted and installed again func deviceId() -> String { - if let alternateDeviceId { - return alternateDeviceId - } - let keychainStore = KeychainStore(serverId: "lestorage.main") do { - return try keychainStore.getValue() + return try self.deviceKeychain.getValue() } catch { let deviceId: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString do { - try keychainStore.add(value: deviceId) + try self.deviceKeychain.add(value: deviceId) } catch { Logger.error(error) } @@ -279,6 +283,32 @@ public class StoreCenter { } } + // MARK: - File system + + /// Creates the store directory + /// - Parameters: + /// - directory: the name of the directory + fileprivate func _createDirectory() { + FileManager.default.createDirectoryInDocuments(directoryName: self.directoryName) + } + + public func directoryURL() throws -> URL { + return try FileUtils.pathForDirectoryInDocuments(directory: self.directoryName) + } + + /// Returns the URL of the Storable json file + func jsonFileURL(for type: T.Type) throws -> URL { + var storageDirectory = try self.directoryURL() + storageDirectory.append(component: T.fileName()) + return storageDirectory + } + + func write(content: String, fileName: String) throws { + var fileURL = try self.directoryURL() + fileURL.append(component: fileName) + try content.write(to: fileURL, atomically: false, encoding: .utf8) + } + // MARK: - Api Calls management /// Instantiates and loads an ApiCallCollection with the provided type @@ -350,8 +380,11 @@ public class StoreCenter { Task { do { - try FileManager.default.removeItem(at: Log.urlForJSONFile()) - try FileManager.default.removeItem(at: FailedAPICall.urlForJSONFile()) + let logURL = try self.jsonFileURL(for: Log.self) + try FileManager.default.removeItem(at: logURL) + + let facURL = try self.jsonFileURL(for: FailedAPICall.self) + try FileManager.default.removeItem(at: facURL) let facApiCallCollection: ApiCallCollection = try self.apiCallCollection() await facApiCallCollection.reset() @@ -530,6 +563,18 @@ public class StoreCenter { } + func testSynchronizeOnceAsync() async throws { + guard self.isAuthenticated else { + throw StoreError.missingToken + } + let lastSync = self._settingsStorage.item.lastSynchronization + let syncGetCollection: ApiCallCollection = try self.apiCallCollection() + + let getSyncData = GetSyncData() + getSyncData.date = lastSync + await syncGetCollection.executeSingleGet(instance: getSyncData) + } + func sendGetRequest(_ type: T.Type, storeId: String?, clear: Bool) async throws { guard self._canSynchronise(), self.canPerformGet(T.self) else { return @@ -635,7 +680,7 @@ public class StoreCenter { } Logger.log(">>> UPDATE \(updateArray.count) \(className)") - let type = try StoreCenter.classFromName(className) + let type = try self.classFromName(className) for updateItem in updateArray { @@ -723,8 +768,8 @@ public class StoreCenter { } /// Returns a Type object for a class name - static func classFromName(_ className: String) throws -> any SyncedStorable.Type { - if let type = ClassLoader.getClass(className) { + func classFromName(_ className: String) throws -> any SyncedStorable.Type { + if let type = ClassLoader.getClass(className, classProject: self.classProject) { if let syncedType = type as? any SyncedStorable.Type { return syncedType } else { @@ -773,7 +818,7 @@ public class StoreCenter { DispatchQueue.main.async { do { - let type = try StoreCenter.classFromName(model) + let type = try self.classFromName(model) try self._store(id: storeId).deleteNoSync(type: type, id: id) } catch { Logger.error(error) @@ -787,7 +832,7 @@ public class StoreCenter { DispatchQueue.main.async { do { - let type = try StoreCenter.classFromName(model) + let type = try self.classFromName(model) if self._instanceShared(id: id, type: type) { let count = self.mainStore.referenceCount(type: type, id: id) if count == 0 { @@ -948,7 +993,7 @@ public class StoreCenter { /// - Parameters: /// - identifier: The name of the directory public func destroyStore(identifier: String) { - let directory = "\(Store.storageDirectory)/\(identifier)" + let directory = "\(self.directoryName)/\(identifier)" FileManager.default.deleteDirectoryInDocuments(directoryName: directory) self._stores.removeValue(forKey: identifier) } diff --git a/LeStorage/SyncedCollection.swift b/LeStorage/SyncedCollection.swift index 72f4c68..dfcb2b3 100644 --- a/LeStorage/SyncedCollection.swift +++ b/LeStorage/SyncedCollection.swift @@ -25,7 +25,7 @@ public class SyncedCollection: BaseCollection, SomeSynced if self.inMemory { try await self.loadDataFromServerIfAllowed() } else { - await self.loadFromFile() + self.loadFromFile() } } catch { Logger.error(error) diff --git a/LeStorage/Utils/ClassLoader.swift b/LeStorage/Utils/ClassLoader.swift index 439dd85..353c522 100644 --- a/LeStorage/Utils/ClassLoader.swift +++ b/LeStorage/Utils/ClassLoader.swift @@ -8,9 +8,9 @@ import Foundation class ClassLoader { - static var classCache: [String: AnyClass] = [:] + static var classCache: [String : AnyClass] = [:] - static func getClass(_ className: String) -> AnyClass? { + static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? { if let cachedClass = classCache[className] { return cachedClass } @@ -23,6 +23,14 @@ class ClassLoader { } } + if let classProject { + let sanitizedBundleName = classProject.replacingOccurrences(of: " ", with: "_") + let fullName = "\(sanitizedBundleName).\(className)" + if let projectClass = _getClass(fullName) { + return projectClass + } + } + let leStorageClassName = "LeStorage.\(className)" if let projectClass = _getClass(leStorageClassName) { return projectClass diff --git a/LeStorage/Utils/KeychainStore.swift b/LeStorage/Utils/KeychainStore.swift index 99a3c00..f5a6545 100644 --- a/LeStorage/Utils/KeychainStore.swift +++ b/LeStorage/Utils/KeychainStore.swift @@ -24,7 +24,14 @@ enum KeychainError: Error { } } -class KeychainStore { +protocol KeychainService { + func add(username: String, value: String) throws + func add(value: String) throws + func getValue() throws -> String + func deleteValue() throws +} + +class KeychainStore: KeychainService { let serverId: String diff --git a/LeStorage/Utils/MockKeychainStore.swift b/LeStorage/Utils/MockKeychainStore.swift new file mode 100644 index 0000000..9dc11ef --- /dev/null +++ b/LeStorage/Utils/MockKeychainStore.swift @@ -0,0 +1,44 @@ +// +// MockKeychainStore.swift +// LeStorage +// +// Created by Laurent Morvillier on 17/04/2025. +// + +import Foundation + +class TokenStore: MicroStorable { + required init() { + + } + var token: String? +} + +class MockKeychainStore: MicroStorage, KeychainService { + + let key = "store" + + func add(username: String, value: String) throws { + try self.add(value: value) + } + + func add(value: String) throws { + self.update { tokenStore in + tokenStore.token = value + } + } + + func getValue() throws -> String { + if let value = self.item.token { + return value + } + throw KeychainError.keychainItemNotFound(serverId: "mock") + } + + func deleteValue() throws { + self.update { tokenStore in + tokenStore.token = nil + } + } + +}