gitbay/Networking/GitbayClient.swift
369 lines · 14426 bytes
1import CryptoKit
2import Foundation
3import os
4
5nonisolated private let logger = Logger(subsystem: "org.gitbay.gitbay", category: "GitbayClient")
6
7/// Speaks the two halves of the gitbay API: `GET /api/v1/read` for the
8/// commands the registry marks read-only, `POST /api/v1/cmd` for the rest.
9///
10/// Both dispatch the same command registry server-side, so this client adds
11/// no semantics of its own — it carries argv there and an envelope back.
12///
13/// A client is bound to one account on one instance. Signing out means
14/// dropping the client, not mutating it.
15nonisolated final class GitbayClient: Sendable {
16
17 /// How long the client will wait out a 429 by itself. Beyond this the
18 /// wait belongs on screen, not inside a request that looks hung.
19 static let maximumAutomaticWait: TimeInterval = 3
20
21 let instance: GitbayInstance
22
23 private let token: String
24 private let session: URLSession
25 private let etags: ETagStore
26 private let redirectGuard: RedirectGuard
27 /// Cache keys are scoped by account without holding the token.
28 private let accountKey: String
29 /// Fired on any 401 — the token is expired or revoked. The session
30 /// layer attaches this after sign-in succeeds, so the sign-in probe's
31 /// own 401 (a mistyped token) never signs anyone out.
32 private let unauthorizedHandler = OSAllocatedUnfairLock<(@Sendable () -> Void)?>(initialState: nil)
33
34 func onUnauthorized(_ handler: @escaping @Sendable () -> Void) {
35 unauthorizedHandler.withLock { $0 = handler }
36 }
37
38 init(
39 instance: GitbayInstance,
40 token: String,
41 session: URLSession = .shared,
42 etags: ETagStore = ETagStore()
43 ) {
44 self.instance = instance
45 self.token = token
46 self.session = session
47 self.etags = etags
48 self.redirectGuard = RedirectGuard(instance: instance)
49 let digest = SHA256.hash(data: Data(token.utf8))
50 self.accountKey = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
51 }
52
53 // MARK: - Reads
54
55 /// Run a read-only command, decoding `data` as `Payload`.
56 ///
57 /// Revalidates with `If-None-Match` when a previous body is cached: a
58 /// 304 costs a round trip and no body, which is the point on a phone.
59 func read<Payload: Decodable & Sendable>(
60 _ argv: [String],
61 as _: Payload.Type
62 ) async throws -> Payload {
63 let envelope: Envelope<Payload> = try await readEnvelope(argv)
64 guard let data = envelope.data else {
65 throw GitbayError.decoding(MissingData(argv: argv))
66 }
67 return data
68 }
69
70 /// Run a read-only command that returns a list.
71 ///
72 /// An empty result arrives as no `data` key at all — Go omits a nil
73 /// slice — so absent means empty here rather than a decode failure.
74 func readList<Element: Decodable & Sendable>(
75 _ argv: [String],
76 of _: Element.Type
77 ) async throws -> [Element] {
78 let envelope: Envelope<[Element]> = try await readEnvelope(argv)
79 return envelope.data ?? []
80 }
81
82 /// One page of a paginated list command. With `--limit`/`--cursor`
83 /// present the server moves the array under `items` and returns the
84 /// opaque `next` cursor alongside; `next` is absent on the last page.
85 nonisolated struct Page<Element: Decodable & Sendable>: Decodable, Sendable {
86 let items: [Element]
87 let next: String?
88 }
89
90 /// Run a paginated list command (`repo list`, `issue list`, `mr
91 /// list`, `feed`). Cursors are opaque and kind-checked server-side —
92 /// pass back exactly what `next` carried, never synthesize one.
93 func readPage<Element: Decodable & Sendable>(
94 _ argv: [String],
95 of _: Element.Type,
96 limit: Int,
97 cursor: String? = nil
98 ) async throws -> Page<Element> {
99 var argv = argv + ["--limit", String(limit)]
100 if let cursor { argv.append(contentsOf: ["--cursor", cursor]) }
101 let envelope: Envelope<Page<Element>> = try await readEnvelope(argv)
102 guard let page = envelope.data else {
103 throw GitbayError.decoding(MissingData(argv: argv))
104 }
105 return page
106 }
107
108 /// Run a read-only command that emits raw text rather than JSON
109 /// (`mr diff`, `build log`). The server wraps those as `output`.
110 func readText(_ argv: [String]) async throws -> String {
111 let envelope: Envelope<NoPayload> = try await readEnvelope(argv)
112 return envelope.output ?? ""
113 }
114
115 private func readEnvelope<Payload: Decodable & Sendable>(
116 _ argv: [String],
117 revalidate: Bool = true
118 ) async throws -> Envelope<Payload> {
119 let url = instance.readURL(argv: argv)
120 let cacheKey = ETagStore.key(account: accountKey, argv: argv)
121 let cached = revalidate ? etags.entry(for: cacheKey) : nil
122
123 var request = URLRequest(url: url)
124 // URLSession's own cache would answer some of these transparently
125 // and the revalidation would never be visible here. The ETag layer
126 // in this file is the only one.
127 request.cachePolicy = .reloadIgnoringLocalCacheData
128 if let cached {
129 request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match")
130 }
131
132 let (body, response) = try await perform(request, argv: argv)
133
134 if response.statusCode == 304 {
135 guard let cached else {
136 // Only reachable if the entry was evicted between the read
137 // above and the response. Ask again without a validator,
138 // which cannot come back 304.
139 return try await readEnvelope(argv, revalidate: false)
140 }
141 return try decode(cached.payload, status: 200, argv: argv, surface: .read)
142 }
143
144 if let etag = response.value(forHTTPHeaderField: "ETag"), response.statusCode == 200 {
145 etags.store(ETagEntry(etag: etag, payload: body), for: cacheKey)
146 }
147 return try decode(body, status: response.statusCode, argv: argv, surface: .read)
148 }
149
150 // MARK: - Writes
151
152 /// Run a command that changes state. Long text goes in `stdin`, never
153 /// in argv — the same discipline the CLI uses for `--file -`.
154 @discardableResult
155 func run<Payload: Decodable & Sendable>(
156 _ argv: [String],
157 stdin: String? = nil,
158 as _: Payload.Type
159 ) async throws -> Payload? {
160 var request = URLRequest(url: instance.cmdURL)
161 request.httpMethod = "POST"
162 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
163 var payload: [String: Any] = ["argv": argv]
164 if let stdin { payload["stdin"] = stdin }
165 request.httpBody = try JSONSerialization.data(withJSONObject: payload)
166
167 let (body, response) = try await perform(request, argv: argv)
168 let envelope: Envelope<Payload> = try decode(
169 body, status: response.statusCode, argv: argv, surface: .command
170 )
171 return envelope.data
172 }
173
174 /// Run a command whose result carries nothing the caller needs.
175 func run(_ argv: [String], stdin: String? = nil) async throws {
176 _ = try await run(argv, stdin: stdin, as: NoPayload.self)
177 }
178
179 // MARK: - Transport
180
181 private func perform(
182 _ request: URLRequest,
183 argv: [String]
184 ) async throws -> (Data, HTTPURLResponse) {
185 guard let url = request.url, instance.isOwn(url) else {
186 throw GitbayError.unexpectedHost(request.url ?? instance.baseURL)
187 }
188 var request = request
189 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
190
191 // One retry, no more. A phone on a flaky network is exactly the
192 // caller that turns a retry loop into a rate-limit spiral.
193 for attempt in 0...1 {
194 let body: Data
195 let response: HTTPURLResponse
196 do {
197 let (data, urlResponse) = try await session.data(for: request, delegate: redirectGuard)
198 guard let http = urlResponse as? HTTPURLResponse else {
199 throw GitbayError.transport(URLError(.badServerResponse))
200 }
201 (body, response) = (data, http)
202 } catch let error as GitbayError {
203 throw error
204 } catch {
205 throw GitbayError.transport(error)
206 }
207
208 let isLastAttempt = attempt == 1
209
210 if response.statusCode == 429 {
211 let wait = retryAfter(response) ?? Self.maximumAutomaticWait
212 guard !isLastAttempt, wait <= Self.maximumAutomaticWait else {
213 throw GitbayError.rateLimited(retryAfter: wait)
214 }
215 try await Task.sleep(for: .seconds(wait))
216 continue
217 }
218
219 if (500...599).contains(response.statusCode), !isLastAttempt {
220 try await Task.sleep(for: .milliseconds(500))
221 continue
222 }
223 return (body, response)
224 }
225 // The loop returns or throws on every path; this satisfies the
226 // compiler rather than describing a reachable state.
227 throw GitbayError.failure("")
228 }
229
230 /// gitbay sends Retry-After as whole seconds.
231 private func retryAfter(_ response: HTTPURLResponse) -> TimeInterval? {
232 guard let value = response.value(forHTTPHeaderField: "Retry-After"),
233 let seconds = TimeInterval(value.trimmingCharacters(in: .whitespaces)),
234 seconds >= 0 else {
235 return nil
236 }
237 return seconds
238 }
239
240 // MARK: - Envelope
241
242 private enum Surface { case read, command }
243
244 /// Dates on the wire are RFC 3339, some with fractional seconds
245 /// (SQLite's `%Y-%m-%dT%H:%M:%fZ` default), some without (`repo log`).
246 private static func decoder() -> JSONDecoder {
247 let decoder = JSONDecoder()
248 decoder.dateDecodingStrategy = .custom { decoder in
249 let text = try decoder.singleValueContainer().decode(String.self)
250 if let date = try? Date(text, strategy: .iso8601) {
251 return date
252 }
253 if let date = try? Date(text, strategy: .iso8601.year().month().day()
254 .dateTimeSeparator(.standard).time(includingFractionalSeconds: true)) {
255 return date
256 }
257 throw DecodingError.dataCorrupted(.init(
258 codingPath: decoder.codingPath,
259 debugDescription: "unrecognized date: \(text)"
260 ))
261 }
262 return decoder
263 }
264
265 private func decode<Payload: Decodable & Sendable>(
266 _ body: Data,
267 status: Int,
268 argv: [String],
269 surface: Surface
270 ) throws -> Envelope<Payload> {
271 // Probe the envelope before touching the payload, so a failure or
272 // a foreign protocol version is recognised whatever shape `data`
273 // has — an error envelope never matches the expected payload.
274 let probe: Envelope<NoPayload>
275 do {
276 probe = try Self.decoder().decode(Envelope<NoPayload>.self, from: body)
277 } catch {
278 throw GitbayError.decoding(error)
279 }
280 guard probe.protocolVersion == 1 else {
281 throw GitbayError.protocolMismatch(probe.protocolVersion)
282 }
283 if let error = failure(probe, status: status, argv: argv, surface: surface) {
284 throw error
285 }
286 do {
287 return try Self.decoder().decode(Envelope<Payload>.self, from: body)
288 } catch {
289 throw GitbayError.decoding(error)
290 }
291 }
292
293 private func failure(
294 _ envelope: Envelope<NoPayload>,
295 status: Int,
296 argv: [String],
297 surface: Surface
298 ) -> GitbayError? {
299 let message = envelope.message ?? ""
300
301 // The gate rejections — bad token, unknown command, a write sent to
302 // the read surface — never reach a command, so they carry no exit
303 // code. Those are read off the status.
304 guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else {
305 switch status {
306 case 200: return nil
307 case 401:
308 unauthorizedHandler.withLock { $0 }?()
309 return .unauthorized(message)
310 case 403: return .denied(message)
311 case 404: return .notFound(message)
312 case 429: return .rateLimited(retryAfter: 0)
313 case 400 where surface == .read:
314 logger.error("read refused: \(message, privacy: .public)")
315 return .notReadable(message)
316 case 400:
317 logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .public)")
318 return .usage(message)
319 default: return .failure(message)
320 }
321 }
322
323 switch code {
324 case .ok:
325 return nil
326 case .usage:
327 // The app built argv wrong. Log it; never put argv on screen.
328 logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .public)")
329 return .usage(message)
330 case .notFound:
331 return .notFound(message)
332 case .denied:
333 return .denied(message)
334 case .failure, .protocolError:
335 return .failure(message)
336 }
337 }
338
339 struct MissingData: LocalizedError, Sendable {
340 let argv: [String]
341 var errorDescription: String? { "The response carried no data." }
342 }
343}
344
345/// Refuses to follow a redirect off the instance. Without this a redirect
346/// would carry the bearer token to whatever host answered.
347private nonisolated final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
348
349 private let instance: GitbayInstance
350
351 init(instance: GitbayInstance) {
352 self.instance = instance
353 }
354
355 func urlSession(
356 _ session: URLSession,
357 task: URLSessionTask,
358 willPerformHTTPRedirection response: HTTPURLResponse,
359 newRequest request: URLRequest,
360 completionHandler: @escaping (URLRequest?) -> Void
361 ) {
362 guard let url = request.url, instance.isOwn(url) else {
363 logger.error("refused redirect off \(self.instance.baseURL.absoluteString, privacy: .public)")
364 completionHandler(nil)
365 return
366 }
367 completionHandler(request)
368 }
369}