1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
|
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<T: Decodable>(_ path: String, as _: T.Type = T.self) async throws -> T {
try await send(method: "GET", path: path, body: nil as Empty?)
}
@discardableResult
func post<B: Encodable, R: Decodable>(_ path: String, body: B) async throws -> R {
try await send(method: "POST", path: path, body: body)
}
@discardableResult
func post<R: Decodable>(_ path: String) async throws -> R {
try await send(method: "POST", path: path, body: nil as Empty?)
}
@discardableResult
func delete<R: Decodable>(_ 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<B: Encodable, R: Decodable>(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<B: Encodable>(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 {}
|