Adds flexible json date decoder + adds app version in http header

sync2
Laurent 9 months ago
parent 1d333e6f03
commit be67b229e0
  1. 4
      LeStorage.xcodeproj/project.pbxproj
  2. 121
      LeStorage/Services.swift
  3. 59
      LeStorage/Utils/Codable+Extensions.swift
  4. 33
      LeStorage/Utils/Date+Extensions.swift

@ -13,6 +13,7 @@
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 */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.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 */; }; C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.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 */; }; 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 = "<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>"; };
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>"; };
C49C731B2D5D042D008DD299 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.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>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
@ -141,6 +143,7 @@
children = ( children = (
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C49C731B2D5D042D008DD299 /* Date+Extensions.swift */,
C4A47DAE2B85FD3800ADC637 /* Errors.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
@ -294,6 +297,7 @@
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C49C731C2D5D042D008DD299 /* Date+Extensions.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,

@ -78,20 +78,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
@ -102,7 +102,7 @@ public class Services {
/// - apiCallId: an optional id referencing an ApiCall /// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T) async throws -> U { fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T) 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)
} }
@ -143,9 +143,9 @@ public class Services {
} }
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: task.0) return try JSON.decoder.decode(V.self, from: task.0)
} else { } 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) StoreCenter.main.log(message: message)
Logger.w(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 /// 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) let requiresToken = self._isTokenRequired(type: T.self, method: .get)
return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier) return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier)
} }
/// Returns a POST request for the resource /// Returns a POST request for the resource
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest { fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .post) let requiresToken = self._isTokenRequired(type: T.self, method: .post)
return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken)
} }
/// Returns a PUT request for the resource /// Returns a PUT request for the resource
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest { fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .put) let requiresToken = self._isTokenRequired(type: T.self, method: .put)
return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
} }
/// Returns a DELETE request for the resource /// Returns a DELETE request for the resource
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest { fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .delete) let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
} }
/// Returns the base URLRequest for a ServiceConf instance /// Returns the base URLRequest for a ServiceConf instance
/// - Parameters: /// - Parameters:
/// - conf: a ServiceConf instance /// - conf: a ServiceConf instance
fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest { fileprivate func _baseRequest(call: ServiceCall) throws -> URLRequest {
return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken) return try self._baseRequest(servicePath: call.path, method: call.method, requiresToken: call.requiresToken)
} }
/// Returns a base request for a path and method /// Returns a base request for a path and method
/// - Parameters: /// - Parameters:
/// - servicePath: the path to add to the API base URL /// - servicePath: the path to add to the API base URL
@ -251,6 +251,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.addAppVersion()
if !(requiresToken == false) { if !(requiresToken == false) {
let token = try self.keychainStore.getValue() let token = try self.keychainStore.getValue()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
@ -269,14 +270,14 @@ public class Services {
/// Executes a POST request /// Executes a POST request
public func post<T: Storable>(_ instance: T) async throws -> T { public func post<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._postRequest(type: T.self) 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) return try await self._runRequest(postRequest)
} }
/// Executes a PUT request /// Executes a PUT request
public func put<T: Storable>(_ instance: T) async throws -> T { public func put<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._putRequest(type: T.self, id: instance.stringId) 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) return try await self._runRequest(postRequest)
} }
@ -301,6 +302,7 @@ public class Services {
request.httpMethod = apiCall.method.rawValue request.httpMethod = apiCall.method.rawValue
request.httpBody = apiCall.body.data(using: .utf8) request.httpBody = apiCall.body.data(using: .utf8)
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method) { if self._isTokenRequired(type: T.self, method: apiCall.method) {
do { do {
@ -349,7 +351,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
@ -430,7 +432,7 @@ public class Services {
/// - email: the email of the user /// - email: the email of the user
public func forgotPassword(email: String) async throws { public func forgotPassword(email: String) async throws {
var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) 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) let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)") Logger.log("response = \(response)")
} }
@ -537,3 +539,14 @@ public protocol UserBase: Codable {
public protocol UserPasswordBase: UserBase { public protocol UserPasswordBase: UserBase {
var password: String { get } 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")
}
}

@ -7,22 +7,43 @@
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 = .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 = { static var decoder: JSONDecoder = {
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601 decoder.dateDecodingStrategy = .custom { decoder in
return decoder 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 { extension Encodable {
@ -32,11 +53,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 +78,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)
} }
} }

@ -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
}()
}
Loading…
Cancel
Save