krz/hutch

an ios client for sourcehut

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

v3.8.2: 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 bytes at a URL using the same authorization header.
280    ///
281    /// sr.ht serves some resources from the API origin rather than the web one 
282    /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>`
283    ///  and those return an auth error to anything without a bearer token. They
284    /// cannot be handed to a browser; they have to be fetched here.
285    func fetchData(url: URL) async throws -> Data {
286        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
287            throw SRHTError.unauthorized
288        }
289        guard Self.isTrustedAuthenticatedTextURL(url) else {
290            throw SRHTError.invalidAuthenticatedURL(url)
291        }
292
293        var request = URLRequest(url: url)
294        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
295        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
296
297        let (data, response): (Data, URLResponse)
298        do {
299            (data, response) = try await session.data(for: request)
300        } catch {
301            throw SRHTError.networkError(error)
302        }
303
304        if let http = response as? HTTPURLResponse {
305            if http.statusCode == 401 {
306                throw SRHTError.unauthorized
307            }
308            if !(200...299).contains(http.statusCode) {
309                throw SRHTError.httpError(http.statusCode)
310            }
311        }
312
313        return data
314    }
315
316    /// Fetch the contents of a URL as plain text, using the same authorization header.
317    /// Used for build logs and other non-GraphQL resources.
318    func fetchText(url: URL) async throws -> String {
319        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
320            throw SRHTError.unauthorized
321        }
322        guard Self.isTrustedAuthenticatedTextURL(url) else {
323            throw SRHTError.invalidAuthenticatedURL(url)
324        }
325
326        var request = URLRequest(url: url)
327        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
328        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
329
330        let (data, response): (Data, URLResponse)
331        do {
332            (data, response) = try await session.data(for: request)
333        } catch {
334            throw SRHTError.networkError(error)
335        }
336
337        if let http = response as? HTTPURLResponse {
338            if http.statusCode == 401 {
339                throw SRHTError.unauthorized
340            }
341            if !(200...299).contains(http.statusCode) {
342                throw SRHTError.httpError(http.statusCode)
343            }
344        }
345
346        guard let text = String(data: data, encoding: .utf8) else {
347            throw SRHTError.decodingError(
348                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
349            )
350        }
351
352        return text
353    }
354
355    // MARK: - Pagination
356
357    /// Returns an `AsyncSequence` that lazily iterates through all pages of a
358    /// paginated sr.ht GraphQL query.
359    ///
360    /// The query must accept a `$cursor: String` variable and return the standard
361    /// `{ results: [T], cursor: String? }` shape at the given key path.
362    func paginated<T: Decodable & Sendable>(
363        service: SRHTService,
364        query: String,
365        variables: [String: any Sendable]? = nil,
366        resultKeyPath: String
367    ) -> SRHTPaginatedSequence<T> {
368        SRHTPaginatedSequence(
369            client: self,
370            service: service,
371            query: query,
372            variables: variables,
373            resultKeyPath: resultKeyPath
374        )
375    }
376
377    /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
378    /// collected results.
379    func fetchAll<T: Decodable & Sendable>(
380        service: SRHTService,
381        query: String,
382        variables: [String: any Sendable]? = nil,
383        resultKeyPath: String
384    ) async throws -> [T] {
385        var all: [T] = []
386        let pages: SRHTPaginatedSequence<T> = paginated(
387            service: service,
388            query: query,
389            variables: variables,
390            resultKeyPath: resultKeyPath
391        )
392        for try await element in pages {
393            all.append(element)
394        }
395        return all
396    }
397}
398
399// MARK: - Data Helper
400
401private extension SRHTClient {
402    /// Builds an authorized POST for `service`. Throws ``SRHTError/unauthorized``
403    /// when no token is set, so callers never have to guard separately.
404    func makeAuthorizedRequest(service: SRHTService, contentType: String) throws -> URLRequest {
405        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
406            throw SRHTError.unauthorized
407        }
408
409        var request = URLRequest(url: service.url)
410        request.httpMethod = "POST"
411        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
412        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
413        request.setValue(contentType, forHTTPHeaderField: "Content-Type")
414        return request
415    }
416
417    /// Sends a prepared request and returns the raw body, mapping transport and
418    /// HTTP failures onto ``SRHTError``. sr.ht reports GraphQL errors under a 200
419    /// as often as under a 4xx, so both paths check the envelope.
420    func send(_ request: URLRequest) async throws -> Data {
421        let (data, response): (Data, URLResponse)
422        do {
423            (data, response) = try await session.data(for: request)
424        } catch {
425            throw SRHTError.networkError(error)
426        }
427
428        if let http = response as? HTTPURLResponse {
429            if http.statusCode == 401 {
430                throw SRHTError.unauthorized
431            }
432            if !(200...299).contains(http.statusCode) {
433                try throwGraphQLErrorsIfPresent(in: data)
434                throw SRHTError.httpError(http.statusCode)
435            }
436        }
437
438        try throwGraphQLErrorsIfPresent(in: data)
439        return data
440    }
441
442    func encodedGraphQLBody(query: String, variables: [String: any Sendable]?) throws -> Data {
443        try encoder.encode(
444            GraphQLRequestBody(
445                query: query,
446                variables: variables?.mapValues { AnyCodable($0) }
447            )
448        )
449    }
450
451    func performGraphQLRequest(
452        service: SRHTService,
453        query: String,
454        variables: [String: any Sendable]?
455    ) async throws -> Data {
456        var request = try makeAuthorizedRequest(service: service, contentType: "application/json")
457        request.httpBody = try encodedGraphQLBody(query: query, variables: variables)
458        return try await send(request)
459    }
460
461    func decodeGraphQLData<T: Decodable>(
462        _ data: Data,
463        service: SRHTService,
464        query: String,
465        variables: [String: any Sendable]?
466    ) throws -> T {
467        let graphQLResponse: GraphQLResponse<T>
468        do {
469            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
470        } catch {
471            #if DEBUG
472            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
473            logger.error(
474                """
475                Decoding failed for \(String(describing: T.self), privacy: .public)
476                service: \(service.rawValue, privacy: .public)
477                query:
478                \(query, privacy: .public)
479                variables:
480                \(String(describing: variables), privacy: .public)
481                error:
482                \(String(describing: error), privacy: .public)
483                response:
484                \(responseBody, privacy: .public)
485                """
486            )
487            #endif
488            throw SRHTError.decodingError(error)
489        }
490
491        if let errors = graphQLResponse.errors, !errors.isEmpty {
492            throw SRHTError.graphQLErrors(errors)
493        }
494
495        guard let result = graphQLResponse.data else {
496            throw SRHTError.decodingError(
497                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
498            )
499        }
500        return result
501    }
502
503    func fetchAndCacheGraphQL<T: Decodable>(
504        service: SRHTService,
505        query: String,
506        variables: [String: any Sendable]?,
507        cacheKey: String,
508        resourceType: CacheResourceType,
509        ttl: TimeInterval
510    ) async throws -> (T, CacheEntryMetadata?) {
511        let data = try await requestCoalescer.value(for: cacheKey) {
512            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
513        }
514        let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
515        responseCache.set(data, forKey: cacheKey)
516        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
517        return (value, metadata)
518    }
519
520    func fetchAndCacheGraphQLData(
521        service: SRHTService,
522        query: String,
523        variables: [String: any Sendable]?,
524        cacheKey: String,
525        resourceType: CacheResourceType,
526        ttl: TimeInterval
527    ) async throws -> CacheEntryMetadata? {
528        let data = try await requestCoalescer.value(for: cacheKey) {
529            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
530        }
531        responseCache.set(data, forKey: cacheKey)
532        return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
533    }
534
535    func fetchAndCacheText(
536        url: URL,
537        cacheKey: String,
538        resourceType: CacheResourceType,
539        ttl: TimeInterval
540    ) async throws -> (String, CacheEntryMetadata?) {
541        let data = try await requestCoalescer.value(for: cacheKey) {
542            let text = try await self.fetchText(url: url)
543            guard let data = text.data(using: .utf8) else {
544                throw SRHTError.decodingError(
545                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8"))
546                )
547            }
548            return data
549        }
550        guard let text = String(data: data, encoding: .utf8) else {
551            throw SRHTError.decodingError(
552                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
553            )
554        }
555        responseCache.set(data, forKey: cacheKey)
556        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
557        return (text, metadata)
558    }
559
560    static func tokenCacheScope(_ token: String) -> String {
561        let digest = SHA256.hash(data: Data(token.utf8))
562        return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
563    }
564
565    func throwGraphQLErrorsIfPresent(in data: Data) throws {
566        if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
567           let errors = envelope.errors,
568           !errors.isEmpty {
569            throw SRHTError.graphQLErrors(errors)
570        }
571    }
572
573    static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
574        guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
575              let host = url.host?.lowercased() else {
576            return false
577        }
578
579        return host.hasSuffix(".sr.ht")
580    }
581}
582
583private actor RequestCoalescer {
584    private var tasks: [String: Task<Data, Error>] = [:]
585
586    func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data {
587        if let task = tasks[key] {
588            return try await task.value
589        }
590
591        let task = Task {
592            try await operation()
593        }
594        tasks[key] = task
595        defer { tasks.removeValue(forKey: key) }
596        return try await task.value
597    }
598}
599
600private extension Data {
601    mutating func append(_ string: String) {
602        if let data = string.data(using: .utf8) {
603            append(data)
604        }
605    }
606}