gitbay/Networking/GitbayClient.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Networking/GitbayClient.swift history · blame · raw

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}