a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Networking/GitbayClient.swift

ui-smoke
gitbay-ios/gitbay/Networking/GitbayClient.swift history · blame · raw

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}