// // 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 getUserDataAccessCall: ServiceCall = ServiceCall( path: "data-access/", method: .get, requiresToken: true) let userNamesCall: ServiceCall = ServiceCall( path: "user-names/", method: .get, requiresToken: true) /// A class used to send HTTP request to the django server public class Services { /// The base API URL to send requests fileprivate(set) var baseURL: String public init(url: String) { 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(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(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( _ request: URLRequest, apiCall: ApiCall ) 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("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")") if let response = task.1 as? HTTPURLResponse { let statusCode = response.statusCode print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) if T.self == GetSyncData.self { await StoreCenter.main.synchronizeContent(task.0) } default: // error 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 StoreCenter.main.rescheduleApiCalls(type: T.self) StoreCenter.main.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)" StoreCenter.main.log(message: message) Logger.w(message) } return try self._decode(data: task.0) } fileprivate func _decode(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(_ 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 print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success break default: // error 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)" StoreCenter.main.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(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(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(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 /// - 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 fileprivate 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 StoreCenter.main.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( _ request: URLRequest, type: T.Type) async throws -> [OperationResult] { 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] = [] if let response = task.1 as? HTTPURLResponse { let statusCode = response.statusCode print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success let decoded: BatchResponse = 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.apiCallId): \(result.status) > \(message)") } rescheduleApiCalls = true break } } default: // error 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 StoreCenter.main.rescheduleApiCalls(type: T.self) // StoreCenter.main.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)" StoreCenter.main.log(message: message) Logger.w(message) } if rescheduleApiCalls { try? await StoreCenter.main.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(from apiCall: ApiCall) 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") if self._isTokenRequired(type: T.self, method: apiCall.method), StoreCenter.main.isAuthenticated { let token = try StoreCenter.main.token() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") } return request } // /// 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) // return try await self._runRequest(postRequest) // } // // public func delete(_ 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(_ apiCall: ApiCall) 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(from apiCalls: [ApiCall]) 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 StoreCenter.main.token() request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") 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 payload = SyncPayload(operations: operations, deviceId: StoreCenter.main.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 StoreCenter.main.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 StoreCenter.main.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 print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success return task.0 // success(task.0) default: // error 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)" StoreCenter.main.log(message: message) Logger.w(message) } return nil } // MARK: - Services /// Executes a GET request public func get(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 public func post(_ 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 public func put(_ 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) } public func delete(_ 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(_ apiCall: ApiCall) async throws -> V { let request = try self._syncGetRequest(from: apiCall) return try await self._runGetApiCallRequest(request, apiCall: apiCall) } /// Executes an ApiCall func runApiCalls(_ apiCalls: [ApiCall]) async throws -> [OperationResult] { 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(from apiCall: ApiCall) 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 StoreCenter.main.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(from apiCall: ApiCall) 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 getUserNames() async throws -> [ShortUser] { return try await self._runRequest(serviceCall: userNamesCall) } // MARK: - Authentication /// Creates an account /// - Parameters: /// - user: A user instance to send to the server public func createAccount(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 = StoreCenter.main.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 StoreCenter.main.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(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) StoreCenter.main.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 = StoreCenter.main.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 public func getUserDataAccess() async throws { let request = try self._baseRequest(call: getUserDataAccessCall) if let data = try await self._runRequest(request) { await StoreCenter.main.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 = StoreCenter.main.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 StoreCenter.main.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 = StoreCenter.main.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(_ instance: T) async throws -> T? { let apiCall: ApiCall = ApiCall(method: .post, data: instance) let results: [OperationResult] = try await self.runApiCalls([apiCall]) return results.first?.data } /// Executes a PUT request public func put(_ instance: T) async throws -> T? { let apiCall: ApiCall = ApiCall(method: .put, data: instance) let results: [OperationResult] = try await self.runApiCalls([apiCall]) return results.first?.data } public func delete(_ instance: T) async throws -> T? { let apiCall: ApiCall = ApiCall(method: .delete, data: instance) let results: [OperationResult] = 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(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(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(type: T.Type, id: String) throws -> URLRequest { return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: true) } } struct SyncPayload: Encodable { var operations: [Operation] var deviceId: String? } struct Operation: Encodable { var apiCallId: String var operation: String var modelName: String var data: T var storeId: String? } struct BatchResponse: Decodable { var results: [OperationResult] } public struct OperationResult: 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") } }