krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.6.0: Hutch/Networking/SRHTClient.swift · raw

  1import CryptoKit
  2import Foundation
  3import os
  4
  5private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
  6
  7struct MultipartUploadFile: Sendable {
  8    let variablePath: String
  9    let fileData: Data
 10    let fileName: String
 11    let mimeType: String
 12}
 13
 14/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
 15private struct EmptyData: Decodable {}
 16
 17/// A lightweight GraphQL client for SourceHut services.
 18/// All requests require a personal access token set via ``token``.
 19final class SRHTClient: Sendable {
 20
 21    private let session: URLSession
 22    private let decoder: JSONDecoder
 23    private let encoder: JSONEncoder
 24    private let cache: any APICache
 25    private let requestCoalescer = RequestCoalescer()
 26
 27    /// The personal access token used for `Authorization: Bearer` headers.
 28    /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
 29    private let tokenLock: OSAllocatedUnfairLock<String?>
 30
 31    /// In-memory response cache for stale-while-revalidate pattern.
 32    let responseCache = ResponseCache()
 33
 34    var hasToken: Bool {
 35        tokenLock.withLock { $0 != nil }
 36    }
 37
 38    init(
 39        session: URLSession = .shared,
 40        token: String? = nil,
 41        cache: (any APICache)? = nil
 42    ) {
 43        self.session = session
 44        self.decoder = JSONDecoder()
 45        self.decoder.dateDecodingStrategy = .srhtFlexible
 46        self.encoder = JSONEncoder()
 47        self.tokenLock = OSAllocatedUnfairLock(initialState: token)
 48        self.cache = cache ?? PersistentAPICache(
 49            configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous")
 50        )
 51    }
 52
 53    /// Update the stored token (e.g. after the user saves a new one in Keychain).
 54    func setToken(_ token: String?) {
 55        tokenLock.withLock { $0 = token }
 56    }
 57
 58    /// Execute a GraphQL query or mutation against a SourceHut service.
 59    ///
 60    /// - Parameters:
 61    ///   - service: The target SourceHut service (determines the endpoint URL).
 62    ///   - query: The GraphQL query or mutation string.
 63    ///   - variables: Optional dictionary of GraphQL variables.
 64    ///   - responseType: The expected `Decodable` type nested under `data`.
 65    /// - Returns: The decoded `data` payload.
 66    func execute<T: Decodable>(
 67        service: SRHTService,
 68        query: String,
 69        variables: [String: any Sendable]? = nil,
 70        responseType _: T.Type
 71    ) async throws -> T {
 72        let data = try await performGraphQLRequest(service: service, query: query, variables: variables)
 73        return try decodeGraphQLData(data, service: service, query: query, variables: variables)
 74    }
 75
 76    func executeCached<T: Decodable>(
 77        service: SRHTService,
 78        query: String,
 79        variables: [String: any Sendable]? = nil,
 80        responseType _: T.Type,
 81        cacheKey: String,
 82        resourceType: CacheResourceType,
 83        ttl: TimeInterval,
 84        policy: CachePolicy = .cacheFirstThenRefresh
 85    ) async throws -> CachedValue<T> {
 86        switch policy {
 87        case .networkOnly:
 88            let data = try await performGraphQLRequest(
 89                service: service,
 90                query: query,
 91                variables: variables
 92            )
 93            let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
 94            return CachedValue(value: value, metadata: nil, source: .network)
 95
 96        case .cacheOnly:
 97            let entry = try await cache.read(cacheKey: cacheKey)
 98            let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
 99            return CachedValue(value: value, metadata: entry.metadata, source: .cache)
100
101        case .cacheFirstThenRefresh:
102            if let entry = try? await cache.read(cacheKey: cacheKey) {
103                let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
104                if entry.metadata.isExpired() {
105                    Task.detached { [self] in
106                        _ = try? await self.fetchAndCacheGraphQLData(
107                            service: service,
108                            query: query,
109                            variables: variables,
110                            cacheKey: cacheKey,
111                            resourceType: resourceType,
112                            ttl: ttl
113                        )
114                    }
115                }
116                return CachedValue(value: value, metadata: entry.metadata, source: .cache)
117            }
118
119            let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
120                service: service,
121                query: query,
122                variables: variables,
123                cacheKey: cacheKey,
124                resourceType: resourceType,
125                ttl: ttl
126            )
127            return CachedValue(value: value, metadata: metadata, source: .network)
128
129        case .refreshIgnoringCache:
130            let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
131                service: service,
132                query: query,
133                variables: variables,
134                cacheKey: cacheKey,
135                resourceType: resourceType,
136                ttl: ttl
137            )
138            return CachedValue(value: value, metadata: metadata, source: .network)
139        }
140    }
141
142    func fetchCachedText(
143        url: URL,
144        cacheKey: String,
145        resourceType: CacheResourceType = .buildLog,
146        ttl: TimeInterval,
147        policy: CachePolicy = .cacheFirstThenRefresh
148    ) async throws -> CachedValue<String> {
149        switch policy {
150        case .networkOnly:
151            let text = try await fetchText(url: url)
152            return CachedValue(value: text, metadata: nil, source: .network)
153        case .cacheOnly:
154            let entry = try await cache.read(cacheKey: cacheKey)
155            guard let text = String(data: entry.payload, encoding: .utf8) else {
156                throw SRHTError.decodingError(
157                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8"))
158                )
159            }
160            return CachedValue(value: text, metadata: entry.metadata, source: .cache)
161        case .cacheFirstThenRefresh:
162            if let entry = try? await cache.read(cacheKey: cacheKey),
163               let text = String(data: entry.payload, encoding: .utf8) {
164                if entry.metadata.isExpired() {
165                    Task.detached { [self] in
166                        _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
167                    }
168                }
169                return CachedValue(value: text, metadata: entry.metadata, source: .cache)
170            }
171            let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
172            return CachedValue(value: text, metadata: metadata, source: .network)
173        case .refreshIgnoringCache:
174            let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
175            return CachedValue(value: text, metadata: metadata, source: .network)
176        }
177    }
178
179    func cachedPayload(forKey cacheKey: String) async -> Data? {
180        if let entry = try? await cache.read(cacheKey: cacheKey) {
181            return entry.payload
182        }
183        return responseCache.get(forKey: cacheKey)
184    }
185
186    func invalidateCache(prefix: String) async {
187        await cache.removeByPrefix(prefix)
188    }
189
190    func removeCachedValue(forKey cacheKey: String) async {
191        await cache.remove(cacheKey: cacheKey)
192        responseCache.remove(forKey: cacheKey)
193    }
194
195    func clearPersistentCache() async {
196        await cache.clearAll()
197        responseCache.clear()
198    }
199
200    // MARK: - Multipart Upload
201
202    /// Execute a GraphQL mutation with a file upload using the
203    /// graphql-multipart-request-spec (multipart/form-data).
204    ///
205    /// - Parameters:
206    ///   - service: The target SourceHut service.
207    ///   - query: The GraphQL mutation string.
208    ///   - variables: Variables dict; the file variable should be set to `nil`.
209    ///   - file: Multipart file payload (`variablePath` is the dot-separated GraphQL variable, e.g. `input.avatar`).
210    ///   - responseType: The expected `Decodable` type nested under `data`.
211    func executeMultipart<T: Decodable>(
212        service: SRHTService,
213        query: String,
214        variables: [String: any Sendable],
215        file: MultipartUploadFile,
216        responseType _: T.Type
217    ) async throws -> T {
218        try await executeMultipartFiles(
219            service: service,
220            query: query,
221            variables: variables,
222            files: [file],
223            responseType: T.self
224        )
225    }
226
227    func executeMultipartFiles<T: Decodable>(
228        service: SRHTService,
229        query: String,
230        variables: [String: any Sendable],
231        files: [MultipartUploadFile],
232        responseType _: T.Type
233    ) async throws -> T {
234        let boundary = "Boundary-\(UUID().uuidString)"
235        var request = try makeAuthorizedRequest(
236            service: service,
237            contentType: "multipart/form-data; boundary=\(boundary)"
238        )
239
240        let operationsData = try encodedGraphQLBody(query: query, variables: variables)
241
242        let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
243            (String(index), ["variables.\(file.variablePath)"])
244        })
245        let mapData = try encoder.encode(mapDict)
246
247        var body = Data()
248
249        body.append("--\(boundary)\r\n")
250        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
251        body.append("Content-Type: application/json\r\n\r\n")
252        body.append(operationsData)
253        body.append("\r\n")
254
255        body.append("--\(boundary)\r\n")
256        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
257        body.append("Content-Type: application/json\r\n\r\n")
258        body.append(mapData)
259        body.append("\r\n")
260
261        for (index, file) in files.enumerated() {
262            body.append("--\(boundary)\r\n")
263            body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
264            body.append("Content-Type: \(file.mimeType)\r\n\r\n")
265            body.append(file.fileData)
266            body.append("\r\n")
267        }
268
269        body.append("--\(boundary)--\r\n")
270        request.httpBody = body
271
272        let data = try await send(request)
273        return try decodeGraphQLData(data, service: service, query: query, variables: variables)
274    }
275
276
277    // MARK: - Plain-text fetch
278
279    /// Fetch the contents of a URL as plain text, using the same authorization header.
280    /// Used for build logs and other non-GraphQL resources.
281    func fetchText(url: URL) async throws -> String {
282        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
283            throw SRHTError.unauthorized
284        }
285        guard Self.isTrustedAuthenticatedTextURL(url) else {
286            throw SRHTError.invalidAuthenticatedURL(url)
287        }
288
289        var request = URLRequest(url: url)
290        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
291        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
292
293        let (data, response): (Data, URLResponse)
294        do {
295            (data, response) = try await session.data(for: request)
296        } catch {
297            throw SRHTError.networkError(error)
298        }
299
300        if let http = response as? HTTPURLResponse {
301            if http.statusCode == 401 {
302                throw SRHTError.unauthorized
303            }
304            if !(200...299).contains(http.statusCode) {
305                throw SRHTError.httpError(http.statusCode)
306            }
307        }
308
309        guard let text = String(data: data, encoding: .utf8) else {
310            throw SRHTError.decodingError(
311                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
312            )
313        }
314
315        return text
316    }
317
318    // MARK: - Pagination
319
320    /// Returns an `AsyncSequence` that lazily iterates through all pages of a
321    /// paginated sr.ht GraphQL query.
322    ///
323    /// The query must accept a `$cursor: String` variable and return the standard
324    /// `{ results: [T], cursor: String? }` shape at the given key path.
325    func paginated<T: Decodable & Sendable>(
326        service: SRHTService,
327        query: String,
328        variables: [String: any Sendable]? = nil,
329        resultKeyPath: String
330    ) -> SRHTPaginatedSequence<T> {
331        SRHTPaginatedSequence(
332            client: self,
333            service: service,
334            query: query,
335            variables: variables,
336            resultKeyPath: resultKeyPath
337        )
338    }
339
340    /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
341    /// collected results.
342    func fetchAll<T: Decodable & Sendable>(
343        service: SRHTService,
344        query: String,
345        variables: [String: any Sendable]? = nil,
346        resultKeyPath: String
347    ) async throws -> [T] {
348        var all: [T] = []
349        let pages: SRHTPaginatedSequence<T> = paginated(
350            service: service,
351            query: query,
352            variables: variables,
353            resultKeyPath: resultKeyPath
354        )
355        for try await element in pages {
356            all.append(element)
357        }
358        return all
359    }
360}
361
362// MARK: - Data Helper
363
364private extension SRHTClient {
365    /// Builds an authorized POST for `service`. Throws ``SRHTError/unauthorized``
366    /// when no token is set, so callers never have to guard separately.
367    func makeAuthorizedRequest(service: SRHTService, contentType: String) throws -> URLRequest {
368        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
369            throw SRHTError.unauthorized
370        }
371
372        var request = URLRequest(url: service.url)
373        request.httpMethod = "POST"
374        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
375        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
376        request.setValue(contentType, forHTTPHeaderField: "Content-Type")
377        return request
378    }
379
380    /// Sends a prepared request and returns the raw body, mapping transport and
381    /// HTTP failures onto ``SRHTError``. sr.ht reports GraphQL errors under a 200
382    /// as often as under a 4xx, so both paths check the envelope.
383    func send(_ request: URLRequest) async throws -> Data {
384        let (data, response): (Data, URLResponse)
385        do {
386            (data, response) = try await session.data(for: request)
387        } catch {
388            throw SRHTError.networkError(error)
389        }
390
391        if let http = response as? HTTPURLResponse {
392            if http.statusCode == 401 {
393                throw SRHTError.unauthorized
394            }
395            if !(200...299).contains(http.statusCode) {
396                try throwGraphQLErrorsIfPresent(in: data)
397                throw SRHTError.httpError(http.statusCode)
398            }
399        }
400
401        try throwGraphQLErrorsIfPresent(in: data)
402        return data
403    }
404
405    func encodedGraphQLBody(query: String, variables: [String: any Sendable]?) throws -> Data {
406        try encoder.encode(
407            GraphQLRequestBody(
408                query: query,
409                variables: variables?.mapValues { AnyCodable($0) }
410            )
411        )
412    }
413
414    func performGraphQLRequest(
415        service: SRHTService,
416        query: String,
417        variables: [String: any Sendable]?
418    ) async throws -> Data {
419        var request = try makeAuthorizedRequest(service: service, contentType: "application/json")
420        request.httpBody = try encodedGraphQLBody(query: query, variables: variables)
421        return try await send(request)
422    }
423
424    func decodeGraphQLData<T: Decodable>(
425        _ data: Data,
426        service: SRHTService,
427        query: String,
428        variables: [String: any Sendable]?
429    ) throws -> T {
430        let graphQLResponse: GraphQLResponse<T>
431        do {
432            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
433        } catch {
434            #if DEBUG
435            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
436            logger.error(
437                """
438                Decoding failed for \(String(describing: T.self), privacy: .public)
439                service: \(service.rawValue, privacy: .public)
440                query:
441                \(query, privacy: .public)
442                variables:
443                \(String(describing: variables), privacy: .public)
444                error:
445                \(String(describing: error), privacy: .public)
446                response:
447                \(responseBody, privacy: .public)
448                """
449            )
450            #endif
451            throw SRHTError.decodingError(error)
452        }
453
454        if let errors = graphQLResponse.errors, !errors.isEmpty {
455            throw SRHTError.graphQLErrors(errors)
456        }
457
458        guard let result = graphQLResponse.data else {
459            throw SRHTError.decodingError(
460                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
461            )
462        }
463        return result
464    }
465
466    func fetchAndCacheGraphQL<T: Decodable>(
467        service: SRHTService,
468        query: String,
469        variables: [String: any Sendable]?,
470        cacheKey: String,
471        resourceType: CacheResourceType,
472        ttl: TimeInterval
473    ) async throws -> (T, CacheEntryMetadata?) {
474        let data = try await requestCoalescer.value(for: cacheKey) {
475            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
476        }
477        let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
478        responseCache.set(data, forKey: cacheKey)
479        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
480        return (value, metadata)
481    }
482
483    func fetchAndCacheGraphQLData(
484        service: SRHTService,
485        query: String,
486        variables: [String: any Sendable]?,
487        cacheKey: String,
488        resourceType: CacheResourceType,
489        ttl: TimeInterval
490    ) async throws -> CacheEntryMetadata? {
491        let data = try await requestCoalescer.value(for: cacheKey) {
492            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
493        }
494        responseCache.set(data, forKey: cacheKey)
495        return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
496    }
497
498    func fetchAndCacheText(
499        url: URL,
500        cacheKey: String,
501        resourceType: CacheResourceType,
502        ttl: TimeInterval
503    ) async throws -> (String, CacheEntryMetadata?) {
504        let data = try await requestCoalescer.value(for: cacheKey) {
505            let text = try await self.fetchText(url: url)
506            guard let data = text.data(using: .utf8) else {
507                throw SRHTError.decodingError(
508                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8"))
509                )
510            }
511            return data
512        }
513        guard let text = String(data: data, encoding: .utf8) else {
514            throw SRHTError.decodingError(
515                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
516            )
517        }
518        responseCache.set(data, forKey: cacheKey)
519        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
520        return (text, metadata)
521    }
522
523    static func tokenCacheScope(_ token: String) -> String {
524        let digest = SHA256.hash(data: Data(token.utf8))
525        return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
526    }
527
528    func throwGraphQLErrorsIfPresent(in data: Data) throws {
529        if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
530           let errors = envelope.errors,
531           !errors.isEmpty {
532            throw SRHTError.graphQLErrors(errors)
533        }
534    }
535
536    static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
537        guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
538              let host = url.host?.lowercased() else {
539            return false
540        }
541
542        return host.hasSuffix(".sr.ht")
543    }
544}
545
546private actor RequestCoalescer {
547    private var tasks: [String: Task<Data, Error>] = [:]
548
549    func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data {
550        if let task = tasks[key] {
551            return try await task.value
552        }
553
554        let task = Task {
555            try await operation()
556        }
557        tasks[key] = task
558        defer { tasks.removeValue(forKey: key) }
559        return try await task.value
560    }
561}
562
563private extension Data {
564    mutating func append(_ string: String) {
565        if let data = string.data(using: .utf8) {
566            append(data)
567        }
568    }
569}