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

850 lines
34 KiB

//
// Services.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
import UIKit
public enum HTTPMethod: String, CaseIterable, Codable {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
struct ServiceCall {
var path: String
var method: HTTPMethod
var requiresToken: Bool
}
let createAccountCall: ServiceCall = ServiceCall(
path: "users/", method: .post, requiresToken: false)
let requestTokenCall: ServiceCall = ServiceCall(
path: "token-auth/", method: .post, requiresToken: false)
let logoutCall: ServiceCall = ServiceCall(
path: "api-token-logout/", method: .post, requiresToken: true)
let getUserCall: ServiceCall = ServiceCall(
path: "user-by-token/", method: .get, requiresToken: true)
let changePasswordCall: ServiceCall = ServiceCall(
path: "change-password/", method: .put, requiresToken: true)
let postDeviceTokenCall: ServiceCall = ServiceCall(
path: "device-token/", method: .post, requiresToken: true)
let getUserDataAccessCallContent: ServiceCall = ServiceCall(
path: "data-access-content/", method: .get, requiresToken: true)
let userAgentsCall: ServiceCall = ServiceCall(
path: "user-agents/", method: .get, requiresToken: true)
/// A class used to send HTTP request to the django server
public class Services {
fileprivate let storeCenter: StoreCenter
/// The base API URL to send requests
fileprivate(set) var baseURL: String
public init(storeCenter: StoreCenter, url: String) {
self.storeCenter = storeCenter
self.baseURL = url
}
static let storeIdURLParameter = "store_id"
// MARK: - Base
/// Runs a request using a configuration object
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
/// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<U: Decodable>(serviceCall: ServiceCall)
async throws -> U {
let request = try self._baseRequest(call: serviceCall)
return try await _runRequest(request)
}
/// Runs a request using a configuration object
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
/// - payload: a codable value stored in the body of the request
/// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T)
async throws -> U {
var request = try self._baseRequest(call: serviceCall)
request.httpBody = try JSON.encoder.encode(payload)
return try await _runRequest(request)
}
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runGetApiCallRequest<T: SyncedStorable>(
_ request: URLRequest, apiCall: ApiCall<T>
) async throws -> Data {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id)
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
try await self.storeCenter.rescheduleApiCalls(type: T.self)
self.storeCenter.logFailedAPICall(
apiCall.id, request: request, collectionName: T.resourceName(),
error: errorMessage.message)
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
return task.0 //try self._decode(data: task.0)
}
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try JSON.decoder.decode(V.self, from: data)
} else {
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runRequest<V: Decodable>(_ request: URLRequest) async throws -> V {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
break
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
return try self._decode(data: task.0)
}
/// Returns if the token is required for a request
/// - Parameters:
/// - type: the type of the request resource
/// - method: the HTTP method of the request
fileprivate func _isTokenRequired<T: SyncedStorable>(type: T.Type, method: HTTPMethod) -> Bool {
let methods = T.tokenExemptedMethods()
if methods.contains(method) {
return false
} else {
return true
}
}
/// Returns a GET request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _getRequest<T: SyncedStorable>(type: T.Type, identifier: String?)
throws
-> URLRequest
{
let requiresToken = self._isTokenRequired(type: T.self, method: .get)
return try self._baseRequest(
servicePath: T.path(), method: .get, requiresToken: requiresToken,
identifier: identifier)
}
/// Returns the base URLRequest for a ServiceConf instance
/// - Parameters:
/// - conf: a ServiceConf instance
fileprivate func _baseRequest(call: ServiceCall, getArguments: [String: String]? = nil) throws -> URLRequest {
return try self._baseRequest(
servicePath: call.path, method: call.method, requiresToken: call.requiresToken, getArguments: getArguments)
}
//
// /// Returns a POST request for the resource
// /// - Parameters:
// /// - type: the type of the request resource
// fileprivate func _postRequest<T: Storable>(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<T: Storable>(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<T: Storable>(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
/// - method: the HTTP method to execute
/// - requiresToken: An optional boolean to indicate if the token is required
/// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values
public func _baseRequest(
servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil,
identifier: String? = nil, getArguments: [String : String]? = nil
) throws -> URLRequest {
var urlString = baseURL + servicePath
var arguments: [String : String] = getArguments ?? [:]
if let identifier {
arguments[Services.storeIdURLParameter] = identifier
}
urlString.append(arguments.toQueryString())
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
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.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// MARK: - Synchronization
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runSyncPostRequest<T: SyncedStorable>(
_ request: URLRequest, type: T.Type) async throws -> [OperationResult<T>] {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("sync POST \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")")
var rescheduleApiCalls: Bool = false
var results: [OperationResult<T>] = []
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
let decoded: BatchResponse<T> = try self._decode(data: task.0)
results = decoded.results
for result in decoded.results {
switch result.status {
case 200..<300:
break
default:
if let message = result.message {
let type = String(describing: T.self)
print("*** \(type) - \(result.data?.stringId ?? ""): \(result.status) > \(message)")
}
rescheduleApiCalls = true
break
}
}
default: // error
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
try await self.storeCenter.rescheduleApiCalls(type: T.self)
// self.storeCenter.logFailedAPICall(
// apiCall.id, request: request, collectionName: T.resourceName(),
// error: errorMessage.message)
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
if rescheduleApiCalls {
try? await self.storeCenter.rescheduleApiCalls(type: T.self)
}
return results
}
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncGetRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
var urlString = "\(baseURL)\(T.resourceName())/" // baseURL + T.resourceName() // "data/"
if let urlParameters = apiCall.formattedURLParameters() {
urlString.append(urlParameters)
}
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method), self.storeCenter.isAuthenticated {
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// /// Executes a PUT request
// public func put<T: Storable>(_ instance: T) async throws -> T {
// var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
// postRequest.httpBody = try jsonEncoder.encode(instance)
// return try await self._runRequest(postRequest)
// }
//
// public func delete<T: Storable>(_ instance: T) async throws -> T {
// let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
// return try await self._runRequest(deleteRequest)
// }
//
// /// Executes an ApiCall
// func runApiCall<T: Storable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
// let request = try self._request(from: apiCall)
// print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)")
// return try await self._runRequest(request, apiCall: apiCall)
//>>>>>>> main
// }
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncPostRequest<T: SyncedStorable>(from apiCalls: [ApiCall<T>]) throws -> URLRequest {
let urlString = "\(baseURL)\(GetSyncData.resourceName())/"
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.addAppVersion()
let modelName = String(describing: T.self)
let operations = apiCalls.map { apiCall in
return Operation(apiCallId: apiCall.id,
operation: apiCall.method.rawValue,
modelName: modelName,
data: apiCall.data,
storeId: apiCall.data?.getStoreId())
}
// let posts = apiCalls.filter({ $0.method == .post })
// for post in posts {
// print("POST \(T.resourceName()): id = \(post.dataId ?? "")")
// }
let payload = SyncPayload(operations: operations,
deviceId: self.storeCenter.deviceId())
request.httpBody = try JSON.encoder.encode(payload)
return request
}
// /// Starts a request to retrieve the synchronization updates
// /// - Parameters:
// /// - since: The date from which updates are retrieved
// func synchronizeLastUpdates(since: Date?) async throws {
// let request = try self._getSyncLogRequest(since: since)
// if let data = try await self._runRequest(request) {
// await self.storeCenter.synchronizeContent(data)
// }
// }
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - since: The date from which updates are retrieved
fileprivate func _getSyncLogRequest(since: Date?) throws -> URLRequest {
let formattedDate = ISO8601DateFormatter().string(from: since ?? Date.distantPast)
let encodedDate =
formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let encodedDateWithPlus = encodedDate.replacingOccurrences(of: "+", with: "%2B")
let urlString = baseURL + "\(GetSyncData.resourceName())/?last_update=\(encodedDateWithPlus)"
Logger.log("urlString = \(urlString)")
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
return request
}
/// Runs the a sync request and forwards the response to the StoreCenter for processing
/// - Parameters:
/// - request: The synchronization request
fileprivate func _runRequest(_ request: URLRequest) async throws -> Data? {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
return task.0
// success(task.0)
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
return nil
}
// MARK: - Services
/// Executes a GET request
public func get<T: SyncedStorable>(identifier: String? = nil) async throws -> [T] {
let getRequest = try _getRequest(type: T.self, identifier: identifier)
return try await self._runRequest(getRequest)
}
/// Executes a POST request on the generated DRF services corresponding to T
public func rawPost<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._postRequest(type: T.self)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a PUT request on the generated DRF services corresponding to T
public func rawPut<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a DELETE request on the generated DRF services corresponding to T
public func rawDelete<T: Storable>(_ instance: T) async throws -> T {
let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
return try await self._runRequest(deleteRequest)
}
/// Executes an ApiCall
func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data {
let request = try self._syncGetRequest(from: apiCall)
return try await self._runGetApiCallRequest(request, apiCall: apiCall)
}
/// Executes an ApiCall
func runApiCalls<T: SyncedStorable>(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
let request = try self._syncPostRequest(from: apiCalls)
return try await self._runSyncPostRequest(request, type: T.self)
}
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _request<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
let url = try self._url(from: apiCall)
var request = URLRequest(url: url)
request.httpMethod = apiCall.method.rawValue
request.httpBody = try apiCall.data?.jsonData()
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method) {
do {
let token = try self.storeCenter.token()
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
} catch {
Logger.log("missing token")
}
}
return request
}
/// Returns the URL corresponding to the ApiCall
/// - Parameters:
/// - apiCall: an instance of ApiCall to build to URL
fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL {
var stringURL: String = self.baseURL
stringURL += apiCall.urlExtension()
if let url = URL(string: stringURL) {
return url
} else {
throw ServiceError.urlCreationError(url: stringURL)
}
}
// MARK: - Others
public func getUserAgents() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userAgentsCall)
}
// MARK: - Authentication
/// Creates an account
/// - Parameters:
/// - user: A user instance to send to the server
public func createAccount<U: UserPasswordBase, V: UserBase>(user: U) async throws -> V {
return try await _runRequest(serviceCall: createAccountCall, payload: user)
}
/// Requests a token for a username and password
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func requestToken(username: String, password: String) async throws -> String {
var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = self.storeCenter.deviceId()
let deviceModel = await UIDevice.current.deviceModel()
let credentials = Credentials(username: username, password: password, deviceId: deviceId, deviceModel: deviceModel)
postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest)
try self.storeCenter.storeToken(username: username, token: response.token)
return response.token
}
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func login<U: UserBase>(username: String, password: String) async throws -> U {
_ = try await requestToken(username: username, password: password)
let postRequest = try self._baseRequest(call: getUserCall)
let loggingDate = Date() // ideally we want the date of the latest retrieved object when loading collection objects
let user: U = try await self._runRequest(postRequest)
self.storeCenter.userDidLogIn(user: user, at: loggingDate)
return user
}
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func logout() async throws {
let deviceId: String = self.storeCenter.deviceId()
let _: Empty = try await self._runRequest(
serviceCall: logoutCall, payload: Logout(deviceId: deviceId))
}
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func postDeviceToken(deviceToken: Data) async throws {
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
let token = DeviceToken(value: tokenString)
// Logger.log("Send device token = \(tokenString)")
let _: Empty = try await self._runRequest(serviceCall: postDeviceTokenCall, payload: token)
}
/// Returns the list of DataAccess
func getUserDataAccessContent() async throws {
let request = try self._baseRequest(call: getUserDataAccessCallContent)
if let data = try await self._runRequest(request) {
await self.storeCenter.userDataAccessRetrieved(data)
}
}
/// A method that sends a request to change a user's password
/// - Parameters:
/// - oldPassword: the account's old password
/// - password1: the account's new password
/// - password2: a repeat of the new password
public func changePassword(oldPassword: String, password1: String, password2: String)
async throws
{
guard let username = self.storeCenter.userName else {
throw ServiceError.missingUserName
}
struct ChangePasswordParams: Codable {
var old_password: String
var new_password1: String
var new_password2: String
}
let params = ChangePasswordParams(
old_password: oldPassword, new_password1: password1, new_password2: password2)
let response: Token = try await self._runRequest(
serviceCall: changePasswordCall, payload: params)
try self.storeCenter.storeToken(username: username, token: response.token)
}
/// The method send a request to reset the user's password
/// - Parameters:
/// - 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 JSON.encoder.encode(Email(email: email))
let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)")
}
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func deleteAccount() async throws {
guard let userId = self.storeCenter.userId else {
throw StoreError.missingUserId
}
let path = "users/\(userId)/"
let deleteAccount = ServiceCall(path: path, method: .delete, requiresToken: true)
let request = try self._baseRequest(call: deleteAccount)
let _: Empty = try await self._runRequest(request)
}
/// Parse a json data and tries to extract its error message
/// - Parameters:
/// - data: some JSON data
fileprivate func errorMessageFromResponse(data: Data) -> ErrorMessage? {
do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
{
if let tuple = jsonObject.first {
var error = ""
if let stringsArray = tuple.value as? [String], let first = stringsArray.first {
error = first
} else if let string = tuple.value as? String {
error = string
}
return ErrorMessage(error: error, domain: tuple.key)
}
}
} catch {
Logger.log("Failed to parse JSON: \(error.localizedDescription)")
}
return nil
}
// MARK: - Convenience method for tests
/// Executes a POST request
public func post<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .post, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
/// Executes a PUT request
public func put<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .put, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
public func delete<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .delete, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
/// Returns a POST request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: true)
}
/// Returns a PUT request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: true)
}
/// Returns a DELETE request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: true)
}
}
struct SyncPayload<T: Encodable>: Encodable {
var operations: [Operation<T>]
var deviceId: String?
}
struct Operation<T: Encodable>: Encodable {
var apiCallId: String
var operation: String
var modelName: String
var data: T
var storeId: String?
}
struct BatchResponse<T: Decodable>: Decodable {
var results: [OperationResult<T>]
}
public struct OperationResult<T: Decodable>: Decodable {
var apiCallId: String
public var status: Int
var data: T?
public var message: String?
}
struct ErrorMessage {
let error: String
let domain: String
var message: String {
return "\(self.error) (\(self.domain))"
}
}
struct AuthResponse: Codable {
let token: String
}
struct Credentials: Codable {
var username: String
var password: String
var deviceId: String
var deviceModel: String?
}
struct Token: Codable {
var token: String
}
struct Email: Codable {
var email: String
}
struct Empty: Codable {
}
struct Logout: Codable {
var deviceId: String
}
struct DeviceToken: Codable {
var value: String
}
public protocol UserBase: Codable {
var id: String { get }
var username: String { get }
var email: String { get }
func uuid() throws -> UUID
}
public protocol UserPasswordBase: UserBase {
var password: String { get }
}
public struct ShortUser: Codable, Identifiable, Equatable {
public var id: String
public var firstName: String
public var lastName: String
}
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")
}
}