Adds ability to have multiple stores

multistore
Laurent 1 year ago
parent 3b9c12c868
commit ae5c292795
  1. 8
      LeStorage.xcodeproj/project.pbxproj
  2. 37
      LeStorage/ApiCallCollection.swift
  3. 1
      LeStorage/Codables/ApiCall.swift
  4. 3
      LeStorage/Codables/FailedAPICall.swift
  5. 2
      LeStorage/ModelObject.swift
  6. 32
      LeStorage/Services.swift
  7. 8
      LeStorage/Storable.swift
  8. 294
      LeStorage/Store.swift
  9. 361
      LeStorage/StoreCenter.swift
  10. 199
      LeStorage/StoredCollection.swift
  11. 12
      LeStorage/Utils/FileManager+Extensions.swift

@ -31,6 +31,7 @@
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; };
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; };
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; };
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -69,6 +70,7 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
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>"; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -119,6 +121,7 @@
C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
@ -141,12 +144,12 @@
isa = PBXGroup;
children = (
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4A47DAE2B85FD3800ADC637 /* Errors.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -291,6 +294,7 @@
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */,
C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */,

@ -7,13 +7,25 @@
import Foundation
protocol ACCollection {
func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async
func hasPendingAPICalls() async -> Bool
func contentOfApiCallFile() async -> String?
func reset() async
}
/// 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> {
actor ApiCallCollection<T: Storable>: ACCollection {
/// The reference to the Store
fileprivate var _store: Store
// fileprivate var _store: Store
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
@ -35,9 +47,8 @@ actor ApiCallCollection<T: Storable> {
}
}
init(store: Store) {
self._store = store
}
// init() {
// }
/// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty
@ -58,7 +69,7 @@ actor ApiCallCollection<T: Storable> {
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
Logger.log("loaded \(T.fileName()) with \(decoded.count) items")
Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items")
self.items = decoded
}
}
@ -101,7 +112,10 @@ actor ApiCallCollection<T: Storable> {
self._hasChanged = true
}
}
func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id)
}
/// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id })
@ -255,7 +269,7 @@ actor ApiCallCollection<T: Storable> {
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws {
let result = try await self._store.execute(apiCall: apiCall)
let result = try await StoreCenter.main.execute(apiCall: apiCall)
switch apiCall.method {
case .post:
if let instance = self.findById(result.stringId) {
@ -275,5 +289,10 @@ actor ApiCallCollection<T: Storable> {
}
return nil
}
/// Returns if the API call collection is not empty
func hasPendingAPICalls() -> Bool {
return self.items.isNotEmpty
}
}

@ -17,6 +17,7 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId()

@ -11,7 +11,8 @@ class FailedAPICall: ModelObject, Storable {
static func resourceName() -> String { return "failed-api-calls" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId()
/// The creation date of the call

@ -10,6 +10,8 @@ import Foundation
/// A class used as the root class for Storable objects
open class ModelObject {
public var store: Store? = nil
public init() { }
open func deleteDependencies() throws {

@ -50,9 +50,12 @@ 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)")
}
@ -103,7 +106,7 @@ public class Services {
case 200..<300:
if let apiCallId,
let collectionName = (T.self as? any Storable.Type)?.resourceName() {
try await Store.main.deleteApiCallById(apiCallId, collectionName: collectionName)
try await StoreCenter.main.deleteApiCallById(apiCallId, collectionName: collectionName)
}
default:
/*
@ -119,10 +122,10 @@ public class Services {
}
if let apiCallId, let type = (T.self as? any Storable.Type) {
try Store.main.rescheduleApiCalls(id: apiCallId, type: type)
Store.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message)
try await StoreCenter.main.rescheduleApiCalls(id: apiCallId, type: type)
StoreCenter.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message)
} else {
Store.main.logFailedAPICall(request: request, error: errorMessage.message)
StoreCenter.main.logFailedAPICall(request: request, error: errorMessage.message)
}
throw ServiceError.responseError(response: errorMessage.error)
@ -147,9 +150,9 @@ public class Services {
/// Returns a GET request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _getRequest<T: Storable>(type: T.Type) throws -> URLRequest {
fileprivate func _getRequest<T: Storable>(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)
return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier)
}
/// Returns a POST request for the resource
@ -188,11 +191,14 @@ public class Services {
/// - 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
fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil) throws -> URLRequest {
fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest {
let urlString = baseURL + servicePath
guard let url = URL(string: urlString) else {
guard var url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
if let identifier {
url.append(path: identifier.urlComponent)
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -207,8 +213,8 @@ public class Services {
// MARK: - Services
/// Executes a GET request
public func get<T: Storable>() async throws -> [T] {
let getRequest = try _getRequest(type: T.self)
public func get<T: Storable>(identifier: StoreIdentifier?) async throws -> [T] {
let getRequest = try _getRequest(type: T.self, identifier: identifier)
return try await self._runRequest(getRequest)
}
@ -315,8 +321,8 @@ public class Services {
_ = try await requestToken(username: username, password: password)
let postRequest = try self._baseRequest(conf: .getUser)
let user: U = try await self._runRequest(postRequest)
Store.main.setUserUUID(uuidString: user.id)
Store.main.setUserName(user.username)
StoreCenter.main.setUserUUID(uuidString: user.id)
StoreCenter.main.setUserName(user.username)
return user
}
@ -327,7 +333,7 @@ public class Services {
/// - password2: a repeat of the new password
public func changePassword(oldPassword: String, password1: String, password2: String) async throws {
guard let username = Store.main.userName() else {
guard let username = StoreCenter.main.userName() else {
throw ServiceError.missingUserName
}

@ -10,6 +10,9 @@ import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable where ID : StringProtocol {
/// The store containing a reference to the instance
var store: Store? { get set }
/// The resource name corresponding to the resource path on the API
/// Also used as the name of the local file
static func resourceName() -> String
@ -17,6 +20,11 @@ public protocol Storable: Codable, Identifiable where ID : StringProtocol {
/// 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
static func filterByStoreIdentifier() -> Bool
/// A method that deletes the local dependencies of the resource
/// Mimics the behavior the cascading delete on the django server
/// Typically when we delete a resource, we automatically delete items that depends on it,

@ -8,10 +8,10 @@
import Foundation
import UIKit
public enum ResetOption {
case all
case synchronizedOnly
}
//public enum ResetOption {
// case all
// case synchronizedOnly
//}
public enum StoreError: Error {
case missingService
@ -22,59 +22,45 @@ public enum StoreError: Error {
case unSynchronizedCollection
}
public class Store {
/// The Store singleton
public static let main = Store()
/// A method to provide ids corresponding to the django storage
public static func randomId() -> String {
return UUID().uuidString.lowercased()
}
public struct StoreIdentifier {
var value: String
var parameterName: String
/// The URL of the django API
public var synchronizationApiURL: String? {
didSet {
if let url = synchronizationApiURL {
self._services = Services(url: url)
}
}
var urlComponent: String {
return "?\(self.parameterName)=\(self.value)"
}
}
open class Store {
/// The services performing the API calls
fileprivate var _services: Services?
/// Returns the service instance
public func service() throws -> Services {
if let service = self._services {
return service
} else {
throw StoreError.missingService
}
}
/// The Store singleton
public static let main = Store()
/// The dictionary of registered StoredCollections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// A store for the Settings object
fileprivate var _settingsStorage: MicroStorage<Settings> = MicroStorage(fileName: "settings.json")
/// The name of the directory to store the json files
static let storageDirectory = "storage"
/// Indicates to Stored Collection if they can synchronize
public var collectionsCanSynchronize: Bool = true {
didSet {
Logger.log(">>> collectionsCanSynchronize = \(self.collectionsCanSynchronize)")
}
fileprivate(set) var identifier: StoreIdentifier? = nil
public init() {
self._createDirectory(directory: Store.storageDirectory)
}
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil
public required init(identifier: String, parameter: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
let directory = "\(Store.storageDirectory)/\(identifier)"
self._createDirectory(directory: directory)
}
fileprivate var blackListedUserName: [String] = []
fileprivate func _createDirectory(directory: String) {
FileManager.default.createDirectoryInDocuments(directoryName: directory)
}
public init() {
FileManager.default.createDirectoryInDocuments(directoryName: Store.storageDirectory)
/// A method to provide ids corresponding to the django storage
public static func randomId() -> String {
return UUID().uuidString.lowercased()
}
/// Registers a collection
@ -82,7 +68,7 @@ public class Store {
public func registerCollection<T : Storable>(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection<T> {
// register collection
let collection = StoredCollection<T>(synchronized: synchronized, store: Store.main, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate, loadCompletion: nil)
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = collection
return collection
@ -92,78 +78,12 @@ public class Store {
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton<T> {
// register collection
let storedObject = StoredSingleton<T>(synchronized: synchronized, store: Store.main, inMemory: inMemory, sendsUpdate: sendsUpdate, loadCompletion: nil)
let storedObject = StoredSingleton<T>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = storedObject
return storedObject
}
// MARK: - Settings
/// Stores the user UUID
func setUserUUID(uuidString: String) {
self._settingsStorage.update { settings in
settings.userId = uuidString
}
}
/// Returns the stored user Id
public var userId: String? {
return self._settingsStorage.item.userId
}
/// Returns the username
public func userName() -> String? {
return self._settingsStorage.item.username
}
/// Sets the username
func setUserName(_ username: String) {
self._settingsStorage.update { settings in
settings.username = username
}
}
/// Returns the stored token
public func token() -> String? {
return try? self.service().keychainStore.getToken()
}
/// Disconnect the user from the storage and resets collection
public func disconnect(resetOption: ResetOption? = nil) {
try? self.service().deleteToken()
self._settingsStorage.update { settings in
settings.username = nil
settings.userId = nil
}
switch resetOption {
case .all:
for collection in self._collections.values {
collection.reset()
}
case .synchronizedOnly:
for collection in self._collections.values {
if collection.synchronized {
collection.reset()
}
}
default:
break
}
}
/// Returns whether the system has a user token
public func hasToken() -> Bool {
do {
_ = try self.service().keychainStore.getToken()
return true
} catch {
return false
}
}
// MARK: - Convenience
/// Looks for an instance by id
@ -192,57 +112,6 @@ public class Store {
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
// MARK: - Api call rescheduling
/// Deletes an ApiCall by [id] and [collectionName]
func deleteApiCallById(_ id: String, collectionName: String) async throws {
if let collection = self._collections[collectionName] {
try await collection.deleteApiCallById(id)
} else {
throw StoreError.collectionNotRegistered(type: collectionName)
}
}
/// Reschedule an ApiCall by id
func rescheduleApiCalls<T: Storable>(id: String, type: T.Type) throws {
guard self.collectionsCanSynchronize else {
return
}
let collection: StoredCollection<T> = try self.collection()
collection.rescheduleApiCallsIfNecessary()
}
/// Executes an ApiCall
fileprivate func _executeApiCall<T: Storable>(_ apiCall: ApiCall<T>) async throws -> T {
return try await self.service().runApiCall(apiCall)
}
/// Executes an API call
func execute<T>(apiCall: ApiCall<T>) async throws -> T {
return try await self._executeApiCall(apiCall)
}
// MARK: -
/// Retrieves all the items on the server
func getItems<T: Storable>() async throws -> [T] {
return try await self.service().get()
}
/// Resets all registered collection
public func reset() {
for collection in self._collections.values {
collection.reset()
}
}
/// Resets all the api call collections
public func resetApiCalls() {
for collection in self._collections.values {
collection.resetApiCalls()
}
}
/// Loads all collection with the data from the server
public func loadCollectionFromServer() {
for collection in self._collections.values {
@ -252,14 +121,11 @@ public class Store {
}
}
/// Returns whether any collection has pending API calls
public func hasPendingAPICalls() async -> Bool {
/// Resets all registered collection
public func reset() {
for collection in self._collections.values {
if await collection.hasPendingAPICalls() {
return true
}
collection.reset()
}
return false
}
/// Returns the names of all collections
@ -267,77 +133,57 @@ public class Store {
return self._collections.values.map { $0.resourceName }
}
/// Returns the content of the api call file
public func apiCallsFileContent(resourceName: String) async -> String {
return await self._collections[resourceName]?.contentOfApiCallFile() ?? ""
// MARK: - Write
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
if let identifier = self.identifier?.value {
url.append(component: identifier)
}
return url
}
/// This method triggers the framework to save and send failed api calls
public func logsFailedAPICalls() {
self._failedAPICallsCollection = self.registerCollection(synchronized: true)
func write(content: String, fileName: String) throws {
var fileURL = try self._directoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
/// If configured for, logs and send to the server a failed API call
/// Logs a failed API call that has failed at least 5 times
func logFailedAPICall(_ apiCallId: String, request: URLRequest, collectionName: String, error: String) {
guard let failedAPICallsCollection = self._failedAPICallsCollection,
let collection = self._collections[collectionName],
collectionName != FailedAPICall.resourceName()
else {
return
}
Task {
if let apiCall = await collection.apiCallById(apiCallId) {
if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 {
do {
let authValue = request.allHTTPHeaderFields?["Authorization"]
let string = try apiCall.jsonString()
let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue)
try failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} catch {
Logger.error(error)
}
}
}
}
func fileURL<T: Storable>(type: T.Type) throws -> URL {
let fileURL = try self._directoryPath()
return fileURL.appending(component: T.fileName())
}
/// Logs a failed Api call with its request and error message
func logFailedAPICall(request: URLRequest, error: String) {
guard let failedAPICallsCollection = self._failedAPICallsCollection,
let body: Data = request.httpBody,
let bodyString = String(data: body, encoding: .utf8),
let url = request.url?.absoluteString else {
return
}
let authValue = request.allHTTPHeaderFields?["Authorization"]
func removeFile<T: Storable>(type: T.Type) {
do {
let failedAPICall = FailedAPICall(callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error, authentication: authValue)
try failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
let url: URL = try self.fileURL(type: type)
if FileManager.default.fileExists(atPath: url.path()) {
try FileManager.default.removeItem(at: url)
}
} catch {
Logger.error(error)
}
}
/// Retrieves all the items on the server
public func getItems<T: Storable>() async throws -> [T] {
if T.filterByStoreIdentifier() {
return try await StoreCenter.main.getItems(identifier: self.identifier)
} else {
return try await StoreCenter.main.getItems()
}
}
func sendInsertion<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendInsertion(instance)
}
public func blackListUserName(_ userName: String) {
self.blackListedUserName.append(userName)
func sendUpdate<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendUpdate(instance)
}
func userIsAllowed() -> Bool {
guard let userName = self.userName() else {
return true
}
return !self.blackListedUserName.contains(where: { $0 == userName } )
func sendDeletion<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendDeletion(instance)
}
}

@ -0,0 +1,361 @@
//
// StoreCenter.swift
// LeStorage
//
// Created by Laurent Morvillier on 25/06/2024.
//
import Foundation
public class StoreCenter {
public static let main: StoreCenter = StoreCenter()
fileprivate var _stores: [String : Store] = [:]
/// The URL of the django API
public var synchronizationApiURL: String? {
didSet {
if let url = synchronizationApiURL {
self._services = Services(url: url)
}
}
}
/// Indicates to Stored Collection if they can synchronize
public var collectionsCanSynchronize: Bool = true
/// A store for the Settings object
fileprivate var _settingsStorage: MicroStorage<Settings> = MicroStorage(fileName: "settings.json")
/// The services performing the API calls
fileprivate var _services: Services?
/// The dictionary of registered StoredCollections
fileprivate var _apiCallCollections: [String : any ACCollection] = [:]
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil
fileprivate var blackListedUserName: [String] = []
init() {
self._loadExistingApiCollections()
}
/// Returns the service instance
public func service() throws -> Services {
if let service = self._services {
return service
} else {
throw StoreError.missingService
}
}
fileprivate func _registerStore(store: Store) {
guard let identifier = store.identifier?.value else {
fatalError("The store has no identifier")
}
self._stores[identifier] = store
}
public func store<T: Store>(identifier: String, parameter: String) -> T {
if let store = self._stores[identifier] as? T {
return store
} else {
let store = T(identifier: identifier, parameter: parameter)
self._registerStore(store: store)
return store
}
}
fileprivate func _loadExistingApiCollections() {
let string = "clubs"
}
// MARK: - Settings
/// Stores the user UUID
func setUserUUID(uuidString: String) {
self._settingsStorage.update { settings in
settings.userId = uuidString
}
}
/// Returns the stored user Id
public var userId: String? {
return self._settingsStorage.item.userId
}
/// Returns the username
public func userName() -> String? {
return self._settingsStorage.item.username
}
/// Sets the username
func setUserName(_ username: String) {
self._settingsStorage.update { settings in
settings.username = username
}
}
/// Returns the stored token
public func token() -> String? {
return try? self.service().keychainStore.getToken()
}
/// Disconnect the user from the storage and resets collection
public func disconnect() {
try? self.service().deleteToken()
self._settingsStorage.update { settings in
settings.username = nil
settings.userId = nil
}
// switch resetOption {
// case .all:
// for collection in self._collections.values {
// collection.reset()
// }
// case .synchronizedOnly:
// for collection in self._collections.values {
// if collection.synchronized {
// collection.reset()
// }
// }
// default:
// break
// }
}
/// Returns whether the system has a user token
public func hasToken() -> Bool {
do {
_ = try self.service().keychainStore.getToken()
return true
} catch {
return false
}
}
func getOrCreateApiCallCollection<T: Storable>() -> ApiCallCollection<T> {
if let apiCallCollection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
return apiCallCollection
}
let apiCallCollection = ApiCallCollection<T>()
self._apiCallCollections[T.resourceName()] = apiCallCollection
return apiCallCollection
// apiCallCollection.loadFromFile()
}
func apiCallCollection<T: Storable>() throws -> ApiCallCollection<T> {
if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
func deleteApiCallByDataId<T: Storable>(type: T.Type, id: String) async throws {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteByDataId(id)
}
func deleteApiCallById<T: Storable>(type: T.Type, id: String) async throws {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteById(id)
}
func deleteApiCallById(_ id: String, collectionName: String) async throws {
if let apiCallCollection = self._apiCallCollections[collectionName] {
await apiCallCollection.deleteById(id)
} else {
throw StoreError.collectionNotRegistered(type: collectionName)
}
}
// MARK: - Api call rescheduling
/// Reschedule an ApiCall by id
func rescheduleApiCalls<T: Storable>(id: String, type: T.Type) async throws {
guard self.collectionsCanSynchronize else {
return
}
let collection: ApiCallCollection<T> = try self.apiCallCollection()
await collection.rescheduleApiCallsIfNecessary()
}
/// Executes an ApiCall
fileprivate func _executeApiCall<T: Storable>(_ apiCall: ApiCall<T>) async throws -> T {
return try await self.service().runApiCall(apiCall)
}
/// Executes an API call
func execute<T>(apiCall: ApiCall<T>) async throws -> T {
return try await self._executeApiCall(apiCall)
}
// MARK: -
/// 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)
}
/// Resets all registered collection
public func reset() {
Store.main.reset()
for store in self._stores.values {
store.reset()
}
}
/// Resets all the api call collections
public func resetApiCalls() {
Task {
for collection in self._apiCallCollections.values {
await collection.reset()
}
}
}
public func resetApiCalls<T: Storable>(collection: StoredCollection<T>) {
do {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
Task {
await apiCallCollection.reset()
}
} catch {
Logger.error(error)
}
}
/// Returns whether any collection has pending API calls
public func hasPendingAPICalls() async -> Bool {
for collection in self._apiCallCollections.values {
if await collection.hasPendingAPICalls() {
return true
}
}
return false
}
/// Returns the content of the api call file
public func apiCallsFileContent(resourceName: String) async -> String {
return await self._apiCallCollections[resourceName]?.contentOfApiCallFile() ?? ""
}
/// This method triggers the framework to save and send failed api calls
public func logsFailedAPICalls() {
self._failedAPICallsCollection = Store.main.registerCollection(synchronized: true)
}
/// If configured for, logs and send to the server a failed API call
/// Logs a failed API call that has failed at least 5 times
func logFailedAPICall(_ apiCallId: String, request: URLRequest, collectionName: String, error: String) {
guard let failedAPICallsCollection = self._failedAPICallsCollection,
let collection = self._apiCallCollections[collectionName],
collectionName != FailedAPICall.resourceName()
else {
return
}
Task {
if let apiCall = await collection.findCallById(apiCallId) {
if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 {
do {
let authValue = request.allHTTPHeaderFields?["Authorization"]
let string = try apiCall.jsonString()
let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue)
try failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} catch {
Logger.error(error)
}
}
}
}
}
/// Logs a failed Api call with its request and error message
func logFailedAPICall(request: URLRequest, error: String) {
guard let failedAPICallsCollection = self._failedAPICallsCollection,
let body: Data = request.httpBody,
let bodyString = String(data: body, encoding: .utf8),
let url = request.url?.absoluteString else {
return
}
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)
}
}
public func blackListUserName(_ userName: String) {
self.blackListedUserName.append(userName)
}
func userIsAllowed() -> Bool {
guard let userName = self.userName() else {
return true
}
return !self.blackListedUserName.contains(where: { $0 == userName } )
}
// fileprivate func _registerStore(identifier: String, parameter: String) -> Store {
// let store = Store(identifier: identifier, parameter: parameter)
// self._stores[identifier] = store
// return store
// }
//
// public func store(identifier: String, parameter: String) -> Store {
// if let store = self._stores[identifier] {
// return store
// }
// return self._registerStore(identifier: identifier, parameter: parameter)
// }
public func destroyStore(identifier: String) {
let directory = "\(Store.storageDirectory)/\(identifier)"
FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
}
/// Returns whether the collection can synchronize
fileprivate func _canSynchronise() -> Bool {
return self.collectionsCanSynchronize && self.userIsAllowed()
}
func sendInsertion<T: Storable>(_ instance: T) async throws {
guard self._canSynchronise() else {
return
}
try await self.apiCallCollection().sendInsertion(instance)
}
func sendUpdate<T: Storable>(_ instance: T) async throws {
guard self._canSynchronise() else {
return
}
try await self.apiCallCollection().sendUpdate(instance)
}
func sendDeletion<T: Storable>(_ instance: T) async throws {
guard self._canSynchronise() else {
return
}
try await self.apiCallCollection().sendDeletion(instance)
}
}

@ -17,25 +17,24 @@ protocol CollectionHolder {
associatedtype Item
var items: [Item] { get }
func reset()
}
protocol SomeCollection: Identifiable {
protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var synchronized: Bool { get }
func allItems() -> [any Storable]
func deleteById(_ id: String) throws
func loadDataFromServerIfAllowed() async throws
func reset()
func resetApiCalls()
// func resetApiCalls()
func deleteApiCallById(_ id: String) async throws
func apiCallById(_ id: String) async -> (any SomeCall)?
// func deleteApiCallById(_ id: String) async throws
// func apiCallById(_ id: String) async -> (any SomeCall)?
func hasPendingAPICalls() async -> Bool
func contentOfApiCallFile() async -> String?
// func hasPendingAPICalls() async -> Bool
// func contentOfApiCallFile() async -> String?
}
@ -62,13 +61,13 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
fileprivate var _store: Store
/// Notifies the closure when the loading is done
fileprivate var loadCompletion: ((StoredCollection<T>) -> ())? = nil
// fileprivate var loadCompletion: ((StoredCollection<T>) -> ())? = nil
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [String : T]? = nil
/// Collection of API calls used to store HTTP calls
fileprivate var apiCallsCollection: ApiCallCollection<T>? = nil
// fileprivate var apiCallsCollection: ApiCallCollection<T>? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
@ -90,7 +89,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Indicates if the collection has loaded objects from the server
fileprivate(set) public var hasLoadedFromServer: Bool = false
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true, loadCompletion: ((StoredCollection<T>) -> ())? = nil) {
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) {
self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed {
@ -99,24 +98,33 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self._inMemory = inMemory
self._sendsUpdate = sendsUpdate
self._store = store
self.loadCompletion = loadCompletion
// self.loadCompletion = loadCompletion
if synchronized {
let apiCallCollection = ApiCallCollection<T>(store: store)
self.apiCallsCollection = apiCallCollection
Task {
do {
try await apiCallCollection.loadFromFile()
} catch {
Logger.error(error)
}
}
}
// if synchronized {
// let apiCallCollection = ApiCallCollection<T>()
// self.apiCallsCollection = apiCallCollection
// Task {
// do {
// try await apiCallCollection.loadFromFile()
// } catch {
// Logger.error(error)
// }
// }
//
// }
self._load()
}
fileprivate init() {
self.synchronized = false
self._store = Store.main
}
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>()
}
var resourceName: String {
return T.resourceName()
}
@ -155,24 +163,31 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
let fileURL = try T.urlForJSONFile()
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
}
DispatchQueue.main.async {
Logger.log("loaded \(T.fileName()) with \(decoded.count) items")
self.items = decoded
self._updateIndexIfNecessary()
self.loadCompletion?(self)
// self.loadCompletion?(self)
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
} else {
DispatchQueue.main.async {
self.loadCompletion?(self)
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
}
}
// else {
// Task {
// do {
// try await self.loadDataFromServerIfAllowed()
// } catch {
// Logger.error(error)
// }
// }
// }
}
@ -192,7 +207,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
let items: [T] = try await self._store.getItems()
try self._addOrUpdate(contentOfs: items, shouldSync: false)
self.hasLoadedFromServer = true
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
} catch {
Logger.error(error)
}
@ -208,6 +225,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
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
@ -276,7 +296,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self._hasChanged = true
}
for instance in sequence {
for var instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
if shouldSync {
@ -288,6 +308,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self._sendInsertionIfNecessary(instance)
}
}
instance.store = self._store
self._indexes?[instance.stringId] = instance
}
@ -315,11 +336,20 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self._hasChanged = true
}
for item in items {
self.items.removeAll(where: { $0.id == item.id })
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
// self.items.removeAll(where: { $0.id == item.id })
Task {
do {
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
} catch {
Logger.error(error)
}
/// Remove related API call if existing
await self.apiCallsCollection?.deleteByDataId(item.stringId)
// await self.apiCallsCollection?.deleteByDataId(item.stringId)
}
}
@ -335,16 +365,16 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Deletes an API Call by its id
/// - Parameters:
/// - id: the id of the API Call
func deleteApiCallById(_ id: String) async throws {
await self.apiCallsCollection?.deleteById(id)
}
/// Returns an API Call by its id
/// - Parameters:
/// - id: the id of the API Call
func apiCallById(_ id: String) async -> (any SomeCall)? {
return await self.apiCallsCollection?.findById(id)
}
// func deleteApiCallById(_ id: String) async throws {
// await self.apiCallsCollection?.deleteById(id)
// }
//
// /// Returns an API Call by its id
// /// - Parameters:
// /// - id: the id of the API Call
// func apiCallById(_ id: String) async -> (any SomeCall)? {
// return await self.apiCallsCollection?.findById(id)
// }
// MARK: - SomeCall
@ -374,7 +404,8 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
Logger.log("Start write to \(T.fileName())...")
do {
let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: T.fileName())
try self._store.write(content: jsonString, fileName: T.fileName())
// try T.writeToStorageDirectory(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error) // TODO how to notify the main project
}
@ -389,27 +420,27 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Removes the items of the collection, deletes the corresponding file, and also reset the related API calls collection
public func reset() {
self.items.removeAll()
self._store.removeFile(type: T.self)
// do {
// let url: URL = try T.urlForJSONFile()
// if FileManager.default.fileExists(atPath: url.path()) {
// try FileManager.default.removeItem(at: url)
// }
// } catch {
// Logger.error(error)
// }
do {
let url: URL = try T.urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) {
try FileManager.default.removeItem(at: url)
}
} catch {
Logger.error(error)
}
self.resetApiCalls()
// self.resetApiCalls()
}
/// Removes the collection related API calls collection
public func resetApiCalls() {
if let apiCallsCollection = self.apiCallsCollection {
Task {
await apiCallsCollection.reset()
}
}
}
// /// Removes the collection related API calls collection
// public func resetApiCalls() {
// if let apiCallsCollection = self.apiCallsCollection {
// Task {
// await apiCallsCollection.reset()
// }
// }
// }
// MARK: - Reschedule calls
@ -417,11 +448,11 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// - Parameters:
/// - instance: the object to POST
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized, self._canSynchronise() else {
guard self.synchronized else {
return
}
Task {
await self.apiCallsCollection?.sendInsertion(instance)
try await self._store.sendInsertion(instance)
}
}
@ -429,11 +460,11 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// - Parameters:
/// - instance: the object to PUT
fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized, self._sendsUpdate, self._canSynchronise() else {
guard self.synchronized, self._sendsUpdate else {
return
}
Task {
await self.apiCallsCollection?.sendUpdate(instance)
try await self._store.sendUpdate(instance)
}
}
@ -441,36 +472,20 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// - Parameters:
/// - instance: the object to DELETE
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized, self._canSynchronise() else {
guard self.synchronized else {
return
}
Task {
await self.apiCallsCollection?.sendDeletion(instance)
try await self._store.sendDeletion(instance)
}
}
/// Returns whether the collection can synchronize
fileprivate func _canSynchronise() -> Bool {
return Store.main.collectionsCanSynchronize && Store.main.userIsAllowed()
}
/// Reschedule the api calls if possible
func rescheduleApiCallsIfNecessary() {
Task {
await self.apiCallsCollection?.rescheduleApiCallsIfNecessary()
}
}
/// Returns the content of the API call file as a String
func contentOfApiCallFile() async -> String? {
return await self.apiCallsCollection?.contentOfApiCallFile()
}
/// Returns if the API call collection is not empty
func hasPendingAPICalls() async -> Bool {
guard let apiCallsCollection else { return false }
return await apiCallsCollection.items.isNotEmpty
}
// func rescheduleApiCallsIfNecessary() {
// Task {
// await self.apiCallsCollection?.rescheduleApiCallsIfNecessary()
// }
// }
// MARK: - RandomAccessCollection

@ -22,4 +22,16 @@ extension FileManager {
}
}
func deleteDirectoryInDocuments(directoryName: String) {
let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first!
let directoryURL = documentsDirectory.appendingPathComponent(directoryName)
if self.fileExists(atPath: directoryURL.path) {
do {
try self.removeItem(at: directoryURL)
} catch {
Logger.error(error)
}
}
}
}

Loading…
Cancel
Save