Add / update / delete sync obtained!

sync2
Laurent 1 year ago
parent b5b32892dc
commit 7a21f27550
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 65
      LeStorage/ApiCallCollection.swift
  3. 42
      LeStorage/Codables/ApiCall.swift
  4. 16
      LeStorage/Codables/GetSyncData.swift
  5. 24
      LeStorage/Codables/SyncResponse.swift
  6. 4
      LeStorage/ModelObject.swift
  7. 96
      LeStorage/Services.swift
  8. 3
      LeStorage/Storable.swift
  9. 8
      LeStorage/Store.swift
  10. 96
      LeStorage/StoreCenter.swift
  11. 37
      LeStorage/StoredCollection+Sync.swift
  12. 68
      LeStorage/StoredCollection.swift
  13. 4
      LeStorage/SyncedStorable.swift
  14. 44
      LeStorage/Utils/Codable+Extensions.swift
  15. 3
      LeStorage/Utils/Errors.swift
  16. 12
      LeStorage/Utils/Formatter.swift

@ -14,6 +14,7 @@
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; }; C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; }; C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
@ -60,6 +61,7 @@
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; }; C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; }; C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
@ -156,6 +158,7 @@
C4A47D582B6D352900ADC637 /* Utils */ = { C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4D477962CB66EEA0077713D /* Date+Extensions.swift */, C4D477962CB66EEA0077713D /* Date+Extensions.swift */,
@ -317,6 +320,7 @@
C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */, C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */, C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,

@ -51,7 +51,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Reschedule Api calls if not empty /// Reschedule Api calls if not empty
func loadFromFile() throws { func loadFromFile() throws {
try self._decodeJSONFile() try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary() // self.rescheduleApiCallsIfNecessary()
} }
/// Returns the file URL of the collection /// Returns the file URL of the collection
@ -223,7 +223,20 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one /// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? { fileprivate func _call(method: HTTPMethod, instance: T? = nil) throws -> ApiCall<T>? {
if let instance {
return try self._callForInstance(method, instance: instance)
} else {
if self.items.contains(where: { $0.method == .get }) {
return nil
} else {
return try self._createCall(.get)
}
}
}
fileprivate func _callForInstance(_ method: HTTPMethod, instance: T) throws -> ApiCall<T>? {
if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) { if let existingCall = self.items.first(where: { $0.dataId == instance.stringId }) {
switch method { switch method {
@ -232,21 +245,25 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
if existingCall.method == HTTPMethod.post { if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here return nil // if the post has not been done, we can just stop here
} else { } else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete return try self._createCall(method, instance: instance) // otherwise it's a put and we want to send the delete
} }
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString() existingCall.body = try instance.jsonString()
return existingCall return existingCall
} }
} else { } else {
return try self._createCall(instance, method: method) return try self._createCall(method, instance: instance)
} }
} }
/// Creates an API call for the Storable [instance] and an HTTP [method] /// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> { fileprivate func _createCall(_ method: HTTPMethod, instance: T? = nil) throws -> ApiCall<T> {
let jsonString = try instance.jsonString() if let instance {
return ApiCall(method: method, dataId: instance.stringId, body: jsonString) let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: instance.stringId, body: jsonString)
} else {
return ApiCall(method: .get)
}
} }
/// Prepares a call for execution by updating its properties and adding it to its collection for storage /// Prepares a call for execution by updating its properties and adding it to its collection for storage
@ -256,10 +273,22 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
self.addOrUpdate(apiCall) self.addOrUpdate(apiCall)
} }
/// Sends an insert api call for the provided [instance]
func sendGetIfNecessary(instance: T) async throws where T : URLParameterConvertible {
do {
let apiCall = ApiCall<T>(method: .get)
apiCall.urlParameters = instance.queryParameters()
let _: Empty? = try await self._prepareAndSendCall(apiCall)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
/// Sends an insert api call for the provided [instance] /// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) async throws -> T? { func sendInsertion(_ instance: T) async throws -> T? {
do { do {
return try await self._synchronize(instance, method: HTTPMethod.post) return try await self._sendServerRequest(HTTPMethod.post, instance: instance)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
@ -271,7 +300,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Sends an update api call for the provided [instance] /// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) async throws -> T? { func sendUpdate(_ instance: T) async throws -> T? {
do { do {
return try await self._synchronize(instance, method: HTTPMethod.put) return try await self._sendServerRequest(HTTPMethod.put, instance: instance)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
@ -282,7 +311,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Sends an delete api call for the provided [instance] /// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) async throws { func sendDeletion(_ instance: T) async throws {
do { do {
let _: Empty? = try await self._synchronize(instance, method: HTTPMethod.delete) let _: Empty? = try await self._sendServerRequest(HTTPMethod.delete, instance: instance)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
@ -291,15 +320,21 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
/// Initiates the process of sending the data with the server /// Initiates the process of sending the data with the server
fileprivate func _synchronize<V: Decodable>(_ instance: T, method: HTTPMethod) async throws -> V? { fileprivate func _sendServerRequest<V: Decodable>(_ method: HTTPMethod, instance: T? = nil) async throws -> V? {
if let apiCall = try self._callForInstance(instance, method: method) { if let apiCall = try self._call(method: method, instance: instance) {
try self._prepareCall(apiCall: apiCall) return try await self._prepareAndSendCall(apiCall)
return try await self._executeApiCall(apiCall) // try self._prepareCall(apiCall: apiCall)
// return try await self._executeApiCall(apiCall)
} else { } else {
return nil return nil
} }
} }
fileprivate func _prepareAndSendCall<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V? {
try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
}
/// Executes an API call /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
// fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T { // fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T {

@ -24,7 +24,7 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// Creation date of the call /// Creation date of the call
var creationDate: Date? = Date() var creationDate: Date? = Date()
/// The HTTP method of the call: post... /// The HTTP method of the call
var method: HTTPMethod var method: HTTPMethod
/// The content of the call /// The content of the call
@ -39,7 +39,10 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The date of the last execution /// The date of the last execution
var lastAttemptDate: Date = Date() var lastAttemptDate: Date = Date()
init(method: HTTPMethod, dataId: String, body: String) { /// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
init(method: HTTPMethod, dataId: String? = nil, body: String? = nil) {
self.method = method self.method = method
self.dataId = dataId self.dataId = dataId
self.body = body self.body = body
@ -49,4 +52,39 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
fatalError("should not happen") fatalError("should not happen")
} }
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.dataId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
}
fileprivate extension Dictionary where Key == String, Value == String {
func toQueryString() -> String? {
guard !self.isEmpty else {
return nil
}
let pairs = self.map { key, value in
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return "\(escapedKey)=\(escapedValue)"
}
return "?" + pairs.joined(separator: "&")
}
} }

@ -7,11 +7,12 @@
import Foundation import Foundation
class GetSyncData: ModelObject, SyncedStorable { class GetSyncData: ModelObject, SyncedStorable, URLParameterConvertible {
static func filterByStoreIdentifier() -> Bool { return false } static func filterByStoreIdentifier() -> Bool { return false }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
var lastUpdate: Date = Date()
var lastUpdate: Date = Date.distantPast
static func resourceName() -> String { static func resourceName() -> String {
return "data" return "data"
@ -22,4 +23,15 @@ class GetSyncData: ModelObject, SyncedStorable {
self.lastUpdate = getSyncData.lastUpdate self.lastUpdate = getSyncData.lastUpdate
} }
func queryParameters() -> [String : String] {
return ["last_update" : self._formattedLastUpdate]
}
fileprivate var _formattedLastUpdate: String {
let formattedDate = ISO8601DateFormatter().string(from: self.lastUpdate)
let encodedDate =
formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return encodedDate.replacingOccurrences(of: "+", with: "%2B")
}
} }

@ -26,7 +26,7 @@ struct SyncResponse: Codable {
var updatesDict = [String: [AnyCodable]]() var updatesDict = [String: [AnyCodable]]()
for key in updatesContainer.allKeys { for key in updatesContainer.allKeys {
let swiftClass = try SyncResponse._classFromClassName(key.stringValue) // let swiftClass = try SyncResponse._classFromClassName(key.stringValue)
let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key) let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key)
let typedArray = decodedArray.compactMap { $0.value as? AnyCodable } let typedArray = decodedArray.compactMap { $0.value as? AnyCodable }
updatesDict[key.stringValue] = typedArray updatesDict[key.stringValue] = typedArray
@ -59,17 +59,17 @@ struct SyncResponse: Codable {
} }
} }
fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type { // fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type {
//
let fullClassName = "PadelClub.\(className)" // let fullClassName = "PadelClub.\(className)"
let modelClass: AnyClass? = NSClassFromString(fullClassName) // let modelClass: AnyClass? = NSClassFromString(fullClassName)
if let type = modelClass as? Codable.Type { // if let type = modelClass as? Codable.Type {
return type // return type
} else { // } else {
throw LeStorageError.cantFindClassFromName(name: className) // throw LeStorageError.cantFindClassFromName(name: className)
} // }
//
} // }
} }

@ -27,10 +27,6 @@ open class ModelObject: NSObject {
static var relationshipNames: [String] = [] static var relationshipNames: [String] = []
open func hasBeenDeleted() {
}
// // MARK: - Codable // // MARK: - Codable
// //
// enum CodingKeys: CodingKey { // enum CodingKeys: CodingKey {

@ -49,20 +49,20 @@ public class Services {
/// The base API URL to send requests /// The base API URL to send requests
fileprivate(set) var baseURL: String fileprivate(set) var baseURL: String
fileprivate var jsonEncoder: JSONEncoder = { // fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder() // let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase // encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = .prettyPrinted // encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .iso8601 // encoder.dateEncodingStrategy = .iso8601
return encoder // return encoder
}() // }()
//
fileprivate var jsonDecoder: JSONDecoder = { // fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder() // let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase // decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601 // decoder.dateDecodingStrategy = .iso8601
return decoder // return decoder
}() // }()
// MARK: - Base // MARK: - Base
@ -75,7 +75,7 @@ public class Services {
async throws -> U async throws -> U
{ {
var request = try self._baseRequest(call: serviceCall) var request = try self._baseRequest(call: serviceCall)
request.httpBody = try jsonEncoder.encode(payload) request.httpBody = try JSON.encoder.encode(payload)
return try await _runRequest(request) return try await _runRequest(request)
} }
@ -99,7 +99,7 @@ public class Services {
try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self { if T.self == GetSyncData.self {
StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder) StoreCenter.main.synchronizeContent(task.0)
} }
default: // error default: // error
@ -131,9 +131,9 @@ public class Services {
fileprivate func _decode<V: Decodable>(data: Data) throws -> V { fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) { if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try jsonDecoder.decode(V.self, from: data) return try JSON.decoder.decode(V.self, from: data)
} else { } else {
return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
} }
} }
@ -277,7 +277,7 @@ public class Services {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method.rawValue request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try jsonEncoder.encode(payload) request.httpBody = try JSON.encoder.encode(payload)
let token = try self.keychainStore.getValue() let token = try self.keychainStore.getValue()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
@ -288,33 +288,41 @@ public class Services {
/// Returns the URLRequest for an ApiCall /// Returns the URLRequest for an ApiCall
/// - Parameters: /// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request /// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest fileprivate func _syncRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
{
let urlString = baseURL + "data/" var urlString = baseURL + "data/"
if let urlParameters = apiCall.formattedURLParameters() {
urlString.append(urlParameters)
}
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString) throw ServiceError.urlCreationError(url: urlString)
} }
guard let body = apiCall.body, let bodyData = body.data(using: .utf8) else {
throw ServiceError.cantDecodeData(content: apiCall.body)
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue if apiCall.method == .get {
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = HTTPMethod.get.rawValue
} else {
// moyennement fan de decoder pour recoder derriere request.httpMethod = HTTPMethod.post.rawValue
let data = try jsonDecoder.decode(T.self, from: bodyData) }
let modelName = String(describing: T.self)
let payload = SyncPayload( request.setValue("application/json", forHTTPHeaderField: "Content-Type")
operation: apiCall.method.rawValue,
modelName: modelName,
data: data,
storeId: data.getStoreId())
request.httpBody = try jsonEncoder.encode(payload) if let body = apiCall.body {
if let data = body.data(using: .utf8) {
let object = try JSON.decoder.decode(T.self, from: data)
let modelName = String(describing: T.self)
let payload = SyncPayload(
operation: apiCall.method.rawValue,
modelName: modelName,
data: object,
storeId: object.getStoreId())
request.httpBody = try JSON.encoder.encode(payload)
} else {
throw ServiceError.cantDecodeData(resource: T.resourceName(), method: apiCall.method.rawValue, content: apiCall.body)
}
}
if self._isTokenRequired(type: T.self, method: apiCall.method) { if self._isTokenRequired(type: T.self, method: apiCall.method) {
let token = try self.keychainStore.getValue() let token = try self.keychainStore.getValue()
@ -371,7 +379,7 @@ public class Services {
print("\(debugURL) ended, status code = \(statusCode)") print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder) StoreCenter.main.synchronizeContent(task.0)
default: // error default: // error
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
@ -463,12 +471,8 @@ public class Services {
/// - apiCall: an instance of ApiCall to build to URL /// - apiCall: an instance of ApiCall to build to URL
fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL { fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL {
var stringURL: String = self.baseURL var stringURL: String = self.baseURL
switch apiCall.method { stringURL += apiCall.urlExtension()
case HTTPMethod.put, HTTPMethod.delete:
stringURL += T.path(id: apiCall.dataId)
default:
stringURL += T.path()
}
if let url = URL(string: stringURL) { if let url = URL(string: stringURL) {
return url return url
} else { } else {
@ -493,7 +497,7 @@ public class Services {
var postRequest = try self._baseRequest(call: requestTokenCall) var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = StoreCenter.main.deviceId() let deviceId = StoreCenter.main.deviceId()
let credentials = Credentials(username: username, password: password, deviceId: deviceId) let credentials = Credentials(username: username, password: password, deviceId: deviceId)
postRequest.httpBody = try jsonEncoder.encode(credentials) postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest) let response: AuthResponse = try await self._runRequest(postRequest)
self._storeToken(username: username, token: response.token) self._storeToken(username: username, token: response.token)
return response.token return response.token
@ -580,7 +584,7 @@ public class Services {
public func forgotPassword(email: String) async throws { public func forgotPassword(email: String) async throws {
var postRequest = try self._baseRequest( var postRequest = try self._baseRequest(
servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false)
postRequest.httpBody = try jsonEncoder.encode(Email(email: email)) postRequest.httpBody = try JSON.encoder.encode(Email(email: email))
let response: Email = try await self._runRequest(postRequest) let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)") Logger.log("response = \(response)")
} }

@ -30,9 +30,6 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
// static var relationshipNames: [String] { get } // static var relationshipNames: [String] { get }
/// A method called after the instance has been deleted from its StoredCollection
func hasBeenDeleted()
func copy(from other: any Storable) func copy(from other: any Storable)
} }

@ -199,12 +199,18 @@ open class Store {
func deleteNoSync<T: Storable>(instance: T) { func deleteNoSync<T: Storable>(instance: T) {
do { do {
let collection: StoredCollection<T> = try self.collection() let collection: StoredCollection<T> = try self.collection()
try collection.deleteById(instance.id) collection.delete(instance: instance)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: StoredCollection<T> = try self.collection()
collection.deleteByStringIdNoSync(id)
}
// MARK: - Write // MARK: - Write
/// Returns the directory URL of the store /// Returns the directory URL of the store

@ -41,6 +41,9 @@ public class StoreCenter {
/// The dictionary of registered StoredCollections /// The dictionary of registered StoredCollections
fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
/// A collection of DataLog objects, used for the synchronization
// fileprivate var _syncGetRequests: ApiCallCollection<GetSyncData>
/// A collection of DataLog objects, used for the synchronization /// A collection of DataLog objects, used for the synchronization
fileprivate var _dataLogs: StoredCollection<DataLog> fileprivate var _dataLogs: StoredCollection<DataLog>
@ -54,9 +57,13 @@ public class StoreCenter {
fileprivate var _blackListedUserName: [String] = [] fileprivate var _blackListedUserName: [String] = []
init() { init() {
// self._syncGetRequests = ApiCallCollection()
self._dataLogs = Store.main.registerCollection() self._dataLogs = Store.main.registerCollection()
self._setupNotifications() self._setupNotifications()
self.loadApiCallCollection(type: GetSyncData.self)
NetworkMonitor.shared.onConnectionEstablished = { NetworkMonitor.shared.onConnectionEstablished = {
self._resumeApiCalls() self._resumeApiCalls()
} }
@ -212,7 +219,7 @@ public class StoreCenter {
if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> { if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName())
} }
/// Deletes an ApiCall, identifying it by dataId /// Deletes an ApiCall, identifying it by dataId
@ -361,11 +368,22 @@ public class StoreCenter {
} }
public func synchronizeLastUpdates() async throws { public func synchronizeLastUpdates() async throws {
let lastSync: Date? = self._settingsStorage.item.lastSynchronization
try await self._services?.synchronizeLastUpdates(since: lastSync) if let lastSync = self._settingsStorage.item.lastSynchronization {
let getSyncData = GetSyncData()
getSyncData.lastUpdate = lastSync
let sync: ApiCallCollection<GetSyncData> = try self.apiCallCollection()
try await sync.sendGetIfNecessary(instance: getSyncData)
} else {
Logger.w("Can't sync due to missing saved date")
}
// let lastSync: Date? = self._settingsStorage.item.lastSynchronization
// try await self._services?.synchronizeLastUpdates(since: lastSync)
} }
func synchronizeContent(_ data: Data, decoder: JSONDecoder) { func synchronizeContent(_ data: Data) {
do { do {
guard guard
@ -378,7 +396,7 @@ public class StoreCenter {
if let updates = json["updates"] as? [String: Any] { if let updates = json["updates"] as? [String: Any] {
do { do {
try self._parseSyncUpdates(updates, decoder: decoder) try self._parseSyncUpdates(updates)
} catch { } catch {
StoreCenter.main.log(message: error.localizedDescription) StoreCenter.main.log(message: error.localizedDescription)
Logger.error(error) Logger.error(error)
@ -387,7 +405,7 @@ public class StoreCenter {
if let deletions = json["deletions"] as? [String: Any] { if let deletions = json["deletions"] as? [String: Any] {
do { do {
try self._parseSyncDeletions(deletions, decoder: decoder) try self._parseSyncDeletions(deletions)
} catch { } catch {
StoreCenter.main.log(message: error.localizedDescription) StoreCenter.main.log(message: error.localizedDescription)
Logger.error(error) Logger.error(error)
@ -406,21 +424,21 @@ public class StoreCenter {
} }
} }
fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws { fileprivate func _parseSyncUpdates(_ updates: [String: Any]) throws {
for (className, updateData) in updates { for (className, updateData) in updates {
guard let updateArray = updateData as? [[String: Any]] else { guard let updateArray = updateData as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)") Logger.w("Invalid update data for \(className)")
continue continue
} }
let type = try self._classFromClassName(className) let type = try StoreCenter.classFromName(className)
for updateItem in updateArray { for updateItem in updateArray {
do { do {
let jsonData = try JSONSerialization.data( let jsonData = try JSONSerialization.data(
withJSONObject: updateItem, options: []) withJSONObject: updateItem, options: [])
let decodedObject = try decoder.decode(type, from: jsonData) let decodedObject = try JSON.decoder.decode(type, from: jsonData)
let storeId: String? = decodedObject.getStoreId() let storeId: String? = decodedObject.getStoreId()
StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId) StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId)
@ -431,38 +449,35 @@ public class StoreCenter {
} }
} }
fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws { fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws {
for (className, updateDeletions) in deletions { for (className, updateDeletions) in deletions {
guard let deletionArray = updateDeletions as? [[String: Any]] else { guard let deletedItem = updateDeletions as? [Any] else {
Logger.w("Invalid update data for \(className)") Logger.w("Invalid update data for \(className)")
continue continue
} }
let type = try self._classFromClassName(className) for deleted in deletedItem {
for updateItem in deletionArray { do {
let data = try JSONSerialization.data(withJSONObject: deleted, options: [])
if let object = updateItem["data"] { let deletedObject = try JSON.decoder.decode(DeletedObject.self, from: 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(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId)
StoreCenter.main.synchronizationDelete( } catch {
instance: decodedObject, storeId: storeId) Logger.error(error)
} catch {
Logger.error(error)
}
} }
} }
} }
} }
fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type { static func classFromName(_ className: String) throws -> any SyncedStorable.Type {
let fullClassName = "PadelClub.\(className)" guard let projectName = Bundle.main.infoDictionary?["CFBundleName"] as? String else {
let modelClass: AnyClass? = NSClassFromString(fullClassName) throw LeStorageError.cantAccessCFBundleName
}
let modelClass: AnyClass? = NSClassFromString("\(projectName).\(className)")
if let type = modelClass as? any SyncedStorable.Type { if let type = modelClass as? any SyncedStorable.Type {
return type return type
} else { } else {
@ -494,13 +509,27 @@ public class StoreCenter {
} }
} }
func synchronizationDelete<T: Storable>(instance: T, storeId: String?) { func synchronizationDelete(id: String, model: String, storeId: String?) {
DispatchQueue.main.async { DispatchQueue.main.async {
self._store(id: storeId)?.deleteNoSync(instance: instance) do {
self._cleanupDataLog(dataId: instance.stringId) let type = try StoreCenter.classFromName(model)
try self._store(id: storeId)?.deleteNoSync(type: type, id: id)
} catch {
Logger.error(error)
}
self._cleanupDataLog(dataId: id)
} }
} }
// 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) { fileprivate func _cleanupDataLog(dataId: String) {
let logs = self._dataLogs.filter { $0.dataId == dataId } let logs = self._dataLogs.filter { $0.dataId == dataId }
self._dataLogs.delete(contentOfs: logs) self._dataLogs.delete(contentOfs: logs)
@ -712,3 +741,8 @@ public class StoreCenter {
} }
} }
class DeletedObject: Codable {
var modelId: String
var storeId: String?
}

@ -10,20 +10,16 @@ import Foundation
extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
/// Migrates if necessary and asynchronously decodes the json file /// Migrates if necessary and asynchronously decodes the json file
func load() { func load() async {
do { do {
if self.inMemory { if self.inMemory {
Task { try await self.loadDataFromServerIfAllowed()
try await self.loadDataFromServerIfAllowed()
}
} else { } else {
try self.loadFromFile() try self.loadFromFile()
} }
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
/// Loads the collection using the server data only if the collection file doesn't exists /// Loads the collection using the server data only if the collection file doesn't exists
@ -86,6 +82,33 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
self.deleteItem(instance) self.deleteItem(instance)
} }
/// Deletes the instance in the collection without synchronization
func deleteByStringIdNoSync(_ id: String) {
defer {
self.setChanged()
}
if let realId = self._buildRealId(id: id) {
if let instance = self.findById(realId) {
self.deleteItem(instance)
}
} else {
Logger.w("CRITICAL: collection \(T.resourceName()) could not build id from \(id)")
StoreCenter.main.log(message: "Could not build an id from \(id)")
}
}
fileprivate func _buildRealId(id: String) -> T.ID? {
switch T.ID.self {
case is String.Type:
return id as? T.ID
case is Int64.Type:
return Formatter.number.number(from: id)?.int64Value as? T.ID
default:
print("ID is neither String nor Int")
return nil
}
}
public func addOrUpdate(instance: T) { public func addOrUpdate(instance: T) {
defer { defer {
self.setChanged() self.setChanged()
@ -119,7 +142,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable {
} }
public func delete(instance: T) throws { public func delete(instance: T) {
defer { defer {
self.setChanged() self.setChanged()
} }

@ -69,15 +69,11 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
} }
/// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file /// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false fileprivate(set) public var hasLoaded: Bool = false
init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) { init(store: Store, indexed: Bool = false, inMemory: Bool = false) {
// self.synchronized = synchronized // self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed { if indexed {
self._indexes = [:] self._indexes = [:]
} }
@ -121,15 +117,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws { func loadFromFile() throws {
try self._decodeJSONFile()
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
} }
/// Decodes the json file into the items array /// Decodes the json file into the items array
@ -143,18 +131,9 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
for item in decoded { for item in decoded {
item.store = self.store item.store = self.store
} }
if self.asynchronousIO { self._setItems(decoded)
DispatchQueue.main.async {
self._setItems(decoded)
self.setAsLoaded()
}
} else {
self._setItems(decoded)
self.setAsLoaded()
}
} else {
self.setAsLoaded()
} }
self.setAsLoaded()
} }
/// Sets the collection as loaded /// Sets the collection as loaded
@ -211,8 +190,14 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.addItem(instance: instance) self.addItem(instance: instance)
} }
func deleteById(_ id: T.ID) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
/// Deletes the instance in the collection 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 { public func delete(instance: T) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
@ -231,17 +216,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
} }
/// Deletes an instance in the collection. Also:
/// - Removes its reference from the index
/// - Notifies the server of the deletion
/// - Calls `hasBeenDeleted` on the deleted instance
// 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 /// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence) self.addSequence(sequence)
@ -293,7 +267,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
instance.deleteDependencies() instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id } self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id) self._indexes?.removeValue(forKey: instance.id)
instance.hasBeenDeleted()
} }
/// Returns the instance corresponding to the provided [id] /// Returns the instance corresponding to the provided [id]
@ -305,12 +278,12 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
/// Deletes the instance corresponding to the provided [id] /// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: T.ID) throws { // public func deleteById(_ id: T.ID) throws {
if let instance = self.findById(id) { // if let instance = self.findById(id) {
self.deleteItem(instance) // self.deleteItem(instance)
// try self.delete(instance: instance) // self._hasChanged = true
} // }
} // }
/// Proceeds to "hard" delete the items without synchronizing them /// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls /// Also removes related API calls
@ -323,7 +296,6 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
if let index = self.items.firstIndex(where: { $0.id == item.id }) { if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index) self.items.remove(at: index)
} }
item.hasBeenDeleted()
// Task { // Task {
// do { // do {
@ -355,11 +327,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
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
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
} else {
self._write() self._write()
} }
} }

@ -22,6 +22,10 @@ public protocol SyncedStorable: Storable {
} }
protocol URLParameterConvertible {
func queryParameters() -> [String : String]
}
public protocol SideStorable { public protocol SideStorable {
var storeId: String? { get set } var storeId: String? { get set }
} }

@ -7,22 +7,26 @@
import Foundation import Foundation
fileprivate var jsonEncoder: JSONEncoder = { class JSON {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase static var encoder: JSONEncoder = {
#if DEBUG let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted encoder.keyEncodingStrategy = .convertToSnakeCase
#endif #if DEBUG
encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = .prettyPrinted
return encoder #endif
}() encoder.dateEncodingStrategy = .iso8601
return encoder
fileprivate var jsonDecoder: JSONDecoder = { }()
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase static var decoder: JSONDecoder = {
decoder.dateDecodingStrategy = .iso8601 let decoder = JSONDecoder()
return decoder decoder.keyDecodingStrategy = .convertFromSnakeCase
}() decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
extension Encodable { extension Encodable {
@ -32,11 +36,11 @@ extension Encodable {
} }
public func jsonData() throws -> Data { public func jsonData() throws -> Data {
return try jsonEncoder.encode(self) return try JSON.encoder.encode(self)
} }
public func prettyJSONString() throws -> String { public func prettyJSONString() throws -> String {
let data = try jsonEncoder.encode(self) let data = try JSON.encoder.encode(self)
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
@ -57,11 +61,11 @@ extension String {
extension Data { extension Data {
public func decode<T : Decodable>() throws -> T { public func decode<T : Decodable>() throws -> T {
return try jsonDecoder.decode(T.self, from: self) return try JSON.decoder.decode(T.self, from: self)
} }
public func decodeArray<T : Decodable>() throws -> [T] { public func decodeArray<T : Decodable>() throws -> [T] {
return try jsonDecoder.decode([T].self, from: self) return try JSON.decoder.decode([T].self, from: self)
} }
} }

@ -26,7 +26,7 @@ public enum ServiceError: Error {
case missingUserName case missingUserName
case missingUserId case missingUserId
case responseError(response: String) case responseError(response: String)
case cantDecodeData(content: String?) case cantDecodeData(resource: String, method: String, content: String?)
} }
public enum UUIDError: Error { public enum UUIDError: Error {
@ -35,4 +35,5 @@ public enum UUIDError: Error {
public enum LeStorageError: Error { public enum LeStorageError: Error {
case cantFindClassFromName(name: String) case cantFindClassFromName(name: String)
case cantAccessCFBundleName
} }

@ -0,0 +1,12 @@
//
// Formatter.swift
// LeStorage
//
// Created by Laurent Morvillier on 30/10/2024.
//
class Formatter {
static let number: NumberFormatter = NumberFormatter()
}
Loading…
Cancel
Save