You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
LeStorage/LeStorage/StoreCenter.swift

479 lines
16 KiB

//
// StoreCenter.swift
// LeStorage
//
// Created by Laurent Morvillier on 25/06/2024.
//
import Foundation
import UIKit
public class StoreCenter {
/// The main instance
public static let main: StoreCenter = StoreCenter()
/// A dictionary of Stores associated to their id
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 SomeCallCollection] = [:]
/// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil
fileprivate var _logs: StoredCollection<Log>? = nil
/// A list of username that cannot synchronize with the server
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
}
}
/// Registers a store into the list of stores
/// - Parameters:
/// - store: A store to save
fileprivate func _registerStore(store: Store) {
guard let identifier = store.identifier?.value else {
fatalError("The store has no identifier")
}
self._stores[identifier] = store
}
/// Returns a store using its identifier, and registers it if it does not exists
/// - Parameters:
/// - identifier: The store identifer
/// - parameter: The parameter name used to filter data on the server
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
}
}
// MARK: - Settings
/// Sets the user info given a user
func setUserInfo(user: UserBase) {
self._settingsStorage.update { settings in
settings.userId = user.id
settings.username = user.username
}
}
/// 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
}
/// Returns the stored token
public func token() -> String? {
return try? self.service().keychainStore.getValue()
}
/// Disconnect the user from the storage and resets collection
public func disconnect() {
try? self.service().deleteToken()
self.resetApiCalls()
self._failedAPICallsCollection?.reset()
self._settingsStorage.update { settings in
settings.username = nil
settings.userId = nil
}
}
/// Returns whether the system has a user token
public func hasToken() -> Bool {
do {
_ = try self.service().keychainStore.getValue()
return true
} catch {
return false
}
}
/// Returns a generated device id
/// If created, stores it inside the keychain to get a consistent value even if the app is deleted
/// as UIDevice.current.identifierForVendor value changes when the app is deleted and installed again
func deviceId() -> String {
let keychainStore = KeychainStore(serverId: "lestorage.main")
do {
return try keychainStore.getValue()
} catch {
let deviceId: String = UIDevice.current.identifierForVendor?.uuidString ??
UUID().uuidString
do {
try keychainStore.add(value: deviceId)
} catch {
Logger.error(error)
}
return deviceId
}
}
// MARK: - Api Calls
/// Instantiates and loads an ApiCallCollection with the provided type
public func loadApiCallCollection<T: Storable>(type: T.Type) {
if self._apiCallCollections[T.resourceName()] == nil {
let apiCallCollection = ApiCallCollection<T>()
self._apiCallCollections[T.resourceName()] = apiCallCollection
Task {
do {
try await apiCallCollection.loadFromFile()
} catch {
Logger.error(error)
}
}
}
}
/// Returns the ApiCall collection using the resource name of the provided T type
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())
}
/// Deletes an ApiCall, identifying it by dataId
/// - 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 {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteByDataId(id)
}
/// Deletes an ApiCall by its id
/// - 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 {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteById(id)
}
/// Deletes an ApiCall by its id
/// - Parameters:
/// - id: the id of the ApiCall
/// - collectionName: the name of the collection of ApiCall
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 ApiCall
fileprivate func _executeApiCall<T: Storable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
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)
// }
/// Executes an API call
func execute<T, V: Decodable>(apiCall: ApiCall<T>) async throws -> V {
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()
}
}
}
/// Resets the ApiCall whose type identifies with the provided collection
/// - Parameters:
/// - collection: The collection identifying the Storable type
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.hasPendingCalls() {
return true
}
}
return false
}
/// Returns the content of the api call file
public func apiCallsFileContent(resourceName: String) async -> String {
return await self._apiCallCollections[resourceName]?.contentOfFile() ?? ""
}
/// 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)
DispatchQueue.main.async {
do {
try failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} catch {
Logger.error(error)
}
}
} 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)
}
}
/// Adds a userName to the black list
/// Black listed username cannot send data to the server
/// - Parameters:
/// - collection: The collection identifying the Storable type
public func blackListUserName(_ userName: String) {
self._blackListedUserName.append(userName)
}
/// Returns whether the current userName is allowed to sync with the server
func userIsAllowed() -> Bool {
guard let userName = self.userName() else {
return true
}
return !self._blackListedUserName.contains(where: { $0 == userName } )
}
/// Deletes the directory using its identifier
/// - Parameters:
/// - identifier: The name of the directory
public func destroyStore(identifier: String) {
let directory = "\(Store.storageDirectory)/\(identifier)"
FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
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)
}
func updateFromServerInstance<T: Storable>(_ result: T) {
if let storedCollection: StoredCollection<T> = self.collectionOfInstance(result) {
if storedCollection.findById(result.id) != nil {
storedCollection.updateFromServerInstance(result)
}
}
}
func collectionOfInstance<T: Storable>(_ instance: T) -> StoredCollection<T>? {
do {
let storedCollection: StoredCollection<T> = try Store.main.collection()
if storedCollection.findById(instance.id) != nil {
return storedCollection
} else {
return self.collectionOfInstanceInSubStores(instance)
}
} catch {
return self.collectionOfInstanceInSubStores(instance)
}
}
func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> StoredCollection<T>? {
for store in self._stores.values {
let storedCollection: StoredCollection<T>? = try? store.collection()
if storedCollection?.findById(instance.id) != nil {
return storedCollection
}
}
return nil
}
// MARK: - Logs
/// Returns the logs collection and instantiates it if necessary
fileprivate func _logsCollection() -> StoredCollection<Log> {
if let logs = self._logs {
return logs
} else {
let logsCollection: StoredCollection<Log> = Store.main.registerCollection(synchronized: true)
self._logs = logsCollection
return logsCollection
}
}
/// 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)
}
}
// MARK: - Migration
/// Migrates the token from the provided service to the main Services instance
public func migrateToken(_ services: Services) throws {
guard let userName = self.userName() else {
return
}
try self.service().migrateToken(services, userName: userName)
}
}