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.
534 lines
21 KiB
534 lines
21 KiB
//
|
|
// Services.swift
|
|
// LeStorage
|
|
//
|
|
// Created by Laurent Morvillier on 02/02/2024.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
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)
|
|
|
|
//fileprivate enum ServiceConf: String {
|
|
// case createAccount = "users/"
|
|
// case requestToken = "token-auth/"
|
|
// case logout = "api-token-logout/"
|
|
// case getUser = "user-by-token/"
|
|
// case changePassword = "change-password/"
|
|
// case postDeviceToken = "device-token/"
|
|
//
|
|
// var method: HTTPMethod {
|
|
// switch self {
|
|
// case .createAccount, .requestToken, .logout, .postDeviceToken:
|
|
// return .post
|
|
// case .changePassword:
|
|
// return .put
|
|
// default:
|
|
// return .get
|
|
// }
|
|
// }
|
|
//
|
|
// var requiresToken: Bool? {
|
|
// switch self {
|
|
// case .createAccount, .requestToken:
|
|
// return false
|
|
// case .getUser, .changePassword, .logout, .postDeviceToken:
|
|
// return true
|
|
//// default:
|
|
//// return nil
|
|
// }
|
|
// }
|
|
//
|
|
//}
|
|
|
|
/// A class used to send HTTP request to the django server
|
|
public class Services {
|
|
|
|
/// A KeychainStore object used to store the user's token
|
|
let keychainStore: KeychainStore
|
|
|
|
// fileprivate var _storeIdentifier: StoreIdentifier?
|
|
|
|
public init(url: String) {
|
|
self.baseURL = url
|
|
self.keychainStore = KeychainStore(serverId: url)
|
|
// self._storeIdentifier = storeId
|
|
Logger.log("create keystore with id: \(url)")
|
|
|
|
}
|
|
|
|
/// 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
|
|
}()
|
|
|
|
// MARK: - Base
|
|
|
|
/// 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 jsonEncoder.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 _runRequest<T: Storable, V: Decodable>(_ request: URLRequest, apiCall: ApiCall<T>) 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("response = \(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)
|
|
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(id: apiCall.id, 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 !(V.self is Empty?.Type) {
|
|
return try jsonDecoder.decode(V.self, from: task.0)
|
|
} else {
|
|
return try jsonDecoder.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("response = \(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 jsonDecoder.decode(V.self, from: 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 : Storable>(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: Storable>(type: T.Type, identifier: StoreIdentifier?) 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 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
|
|
fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest {
|
|
var urlString = baseURL + servicePath
|
|
if let identifier {
|
|
urlString.append(identifier.urlComponent)
|
|
}
|
|
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")
|
|
if !(requiresToken == false) {
|
|
let token = try self.keychainStore.getValue()
|
|
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
return request
|
|
}
|
|
|
|
// MARK: - Services
|
|
|
|
/// Executes a GET request
|
|
public func get<T: Storable>(identifier: StoreIdentifier? = 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<T: Storable>(_ instance: T) async throws -> T {
|
|
var postRequest = try self._postRequest(type: T.self)
|
|
postRequest.httpBody = try jsonEncoder.encode(instance)
|
|
return try await self._runRequest(postRequest)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Returns the URLRequest for an ApiCall
|
|
/// - Parameters:
|
|
/// - apiCall: An ApiCall instance to configure the returned request
|
|
fileprivate func _request<T: Storable>(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 = apiCall.body.data(using: .utf8)
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
if self._isTokenRequired(type: T.self, method: apiCall.method) {
|
|
do {
|
|
let token = try self.keychainStore.getValue()
|
|
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
|
|
switch apiCall.method {
|
|
case HTTPMethod.put, HTTPMethod.delete:
|
|
stringURL += T.path(id: apiCall.dataId)
|
|
default:
|
|
stringURL += T.path()
|
|
}
|
|
if let url = URL(string: stringURL) {
|
|
return url
|
|
} else {
|
|
throw ServiceError.urlCreationError(url: stringURL)
|
|
}
|
|
}
|
|
|
|
// 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 = StoreCenter.main.deviceId()
|
|
let credentials = Credentials(username: username, password: password, deviceId: deviceId)
|
|
postRequest.httpBody = try jsonEncoder.encode(credentials)
|
|
let response: AuthResponse = try await self._runRequest(postRequest)
|
|
self._storeToken(username: username, token: response.token)
|
|
return response.token
|
|
}
|
|
|
|
/// Stores a token for a corresponding username
|
|
/// - Parameters:
|
|
/// - username: the key used to store the token
|
|
/// - token: the token to store
|
|
fileprivate func _storeToken(username: String, token: String) {
|
|
do {
|
|
try self.keychainStore.deleteValue()
|
|
try self.keychainStore.add(username: username, value: token)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
/// 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 user: U = try await self._runRequest(postRequest)
|
|
// StoreCenter.main.setUserUUID(uuidString: user.id)
|
|
// StoreCenter.main.setUserName(user.username)
|
|
StoreCenter.main.setUserInfo(user: user)
|
|
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)
|
|
}
|
|
|
|
/// 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)
|
|
|
|
self._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 jsonEncoder.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 ServiceError.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)
|
|
}
|
|
|
|
/// Deletes the locally stored token
|
|
func deleteToken() throws {
|
|
try self.keychainStore.deleteValue()
|
|
}
|
|
|
|
/// Returns whether the Service has an associated token
|
|
public func hasToken() -> Bool {
|
|
do {
|
|
_ = try self.keychainStore.getValue()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
func migrateToken(_ services: Services, userName: String) throws {
|
|
try self._storeToken(username: userName, token: services.keychainStore.getValue())
|
|
}
|
|
|
|
}
|
|
|
|
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
|
|
}
|
|
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 }
|
|
}
|
|
|