sync2
Laurent 9 months ago
commit 61db14f003
  1. 2
      LeStorage.xcodeproj/project.pbxproj
  2. 72
      LeStorage/ApiCallCollection.swift
  3. 93
      LeStorage/Services.swift
  4. 85
      LeStorageTests/ApiCallTests.swift

@ -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 = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49C731B2D5D042D008DD299 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };

@ -232,28 +232,60 @@ actor ApiCallCollection<T: SyncedStorable>: 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<T>? {
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<T>? {
//
// 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<T> {
// fileprivate func _callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) async throws -> ApiCall<T> {
//
// // 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<T> {
// 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<T>
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<T: SyncedStorable>: SomeCallCollection {
var apiCalls: [ApiCall<T>] = []
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)

@ -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<T: Storable>(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<T: Storable>(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<T: Storable>(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<T: Storable>(_ 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<T: SyncedStorable>(_ 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<T: Storable>(_ 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<T: Storable>(_ 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<T: Storable>(_ 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<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) 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")
}
}

@ -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<Thing>()
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<Thing>()
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<Thing>()
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)
}
}
Loading…
Cancel
Save