first commit

sync2
Laurent 1 year ago
parent aa78348e98
commit 56a2f6e618
  1. 16
      LeStorage.xcodeproj/project.pbxproj
  2. 13
      LeStorage/ApiCallCollection.swift
  3. 33
      LeStorage/Codables/DataLog.swift
  4. 2
      LeStorage/Codables/FailedAPICall.swift
  5. 2
      LeStorage/Codables/Log.swift
  6. 2
      LeStorage/Codables/Settings.swift
  7. 50
      LeStorage/ModelObject.swift
  8. 433
      LeStorage/Services.swift
  9. 12
      LeStorage/Storable.swift
  10. 176
      LeStorage/Store.swift
  11. 371
      LeStorage/StoreCenter.swift
  12. 217
      LeStorage/StoredCollection+Sync.swift
  13. 307
      LeStorage/StoredCollection.swift
  14. 8
      LeStorage/StoredSingleton.swift
  15. 38
      LeStorage/SyncedStorable.swift
  16. 19
      LeStorage/Utils/Date+Extensions.swift
  17. 5
      LeStorage/Utils/Errors.swift
  18. 6
      LeStorage/Utils/Logger.swift
  19. 62
      LeStorageTests/CollectionsTests.swift
  20. 10
      LeStorageTests/IdentifiableTests.swift
  21. 119
      LeStorageTests/StoredCollectionTests.swift

@ -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 = "<group>"; };
C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; };
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = "<group>"; };
C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredCollection+Sync.swift"; sourceTree = "<group>"; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
C4FC2E302C353E7B0021F3BF /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
/* 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 */,

@ -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<T: Storable>: SomeCallCollection {
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
@ -108,6 +108,13 @@ actor ApiCallCollection<T: Storable>: 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<T>? {
return self.items.first(where: { $0.id == id })
@ -158,7 +165,7 @@ actor ApiCallCollection<T: Storable>: 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

@ -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
}
}

@ -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 [] }

@ -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 [] }

@ -16,4 +16,6 @@ class Settings: MicroStorable {
var userId: String? = nil
var username: String? = nil
var deviceId: String? = nil
var lastSynchronization: Date? = nil
}

@ -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)
// }
}

