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

434 lines
16 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"
}
fileprivate enum ServiceConf: String {
case createAccount = "users/"
case requestToken = "api-token-auth/"
case getUser = "user-by-token/"
case changePassword = "change-password/"
var method: HTTPMethod {
switch self {
case .createAccount, .requestToken:
return .post
case .changePassword:
return .put
default:
return .get
}
}
var requiresToken: Bool? {
switch self {
case .createAccount, .requestToken:
return false
case .getUser, .changePassword:
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>(serviceConf: ServiceConf, payload: T, apiCallId: String? = nil) async throws -> U {
var request = try self._baseRequest(conf: serviceConf)
request.httpBody = try jsonEncoder.encode(payload)
return try await _runRequest(request, apiCallId: apiCallId)
}
/// 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: Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
Logger.log("Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
Logger.log("response = \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
Logger.log("request ended with status code = \(statusCode)")
switch statusCode {
case 200..<300:
if let apiCallId,
let collectionName = (T.self as? any Storable.Type)?.resourceName() {
try await StoreCenter.main.deleteApiCallById(apiCallId, collectionName: collectionName)
}
default:
/*
request ended with status code = 401
{"detail":"Informations d'authentification non fournies."}
*/
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
}
if let apiCallId, let type = (T.self as? any Storable.Type) {
try await StoreCenter.main.rescheduleApiCalls(id: apiCallId, type: type)
StoreCenter.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message)
} else {
StoreCenter.main.logFailedAPICall(request: request, error: errorMessage.message)
}
throw ServiceError.responseError(response: errorMessage.error)
}
}
return try jsonDecoder.decode(T.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(conf: ServiceConf) throws -> URLRequest {
return try self._baseRequest(servicePath: conf.rawValue, method: conf.method, requiresToken: conf.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.getToken()
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>(_ apiCall: ApiCall<T>) async throws -> T {
let request = try self._request(from: apiCall)
return try await self._runRequest(request, apiCallId: apiCall.id)
}
/// 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.getToken()
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(serviceConf: .createAccount, 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(conf: .requestToken)
let credentials = Credentials(username: username, password: password)
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.deleteToken()
try self.keychainStore.add(username: username, token: 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(conf: .getUser)
let user: U = try await self._runRequest(postRequest)
StoreCenter.main.setUserUUID(uuidString: user.id)
StoreCenter.main.setUserName(user.username)
return user
}
/// 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(serviceConf: .changePassword, 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)")
}
/// Deletes the locally stored token
func deleteToken() throws {
try self.keychainStore.deleteToken()
}
/// Returns whether the Service has an associated token
public func hasToken() -> Bool {
do {
_ = try self.keychainStore.getToken()
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
}
}
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
}
struct Token: Codable {
var token: String
}
struct Email: Codable {
var email: 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 }
}