import Foundation /// Async HTTP client for the Makima API. /// /// Injects the `x-makima-api-key` header on every request (verified in /// `src/server/auth.rs` — API keys live in a custom header, not `Authorization`). /// /// Instances are cheap; one per active `ServerProfile`. final class APIClient { let profile: ServerProfile let apiKey: String let session: URLSession init(profile: ServerProfile, apiKey: String, session: URLSession = .shared) { self.profile = profile self.apiKey = apiKey self.session = session } // MARK: - Public func get(_ path: String, as _: T.Type = T.self) async throws -> T { try await send(method: "GET", path: path, body: nil as Empty?) } @discardableResult func post(_ path: String, body: B) async throws -> R { try await send(method: "POST", path: path, body: body) } @discardableResult func post(_ path: String) async throws -> R { try await send(method: "POST", path: path, body: nil as Empty?) } @discardableResult func delete(_ path: String, as _: R.Type = R.self) async throws -> R { try await send(method: "DELETE", path: path, body: nil as Empty?) } /// Make the request without decoding a response body. Use for endpoints /// whose response we don't model yet. func send(method: String, path: String) async throws { let (_, status) = try await rawSend(method: method, path: path, body: nil as Empty?) try mapStatus(status, data: Data()) } // MARK: - Internals private struct Empty: Encodable {} private func send(method: String, path: String, body: B?) async throws -> R { let (data, status) = try await rawSend(method: method, path: path, body: body) try mapStatus(status, data: data) if R.self == EmptyResponse.self { return EmptyResponse() as! R } do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(R.self, from: data) } catch { throw APIError.decoding(String(describing: error)) } } private func rawSend(method: String, path: String, body: B?) async throws -> (Data, Int) { guard let base = profile.apiBaseURL else { throw APIError.invalidURL(profile.baseURLString) } // Compose URL. `path` may start with `/` — normalise. let trimmed = path.hasPrefix("/") ? String(path.dropFirst()) : path guard let url = URL(string: trimmed, relativeTo: base)?.absoluteURL else { throw APIError.invalidURL(trimmed) } var request = URLRequest(url: url) request.httpMethod = method request.setValue(apiKey, forHTTPHeaderField: "x-makima-api-key") request.setValue("application/json", forHTTPHeaderField: "accept") request.setValue("makima-ios/\(Self.appVersion)", forHTTPHeaderField: "user-agent") if let body { request.setValue("application/json", forHTTPHeaderField: "content-type") let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 do { request.httpBody = try encoder.encode(body) } catch { throw APIError.decoding("encoding request body: \(error)") } } do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } return (data, http.statusCode) } catch let urlErr as URLError where urlErr.code == .cancelled { throw APIError.cancelled } catch { throw APIError.network(String(describing: error)) } } private func mapStatus(_ status: Int, data: Data) throws { switch status { case 200..<300: return case 401, 403: throw APIError.unauthorized case 404: throw APIError.notFound default: let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message throw APIError.http(status: status, message: msg) } } static var appVersion: String { (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" } } struct EmptyResponse: Decodable {}