@ -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<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T) async throws -> U {
fileprivate func _runRequest<T: Encodable, U: Decodable>(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<T: Storable, V: Decodable>(_ request: URLRequest, apiCall: ApiCall<T>) async throws -> V {
fileprivate func _runRequest<T: SyncedStorable, V: Decodable>(
_ request: URLRequest, apiCall: ApiCall<T>
) 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<T : Storable>(type: T.Type, method: HTTPMethod) -> Bool {
fileprivate func _isTokenRequired<T: SyncedStorable>(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<T: Storable>(type: T.Type, identifier: StoreIdentifier?) throws -> URLRequest {
fileprivate func _getRequest<T: SyncedStorable>(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<T: Storable>(type: T.Type) throws -> URLRequest {
fileprivate func _postRequest<T: SyncedStorable>(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<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
fileprivate func _putRequest<T: SyncedStorable>(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<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
fileprivate func _deleteRequest<T: SyncedStorable>(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<T: SyncedStorable>(from apiCall: ApiCall<T>) 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<T: Storable>(identifier: StoreIdentifier? = nil) async throws -> [T] {
public func get<T: SyncedStorable>(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<T: Storable>(_ 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<T: Storable>(_ 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<T: SyncedStorable>(_ 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<T: Storable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
let request = try self._request(from: apiCall)
func runApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) 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<T: Storable>(from apiCall: ApiCall<T>) throws -> URLRequest {
fileprivate func _request<T: SyncedStorable>(from apiCall: ApiCall<T>) 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<U: UserPasswordBase, V: UserBase>(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<U: UserBase>(username: String, password: String) async throws -> U {
public func login<U: UserBase>(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<T: Encodable>: 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
}

@ -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 }

@ -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<T : Storable>(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection<T> {
public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false) -> StoredCollection<T> {
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
let collection = StoredCollection<T>(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<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false) -> StoredCollection<T> {
if self._created, let identifier {
self._migrate(collection, identifier: identifier, type: T.self)
}
let collection = StoredCollection<T>(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<T : Storable>(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton<T> {
let storedObject = StoredSingleton<T>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
let storedObject = StoredSingleton<T>(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<T: SyncedStorable>(_ instance: T) {
do {
let collection: StoredCollection<T> = try self.collection()
collection.addOrUpdateIfNewer(instance)
} catch {
Logger.error(error)
}
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: Storable>(instance: T) {
do {
let collection: StoredCollection<T> = 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<T: Storable>() async throws -> [T] {
public func getItems<T: SyncedStorable>() 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<T: Storable>(_ instance: T) async throws -> T? {
func sendInsertion<T: SyncedStorable>(_ 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<T: Storable>(_ instance: T) async throws -> T? {
@discardableResult func sendUpdate<T: SyncedStorable>(_ 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<T: Storable>(_ instance: T) async throws {
func sendDeletion<T: SyncedStorable>(_ 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<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
self._validIds.append(identifier.value)
let oldCollection: StoredCollection<T> = StoredCollection<T>(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<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
//
// self._validIds.append(identifier.value)
//
// let oldCollection: StoredCollection<T> = StoredCollection<T>(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
// }
//
//}

@ -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<Settings> = 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<DataLog>
/// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = 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<T: Storable>(type: T.Type) {
public func loadApiCallCollection<T: SyncedStorable>(type: T.Type) {
if self._apiCallCollections[T.resourceName()] == nil {
let apiCallCollection = ApiCallCollection<T>()
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<T: Storable>(type: T.Type, id: String) async throws {
func deleteApiCallByDataId<T: SyncedStorable>(type: T.Type, id: String) async throws {
let apiCallCollection: ApiCallCollection<T> = 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<T: Storable>(type: T.Type, id: String) async throws {
func deleteApiCallById<T: SyncedStorable>(type: T.Type, id: String) async throws {
let apiCallCollection: ApiCallCollection<T> = 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<T: SyncedStorable>(collection: StoredCollection<T>) {
do {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
Task {
await apiCallCollection.reset()
}
} catch {
Logger.error(error)
}
}
// MARK: - Api call rescheduling
/// Reschedule an ApiCall by id
func rescheduleApiCalls<T: Storable>(id: String, type: T.Type) async throws {
func rescheduleApiCalls<T: SyncedStorable>(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<T: Storable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
fileprivate func _executeApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
return try await self.service().runApiCall(apiCall)
}
/// Executes an API call
func execute<T, V: Decodable>(apiCall: ApiCall<T>) async throws -> V {
func execute<T: SyncedStorable, V: Decodable>(apiCall: ApiCall<T>) async throws -> V {
return try await self._executeApiCall(apiCall)
}
// MARK: -
// MARK: - Api calls
/// Retrieves all the items on the server
func getItems<T: Storable>(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<T: SyncedStorable>(_ 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<T: SyncedStorable>(_ 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<T: Storable>(collection: StoredCollection<T>) {
/// - instance: an object to delete
func sendDeletion<T: SyncedStorable>(_ instance: T) async throws {
guard self._canSynchronise() else {
return
}
try await self.apiCallCollection().sendDeletion(instance)
}
/// Retrieves all the items on the server
func getItems<T: SyncedStorable>(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<T> = 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<T: Storable>(_ instance: T) -> Bool {
return self._dataLogs.contains(where: { $0.dataId == instance.stringId && $0.operation == .delete })
}
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?) {
let hasAlreadyBeenDeleted: Bool = self._hasAlreadyBeenDeleted(instance)
if !hasAlreadyBeenDeleted {
DispatchQueue.main.async {
self._store(id: storeId)?.addOrUpdateIfNewer(instance)
}
}
}
func synchronizationDelete<T: Storable>(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<T: Storable>(_ instance: T) {
// self._addDataLog(instance, method: .post)
// }
func createDeleteLog<T: Storable>(_ instance: T) {
self._addDataLog(instance, method: .delete)
}
fileprivate func _addDataLog<T: Storable>(_ 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<T: SyncedStorable>(type: T.Type) async -> Int {
do {
let collection: ApiCallCollection<T> = 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<T: Storable>(_ 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<T: Storable>(_ 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<T: Storable>(_ 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<T: Storable>(_ result: T) {
func updateFromServerInstance<T: SyncedStorable>(_ result: T) {
if let storedCollection: StoredCollection<T> = 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<Log> = Store.main.registerCollection(synchronized: true)
let logsCollection: StoredCollection<Log> = 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)
}
}

@ -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<T>) 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<T>) {
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<T>) {
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)
}
}

@ -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<T: Storable>: 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<T: Storable>: 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<T> {
@ -105,16 +98,16 @@ public class StoredCollection<T: Storable>: 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<T: Storable>: 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<T: Storable>: 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<T: Storable>: 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<T>) 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<T: Storable>: 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<T>) throws {
public func delete(contentOfs sequence: any Sequence<T>) {
defer {
self._hasChanged = true
}
for instance in sequence {
try self._delete(instance)
self.deleteItem(instance)
}
}
@ -274,46 +230,62 @@ public class StoredCollection<T: Storable>: 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<T>) throws {
self._addOrUpdate(contentOfs: sequence)
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence)
// self._addOrUpdate(contentOfs: sequence)
}
/// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence, shouldSync: false)
}
/// Inserts or updates all items in the sequence
fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) {
func addSequence(_ sequence: any Sequence<T>) {
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<T: Storable>: 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<T: Storable>: 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<T: Storable>: 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<T: Storable>: 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<T: Storable>: 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

@ -8,7 +8,7 @@
import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: Storable>: StoredCollection<T> {
public class StoredSingleton<T: SyncedStorable>: StoredCollection<T> {
/// Sets the singleton to the collection without synchronizing it
public func setItemNoSync(_ instance: T) {
@ -16,9 +16,9 @@ public class StoredSingleton<T: Storable>: StoredCollection<T> {
}
/// 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<T: Storable>: StoredCollection<T> {
// MARK: - Protects from use
public override func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
public override func addOrUpdate(contentOfs sequence: any Sequence<T>) {
fatalError("method unavailable for StoredSingleton, use update")
}

@ -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
}
}

@ -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
}
}

@ -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)
}

@ -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)

@ -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<Car> = Store.main.registerCollection(inMemory: true)
let boats: StoredCollection<Boat> = 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)
}
}

@ -38,13 +38,13 @@ class StringObject: ModelObject, Storable {
}
}
struct LeStorageTests {
struct IdentifiableTests {
@Test func testIntIds() async throws {
let intObjects: StoredCollection<IntObject> = Store.main.registerCollection(synchronized: false)
let intObjects: StoredCollection<IntObject> = 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<StringObject> = Store.main.registerCollection(synchronized: false)
let stringObjects: StoredCollection<StringObject> = 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")

@ -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<MockStorable>!
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"
}
}
Loading…
Cancel
Save