diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 82a5a0f..4068fe4 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -30,6 +30,10 @@ C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; }; C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; }; C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; }; + C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; }; + C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; }; + C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; }; + C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */; }; C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; }; C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E302C353E7B0021F3BF /* Log.swift */; }; /* End PBXBuildFile section */ @@ -69,6 +73,10 @@ C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = ""; }; + C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = ""; }; + C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredCollection+Sync.swift"; sourceTree = ""; }; C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = ""; }; C4FC2E302C353E7B0021F3BF /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -127,7 +135,9 @@ C425D4572B6D2519002A7B48 /* Store.swift */, C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */, + C4D4779E2CB92FD80077713D /* SyncedStorable.swift */, C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, + C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C4A47D932B7CF7C500ADC637 /* MicroStorage.swift */, C4A47D822B7665BC00ADC637 /* Wip */, @@ -141,6 +151,7 @@ children = ( C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, + C4D477962CB66EEA0077713D /* Date+Extensions.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */, C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, @@ -162,6 +173,7 @@ isa = PBXGroup; children = ( C4A47D992B7CFFC500ADC637 /* ApiCall.swift */, + C4D4779C2CB923720077713D /* DataLog.swift */, C45D35902C0A1DB5000F379F /* FailedAPICall.swift */, C4FC2E302C353E7B0021F3BF /* Log.swift */, C4A47D9A2B7CFFC500ADC637 /* Settings.swift */, @@ -287,13 +299,16 @@ C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */, C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */, C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */, + C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */, C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */, + C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */, C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */, C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */, C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, + C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, @@ -303,6 +318,7 @@ C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */, C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */, + C4D4779D2CB923720077713D /* DataLog.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */, C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */, diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index e9b74fb..c21b78c 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -1,5 +1,5 @@ // -// SafeCollection.swift +// ApiCallCollection.swift // LeStorage // // Created by Laurent Morvillier on 17/06/2024. @@ -22,7 +22,7 @@ protocol SomeCallCollection { /// 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: SomeCallCollection { +actor ApiCallCollection: SomeCallCollection { /// The list of api calls fileprivate(set) var items: [ApiCall] = [] @@ -108,6 +108,13 @@ actor ApiCallCollection: SomeCallCollection { } } +// func hasDeleteCallForDataId(_ dataId: String) -> Bool { +// if let apiCall = self.items.first(where: { $0.dataId == dataId }) { +// return apiCall.method == .delete +// } +// return false +// } + /// Returns the Api call associated with the provided id func findById(_ id: String) -> ApiCall? { return self.items.first(where: { $0.id == id }) @@ -158,7 +165,7 @@ actor ApiCallCollection: SomeCallCollection { fileprivate func _rescheduleApiCalls() async { // Logger.log("\(T.resourceName()) > RESCHED") - guard !self._isRescheduling else { return } + guard !self._isRescheduling, StoreCenter.main.collectionsCanSynchronize else { return } guard self.items.isNotEmpty else { return } self._isRescheduling = true diff --git a/LeStorage/Codables/DataLog.swift b/LeStorage/Codables/DataLog.swift new file mode 100644 index 0000000..19424fe --- /dev/null +++ b/LeStorage/Codables/DataLog.swift @@ -0,0 +1,33 @@ +// +// DataLog.swift +// LeStorage +// +// Created by Laurent Morvillier on 11/10/2024. +// + +import Foundation + +class DataLog: ModelObject, Storable { + + static func resourceName() -> String { return "modelLogs" } + static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + static func filterByStoreIdentifier() -> Bool { return false } + + var id: String = Store.randomId() + + /// The id of the underlying data + var dataId: String + + /// The name of class of the underlying data + var modelName: String + + /// The operation performed on the underlying data + var operation: HTTPMethod + + init(dataId: String, modelName: String, operation: HTTPMethod) { + self.dataId = dataId + self.modelName = modelName + self.operation = operation + } + +} diff --git a/LeStorage/Codables/FailedAPICall.swift b/LeStorage/Codables/FailedAPICall.swift index 1cdfad1..737403c 100644 --- a/LeStorage/Codables/FailedAPICall.swift +++ b/LeStorage/Codables/FailedAPICall.swift @@ -7,7 +7,7 @@ import Foundation -class FailedAPICall: ModelObject, Storable { +class FailedAPICall: SyncedModelObject, SyncedStorable { static func resourceName() -> String { return "failed-api-calls" } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } diff --git a/LeStorage/Codables/Log.swift b/LeStorage/Codables/Log.swift index 943fb97..e767c86 100644 --- a/LeStorage/Codables/Log.swift +++ b/LeStorage/Codables/Log.swift @@ -7,7 +7,7 @@ import Foundation -class Log: ModelObject, Storable { +class Log: SyncedModelObject, SyncedStorable { static func resourceName() -> String { return "logs" } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } diff --git a/LeStorage/Codables/Settings.swift b/LeStorage/Codables/Settings.swift index bd04379..6b68ba4 100644 --- a/LeStorage/Codables/Settings.swift +++ b/LeStorage/Codables/Settings.swift @@ -16,4 +16,6 @@ class Settings: MicroStorable { var userId: String? = nil var username: String? = nil var deviceId: String? = nil + var lastSynchronization: Date? = nil + } diff --git a/LeStorage/ModelObject.swift b/LeStorage/ModelObject.swift index d9a0bd7..6b6bf9e 100644 --- a/LeStorage/ModelObject.swift +++ b/LeStorage/ModelObject.swift @@ -9,13 +9,15 @@ import Foundation /// A class used as the root class for Storable objects /// Provides default implementations of the Storable protocol -open class ModelObject { +open class ModelObject: NSObject { public var store: Store? = nil - public init() { } + var storeId: String? = nil - open func deleteDependencies() throws { + public override init() { } + + open func deleteDependencies() { } @@ -29,4 +31,46 @@ open class ModelObject { } +// // MARK: - Codable +// +// enum CodingKeys: CodingKey { +// case storeId +// } +// +// public required init(from decoder: any Decoder) throws { +// let decoder = try decoder.container(keyedBy: CodingKeys.self) +// self.storeId = try decoder.decodeIfPresent(String.self, forKey: CodingKeys.storeId) +// } +// +// public func encode(to encoder: any Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// try container.encodeIfPresent(self.storeId, forKey: .storeId) +// } + +} + +open class SyncedModelObject: ModelObject { + + public var lastUpdate: Date = Date() + +// enum CodingKeys: CodingKey { +// case lastUpdate +// } +// +// public override init() { +// super.init() +// } +// +// public required init(from decoder: any Decoder) throws { +// try super.init(from: decoder) +// let decoder = try decoder.container(keyedBy: CodingKeys.self) +// self.lastUpdate = try decoder.decode(Date.self, forKey: CodingKeys.lastUpdate) +// } +// +// open override func encode(to encoder: any Encoder) throws { +// try super.encode(to: encoder) +// var container = encoder.container(keyedBy: CodingKeys.self) +// try container.encodeIfPresent(self.lastUpdate, forKey: .lastUpdate) +// } + } diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 4fd0be2..688fe3f 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -20,64 +20,35 @@ struct ServiceCall { var requiresToken: Bool } -let createAccountCall: ServiceCall = ServiceCall(path: "users/", method: .post, requiresToken: false) -let requestTokenCall: ServiceCall = ServiceCall(path: "token-auth/", method: .post, requiresToken: false) -let logoutCall: ServiceCall = ServiceCall(path: "api-token-logout/", method: .post, requiresToken: true) -let getUserCall: ServiceCall = ServiceCall(path: "user-by-token/", method: .get, requiresToken: true) -let changePasswordCall: ServiceCall = ServiceCall(path: "change-password/", method: .put, requiresToken: true) -let postDeviceTokenCall: ServiceCall = ServiceCall(path: "device-token/", method: .post, requiresToken: true) - -//fileprivate enum ServiceConf: String { -// case createAccount = "users/" -// case requestToken = "token-auth/" -// case logout = "api-token-logout/" -// case getUser = "user-by-token/" -// case changePassword = "change-password/" -// case postDeviceToken = "device-token/" -// -// var method: HTTPMethod { -// switch self { -// case .createAccount, .requestToken, .logout, .postDeviceToken: -// return .post -// case .changePassword: -// return .put -// default: -// return .get -// } -// } -// -// var requiresToken: Bool? { -// switch self { -// case .createAccount, .requestToken: -// return false -// case .getUser, .changePassword, .logout, .postDeviceToken: -// return true -//// default: -//// return nil -// } -// } -// -//} +let createAccountCall: ServiceCall = ServiceCall( + path: "users/", method: .post, requiresToken: false) +let requestTokenCall: ServiceCall = ServiceCall( + path: "token-auth/", method: .post, requiresToken: false) +let logoutCall: ServiceCall = ServiceCall( + path: "api-token-logout/", method: .post, requiresToken: true) +let getUserCall: ServiceCall = ServiceCall( + path: "user-by-token/", method: .get, requiresToken: true) +let changePasswordCall: ServiceCall = ServiceCall( + path: "change-password/", method: .put, requiresToken: true) +let postDeviceTokenCall: ServiceCall = ServiceCall( + path: "device-token/", method: .post, requiresToken: true) /// A class used to send HTTP request to the django server 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)") - + } - + /// The base API URL to send requests fileprivate(set) var baseURL: String - + fileprivate var jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase @@ -92,7 +63,7 @@ public class Services { decoder.dateDecodingStrategy = .iso8601 return decoder }() - + // MARK: - Base /// Runs a request using a configuration object @@ -100,40 +71,47 @@ public class Services { /// - serviceConf: A instance of ServiceConf /// - payload: a codable value stored in the body of the request /// - apiCallId: an optional id referencing an ApiCall - fileprivate func _runRequest(serviceCall: ServiceCall, payload: T) async throws -> U { + fileprivate func _runRequest(serviceCall: ServiceCall, payload: T) + async throws -> U + { var request = try self._baseRequest(call: serviceCall) request.httpBody = try jsonEncoder.encode(payload) return try await _runRequest(request) } - + /// Runs a request using a traditional URLRequest /// - Parameters: /// - request: the URLRequest to run /// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure - fileprivate func _runRequest(_ request: URLRequest, apiCall: ApiCall) async throws -> V { + fileprivate func _runRequest( + _ request: URLRequest, apiCall: ApiCall + ) async throws -> V { let debugURL = request.url?.absoluteString ?? "" print("Run \(request.httpMethod ?? "") \(debugURL)") let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) print("response = \(String(data: task.0, encoding: .utf8) ?? "")") - + if let response = task.1 as? HTTPURLResponse { let statusCode = response.statusCode print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { - case 200..<300: // success - try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) - default: // error - Logger.log("Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") + case 200..<300: // success + try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) + default: // error + Logger.log( + "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") let errorString: String = String(data: task.0, encoding: .utf8) ?? "" var errorMessage = ErrorMessage(error: errorString, domain: "") - + if let message = self.errorMessageFromResponse(data: task.0) { errorMessage = message } try await StoreCenter.main.rescheduleApiCalls(id: apiCall.id, type: T.self) - StoreCenter.main.logFailedAPICall(apiCall.id, request: request, collectionName: T.resourceName(), error: errorMessage.message) - + StoreCenter.main.logFailedAPICall( + apiCall.id, request: request, collectionName: T.resourceName(), + error: errorMessage.message) + throw ServiceError.responseError(response: errorMessage.error) } } else { @@ -141,14 +119,14 @@ public class Services { StoreCenter.main.log(message: message) Logger.w(message) } - + if !(V.self is Empty?.Type) { return try jsonDecoder.decode(V.self, from: task.0) } else { return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) } } - + /// Runs a request using a traditional URLRequest /// - Parameters: /// - request: the URLRequest to run @@ -158,15 +136,16 @@ public class Services { print("Run \(request.httpMethod ?? "") \(debugURL)") let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) print("response = \(String(data: task.0, encoding: .utf8) ?? "")") - + if let response = task.1 as? HTTPURLResponse { let statusCode = response.statusCode print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { - case 200..<300: // success + case 200..<300: // success break - default: // error - Logger.log("Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") + default: // error + Logger.log( + "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") let errorString: String = String(data: task.0, encoding: .utf8) ?? "" var errorMessage = ErrorMessage(error: errorString, domain: "") if let message = self.errorMessageFromResponse(data: task.0) { @@ -181,12 +160,12 @@ public class Services { } return try jsonDecoder.decode(V.self, from: task.0) } - + /// Returns if the token is required for a request /// - Parameters: /// - type: the type of the request resource /// - method: the HTTP method of the request - fileprivate func _isTokenRequired(type: T.Type, method: HTTPMethod) -> Bool { + fileprivate func _isTokenRequired(type: T.Type, method: HTTPMethod) -> Bool { let methods = T.tokenExemptedMethods() if methods.contains(method) { return false @@ -194,53 +173,67 @@ public class Services { return true } } - + /// Returns a GET request for the resource /// - Parameters: /// - type: the type of the request resource - fileprivate func _getRequest(type: T.Type, identifier: StoreIdentifier?) 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, identifier: identifier) + return try self._baseRequest( + servicePath: T.path(), method: .get, requiresToken: requiresToken, + identifier: identifier) } - + /// Returns a POST request for the resource /// - Parameters: /// - type: the type of the request resource - fileprivate func _postRequest(type: T.Type) throws -> URLRequest { + fileprivate func _postRequest(type: T.Type) throws -> URLRequest { let requiresToken = self._isTokenRequired(type: T.self, method: .post) - return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) + return try self._baseRequest( + servicePath: T.path(), method: .post, requiresToken: requiresToken) } - + /// Returns a PUT request for the resource /// - Parameters: /// - type: the type of the request resource - fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { + fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { let requiresToken = self._isTokenRequired(type: T.self, method: .put) - return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) + return try self._baseRequest( + servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) } - + /// Returns a DELETE request for the resource /// - Parameters: /// - type: the type of the request resource - fileprivate func _deleteRequest(type: T.Type, id: String) throws -> URLRequest { + fileprivate func _deleteRequest(type: T.Type, id: String) throws + -> URLRequest + { let requiresToken = self._isTokenRequired(type: T.self, method: .delete) - return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) + return try self._baseRequest( + servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) } - + /// Returns the base URLRequest for a ServiceConf instance /// - Parameters: /// - conf: a ServiceConf instance fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { - return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) + return try self._baseRequest( + servicePath: call.path, method: call.method, requiresToken: call.requiresToken) } - + /// Returns a base request for a path and method /// - Parameters: /// - 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 /// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values - fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest { + fileprivate func _baseRequest( + servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, + identifier: StoreIdentifier? = nil + ) throws -> URLRequest { var urlString = baseURL + servicePath if let identifier { urlString.append(identifier.urlComponent) @@ -257,46 +250,192 @@ public class Services { } return request } - + + // MARK: - Synchronization + + /// Returns a base request for a path and method + /// - Parameters: + /// - method: the HTTP method to execute + /// - payload: the content to put in the httpBody + fileprivate func _baseSyncRequest(method: HTTPMethod, payload: Encodable) throws -> URLRequest { + let urlString = baseURL + "data/" + + guard let url = URL(string: urlString) else { + throw ServiceError.urlCreationError(url: urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try jsonEncoder.encode(payload) + + let token = try self.keychainStore.getValue() + request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + + return request + } + + /// Returns the URLRequest for an ApiCall + /// - Parameters: + /// - apiCall: An ApiCall instance to configure the returned request + fileprivate func _syncRequest(from apiCall: ApiCall) throws -> URLRequest + { + + let urlString = baseURL + "data/" + + guard let url = URL(string: urlString) else { + throw ServiceError.urlCreationError(url: urlString) + } + guard let bodyData = apiCall.body.data(using: .utf8) else { + throw ServiceError.cantDecodeData(content: apiCall.body) + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.post.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // moyennement fan de decoder pour recoder derriere + let data = try jsonDecoder.decode(T.self, from: bodyData) + let modelName = String(describing: T.self) + + let payload = SyncPayload( + operation: apiCall.method.rawValue, + modelName: modelName, + data: data, + storeId: data.getStoreId()) + + request.httpBody = try jsonEncoder.encode(payload) + + if self._isTokenRequired(type: T.self, method: apiCall.method) { + let token = try self.keychainStore.getValue() + request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + } + + return request + } + + /// Starts a request to retrieve the synchronization updates + /// - Parameters: + /// - since: The date from which updates are retrieved + func synchronizeLastUpdates(since: Date?) async throws { + let request = try self._getSyncLogRequest(since: since) + try await self._runGetSyncLogRequest(request) + } + + /// Returns the URLRequest for an ApiCall + /// - Parameters: + /// - since: The date from which updates are retrieved + fileprivate func _getSyncLogRequest(since: Date?) throws -> URLRequest { + + let formattedDate = ISO8601DateFormatter().string(from: since ?? Date.distantPast) + let encodedDate = + formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedDateWithPlus = encodedDate.replacingOccurrences(of: "+", with: "%2B") + let urlString = baseURL + "data/?last_update=\(encodedDateWithPlus)" + + guard let url = URL(string: urlString) else { + throw ServiceError.urlCreationError(url: urlString) + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let token = try self.keychainStore.getValue() + request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + + return request + } + + /// Runs the a sync request and forwards the response to the StoreCenter for processing + /// - Parameters: + /// - request: The synchronization request + fileprivate func _runGetSyncLogRequest(_ request: URLRequest) async throws { + let debugURL = request.url?.absoluteString ?? "" + print("Run \(request.httpMethod ?? "") \(debugURL)") + let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) + print("response = \(String(data: task.0, encoding: .utf8) ?? "")") + + if let response = task.1 as? HTTPURLResponse { + let statusCode = response.statusCode + print("\(debugURL) ended, status code = \(statusCode)") + switch statusCode { + case 200..<300: // success + StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder) + default: // error + Logger.log( + "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") + let errorString: String = String(data: task.0, encoding: .utf8) ?? "" + var errorMessage = ErrorMessage(error: errorString, domain: "") + if let message = self.errorMessageFromResponse(data: task.0) { + errorMessage = message + } + throw ServiceError.responseError(response: errorMessage.error) + } + } else { + let message: String = "Unexpected and unmanaged URL Response \(task.1)" + StoreCenter.main.log(message: message) + Logger.w(message) + } + } + // MARK: - Services - + /// Executes a GET request - public func get(identifier: StoreIdentifier? = nil) async throws -> [T] { + public func get(identifier: StoreIdentifier? = nil) async throws -> [T] { let getRequest = try _getRequest(type: T.self, identifier: identifier) return try await self._runRequest(getRequest) } /// Executes a POST request public func post(_ instance: T) async throws -> T { - var postRequest = try self._postRequest(type: T.self) - postRequest.httpBody = try jsonEncoder.encode(instance) - return try await self._runRequest(postRequest) + + let method: HTTPMethod = .post + let payload = SyncPayload( + operation: method.rawValue, + modelName: String(describing: T.self), + data: instance) + let syncRequest = try self._baseSyncRequest(method: .post, payload: payload) + return try await self._runRequest(syncRequest) + + // var postRequest = try self._postRequest(type: T.self) + // postRequest.httpBody = try jsonEncoder.encode(instance) + // return try await self._runRequest(postRequest) } - + /// Executes a PUT request - public func put(_ instance: T) async throws -> T { - var postRequest = try self._putRequest(type: T.self, id: instance.stringId) - postRequest.httpBody = try jsonEncoder.encode(instance) - return try await self._runRequest(postRequest) + public func put(_ instance: T) async throws -> T { + + let method: HTTPMethod = .put + let payload = SyncPayload( + operation: method.rawValue, + modelName: String(describing: T.self), + data: instance) + let syncRequest = try self._baseSyncRequest(method: .post, payload: payload) + return try await self._runRequest(syncRequest) + + // var postRequest = try self._putRequest(type: T.self, id: instance.stringId) + // postRequest.httpBody = try jsonEncoder.encode(instance) + // return try await self._runRequest(postRequest) } - + /// Executes an ApiCall - func runApiCall(_ apiCall: ApiCall) async throws -> V { - let request = try self._request(from: apiCall) + func runApiCall(_ apiCall: ApiCall) async throws -> V { + let request = try self._syncRequest(from: apiCall) print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)") return try await self._runRequest(request, apiCall: apiCall) } - + /// Returns the URLRequest for an ApiCall /// - Parameters: /// - apiCall: An ApiCall instance to configure the returned request - fileprivate func _request(from apiCall: ApiCall) throws -> URLRequest { + fileprivate func _request(from apiCall: ApiCall) throws -> URLRequest { let url = try self._url(from: apiCall) var request = URLRequest(url: url) request.httpMethod = apiCall.method.rawValue request.httpBody = apiCall.body.data(using: .utf8) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + if self._isTokenRequired(type: T.self, method: apiCall.method) { do { let token = try self.keychainStore.getValue() @@ -305,10 +444,10 @@ public class Services { Logger.log("missing token") } } - + return request } - + /// Returns the URL corresponding to the ApiCall /// - Parameters: /// - apiCall: an instance of ApiCall to build to URL @@ -326,16 +465,16 @@ public class Services { throw ServiceError.urlCreationError(url: stringURL) } } - + // MARK: - Authentication - + /// Creates an account /// - Parameters: /// - user: A user instance to send to the server public func createAccount(user: U) async throws -> V { return try await _runRequest(serviceCall: createAccountCall, payload: user) } - + /// Requests a token for a username and password /// - Parameters: /// - username: the account's username @@ -349,7 +488,7 @@ public class Services { self._storeToken(username: username, token: response.token) return response.token } - + /// Stores a token for a corresponding username /// - Parameters: /// - username: the key used to store the token @@ -362,94 +501,100 @@ public class Services { Logger.error(error) } } - + /// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// - Parameters: /// - username: the account's username /// - password: the account's password - public func login(username: String, password: String) async throws -> U { + public func login(username: String, password: String) async throws -> U { _ = try await requestToken(username: username, password: password) let postRequest = try self._baseRequest(call: getUserCall) let user: U = try await self._runRequest(postRequest) -// StoreCenter.main.setUserUUID(uuidString: user.id) -// StoreCenter.main.setUserName(user.username) + // StoreCenter.main.setUserUUID(uuidString: user.id) + // StoreCenter.main.setUserName(user.username) StoreCenter.main.setUserInfo(user: user) return user } - + /// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// - Parameters: /// - username: the account's username /// - password: the account's password - public func logout() async throws { + public func logout() async throws { let deviceId: String = StoreCenter.main.deviceId() - let _: Empty = try await self._runRequest(serviceCall: logoutCall, payload: Logout(deviceId: deviceId)) + let _: Empty = try await self._runRequest( + serviceCall: logoutCall, payload: Logout(deviceId: deviceId)) } - + /// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// - Parameters: /// - username: the account's username /// - password: the account's password - public func postDeviceToken(deviceToken: Data) async throws { + public func postDeviceToken(deviceToken: Data) async throws { let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() let token = DeviceToken(value: tokenString) -// Logger.log("Send device token = \(tokenString)") + // Logger.log("Send device token = \(tokenString)") let _: Empty = try await self._runRequest(serviceCall: postDeviceTokenCall, payload: token) } - + /// A method that sends a request to change a user's password /// - Parameters: /// - oldPassword: the account's old password /// - password1: the account's new password /// - password2: a repeat of the new password - public func changePassword(oldPassword: String, password1: String, password2: String) async throws { - + public func changePassword(oldPassword: String, password1: String, password2: String) + async throws + { + guard let username = StoreCenter.main.userName() else { throw ServiceError.missingUserName } - + struct ChangePasswordParams: Codable { var old_password: String var new_password1: String var new_password2: String } - - let params = ChangePasswordParams(old_password: oldPassword, new_password1: password1, new_password2: password2) - let response: Token = try await self._runRequest(serviceCall: changePasswordCall, payload: params) - + + let params = ChangePasswordParams( + old_password: oldPassword, new_password1: password1, new_password2: password2) + let response: Token = try await self._runRequest( + serviceCall: changePasswordCall, payload: params) + self._storeToken(username: username, token: response.token) } - + /// The method send a request to reset the user's password /// - Parameters: /// - email: the email of the user public func forgotPassword(email: String) async throws { - var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) + var postRequest = try self._baseRequest( + servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) postRequest.httpBody = try jsonEncoder.encode(Email(email: email)) let response: Email = try await self._runRequest(postRequest) Logger.log("response = \(response)") } - + /// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// - Parameters: /// - username: the account's username /// - password: the account's password - public func deleteAccount() async throws { + public func deleteAccount() async throws { guard let userId = StoreCenter.main.userId else { throw ServiceError.missingUserId } let path = "users/\(userId)/" let deleteAccount = ServiceCall(path: path, method: .delete, requiresToken: true) - + let request = try self._baseRequest(call: deleteAccount) let _: Empty = try await self._runRequest(request) } - + /// Deletes the locally stored token func deleteToken() throws { try self.keychainStore.deleteValue() } - + /// Returns whether the Service has an associated token public func hasToken() -> Bool { do { @@ -459,13 +604,15 @@ public class Services { return false } } - + /// Parse a json data and tries to extract its error message /// - Parameters: /// - data: some JSON data fileprivate func errorMessageFromResponse(data: Data) -> ErrorMessage? { do { - if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + as? [String: Any] + { if let tuple = jsonObject.first { var error = "" if let stringsArray = tuple.value as? [String], let first = stringsArray.first { @@ -481,17 +628,29 @@ public class Services { } return nil } - + func migrateToken(_ services: Services, userName: String) throws { try self._storeToken(username: userName, token: services.keychainStore.getValue()) } - + +} + +//struct GetSyncLog: Codable { +// var updates: [String: Codable] +// var deletions: [String: Codable] +//} + +struct SyncPayload: Encodable { + var operation: String + var modelName: String + var data: T + var storeId: String? } struct ErrorMessage { let error: String let domain: String - + var message: String { return "\(self.error) (\(self.domain))" } @@ -512,7 +671,7 @@ struct Email: Codable { var email: String } struct Empty: Codable { - + } struct Logout: Codable { var deviceId: String @@ -525,7 +684,7 @@ public protocol UserBase: Codable { var id: String { get } var username: String { get } var email: String { get } - + func uuid() throws -> UUID } diff --git a/LeStorage/Storable.swift b/LeStorage/Storable.swift index dbabd52..2fb1d83 100644 --- a/LeStorage/Storable.swift +++ b/LeStorage/Storable.swift @@ -8,7 +8,7 @@ import Foundation /// A protocol describing classes that can be stored locally in JSON and synchronized on our django server -public protocol Storable: Codable, Identifiable { +public protocol Storable: Codable, Identifiable, NSObjectProtocol { /// The store containing a reference to the instance var store: Store? { get set } @@ -17,9 +17,6 @@ public protocol Storable: Codable, Identifiable { /// Also used as the name of the local file static func resourceName() -> String - /// 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 @@ -29,12 +26,7 @@ public protocol Storable: Codable, Identifiable { /// Mimics the behavior the cascading delete on the django server /// Typically when we delete a resource, we automatically delete items that depends on it, /// so when we do that on the server, we also need to do it locally - func deleteDependencies() throws - - /// A method called to retrieve data added by the server on a POST request - /// The method will be called after a POST has succeeded, - /// and will provide a copy of what's on the server - func copyFromServerInstance(_ instance: any Storable) -> Bool + func deleteDependencies() static var relationshipNames: [String] { get } diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 2bbca5b..8457a73 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -41,7 +41,7 @@ open class Store { /// 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 fileprivate(set) var identifier: StoreIdentifier? = nil @@ -72,22 +72,26 @@ open class Store { /// Registers a collection /// - Parameters: - /// - synchronized: indicates if the data is synchronized with the server /// - 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 - /// - sendsUpdate: Indicates if updates of items should be sent to the server - public func registerCollection(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection { + public func registerCollection(indexed: Bool = false, inMemory: Bool = false) -> StoredCollection { - let collection = StoredCollection(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate) + let collection = StoredCollection(store: self, indexed: indexed, inMemory: inMemory) self._collections[T.resourceName()] = collection - if synchronized { - StoreCenter.main.loadApiCallCollection(type: T.self) - } + return collection + } + + /// Registers a synchronized collection + /// - 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) -> StoredCollection { - if self._created, let identifier { - self._migrate(collection, identifier: identifier, type: T.self) - } + let collection = StoredCollection(store: self, indexed: indexed, inMemory: inMemory) + self._collections[T.resourceName()] = collection + + StoreCenter.main.loadApiCallCollection(type: T.self) return collection } @@ -99,13 +103,13 @@ open class Store { /// - sendsUpdate: Indicates if updates of items should be sent to the server public func registerObject(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton { - let storedObject = StoredSingleton(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate) + let storedObject = StoredSingleton(store: self, inMemory: inMemory) self._collections[T.resourceName()] = storedObject if synchronized { StoreCenter.main.loadApiCallCollection(type: T.self) } - + return storedObject } @@ -143,31 +147,68 @@ open class Store { /// Loads all collection with the data from the server public func loadCollectionsFromServer() { - for collection in self._collections.values { - if collection.synchronized { - Task { - try? await collection.loadDataFromServerIfAllowed() + for collection in self._StoredCollections() { + Task { + try? await collection.loadDataFromServerIfAllowed() + } + } + } + + /// Loads all synchronized collection with server data if they don't already have a local file + public func loadCollectionsFromServerIfNoFile() { + for collection in self._StoredCollections() { + Task { + do { + try await collection.loadCollectionsFromServerIfNoFile() + } catch { + Logger.error(error) } } } } + fileprivate func _StoredCollections() -> [any SomeSyncedCollection] { + return self._collections.values.compactMap { $0 as? any SomeSyncedCollection } + } + /// Resets all registered collection public func reset() { for collection in self._collections.values { collection.reset() } } - + /// Returns the names of all collections public func collectionNames() -> [String] { return self._collections.values.map { $0.resourceName } } + // MARK: - Synchronization + + /// Calls addOrUpdateIfNewer from the collection corresponding to the instance + func addOrUpdateIfNewer(_ instance: T) { + do { + let collection: StoredCollection = try self.collection() + collection.addOrUpdateIfNewer(instance) + } catch { + Logger.error(error) + } + } + + /// Calls deleteById from the collection corresponding to the instance + func deleteNoSync(instance: T) { + do { + let collection: StoredCollection = try self.collection() + try collection.deleteById(instance.id) + } catch { + Logger.error(error) + } + } + // MARK: - Write /// Returns the directory URL of the store - fileprivate func _directoryPath() throws -> URL { + fileprivate func _directoryPath() throws -> URL { var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) if let identifier = self.identifier?.value { url.append(component: identifier) @@ -208,91 +249,76 @@ open class Store { } /// Retrieves all the items on the server - public func getItems() async throws -> [T] { + 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() } } - + /// Requests an insertion to the StoreCenter /// - Parameters: /// - instance: an object to insert - func sendInsertion(_ instance: T) async throws -> T? { + func sendInsertion(_ instance: T) async throws -> T? { return try await StoreCenter.main.sendInsertion(instance) } /// Requests an update to the StoreCenter /// - Parameters: /// - instance: an object to update - @discardableResult func sendUpdate(_ instance: T) async throws -> T? { + @discardableResult func sendUpdate(_ instance: T) async throws -> T? { return try await StoreCenter.main.sendUpdate(instance) } /// Requests a deletion to the StoreCenter /// - Parameters: /// - instance: an object to delete - func sendDeletion(_ instance: T) async throws { + func sendDeletion(_ instance: T) async throws { return try await StoreCenter.main.sendDeletion(instance) } - /// Loads all synchronized collection with server data if they don't already have a local file - public func loadCollectionsFromServerIfNoFile() { - for collection in self._collections.values { - if collection.synchronized { - Task { - do { - try await collection.loadCollectionsFromServerIfNoFile() - } catch { - Logger.error(error) - } - } - } - } - } - /// Returns whether all collections have loaded locally public func collectionsAllLoaded() -> Bool { return self._collections.values.allSatisfy { $0.hasLoaded } } - fileprivate var _validIds: [String] = [] - - fileprivate func _migrate(_ collection: StoredCollection, identifier: StoreIdentifier, type: T.Type) { - - self._validIds.append(identifier.value) - - let oldCollection: StoredCollection = StoredCollection(synchronized: false, store: Store.main, asynchronousIO: false) - - let filtered: [T] = oldCollection.items.filter { item in - var propertyValue: String? = item.stringForPropertyName(identifier.parameterName) - if propertyValue == nil { - let values = T.relationshipNames.map { item.stringForPropertyName($0) } - propertyValue = values.compactMap { $0 }.first - } - return self._validIds.first(where: { $0 == propertyValue }) != nil - } - - if filtered.count > 0 { - self._validIds.append(contentsOf: filtered.map { $0.stringId }) - try? collection.addOrUpdateNoSync(contentOfs: filtered) - Logger.log("Migrated \(filtered.count) \(T.resourceName())") - } - } + // fileprivate var _validIds: [String] = [] + // + // fileprivate func _migrate(_ collection: StoredCollection, identifier: StoreIdentifier, type: T.Type) { + // + // self._validIds.append(identifier.value) + // + // let oldCollection: StoredCollection = StoredCollection(synchronized: false, store: Store.main, asynchronousIO: false) + // + // let filtered: [T] = oldCollection.items.filter { item in + // var propertyValue: String? = item.stringForPropertyName(identifier.parameterName) + // if propertyValue == nil { + // let values = T.relationshipNames.map { item.stringForPropertyName($0) } + // propertyValue = values.compactMap { $0 }.first + // } + // return self._validIds.first(where: { $0 == propertyValue }) != nil + // } + // + // if filtered.count > 0 { + // self._validIds.append(contentsOf: filtered.map { $0.stringId }) + // try? collection.addOrUpdateNoSync(contentOfs: filtered) + // Logger.log("Migrated \(filtered.count) \(T.resourceName())") + // } + // } } -fileprivate extension Storable { - - func stringForPropertyName(_ propertyName: String) -> String? { - let mirror = Mirror(reflecting: self) - for child in mirror.children { - if let label = child.label, label == "_\(propertyName)" { - return child.value as? String - } - } - return nil - } - -} +//fileprivate extension Storable { +// +// func stringForPropertyName(_ propertyName: String) -> String? { +// let mirror = Mirror(reflecting: self) +// for child in mirror.children { +// if let label = child.label, label == "_\(propertyName)" { +// return child.value as? String +// } +// } +// return nil +// } +// +//} diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index 40e6106..3577d51 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -28,6 +28,9 @@ public class StoreCenter { /// Indicates to Stored Collection if they can synchronize public var collectionsCanSynchronize: Bool = true + /// Force the absence of synchronization + public var forceNoSynchronization: Bool = false + /// A store for the Settings object fileprivate var _settingsStorage: MicroStorage = MicroStorage(fileName: "settings.json") @@ -36,7 +39,10 @@ public class StoreCenter { /// The dictionary of registered StoredCollections fileprivate var _apiCallCollections: [String : any SomeCallCollection] = [:] - + + /// A collection of DataLog objects, used for the synchronization + fileprivate var _dataLogs: StoredCollection + /// A collection storing FailedAPICall objects fileprivate var _failedAPICallsCollection: StoredCollection? = nil @@ -47,7 +53,8 @@ public class StoreCenter { fileprivate var _blackListedUserName: [String] = [] init() { -// self._loadExistingApiCollections() + self._dataLogs = Store.main.registerCollection() + self._setupNotifications() } /// Returns the service instance @@ -59,6 +66,29 @@ public class StoreCenter { } } + private func _setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(_willEnterForegroundNotification), + name: UIScene.willEnterForegroundNotification, + object: nil) + } + + @objc fileprivate func _willEnterForegroundNotification() { + Logger.log("_willEnterForegroundNotification") + self._launchSynchronization() + } + + @objc fileprivate func _launchSynchronization() { + Task{ + do { + try await self.synchronizeLastUpdates() + } catch { + Logger.error(error) + } + } + } + /// Registers a store into the list of stores /// - Parameters: /// - store: A store to save @@ -66,6 +96,9 @@ public class StoreCenter { guard let identifier = store.identifier?.value else { fatalError("The store has no identifier") } + if self._stores[identifier] != nil { + fatalError("A store with this identifier has already been registered: \(identifier)") + } self._stores[identifier] = store } @@ -118,6 +151,7 @@ public class StoreCenter { self._settingsStorage.update { settings in settings.username = nil settings.userId = nil + settings.lastSynchronization = nil } } @@ -151,10 +185,10 @@ public class StoreCenter { } } - // MARK: - Api Calls + // MARK: - Api Calls management /// Instantiates and loads an ApiCallCollection with the provided type - public func loadApiCallCollection(type: T.Type) { + public func loadApiCallCollection(type: T.Type) { if self._apiCallCollections[T.resourceName()] == nil { let apiCallCollection = ApiCallCollection() self._apiCallCollections[T.resourceName()] = apiCallCollection @@ -180,7 +214,7 @@ public class StoreCenter { /// - 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 { + func deleteApiCallByDataId(type: T.Type, id: String) async throws { let apiCallCollection: ApiCallCollection = try self.apiCallCollection() await apiCallCollection.deleteByDataId(id) } @@ -189,7 +223,7 @@ public class StoreCenter { /// - Parameters: /// - type: the subsequent type of the ApiCall /// - id: the id of the ApiCall - func deleteApiCallById(type: T.Type, id: String) async throws { + func deleteApiCallById(type: T.Type, id: String) async throws { let apiCallCollection: ApiCallCollection = try self.apiCallCollection() await apiCallCollection.deleteById(id) } @@ -205,11 +239,34 @@ public class StoreCenter { throw StoreError.collectionNotRegistered(type: collectionName) } } - + + /// 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) + } + } + // MARK: - Api call rescheduling /// Reschedule an ApiCall by id - func rescheduleApiCalls(id: String, type: T.Type) async throws { + func rescheduleApiCalls(id: String, type: T.Type) async throws { guard self.collectionsCanSynchronize else { return } @@ -218,53 +275,239 @@ public class StoreCenter { } /// Executes an ApiCall - fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws -> V { + fileprivate func _executeApiCall(_ apiCall: ApiCall) async throws -> V { return try await self.service().runApiCall(apiCall) } /// Executes an API call - func execute(apiCall: ApiCall) async throws -> V { + func execute(apiCall: ApiCall) async throws -> V { return try await self._executeApiCall(apiCall) } - // MARK: - + // MARK: - Api calls - /// Retrieves all the items on the server - func getItems(identifier: StoreIdentifier? = nil) async throws -> [T] { - return try await self.service().get(identifier: identifier) + /// Returns whether the collection can synchronize + fileprivate func _canSynchronise() -> Bool { + return !self.forceNoSynchronization && self.collectionsCanSynchronize && self.userIsAllowed() } - /// Resets all registered collection - public func reset() { - Store.main.reset() - for store in self._stores.values { - store.reset() + /// Transmit the insertion request to the ApiCall collection + /// - Parameters: + /// - instance: an object to insert + func sendInsertion(_ instance: T) async throws -> T? { + guard self._canSynchronise() else { + return nil } + return try await self.apiCallCollection().sendInsertion(instance) } - /// Resets all the api call collections - public func resetApiCalls() { - Task { - for collection in self._apiCallCollections.values { - await collection.reset() - } + /// Transmit the update request to the ApiCall collection + /// - Parameters: + /// - instance: an object to update + func sendUpdate(_ instance: T) async throws -> T? { + guard self._canSynchronise() else { + return nil } + return try await self.apiCallCollection().sendUpdate(instance) } - /// Resets the ApiCall whose type identifies with the provided collection + /// Transmit the deletion request to the ApiCall collection /// - Parameters: - /// - collection: The collection identifying the Storable type - public func resetApiCalls(collection: StoredCollection) { + /// - instance: an object to delete + func sendDeletion(_ instance: T) async throws { + guard self._canSynchronise() else { + return + } + try await self.apiCallCollection().sendDeletion(instance) + } + + /// Retrieves all the items on the server + func getItems(identifier: StoreIdentifier? = nil) async throws -> [T] { + return try await self.service().get(identifier: identifier) + } + + // MARK: - Synchronization + + public func initialSynchronization() { + self._settingsStorage.update { settings in + settings.lastSynchronization = Date() + } + Store.main.loadCollectionsFromServer() + } + + public func synchronizeLastUpdates() async throws { + let lastSync: Date? = self._settingsStorage.item.lastSynchronization + try await self._services?.synchronizeLastUpdates(since: lastSync) + } + + func synchronizeContent(_ data: Data, decoder: JSONDecoder) { + do { - let apiCallCollection: ApiCallCollection = try self.apiCallCollection() - Task { - await apiCallCollection.reset() + guard let json = try JSONSerialization.jsonObject(with: data, options: []) + as? [String: Any] + else { + Logger.w("data unrecognized") + return + } + + if let updates = json["updates"] as? [String: Any] { + do { + try self._parseSyncUpdates(updates, decoder: decoder) + } catch { + StoreCenter.main.log(message: error.localizedDescription) + Logger.error(error) + } + } + + if let deletions = json["deletions"] as? [String: Any] { + do { + try self._parseSyncDeletions(deletions, decoder: decoder) + } catch { + StoreCenter.main.log(message: error.localizedDescription) + Logger.error(error) + } + } + + if let dateString: String = json["date"] as? String, + let syncDate = Date.iso8601Formatter.date(from: dateString) { + + self._settingsStorage.update { settings in + settings.lastSynchronization = syncDate + } } + } catch { Logger.error(error) } } + fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws { + for (className, updateData) in updates { + guard let updateArray = updateData as? [[String: Any]] else { + Logger.w("Invalid update data for \(className)") + continue + } + + let type = try self._classFromClassName(className) + + for updateItem in updateArray { + + do { + let jsonData = try JSONSerialization.data(withJSONObject: updateItem, options: []) + let decodedObject = try decoder.decode(type, from: jsonData) + + let storeId: String? = decodedObject.getStoreId() + StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId) + } catch { + Logger.error(error) + } + } + } + } + + fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws { + for (className, updateDeletions) in deletions { + guard let deletionArray = updateDeletions as? [[String: Any]] else { + Logger.w("Invalid update data for \(className)") + continue + } + + let type = try self._classFromClassName(className) + + for updateItem in deletionArray { + + if let object = updateItem["data"] { + do { + let jsonData = try JSONSerialization.data(withJSONObject: object, options: []) + let decodedObject = try decoder.decode(type, from: jsonData) + + let storeId = updateItem["storeId"] as? String + StoreCenter.main.synchronizationDelete(instance: decodedObject, storeId: storeId) + } catch { + Logger.error(error) + } + } + } + } + } + + fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type { + + let fullClassName = "PadelClub.\(className)" + let modelClass: AnyClass? = NSClassFromString(fullClassName) + if let type = modelClass as? any SyncedStorable.Type { + return type + } else { + throw LeStorageError.cantFindClassFromName(name: className) + } + + } + + fileprivate func _store(id: String?) -> Store? { + if let storeId = id { + return self._stores[storeId] + } else { + return Store.main + } + } + + fileprivate func _hasAlreadyBeenDeleted(_ instance: T) -> Bool { + return self._dataLogs.contains(where: { $0.dataId == instance.stringId && $0.operation == .delete }) + } + + func synchronizationAddOrUpdate(_ instance: T, storeId: String?) { + let hasAlreadyBeenDeleted: Bool = self._hasAlreadyBeenDeleted(instance) + if !hasAlreadyBeenDeleted { + DispatchQueue.main.async { + self._store(id: storeId)?.addOrUpdateIfNewer(instance) + } + } + } + + func synchronizationDelete(instance: T, storeId: String?) { + DispatchQueue.main.async { + self._store(id: storeId)?.deleteNoSync(instance: instance) + self._cleanupDataLog(dataId: instance.stringId) + } + } + + fileprivate func _cleanupDataLog(dataId: String) { + let logs = self._dataLogs.filter { $0.dataId == dataId } + self._dataLogs.delete(contentOfs: logs) + } + +// func createInsertLog(_ instance: T) { +// self._addDataLog(instance, method: .post) +// } + + func createDeleteLog(_ instance: T) { + self._addDataLog(instance, method: .delete) + } + + fileprivate func _addDataLog(_ instance: T, method: HTTPMethod) { + let dataLog = DataLog(dataId: instance.stringId, modelName: String(describing: T.self), operation: method) + self._dataLogs.addOrUpdate(instance: dataLog) + } + + // MARK: - Miscellanous + + public func apiCallCount(type: T.Type) async -> Int { + do { + let collection: ApiCallCollection = try self.apiCallCollection() + return await collection.items.count + } catch { + return -1 + } + } + + /// Resets all registered collection + public func reset() { + Store.main.reset() + for store in self._stores.values { + store.reset() + } + } + /// Returns whether any collection has pending API calls public func hasPendingAPICalls() async -> Bool { for collection in self._apiCallCollections.values { @@ -282,7 +525,7 @@ public class StoreCenter { /// This method triggers the framework to save and send failed api calls public func logsFailedAPICalls() { - self._failedAPICallsCollection = Store.main.registerCollection(synchronized: true) + self._failedAPICallsCollection = Store.main.registerCollection() } /// If configured for, logs and send to the server a failed API call @@ -307,11 +550,7 @@ public class StoreCenter { let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue) DispatchQueue.main.async { - do { - try failedAPICallsCollection.addOrUpdate(instance: failedAPICall) - } catch { - Logger.error(error) - } + failedAPICallsCollection.addOrUpdate(instance: failedAPICall) } } catch { Logger.error(error) @@ -333,13 +572,8 @@ public class StoreCenter { } 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) - } + let failedAPICall = FailedAPICall(callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error, authentication: authValue) + failedAPICallsCollection.addOrUpdate(instance: failedAPICall) } @@ -368,43 +602,10 @@ public class StoreCenter { 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 -> T? { - guard self._canSynchronise() else { - return nil - } - 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 -> T? { - guard self._canSynchronise() else { - return nil - } - 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) - } + // MARK: - Instant update /// Updates a local object with a server instance - func updateFromServerInstance(_ result: T) { + func updateFromServerInstance(_ result: T) { if let storedCollection: StoredCollection = self.collectionOfInstance(result) { if storedCollection.findById(result.id) != nil { storedCollection.updateFromServerInstance(result) @@ -444,7 +645,7 @@ public class StoreCenter { if let logs = self._logs { return logs } else { - let logsCollection: StoredCollection = Store.main.registerCollection(synchronized: true) + let logsCollection: StoredCollection = Store.main.registerCollection() self._logs = logsCollection return logsCollection } @@ -453,11 +654,7 @@ public class StoreCenter { /// Logs a message in the logs collection public func log(message: String) { let log = Log(message: message) - do { - try self._logsCollection().addOrUpdate(instance: log) - } catch { - Logger.error(error) - } + self._logsCollection().addOrUpdate(instance: log) } // MARK: - Migration @@ -470,4 +667,8 @@ public class StoreCenter { try self.service().migrateToken(services, userName: userName) } + deinit { + NotificationCenter.default.removeObserver(self) + } + } diff --git a/LeStorage/StoredCollection+Sync.swift b/LeStorage/StoredCollection+Sync.swift new file mode 100644 index 0000000..01b56b1 --- /dev/null +++ b/LeStorage/StoredCollection+Sync.swift @@ -0,0 +1,217 @@ +// +// StoredCollection.swift +// LeStorage +// +// Created by Laurent Morvillier on 11/10/2024. +// + +import Foundation + +extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { + + /// Migrates if necessary and asynchronously decodes the json file + func load() { + + do { + if self.inMemory { + Task { + try await self.loadDataFromServerIfAllowed() + } + } else { + try self.loadFromFile() + } + } catch { + Logger.error(error) + } + + } + + /// Loads the collection using the server data only if the collection file doesn't exists + func loadCollectionsFromServerIfNoFile() async throws { + let fileURL: URL = try self.store.fileURL(type: T.self) + if !FileManager.default.fileExists(atPath: fileURL.path()) { + try await self.loadDataFromServerIfAllowed() + } + } + + /// Retrieves the data from the server and loads it into the items array + public func loadDataFromServerIfAllowed() async throws { + guard !(self is StoredSingleton) else { + throw StoreError.cannotSyncCollection(name: self.resourceName) + } + do { + let items: [T] = try await self.store.getItems() + if items.count > 0 { + DispatchQueue.main.async { + self.addOrUpdateNoSync(contentOfs: items) + } + } + } catch { + Logger.error(error) + } + self.setAsLoaded() + } + + /// Updates a local item from a server instance. This method is typically used when the server makes update + /// to an object when it's inserted. The StoredCollection possibly needs to update its own copy with new values. + /// - serverInstance: the instance of the object on the server + func updateFromServerInstance(_ serverInstance: T) { + DispatchQueue.main.async { + if let localInstance = self.findById(serverInstance.id) { + let modified = localInstance.copyFromServerInstance(serverInstance) + if modified { + self.setChanged() + } + } + } + } + + // MARK: - Basic operations + + /// Adds or update an instance without synchronizing it + func addOrUpdateNoSync(_ instance: T) throws { + self.addOrUpdateItem(instance: instance) + } + + /// Adds or update a sequence of elements without synchronizing it + func addOrUpdateNoSync(contentOfs sequence: any Sequence) { + self.addSequence(sequence) + } + + /// Deletes the instance in the collection without synchronization + func deleteNoSync(instance: T) throws { + defer { + self.setChanged() + } + self.deleteItem(instance) + } + + public func addOrUpdate(instance: T) { + defer { + self.setChanged() + } + + instance.lastUpdate = Date() + if let index = self.items.firstIndex(where: { $0.id == instance.id }) { + self.updateItem(instance, index: index) + self._sendUpdateIfNecessary(instance) + } else { + self.addItem(instance: instance) + self._sendInsertionIfNecessary(instance) + } + } + + public func addOrUpdate(contentOfs sequence: any Sequence) { + defer { + self.setChanged() + } + + for instance in sequence { + instance.lastUpdate = Date() + if let index = self.items.firstIndex(where: { $0.id == instance.id }) { + self.updateItem(instance, index: index) + self._sendUpdateIfNecessary(instance) + } else { // insert + self.addItem(instance: instance) + self._sendInsertionIfNecessary(instance) + } + } + + } + + public func delete(instance: T) throws { + defer { + self.setChanged() + } + + self.deleteItem(instance) + StoreCenter.main.createDeleteLog(instance) + self._sendDeletionIfNecessary(instance) + } + + // MARK: - Reschedule calls + + /// Sends an insert api call for the provided + /// Calls copyFromServerInstance on the instance with the result of the HTTP call + /// - Parameters: + /// - instance: the object to POST + fileprivate func _sendInsertionIfNecessary(_ instance: T) { + + Task { + do { + if let result = try await self.store.sendInsertion(instance) { + self.updateFromServerInstance(result) + } + } catch { + Logger.error(error) + } + } + } + + /// Sends an update api call for the provided [instance] + /// - Parameters: + /// - instance: the object to PUT + fileprivate func _sendUpdateIfNecessary(_ instance: T) { + Task { + do { + try await self.store.sendUpdate(instance) + } catch { + Logger.error(error) + } + } + } + + /// Sends an delete api call for the provided [instance] + /// - Parameters: + /// - instance: the object to DELETE + fileprivate func _sendDeletionIfNecessary(_ instance: T) { + Task { + do { + try await self.store.sendDeletion(instance) + } catch { + Logger.error(error) + } + } + } + + // MARK: - Synchronization + + func addOrUpdateIfNewer(_ instance: T) { + defer { + self.setChanged() + } + + if let index = self.items.firstIndex(where: { $0.id == instance.id }) { + let localInstance = self.items[index] + if instance.lastUpdate > localInstance.lastUpdate { + self.updateItem(instance, index: index) + } + } else { // insert + self.addItem(instance: instance) + } + + } + + // MARK: - Migrations + + /// Makes POST ApiCall for all items in the collection + public func insertAllIntoCurrentService() { + for item in self.items { + self._sendInsertionIfNecessary(item) + } + } + + /// Makes POST ApiCall for the provided item + public func insertIntoCurrentService(item: T) { + self._sendInsertionIfNecessary(item) + } + + /// Sends a POST request for the instance, and changes the collection to perform a write + public func writeChangeAndInsertOnServer(instance: T) { + defer { + self.setChanged() + } + self._sendInsertionIfNecessary(instance) + } + +} diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index 982bab5..52acbc0 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -22,14 +22,14 @@ protocol CollectionHolder { protocol SomeCollection: CollectionHolder, Identifiable { var resourceName: String { get } - var synchronized: Bool { get } var hasLoaded: Bool { get } func allItems() -> [any Storable] - +} + +protocol SomeSyncedCollection: SomeCollection { func loadDataFromServerIfAllowed() async throws func loadCollectionsFromServerIfNoFile() async throws - } extension Notification.Name { @@ -39,20 +39,14 @@ extension Notification.Name { public class StoredCollection: RandomAccessCollection, SomeCollection, CollectionHolder { - /// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL - let synchronized: Bool - /// Doesn't write the collection in a file - fileprivate var _inMemory: Bool = false - - /// Indicates if the synchronized collection sends update to the API - fileprivate var _sendsUpdate: Bool = true + fileprivate(set) var inMemory: Bool = false /// The list of stored items @Published public fileprivate(set) var items: [T] = [] /// The reference to the Store - fileprivate var _store: Store + fileprivate(set) var store: Store /// Provides fast access for instances if the collection has been instanced with [indexed] = true fileprivate var _indexes: [T.ID : T]? = nil @@ -77,22 +71,21 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Indicates if the collection has loaded locally, with or without a file fileprivate(set) public var hasLoaded: Bool = false - init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) { - self.synchronized = synchronized + init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) { +// self.synchronized = synchronized self.asynchronousIO = asynchronousIO if indexed { self._indexes = [:] } - self._inMemory = inMemory - self._sendsUpdate = sendsUpdate - self._store = store + self.inMemory = inMemory + self.store = store - self._load() + self.load() } fileprivate init() { - self.synchronized = false - self._store = Store.main +// self.synchronized = false + self.store = Store.main } public static func placeholder() -> StoredCollection { @@ -105,16 +98,16 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti // MARK: - Loading + func setChanged() { + self._hasChanged = true + } + /// Migrates if necessary and asynchronously decodes the json file - fileprivate func _load() { + func load() { do { - if self._inMemory { - Task { - try await self.loadDataFromServerIfAllowed() - } - } else { - try self._loadFromFile() + if !self.inMemory { + try self.loadFromFile() } } catch { Logger.error(error) @@ -123,7 +116,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } /// Starts the JSON file decoding synchronously or asynchronously - fileprivate func _loadFromFile() throws { + func loadFromFile() throws { if self.asynchronousIO { Task(priority: .high) { @@ -138,31 +131,31 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Decodes the json file into the items array fileprivate func _decodeJSONFile() throws { - let fileURL = try self._store.fileURL(type: T.self) + 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 + for item in decoded { + item.store = self.store } if self.asynchronousIO { DispatchQueue.main.async { self._setItems(decoded) - self._setAsLoaded() + self.setAsLoaded() } } else { self._setItems(decoded) - self._setAsLoaded() + self.setAsLoaded() } } else { - self._setAsLoaded() + self.setAsLoaded() } } /// Sets the collection as loaded /// Send a CollectionDidLoad event - fileprivate func _setAsLoaded() { + func setAsLoaded() { self.hasLoaded = true DispatchQueue.main.async { NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) @@ -182,63 +175,26 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } } - /// Retrieves the data from the server and loads it into the items array - public func loadDataFromServerIfAllowed() async throws { - guard self.synchronized, !(self is StoredSingleton) else { - throw StoreError.cannotSyncCollection(name: self.resourceName) - } - do { - let items: [T] = try await self._store.getItems() - if items.count > 0 { - DispatchQueue.main.async { - self._addOrUpdate(contentOfs: items, shouldSync: false) - } - } - } catch { - Logger.error(error) - } - self._setAsLoaded() - } - - /// Loads the collection using the server data only if the collection file doesn't exists - func loadCollectionsFromServerIfNoFile() async throws { - let fileURL: URL = try self._store.fileURL(type: T.self) - if !FileManager.default.fileExists(atPath: fileURL.path()) { - try await self.loadDataFromServerIfAllowed() - } - } - // MARK: - Basic operations /// Adds or updates the provided instance inside the collection /// Adds it if its id is not found, and otherwise updates it - public func addOrUpdate(instance: T) throws { + public func addOrUpdate(instance: T) { + self.addOrUpdateItem(instance: instance) + } + + func addOrUpdateItem(instance: T) { defer { 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 - self._sendUpdateIfNecessary(instance) - } else { // insert - self.items.append(instance) - self._sendInsertionIfNecessary(instance) - } - self._indexes?[instance.id] = instance - - } - - /// Sends a POST request for the instance, and changes the collection to perform a write - public func writeChangeAndInsertOnServer(instance: T) { - defer { - self._hasChanged = true + self.updateItem(instance, index: index) + } else { + self.addItem(instance: instance) } - self._sendInsertionIfNecessary(instance) + } /// A method the treat the collection as a single instance holder @@ -247,26 +203,26 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._hasChanged = true } self.items.removeAll() - self.items.append(instance) + self.addItem(instance: instance) } - /// Deletes the instance in the collection by id and sets the collection as changed to trigger a write + /// Deletes the instance in the collection and sets the collection as changed to trigger a write public func delete(instance: T) throws { defer { self._hasChanged = true } - try self._delete(instance) + self.deleteItem(instance) } /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write - public func delete(contentOfs sequence: any Sequence) throws { + public func delete(contentOfs sequence: any Sequence) { defer { self._hasChanged = true } for instance in sequence { - try self._delete(instance) + self.deleteItem(instance) } } @@ -274,46 +230,62 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// - Removes its reference from the index /// - Notifies the server of the deletion /// - Calls `hasBeenDeleted` on the deleted instance - fileprivate func _delete(_ instance: T) throws { - try instance.deleteDependencies() - self.items.removeAll { $0.id == instance.id } - self._indexes?.removeValue(forKey: instance.id) - self._sendDeletionIfNecessary(instance) - instance.hasBeenDeleted() - } +// fileprivate func _delete(_ instance: T) throws { +// instance.deleteDependencies() +// self.items.removeAll { $0.id == instance.id } +// self._indexes?.removeValue(forKey: instance.id) +// instance.hasBeenDeleted() +// } /// Adds or update a sequence of elements - public func addOrUpdate(contentOfs sequence: any Sequence) throws { - self._addOrUpdate(contentOfs: sequence) + public func addOrUpdate(contentOfs sequence: any Sequence) { + self.addSequence(sequence) +// self._addOrUpdate(contentOfs: sequence) } - - /// Adds or update a sequence of elements without synchronizing it - func addOrUpdateNoSync(contentOfs sequence: any Sequence) throws { - self._addOrUpdate(contentOfs: sequence, shouldSync: false) - } - - /// Inserts or updates all items in the sequence - fileprivate func _addOrUpdate(contentOfs sequence: any Sequence, shouldSync: Bool = true) { + + func addSequence(_ sequence: any Sequence) { defer { self._hasChanged = true } - for var instance in sequence { + for instance in sequence { if let index = self.items.firstIndex(where: { $0.id == instance.id }) { - self.items[index] = instance - if shouldSync { - self._sendUpdateIfNecessary(instance) - } + self.updateItem(instance, index: index) } else { // insert - self.items.append(instance) - if shouldSync { - self._sendInsertionIfNecessary(instance) - } + self.addItem(instance: instance) } - instance.store = self._store - self._indexes?[instance.id] = instance } - + + } + + fileprivate func _affectStoreIdIfNecessary(instance: T) { + if let storeId = self.store.identifier?.value { + if var altStorable = instance as? SideStorable { + altStorable.storeId = storeId + } else { + fatalError("instance does not implement AltStorable, thus sync cannot work") + } + } + } + + func addItem(instance: T) { + self._affectStoreIdIfNecessary(instance: instance) + self.items.append(instance) + instance.store = self.store + self._indexes?[instance.id] = instance + } + + func updateItem(_ instance: T, index: Int) { + self.items[index] = instance + instance.store = self.store + self._indexes?[instance.id] = instance + } + + func deleteItem(_ instance: T) { + instance.deleteDependencies() + self.items.removeAll { $0.id == instance.id } + self._indexes?.removeValue(forKey: instance.id) + instance.hasBeenDeleted() } /// Returns the instance corresponding to the provided [id] @@ -327,7 +299,8 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Deletes the instance corresponding to the provided [id] public func deleteById(_ id: T.ID) throws { if let instance = self.findById(id) { - try self.delete(instance: instance) + self.deleteItem(instance) +// try self.delete(instance: instance) } } @@ -344,35 +317,21 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti } item.hasBeenDeleted() - Task { - do { - try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId) - } catch { - Logger.error(error) - } - } +// Task { +// do { +// try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId) +// } catch { +// Logger.error(error) +// } +// } } } /// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls - public func deleteAll() throws { - try self.delete(contentOfs: self.items) - } - - // MARK: - Migrations - - /// Makes POST ApiCall for all items in the collection - public func insertAllIntoCurrentService() { - for item in self.items { - self._sendInsertionIfNecessary(item) - } - } - - /// Makes POST ApiCall for the provided item - public func insertIntoCurrentService(item: T) { - self._sendInsertionIfNecessary(item) - } +// public func deleteAll() throws { +// try self.delete(contentOfs: self.items) +// } // MARK: - SomeCall @@ -386,7 +345,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Schedules a write operation fileprivate func _scheduleWrite() { - guard !self._inMemory else { return } + guard !self.inMemory else { return } if self.asynchronousIO { DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time @@ -402,7 +361,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti // Logger.log("Start write to \(T.fileName())...") do { let jsonString: String = try self.items.jsonString() - try self._store.write(content: jsonString, fileName: T.fileName()) + try self.store.write(content: jsonString, fileName: T.fileName()) } catch { Logger.error(error) // TODO how to notify the main project } @@ -417,71 +376,7 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti /// Removes the items of the collection and deletes the corresponding file public func reset() { self.items.removeAll() - self._store.removeFile(type: T.self) - } - - // MARK: - Reschedule calls - - /// Sends an insert api call for the provided - /// Calls copyFromServerInstance on the instance with the result of the HTTP call - /// - Parameters: - /// - instance: the object to POST - fileprivate func _sendInsertionIfNecessary(_ instance: T) { - guard self.synchronized else { - return - } - Task { - do { - if let result = try await self._store.sendInsertion(instance) { - self.updateFromServerInstance(result) - } - } catch { - Logger.error(error) - } - } - } - - /// Updates a local item from a server instance. This method is typically used when the server makes update - /// to an object when it's inserted. The StoredCollection possibly needs to update its own copy with new values. - /// - serverInstance: the instance of the object on the server - func updateFromServerInstance(_ serverInstance: T) { - DispatchQueue.main.async { - if let localInstance = self.findById(serverInstance.id) { - self._hasChanged = localInstance.copyFromServerInstance(serverInstance) - } - } - } - - /// Sends an update api call for the provided [instance] - /// - Parameters: - /// - instance: the object to PUT - fileprivate func _sendUpdateIfNecessary(_ instance: T) { - guard self.synchronized, self._sendsUpdate else { - return - } - Task { - do { - try await self._store.sendUpdate(instance) - } catch { - Logger.error(error) - } - } - } - - /// Sends an delete api call for the provided [instance] - /// - Parameters: - /// - instance: the object to DELETE - fileprivate func _sendDeletionIfNecessary(_ instance: T) { - guard self.synchronized else { - return - } - Task { - do { - try await self._store.sendDeletion(instance) - } catch { - Logger.error(error) - } - } + self.store.removeFile(type: T.self) } // MARK: - RandomAccessCollection diff --git a/LeStorage/StoredSingleton.swift b/LeStorage/StoredSingleton.swift index 9fb2d70..77ccad0 100644 --- a/LeStorage/StoredSingleton.swift +++ b/LeStorage/StoredSingleton.swift @@ -8,7 +8,7 @@ import Foundation /// A class extending the capabilities of StoredCollection but supposedly manages only one item -public class StoredSingleton: StoredCollection { +public class StoredSingleton: StoredCollection { /// Sets the singleton to the collection without synchronizing it public func setItemNoSync(_ instance: T) { @@ -16,9 +16,9 @@ public class StoredSingleton: StoredCollection { } /// updates the existing singleton - public func update() throws { + public func update() { if let item = self.item() { - try self.addOrUpdate(instance: item) + self.addOrUpdate(instance: item) } } @@ -29,7 +29,7 @@ public class StoredSingleton: StoredCollection { // MARK: - Protects from use - public override func addOrUpdate(contentOfs sequence: any Sequence) throws { + public override func addOrUpdate(contentOfs sequence: any Sequence) { fatalError("method unavailable for StoredSingleton, use update") } diff --git a/LeStorage/SyncedStorable.swift b/LeStorage/SyncedStorable.swift new file mode 100644 index 0000000..6264854 --- /dev/null +++ b/LeStorage/SyncedStorable.swift @@ -0,0 +1,38 @@ +// +// SyncedStorable.swift +// LeStorage +// +// Created by Laurent Morvillier on 11/10/2024. +// + +import Foundation + +public protocol SyncedStorable: Storable { + + var lastUpdate: Date { get set } + + /// Returns HTTP methods that do not need to pass the token to the request + static func tokenExemptedMethods() -> [HTTPMethod] + + /// A method called to retrieve data added by the server on a POST request + /// The method will be called after a POST has succeeded, + /// and will provide a copy of what's on the server + /// Should return true to trigger a write on the collection, or false if nothing changed + func copyFromServerInstance(_ instance: any Storable) -> Bool + +} + +public protocol SideStorable { + var storeId: String? { get set } +} + +extension SyncedStorable { + + func getStoreId() -> String? { + if let alt = self as? SideStorable { + return alt.storeId + } + return nil + } + +} diff --git a/LeStorage/Utils/Date+Extensions.swift b/LeStorage/Utils/Date+Extensions.swift new file mode 100644 index 0000000..2936908 --- /dev/null +++ b/LeStorage/Utils/Date+Extensions.swift @@ -0,0 +1,19 @@ +// +// Date+Extensions.swift +// LeStorage +// +// Created by Laurent Morvillier on 09/10/2024. +// + +import Foundation + +extension Date { + + static var iso8601Formatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + return iso8601Formatter + } + +} diff --git a/LeStorage/Utils/Errors.swift b/LeStorage/Utils/Errors.swift index 2eb0d9f..8fcc875 100644 --- a/LeStorage/Utils/Errors.swift +++ b/LeStorage/Utils/Errors.swift @@ -26,8 +26,13 @@ public enum ServiceError: Error { case missingUserName case missingUserId case responseError(response: String) + case cantDecodeData(content: String) } public enum UUIDError: Error { case cantConvertString(string: String) } + +public enum LeStorageError: Error { + case cantFindClassFromName(name: String) +} diff --git a/LeStorage/Utils/Logger.swift b/LeStorage/Utils/Logger.swift index 4d79490..70a0653 100644 --- a/LeStorage/Utils/Logger.swift +++ b/LeStorage/Utils/Logger.swift @@ -17,9 +17,9 @@ import Foundation print("\(filestr.lastPathComponent).\(line).\(function): \(message)") } - @objc static public func error(_ error: Error) { - Logger.error(error, file: #file, function: #function, line: #line) - } +// @objc static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { +// Logger.error(error, file: file, function: function, line: line) +// } static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) { let filestr: NSString = NSString(string: file) diff --git a/LeStorageTests/CollectionsTests.swift b/LeStorageTests/CollectionsTests.swift new file mode 100644 index 0000000..93b2e79 --- /dev/null +++ b/LeStorageTests/CollectionsTests.swift @@ -0,0 +1,62 @@ +// +// CollectionsTests.swift +// LeStorageTests +// +// Created by Laurent Morvillier on 15/10/2024. +// + +import Testing +import LeStorage + +class Car: ModelObject, Storable { + + var id: String = Store.randomId() + + static func resourceName() -> String { return "car" } + static func filterByStoreIdentifier() -> Bool { return false } + static var relationshipNames: [String] = [] +} + +class Boat: ModelObject, SyncedStorable { + + var id: String = Store.randomId() + var lastUpdate: Date = Date() + + static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } + static func resourceName() -> String { return "boat" } + static func filterByStoreIdentifier() -> Bool { return false } + static var relationshipNames: [String] = [] + + var storeId: String? { return nil } +} + +struct CollectionsTests { + + @Test func differentiationTest() async throws { + + let cars: StoredCollection = Store.main.registerCollection(inMemory: true) + let boats: StoredCollection = Store.main.registerSynchronizedCollection(inMemory: true) + + #expect(cars.count == 0) + cars.addOrUpdate(instance: Car()) + #expect(cars.count == 1) + + #expect(boats.count == 0) + let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) + + boats.addOrUpdate(instance: Boat()) + #expect(boats.count == 1) + + let newApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) + + #expect(oldApiCallCount == newApiCallCount - 1) + + cars.reset() + boats.reset() + #expect(cars.count == 0) + #expect(boats.count == 0) + + + } + +} diff --git a/LeStorageTests/LeStorageTests.swift b/LeStorageTests/IdentifiableTests.swift similarity index 88% rename from LeStorageTests/LeStorageTests.swift rename to LeStorageTests/IdentifiableTests.swift index bbd46ae..5260d78 100644 --- a/LeStorageTests/LeStorageTests.swift +++ b/LeStorageTests/IdentifiableTests.swift @@ -38,13 +38,13 @@ class StringObject: ModelObject, Storable { } } -struct LeStorageTests { +struct IdentifiableTests { @Test func testIntIds() async throws { - let intObjects: StoredCollection = Store.main.registerCollection(synchronized: false) + let intObjects: StoredCollection = Store.main.registerCollection() let int = IntObject(id: 12, name: "test") - try? intObjects.addOrUpdate(instance: int) + intObjects.addOrUpdate(instance: int) if let search = intObjects.findById(12) { #expect(search.id == 12) @@ -54,10 +54,10 @@ struct LeStorageTests { } @Test func testStringIds() async throws { - let stringObjects: StoredCollection = Store.main.registerCollection(synchronized: false) + let stringObjects: StoredCollection = Store.main.registerCollection() let string = StringObject(id: "coco", name: "name") - try? stringObjects.addOrUpdate(instance: string) + stringObjects.addOrUpdate(instance: string) if let search = stringObjects.findById("coco") { #expect(search.id == "coco") diff --git a/LeStorageTests/StoredCollectionTests.swift b/LeStorageTests/StoredCollectionTests.swift new file mode 100644 index 0000000..c02a5b9 --- /dev/null +++ b/LeStorageTests/StoredCollectionTests.swift @@ -0,0 +1,119 @@ +// +// StoredCollectionTests.swift +// LeStorageTests +// +// Created by Laurent Morvillier on 16/10/2024. +// +import XCTest + +@testable import LeStorage + +class StoredCollectionTests: XCTestCase { + + var collection: StoredCollection! + + override func setUp() { + super.setUp() + self.collection = Store.main.registerCollection() + } + + override func tearDown() { + self.collection.clear() + super.tearDown() + } + + func testInitialization() { + XCTAssertEqual(collection.items.count, 0) + } + + func testAddOrUpdate() throws { + let item = MockStorable(id: "1", name: "Test") + collection.addOrUpdate(instance: item) + + XCTAssertEqual(collection.items.count, 1) + XCTAssertEqual(collection.items[0].id, "1") + } + + func testDelete() throws { + let item = MockStorable(id: "1", name: "Test") + collection.addOrUpdate(instance: item) + XCTAssertEqual(collection.items.count, 1) + + try collection.delete(instance: item) + XCTAssertEqual(collection.items.count, 0) + } + + func testFindById() throws { + let item = MockStorable(id: "1", name: "Test") + collection.addOrUpdate(instance: item) + + let foundItem = collection.findById("1") + XCTAssertNotNil(foundItem) + XCTAssertEqual(foundItem?.id, "1") + } + + func testDeleteById() throws { + let item = MockStorable(id: "1", name: "Test") + collection.addOrUpdate(instance: item) + + try collection.deleteById("1") + XCTAssertNil(collection.findById("1")) + } + + func testAddOrUpdateMultiple() throws { + let items = [ + MockStorable(id: "1", name: "Test1"), + MockStorable(id: "2", name: "Test2"), + ] + + collection.addOrUpdate(contentOfs: items) + XCTAssertEqual(collection.items.count, 2) + } + + func testDeleteAll() throws { + let items = [ + MockStorable(id: "1", name: "Test1"), + MockStorable(id: "2", name: "Test2"), + ] + + collection.addOrUpdate(contentOfs: items) + XCTAssertEqual(collection.items.count, 2) + + collection.clear() + XCTAssertEqual(collection.items.count, 0) + } + + func testRandomAccessCollection() { + let items = [ + MockStorable(id: "1", name: "Test1"), + MockStorable(id: "2", name: "Test2"), + MockStorable(id: "3", name: "Test3"), + ] + + collection.addOrUpdate(contentOfs: items) + + XCTAssertEqual(collection.startIndex, 0) + XCTAssertEqual(collection.endIndex, 3) + XCTAssertEqual(collection[1].name, "Test2") + } +} + +// Mock Storable for testing purposes +class MockStorable: ModelObject, Storable { + static func filterByStoreIdentifier() -> Bool { + return false + } + + var id: String = Store.randomId() + var name: String + + init(id: String, name: String) { + self.id = id + self.name = name + } + + static func resourceName() -> String { + return "mocks" + } + +}