diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 7df4434..1a73f6a 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; + C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C731B2D5D042D008DD299 /* Date+Extensions.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; @@ -71,6 +72,7 @@ C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; + C49C731B2D5D042D008DD299 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = ""; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; diff --git a/LeStorage/ApiCallCollection.swift b/LeStorage/ApiCallCollection.swift index 8e22ec0..657178e 100644 --- a/LeStorage/ApiCallCollection.swift +++ b/LeStorage/ApiCallCollection.swift @@ -232,28 +232,60 @@ actor ApiCallCollection: SomeCallCollection { /// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// The method updates existing calls or creates a new one - fileprivate func _call(method: HTTPMethod, instance: T? = nil) async throws -> ApiCall? { - - if let instance { - return try await self._callForInstance(instance, method: method) - } else { - if self.items.contains(where: { $0.method == .get }) { - return nil - } else { - return try self._createGetCall() - } - } - } +// fileprivate func _call(method: HTTPMethod, instance: T? = nil) async throws -> ApiCall? { +// +// if let instance { +// return try await self._callForInstance(instance, method: method) +// } else { +// if self.items.contains(where: { $0.method == .get }) { +// return nil +// } else { +// return try self._createGetCall() +// } +// } +// } - fileprivate func _callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) async throws -> ApiCall { +// fileprivate func _callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) async throws -> ApiCall { +// +// // cleanup +// let existingCalls = self.items.filter { $0.data?.id == instance.id } +// self._deleteCalls(existingCalls) +// +// // create +// let call = try self._createCall(method, instance: instance, transactionId: transactionId) +// self._prepareCall(apiCall: call) +// } + + func callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) throws -> ApiCall { // cleanup - let existingCalls = self.items.filter { $0.data?.id == instance.id } - self._deleteCalls(existingCalls) + let existingCalls = self.items.filter { $0.data?.stringId == instance.stringId } + if existingCalls.count > 1 { + StoreCenter.main.log(message: "There are multiple calls registered for a single item: \(T.resourceName()), id = \(instance.stringId)") + } + let currentHTTPMethod = existingCalls.first?.method + let call: ApiCall + if let currentHTTPMethod { + switch (currentHTTPMethod, method) { + case (.post, .put): + call = try self._createCall(.post, instance: instance, transactionId: transactionId) + case (.post, .delete): + call = try self._createCall(.delete, instance: instance, transactionId: transactionId) + case (.put, .put): + call = try self._createCall(.put, instance: instance, transactionId: transactionId) + case (.put, .delete): + call = try self._createCall(.delete, instance: instance, transactionId: transactionId) + default: + call = try self._createCall(method, instance: instance, transactionId: transactionId) + StoreCenter.main.log(message: "case \(currentHTTPMethod) / \(method) should not happen") + } + } else { + call = try self._createCall(method, instance: instance, transactionId: transactionId) + } - // create - let call = try self._createCall(method, instance: instance, transactionId: transactionId) + self._deleteCalls(existingCalls) self._prepareCall(apiCall: call) + return call } @@ -300,15 +332,15 @@ actor ApiCallCollection: SomeCallCollection { var apiCalls: [ApiCall] = [] let transactionId = Store.randomId() for insert in batch.inserts { - let call = try await self._callForInstance(insert, method: .post, transactionId: transactionId) + let call = try self.callForInstance(insert, method: .post, transactionId: transactionId) apiCalls.append(call) } for update in batch.updates { - let call = try await self._callForInstance(update, method: .put, transactionId: transactionId) + let call = try self.callForInstance(update, method: .put, transactionId: transactionId) apiCalls.append(call) } for delete in batch.deletes { - let call = try await self._callForInstance(delete, method: .delete, transactionId: transactionId) + let call = try self.callForInstance(delete, method: .delete, transactionId: transactionId) apiCalls.append(call) } return try await self._executeApiCalls(apiCalls) diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index bbf013a..18bf1ab 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -51,7 +51,7 @@ public class Services { self.keychainStore = KeychainStore(serverId: url) Logger.log("create keystore with id: \(url)") } - + // MARK: - Base /// Runs a request using a configuration object @@ -202,6 +202,38 @@ public class Services { servicePath: call.path, method: call.method, requiresToken: call.requiresToken, getArguments: getArguments) } +// +// /// Returns a POST request for the resource +// /// - Parameters: +// /// - type: the type of the request resource +// 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) +// } +// +// /// 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 { +// let requiresToken = self._isTokenRequired(type: T.self, method: .put) +// 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 { +// let requiresToken = self._isTokenRequired(type: T.self, method: .delete) +// 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) + } + /// Returns a base request for a path and method /// - Parameters: /// - servicePath: the path to add to the API base URL @@ -228,6 +260,7 @@ public class Services { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.addAppVersion() if !(requiresToken == false) { let token = try self.keychainStore.getValue() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") @@ -488,31 +521,25 @@ public class Services { } /// Executes a POST request -// public func post(_ instance: T) async throws -> T { -// -// let method: HTTPMethod = .post -// let payload = SyncPayload( -// operation: method.rawValue, -// modelName: String(describing: T.self), -// data: instance, -// deviceId: StoreCenter.main.deviceId()) -// let syncRequest = try self._baseSyncRequest(method: .post, payload: payload) -// return try await self._runRequest(syncRequest) -// } -// -// /// Executes a PUT request -// 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, -// deviceId: StoreCenter.main.deviceId()) -// let syncRequest = try self._baseSyncRequest(method: .post, payload: payload) -// return try await self._runRequest(syncRequest) -// } + public func post(_ instance: T) async throws -> T { + var postRequest = try self._postRequest(type: T.self) + postRequest.httpBody = try JSON.encoder.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 JSON.encoder.encode(instance) + return try await self._runRequest(postRequest) + } + + public func delete(_ instance: T) async throws -> T { + let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId) + return try await self._runRequest(deleteRequest) + } + /// Executes an ApiCall func runGetApiCall(_ apiCall: ApiCall) async throws -> V { let request = try self._syncGetRequest(from: apiCall) @@ -534,7 +561,7 @@ public class Services { request.httpMethod = apiCall.method.rawValue request.httpBody = try apiCall.data?.jsonData() request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + request.addAppVersion() if self._isTokenRequired(type: T.self, method: apiCall.method) { do { let token = try self.keychainStore.getValue() @@ -678,8 +705,7 @@ public class Services { /// - 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 JSON.encoder.encode(Email(email: email)) let response: Email = try await self._runRequest(postRequest) Logger.log("response = \(response)") @@ -861,3 +887,14 @@ public struct ShortUser: Codable, Identifiable, Equatable { public var firstName: String public var lastName: String } + +fileprivate extension URLRequest { + + mutating func addAppVersion() { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + let appVersion = "\(version) (\(build))" + self.setValue(appVersion, forHTTPHeaderField: "App-Version") + } + +} diff --git a/LeStorageTests/ApiCallTests.swift b/LeStorageTests/ApiCallTests.swift new file mode 100644 index 0000000..bd5a7eb --- /dev/null +++ b/LeStorageTests/ApiCallTests.swift @@ -0,0 +1,85 @@ +// +// ApiCallTests.swift +// LeStorageTests +// +// Created by Laurent Morvillier on 15/02/2025. +// + +import Testing +@testable import LeStorage + +class Thing: ModelObject, Storable { + static func resourceName() -> String { return "thing" } + static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } + static func filterByStoreIdentifier() -> Bool { return false } + + var id: String = Store.randomId() + var name: String + + init(name: String) { + self.name = name + } +} + +struct ApiCallTests { + + @Test func testApiCallProvisioning1() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendInsertion(thing) + + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .post) + } + + thing.name = "woo" + let _ = try await collection.sendUpdate(thing) + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .post) + } + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + } + + @Test func testApiCallProvisioning2() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendUpdate(thing) + + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .put) + } + + thing.name = "woo" + let _ = try await collection.sendUpdate(thing) + await #expect(collection.items.count == 1) + if let apiCall = await collection.items.first { + #expect(apiCall.method == .put) + } + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + } + + @Test func testApiCallProvisioning3() async throws { + let collection = ApiCallCollection() + + let thing = Thing(name: "yeah") + + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + let _ = try await collection.sendDeletion(thing) + await #expect(collection.items.count == 1) + } + +}