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