import Foundation public struct Client { public let url: URL public var token: String? public var headers: [HTTPHeader] public init(url: URL, token: String? = nil, headers: [HTTPHeader] = []) { self.url = url self.token = token self.headers = headers } static let decoder: JSONDecoder = { var dec = JSONDecoder() dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds return dec }() static let encoder: JSONEncoder = { var enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601withFractionalSeconds return enc }() private func doRequest( path: String, method: HTTPMethod, body: Data? = nil ) async throws(ClientError) -> HTTPResponse { let url = url.appendingPathComponent(path) var req = URLRequest(url: url) if let token { req.addValue(token, forHTTPHeaderField: Headers.sessionToken) } req.httpMethod = method.rawValue for header in headers { req.addValue(header.value, forHTTPHeaderField: header.name) } req.httpBody = body let data: Data let resp: URLResponse do { (data, resp) = try await URLSession.shared.data(for: req) } catch { throw .network(error) } guard let httpResponse = resp as? HTTPURLResponse else { throw .unexpectedResponse(data) } return HTTPResponse(resp: httpResponse, data: data, req: req) } func request( _ path: String, method: HTTPMethod, body: some Encodable & Sendable ) async throws(ClientError) -> HTTPResponse { let encodedBody: Data? do { encodedBody = try Client.encoder.encode(body) } catch { throw .encodeFailure(error) } return try await doRequest(path: path, method: method, body: encodedBody) } func request( _ path: String, method: HTTPMethod ) async throws(ClientError) -> HTTPResponse { try await doRequest(path: path, method: method) } func responseAsError(_ resp: HTTPResponse) -> ClientError { do { let body = try Client.decoder.decode(Response.self, from: resp.data) let out = APIError( response: body, statusCode: resp.resp.statusCode, method: resp.req.httpMethod!, url: resp.req.url! ) return .api(out) } catch { return .unexpectedResponse(resp.data.prefix(1024)) } } } public struct APIError: Decodable, Sendable { let response: Response let statusCode: Int let method: String let url: URL var description: String { var components = ["\(method) \(url.absoluteString)\nUnexpected status code \(statusCode):\n\(response.message)"] if let detail = response.detail { components.append("\tError: \(detail)") } if let validations = response.validations, !validations.isEmpty { let validationMessages = validations.map { "\t\($0.field): \($0.detail)" } components.append(contentsOf: validationMessages) } return components.joined(separator: "\n") } } public struct Response: Decodable, Sendable { let message: String let detail: String? let validations: [FieldValidation]? } public struct FieldValidation: Decodable, Sendable { let field: String let detail: String } public enum ClientError: Error { case api(APIError) case network(any Error) case unexpectedResponse(Data) case encodeFailure(any Error) public var description: String { switch self { case let .api(error): error.description case let .network(error): error.localizedDescription case let .unexpectedResponse(data): "Unexpected or non HTTP response: \(data)" case let .encodeFailure(error): "Failed to encode body: \(error)" } } public var localizedDescription: String { description } }