multistore
Laurent 2 years ago
parent 9cfa848a11
commit cc0c8db826
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 42
      LeStorage/ApiCall.swift
  3. 52
      LeStorage/Services.swift
  4. 2
      LeStorage/Storable.swift
  5. 106
      LeStorage/Store.swift
  6. 23
      LeStorage/StoredCollection.swift

@ -18,6 +18,7 @@
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */; };
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D602B6D3C1300ADC637 /* Services.swift */; };
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D642B6E92FE00ADC637 /* Storable.swift */; };
C4A47D672B6FF83A00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D662B6FF83A00ADC637 /* ApiCall.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -43,6 +44,7 @@
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
C4A47D602B6D3C1300ADC637 /* Services.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = "<group>"; };
C4A47D642B6E92FE00ADC637 /* Storable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = "<group>"; };
C4A47D662B6FF83A00ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -88,6 +90,7 @@
C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D602B6D3C1300ADC637 /* Services.swift */,
C4A47D662B6FF83A00ADC637 /* ApiCall.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
@ -230,6 +233,7 @@
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D672B6FF83A00ADC637 /* ApiCall.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

@ -0,0 +1,42 @@
//
// ApiCall.swift
// LeStorage
//
// Created by Laurent Morvillier on 04/02/2024.
//
import Foundation
protocol SomeCall : Storable {
func execute() throws
}
struct ApiCall<T : Storable> : Storable, SomeCall {
static func resourceName() -> String { return "apicalls" }
var id: String = UUID().uuidString
/// The http URL of the call
var url: String
/// The HTTP method of the call: post...
var method: String
/// The content of the call
var body: Data
/// The number of times the call has been executed
var attemptsCount = 1
/// The date of the last execution
var lastAttemptDate = Date()
/// Executes the api call
func execute() throws {
Task {
try await Store.main.execute(apiCall: self)
}
}
}

@ -36,11 +36,20 @@ class Services {
// MARK: - Base
fileprivate func runRequest<T : Decodable>(_ request: URLRequest) async throws -> T {
fileprivate func runRequest<T : Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200...300:
if let apiCallId,
let collectionName = (T.self as? any Storable.Type)?.resourceName() {
try Store.main.deleteApiCallById(apiCallId, collectionName: collectionName)
}
default:
Store.main.startCallsRescheduling()
}
Logger.log("status code = \(statusCode)")
}
@ -78,26 +87,45 @@ class Services {
// MARK: - Services
func get<T : Storable>() async throws -> [T] {
let getRequest = try getRequest(servicePath: T.resourceName + "/")
let getRequest = try getRequest(servicePath: T.resourceName() + "/")
return try await self.runRequest(getRequest)
}
func insert<T : Storable>(_ instance: T) async throws -> T {
var postRequest = try postRequest(servicePath: T.resourceName + "/")
postRequest.httpBody = try instance.jsonData()
return try await self.runRequest(postRequest)
let apiCall = try self._createCall(method: Method.post, instance: instance)
return try await self.runApiCall(apiCall)
}
func update<T : Storable>(_ instance: T) async throws -> T {
var putRequest = try putRequest(servicePath: T.resourceName + "/")
putRequest.httpBody = try instance.jsonData()
return try await self.runRequest(putRequest)
let apiCall = try self._createCall(method: Method.put, instance: instance)
return try await self.runApiCall(apiCall)
}
func delete<T : Storable>(_ instance: T) async throws -> T {
var deleteRequest = try deleteRequest(servicePath: T.resourceName + "/")
deleteRequest.httpBody = try instance.jsonData()
return try await self.runRequest(deleteRequest)
let apiCall = try self._createCall(method: Method.delete, instance: instance)
return try await self.runApiCall(apiCall)
}
fileprivate func _createCall<T : Storable>(method: Method, instance: T) throws -> ApiCall<T> {
let data = try instance.jsonData()
let url = self._baseURL + T.resourceName() + "/"
return ApiCall(url: url, method: method.rawValue, body: data)
}
func runApiCall<T : Storable>(_ apiCall: ApiCall<T>) async throws -> T {
try Store.main.registerApiCall(apiCall)
let request = try self._request(from: apiCall)
return try await self.runRequest(request, apiCallId: apiCall.id)
}
fileprivate func _request<T : Storable>(from apiCall: ApiCall<T>) throws -> URLRequest {
guard let url = URL(string: apiCall.url) else {
throw ServiceError.urlCreationError(url: apiCall.url)
}
var request = URLRequest(url: url)
request.httpMethod = apiCall.method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}
}

@ -8,7 +8,7 @@
import Foundation
public protocol Storable : Codable, Identifiable where ID : StringProtocol {
static var resourceName: String { get }
static func resourceName() -> String
}
extension Storable {

@ -7,6 +7,13 @@
import Foundation
enum StoreError: Error {
case missingService
case unexpectedCollectionType(name: String)
case apiCallCollectionNotRegistered(type: String)
case collectionNotRegistered(type: String)
}
public class Store {
public static let main = Store()
@ -24,13 +31,26 @@ public class Store {
}
fileprivate var _services: Services?
fileprivate var collections: [String : any SomeCollection] = [:]
fileprivate var _collections: [String : any SomeCollection] = [:]
fileprivate var _apiCallsCollections: [String : any SomeCollection] = [:]
fileprivate var _apiCallsTimer: Timer? = nil
fileprivate var _reschedulingCount: Int = 0
public init() { }
public func registerCollection<T : Storable>(synchronized: Bool) -> StoredCollection<T> {
// register collection
let collection = StoredCollection<T>(synchronized: synchronized, store: self)
self.collections[T.resourceName] = collection
self._collections[T.resourceName()] = collection
if synchronized { // register additional collection for api calls
let apiCallCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: self)
self._apiCallsCollections[T.resourceName()] = apiCallCollection
}
return collection
}
@ -39,11 +59,87 @@ public class Store {
}
func findById<T : Storable>(_ id: String) -> T? {
guard let collection = self.collections[T.resourceName] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName) not registered")
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered")
return nil
}
return collection.findById(id)
}
// MARK: - Api call rescheduling
func apiCallCollection<T : Storable>() throws -> StoredCollection<ApiCall<T>> {
if let apiCallCollection = self._apiCallsCollections[T.resourceName()] as? StoredCollection<ApiCall<T>> {
return apiCallCollection
}
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
}
func registerApiCall<T : Storable>(_ apiCall: ApiCall<T>) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
collection.addOrUpdate(instance: apiCall)
}
func deleteApiCallById<T : Storable> (_ id: String, type: T.Type) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
collection.deleteById(id)
}
func deleteApiCallById(_ id: String, collectionName: String) throws {
if let collection = self._apiCallsCollections[collectionName] {
collection.deleteById(id)
return
}
throw StoreError.collectionNotRegistered(type: collectionName)
}
func deleteApiCall<T : Storable> (_ apiCall: ApiCall<T>) throws {
let collection: StoredCollection<ApiCall<T>> = try self.apiCallCollection()
collection.delete(instance: apiCall)
}
func startCallsRescheduling() {
self._reschedulingCount += 1
let delay = pow(2, 1 + self._reschedulingCount)
let seconds = NSDecimalNumber(decimal: delay).doubleValue
self._apiCallsTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { timer in
self._executeApiCalls()
})
}
fileprivate func _executeApiCalls() {
DispatchQueue(label: "lestorage.queue.network").async {
do {
for collection in self._apiCallsCollections.values {
if let apiCalls = collection.allItems() as? [any SomeCall] {
for apiCall in apiCalls {
try apiCall.execute()
}
} else {
Logger.w("_apiCallsCollections item not castable to [any SomeCall] ")
}
}
} catch {
Logger.error(error)
}
}
}
fileprivate func _executeApiCall<T : Storable>(_ apiCall: ApiCall<T>) async throws -> T {
guard let service else {
throw StoreError.missingService
}
return try await service.runApiCall(apiCall)
}
func execute<T>(apiCall: ApiCall<T>) async throws {
_ = try await self._executeApiCall(apiCall)
}
}

@ -8,7 +8,8 @@
import Foundation
protocol SomeCollection : Identifiable {
func allItems() -> [any Storable]
func deleteById(_ id: String)
}
public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollection, ObservableObject {
@ -22,7 +23,6 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
fileprivate var _hasChanged: Bool = false {
didSet {
if self._hasChanged == true {
self.objectWillChange.send()
self._scheduleWrite()
self._hasChanged = false
}
@ -36,7 +36,7 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
}
fileprivate var _fileName: String {
return T.resourceName + ".json"
return T.resourceName() + ".json"
}
public func addOrUpdate(instance: T) {
@ -67,6 +67,18 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
return self.items.first(where: { $0.id == id })
}
public func deleteById(_ id: String) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
// MARK: - SomeCall
func allItems() -> [any Storable] {
return self.items
}
// MARK: - File access
fileprivate func _scheduleWrite() {
@ -106,7 +118,6 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
DispatchQueue.main.sync {
Logger.log("loaded \(self._fileName) with \(decoded.count) items")
self.items = decoded
self.objectWillChange.send()
}
}
@ -185,4 +196,8 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec
}
}
public func append(_ newElement: T) {
self.addOrUpdate(instance: newElement)
}
}

Loading…
Cancel
Save