diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 82a5a0f..8048c66 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; }; C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; }; + C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49C731B2D5D042D008DD299 /* Date+Extensions.swift */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; @@ -52,6 +53,7 @@ C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = ""; }; C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = ""; }; C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = ""; }; + C49C731B2D5D042D008DD299 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = ""; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; @@ -141,6 +143,7 @@ children = ( C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, + C49C731B2D5D042D008DD299 /* Date+Extensions.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */, C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, @@ -294,6 +297,7 @@ C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, + C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 63145c7..e258c1c 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -78,20 +78,20 @@ public class Services { /// The base API URL to send requests fileprivate(set) var baseURL: String - fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .iso8601 - return encoder - }() - - fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() +// fileprivate var jsonEncoder: JSONEncoder = { +// let encoder = JSONEncoder() +// encoder.keyEncodingStrategy = .convertToSnakeCase +// encoder.outputFormatting = .prettyPrinted +// encoder.dateEncodingStrategy = .iso8601 +// return encoder +// }() +// +// fileprivate var jsonDecoder: JSONDecoder = { +// let decoder = JSONDecoder() +// decoder.keyDecodingStrategy = .convertFromSnakeCase +// decoder.dateDecodingStrategy = .iso8601 +// return decoder +// }() // MARK: - Base @@ -102,7 +102,7 @@ public class Services { /// - apiCallId: an optional id referencing an ApiCall fileprivate func _runRequest(serviceCall: ServiceCall, payload: T) async throws -> U { 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) } @@ -143,9 +143,9 @@ public class Services { } if !(V.self is Empty?.Type) && !(V.self is Empty.Type) { - return try jsonDecoder.decode(V.self, from: task.0) + return try JSON.decoder.decode(V.self, from: task.0) } else { - return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) + return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!) } } @@ -179,7 +179,7 @@ public class Services { StoreCenter.main.log(message: message) Logger.w(message) } - return try jsonDecoder.decode(V.self, from: task.0) + return try JSON.decoder.decode(V.self, from: task.0) } /// Returns if the token is required for a request @@ -202,38 +202,38 @@ public class Services { let requiresToken = self._isTokenRequired(type: T.self, method: .get) return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier) } - - /// Returns a POST request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _postRequest(type: T.Type) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .post) - return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) - } - - /// Returns a PUT request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .put) - return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) - } - - /// Returns a DELETE request for the resource - /// - Parameters: - /// - type: the type of the request resource - fileprivate func _deleteRequest(type: T.Type, id: String) throws -> URLRequest { - let requiresToken = self._isTokenRequired(type: T.self, method: .delete) - return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) - } - - /// Returns the base URLRequest for a ServiceConf instance - /// - Parameters: - /// - conf: a ServiceConf instance - fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { - return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) - } - + + /// Returns a POST request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _postRequest(type: T.Type) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .post) + return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) + } + + /// Returns a PUT request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _putRequest(type: T.Type, id: String) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .put) + return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) + } + + /// Returns a DELETE request for the resource + /// - Parameters: + /// - type: the type of the request resource + fileprivate func _deleteRequest(type: T.Type, id: String) throws -> URLRequest { + let requiresToken = self._isTokenRequired(type: T.self, method: .delete) + return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) + } + + /// Returns the base URLRequest for a ServiceConf instance + /// - Parameters: + /// - conf: a ServiceConf instance + fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { + return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) + } + /// Returns a base request for a path and method /// - Parameters: /// - servicePath: the path to add to the API base URL @@ -251,6 +251,7 @@ public class Services { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.addAppVersion() if !(requiresToken == false) { let token = try self.keychainStore.getValue() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") @@ -269,14 +270,14 @@ public class Services { /// Executes a POST request public func post(_ instance: T) async throws -> T { var postRequest = try self._postRequest(type: T.self) - postRequest.httpBody = try jsonEncoder.encode(instance) + postRequest.httpBody = try JSON.encoder.encode(instance) return try await self._runRequest(postRequest) } /// Executes a PUT request public func put(_ instance: T) async throws -> T { var postRequest = try self._putRequest(type: T.self, id: instance.stringId) - postRequest.httpBody = try jsonEncoder.encode(instance) + postRequest.httpBody = try JSON.encoder.encode(instance) return try await self._runRequest(postRequest) } @@ -301,6 +302,7 @@ public class Services { request.httpMethod = apiCall.method.rawValue request.httpBody = apiCall.body.data(using: .utf8) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.addAppVersion() if self._isTokenRequired(type: T.self, method: apiCall.method) { do { @@ -349,7 +351,7 @@ public class Services { var postRequest = try self._baseRequest(call: requestTokenCall) let deviceId = StoreCenter.main.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) self._storeToken(username: username, token: response.token) return response.token @@ -430,7 +432,7 @@ public class Services { /// - email: the email of the user public func forgotPassword(email: String) async throws { var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) - 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) Logger.log("response = \(response)") } @@ -537,3 +539,14 @@ public protocol UserBase: Codable { public protocol UserPasswordBase: UserBase { var password: String { get } } + +fileprivate extension URLRequest { + + mutating func addAppVersion() { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + let appVersion = "\(version) (\(build))" + self.setValue(appVersion, forHTTPHeaderField: "App-Version") + } + +} diff --git a/LeStorage/Utils/Codable+Extensions.swift b/LeStorage/Utils/Codable+Extensions.swift index 5bd1c8d..5fd14e7 100644 --- a/LeStorage/Utils/Codable+Extensions.swift +++ b/LeStorage/Utils/Codable+Extensions.swift @@ -7,22 +7,43 @@ import Foundation -fileprivate var jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - #if DEBUG - encoder.outputFormatting = .prettyPrinted - #endif - encoder.dateEncodingStrategy = .iso8601 - return encoder -}() +class JSON { + + static var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + #if DEBUG + encoder.outputFormatting = .prettyPrinted + #endif + encoder.dateEncodingStrategy = .custom { date, encoder in + let dateString = Date.iso8601FractionalFormatter.string(from: date) + var container = encoder.singleValueContainer() + try container.encode(dateString) + } // need dates with thousandth precision + return encoder + }() -fileprivate var jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return decoder -}() + static var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.iso8601FractionalFormatter.date(from: dateString) { + return date + } else if let date = Date.iso8601Formatter.date(from: dateString) { + return date + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date format: \(dateString)" + ) + } + } // need dates with thousandth precision + return decoder + }() + +} extension Encodable { @@ -32,11 +53,11 @@ extension Encodable { } public func jsonData() throws -> Data { - return try jsonEncoder.encode(self) + return try JSON.encoder.encode(self) } public func prettyJSONString() throws -> String { - let data = try jsonEncoder.encode(self) + let data = try JSON.encoder.encode(self) return String(data: data, encoding: .utf8) ?? "" } @@ -57,11 +78,11 @@ extension String { extension Data { public func decode() throws -> T { - return try jsonDecoder.decode(T.self, from: self) + return try JSON.decoder.decode(T.self, from: self) } public func decodeArray() throws -> [T] { - return try jsonDecoder.decode([T].self, from: self) + return try JSON.decoder.decode([T].self, from: self) } } diff --git a/LeStorage/Utils/Date+Extensions.swift b/LeStorage/Utils/Date+Extensions.swift new file mode 100644 index 0000000..c3d81e6 --- /dev/null +++ b/LeStorage/Utils/Date+Extensions.swift @@ -0,0 +1,33 @@ +// +// Date+Extensions.swift +// LeStorage +// +// Created by Laurent Morvillier on 09/10/2024. +// + +import Foundation + +extension Date { + + static var iso8601Formatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + return iso8601Formatter + } + + public static var iso8601FractionalFormatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds] + return iso8601Formatter + } + + public static var microSecondFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" // puts 000 for the last decimals + formatter.timeZone = TimeZone(abbreviation: "UTC") + return formatter + }() + +}