krz/hutch

an ios client for sourcehut

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

v3.4.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        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
 73            throw SRHTError.unauthorized
 74        }
 75
 76        // Build request
 77        var request = URLRequest(url: service.url)
 78        request.httpMethod = "POST"
 79        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
 80        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 81        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 82
 83        let body = GraphQLRequestBody(
 84            query: query,
 85            variables: variables?.mapValues { AnyCodable($0) }
 86        )
 87        request.httpBody = try encoder.encode(body)
 88
 89        // Execute
 90        let (data, response): (Data, URLResponse)
 91        do {
 92            (data, response) = try await session.data(for: request)
 93        } catch {
 94            throw SRHTError.networkError(error)
 95        }
 96
 97        // Check HTTP status
 98        if let http = response as? HTTPURLResponse {
 99            if http.statusCode == 401 {
100                throw SRHTError.unauthorized
101            }
102            if !(200...299).contains(http.statusCode) {
103                try throwGraphQLErrorsIfPresent(in: data)
104                throw SRHTError.httpError(http.statusCode)
105            }
106        }
107
108        try throwGraphQLErrorsIfPresent(in: data)
109
110        // Decode GraphQL response envelope
111        let graphQLResponse: GraphQLResponse<T>
112        do {
113            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
114        } catch {
115            #if DEBUG
116            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
117            let variablesDescription = String(describing: variables)
118            if let decodingError = error as? DecodingError {
119                logger.error(
120                    """
121                    Decoding failed for \(String(describing: T.self), privacy: .public)
122                    service: \(service.rawValue, privacy: .public)
123                    query:
124                    \(query, privacy: .public)
125                    variables:
126                    \(variablesDescription, privacy: .public)
127                    decodingError:
128                    \(String(describing: decodingError), privacy: .public)
129                    response:
130                    \(responseBody, privacy: .public)
131                    """
132                )
133            } else {
134                logger.error(
135                    """
136                    Decoding failed for \(String(describing: T.self), privacy: .public)
137                    service: \(service.rawValue, privacy: .public)
138                    query:
139                    \(query, privacy: .public)
140                    variables:
141                    \(variablesDescription, privacy: .public)
142                    error:
143                    \(String(describing: error), privacy: .public)
144                    response:
145                    \(responseBody, privacy: .public)
146                    """
147                )
148            }
149            #else
150            logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
151            #endif
152            throw SRHTError.decodingError(error)
153        }
154
155        // Surface GraphQL-level errors
156        if let errors = graphQLResponse.errors, !errors.isEmpty {
157            throw SRHTError.graphQLErrors(errors)
158        }
159
160        guard let result = graphQLResponse.data else {
161            throw SRHTError.decodingError(
162                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
163            )
164        }
165
166        return result
167    }
168
169    func executeCached<T: Decodable>(
170        service: SRHTService,
171        query: String,
172        variables: [String: any Sendable]? = nil,
173        responseType _: T.Type,
174        cacheKey: String,
175        resourceType: CacheResourceType,
176        ttl: TimeInterval,
177        policy: CachePolicy = .cacheFirstThenRefresh
178    ) async throws -> CachedValue<T> {
179        switch policy {
180        case .networkOnly:
181            let data = try await performGraphQLRequest(
182                service: service,
183                query: query,
184                variables: variables
185            )
186            let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
187            return CachedValue(value: value, metadata: nil, source: .network)
188
189        case .cacheOnly:
190            let entry = try await cache.read(cacheKey: cacheKey)
191            let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
192            return CachedValue(value: value, metadata: entry.metadata, source: .cache)
193
194        case .cacheFirstThenRefresh:
195            if let entry = try? await cache.read(cacheKey: cacheKey) {
196                let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
197                if entry.metadata.isExpired() {
198                    Task.detached { [self] in
199                        _ = try? await self.fetchAndCacheGraphQLData(
200                            service: service,
201                            query: query,
202                            variables: variables,
203                            cacheKey: cacheKey,
204                            resourceType: resourceType,
205                            ttl: ttl
206                        )
207                    }
208                }
209                return CachedValue(value: value, metadata: entry.metadata, source: .cache)
210            }
211
212            let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
213                service: service,
214                query: query,
215                variables: variables,
216                cacheKey: cacheKey,
217                resourceType: resourceType,
218                ttl: ttl
219            )
220            return CachedValue(value: value, metadata: metadata, source: .network)
221
222        case .refreshIgnoringCache:
223            let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
224                service: service,
225                query: query,
226                variables: variables,
227                cacheKey: cacheKey,
228                resourceType: resourceType,
229                ttl: ttl
230            )
231            return CachedValue(value: value, metadata: metadata, source: .network)
232        }
233    }
234
235    func fetchCachedText(
236        url: URL,
237        cacheKey: String,
238        resourceType: CacheResourceType = .buildLog,
239        ttl: TimeInterval,
240        policy: CachePolicy = .cacheFirstThenRefresh
241    ) async throws -> CachedValue<String> {
242        switch policy {
243        case .networkOnly:
244            let text = try await fetchText(url: url)
245            return CachedValue(value: text, metadata: nil, source: .network)
246        case .cacheOnly:
247            let entry = try await cache.read(cacheKey: cacheKey)
248            guard let text = String(data: entry.payload, encoding: .utf8) else {
249                throw SRHTError.decodingError(
250                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8"))
251                )
252            }
253            return CachedValue(value: text, metadata: entry.metadata, source: .cache)
254        case .cacheFirstThenRefresh:
255            if let entry = try? await cache.read(cacheKey: cacheKey),
256               let text = String(data: entry.payload, encoding: .utf8) {
257                if entry.metadata.isExpired() {
258                    Task.detached { [self] in
259                        _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
260                    }
261                }
262                return CachedValue(value: text, metadata: entry.metadata, source: .cache)
263            }
264            let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
265            return CachedValue(value: text, metadata: metadata, source: .network)
266        case .refreshIgnoringCache:
267            let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
268            return CachedValue(value: text, metadata: metadata, source: .network)
269        }
270    }
271
272    func cachedPayload(forKey cacheKey: String) async -> Data? {
273        if let entry = try? await cache.read(cacheKey: cacheKey) {
274            return entry.payload
275        }
276        return responseCache.get(forKey: cacheKey)
277    }
278
279    func invalidateCache(prefix: String) async {
280        await cache.removeByPrefix(prefix)
281    }
282
283    func removeCachedValue(forKey cacheKey: String) async {
284        await cache.remove(cacheKey: cacheKey)
285        responseCache.remove(forKey: cacheKey)
286    }
287
288    func clearPersistentCache() async {
289        await cache.clearAll()
290        responseCache.clear()
291    }
292
293    // MARK: - Multipart Upload
294
295    /// Execute a GraphQL mutation with a file upload using the
296    /// graphql-multipart-request-spec (multipart/form-data).
297    ///
298    /// - Parameters:
299    ///   - service: The target SourceHut service.
300    ///   - query: The GraphQL mutation string.
301    ///   - variables: Variables dict; the file variable should be set to `nil`.
302    ///   - file: Multipart file payload (`variablePath` is the dot-separated GraphQL variable, e.g. `input.avatar`).
303    ///   - responseType: The expected `Decodable` type nested under `data`.
304    func executeMultipart<T: Decodable>(
305        service: SRHTService,
306        query: String,
307        variables: [String: any Sendable],
308        file: MultipartUploadFile,
309        responseType _: T.Type
310    ) async throws -> T {
311        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
312            throw SRHTError.unauthorized
313        }
314
315        let boundary = "Boundary-\(UUID().uuidString)"
316
317        var request = URLRequest(url: service.url)
318        request.httpMethod = "POST"
319        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
320        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
321        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
322
323        // Build the operations JSON (file variable mapped to null)
324        let operationsBody = GraphQLRequestBody(
325            query: query,
326            variables: variables.mapValues { AnyCodable($0) }
327        )
328        let operationsData = try encoder.encode(operationsBody)
329
330        // Build the map JSON: { "0": ["variables.<variablePath>"] }
331        let mapDict = ["0": ["variables.\(file.variablePath)"]]
332        let mapData = try encoder.encode(mapDict)
333
334        // Assemble multipart body
335        var body = Data()
336
337        // Part: operations
338        body.append("--\(boundary)\r\n")
339        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
340        body.append("Content-Type: application/json\r\n\r\n")
341        body.append(operationsData)
342        body.append("\r\n")
343
344        // Part: map
345        body.append("--\(boundary)\r\n")
346        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
347        body.append("Content-Type: application/json\r\n\r\n")
348        body.append(mapData)
349        body.append("\r\n")
350
351        // Part: file
352        body.append("--\(boundary)\r\n")
353        body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(file.fileName)\"\r\n")
354        body.append("Content-Type: \(file.mimeType)\r\n\r\n")
355        body.append(file.fileData)
356        body.append("\r\n")
357
358        // Closing boundary
359        body.append("--\(boundary)--\r\n")
360
361        request.httpBody = body
362
363        let (data, response): (Data, URLResponse)
364        do {
365            (data, response) = try await session.data(for: request)
366        } catch {
367            throw SRHTError.networkError(error)
368        }
369
370        if let http = response as? HTTPURLResponse {
371            if http.statusCode == 401 {
372                throw SRHTError.unauthorized
373            }
374            if !(200...299).contains(http.statusCode) {
375                try throwGraphQLErrorsIfPresent(in: data)
376                throw SRHTError.httpError(http.statusCode)
377            }
378        }
379
380        try throwGraphQLErrorsIfPresent(in: data)
381
382        let graphQLResponse: GraphQLResponse<T>
383        do {
384            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
385        } catch {
386            #if DEBUG
387            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
388            let variablesDescription = String(describing: variables)
389            if let decodingError = error as? DecodingError {
390                logger.error(
391                    """
392                    Decoding failed for \(String(describing: T.self), privacy: .public)
393                    service: \(service.rawValue, privacy: .public)
394                    query:
395                    \(query, privacy: .public)
396                    variables:
397                    \(variablesDescription, privacy: .public)
398                    decodingError:
399                    \(String(describing: decodingError), privacy: .public)
400                    response:
401                    \(responseBody, privacy: .public)
402                    """
403                )
404            } else {
405                logger.error(
406                    """
407                    Decoding failed for \(String(describing: T.self), privacy: .public)
408                    service: \(service.rawValue, privacy: .public)
409                    query:
410                    \(query, privacy: .public)
411                    variables:
412                    \(variablesDescription, privacy: .public)
413                    error:
414                    \(String(describing: error), privacy: .public)
415                    response:
416                    \(responseBody, privacy: .public)
417                    """
418                )
419            }
420            #else
421            logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
422            #endif
423            throw SRHTError.decodingError(error)
424        }
425
426        if let errors = graphQLResponse.errors, !errors.isEmpty {
427            throw SRHTError.graphQLErrors(errors)
428        }
429
430        guard let result = graphQLResponse.data else {
431            throw SRHTError.decodingError(
432                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
433            )
434        }
435
436        return result
437    }
438
439    func executeMultipartFiles<T: Decodable>(
440        service: SRHTService,
441        query: String,
442        variables: [String: any Sendable],
443        files: [MultipartUploadFile],
444        responseType _: T.Type
445    ) async throws -> T {
446        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
447            throw SRHTError.unauthorized
448        }
449
450        let boundary = "Boundary-\(UUID().uuidString)"
451
452        var request = URLRequest(url: service.url)
453        request.httpMethod = "POST"
454        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
455        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
456        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
457
458        let operationsBody = GraphQLRequestBody(
459            query: query,
460            variables: variables.mapValues { AnyCodable($0) }
461        )
462        let operationsData = try encoder.encode(operationsBody)
463
464        let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
465            (String(index), ["variables.\(file.variablePath)"])
466        })
467        let mapData = try encoder.encode(mapDict)
468
469        var body = Data()
470
471        body.append("--\(boundary)\r\n")
472        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
473        body.append("Content-Type: application/json\r\n\r\n")
474        body.append(operationsData)
475        body.append("\r\n")
476
477        body.append("--\(boundary)\r\n")
478        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
479        body.append("Content-Type: application/json\r\n\r\n")
480        body.append(mapData)
481        body.append("\r\n")
482
483        for (index, file) in files.enumerated() {
484            body.append("--\(boundary)\r\n")
485            body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
486            body.append("Content-Type: \(file.mimeType)\r\n\r\n")
487            body.append(file.fileData)
488            body.append("\r\n")
489        }
490
491        body.append("--\(boundary)--\r\n")
492        request.httpBody = body
493
494        let (data, response): (Data, URLResponse)
495        do {
496            (data, response) = try await session.data(for: request)
497        } catch {
498            throw SRHTError.networkError(error)
499        }
500
501        if let http = response as? HTTPURLResponse {
502            if http.statusCode == 401 {
503                throw SRHTError.unauthorized
504            }
505            if !(200...299).contains(http.statusCode) {
506                try throwGraphQLErrorsIfPresent(in: data)
507                throw SRHTError.httpError(http.statusCode)
508            }
509        }
510
511        try throwGraphQLErrorsIfPresent(in: data)
512
513        let graphQLResponse: GraphQLResponse<T>
514        do {
515            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
516        } catch {
517            throw SRHTError.decodingError(error)
518        }
519
520        if let errors = graphQLResponse.errors, !errors.isEmpty {
521            throw SRHTError.graphQLErrors(errors)
522        }
523
524        guard let result = graphQLResponse.data else {
525            throw SRHTError.decodingError(
526                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
527            )
528        }
529
530        return result
531    }
532
533    // MARK: - Cached Execute
534
535    /// Execute a query and cache the raw response data. Returns cached data
536    /// immediately on cache hit, then refreshes in the background via the
537    /// `onRefresh` callback.
538    func executeCached<T: Decodable>(
539        service: SRHTService,
540        query: String,
541        variables: [String: any Sendable]? = nil,
542        responseType _: T.Type,
543        cacheKey: String
544    ) async throws -> T {
545        // Try cache first
546        if let cachedData = responseCache.get(forKey: cacheKey),
547           let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
548           let data = cached.data {
549            return data
550        }
551
552        // No cache hit  fetch normally
553        return try await executeAndCache(
554            service: service,
555            query: query,
556            variables: variables,
557            responseType: T.self,
558            cacheKey: cacheKey
559        )
560    }
561
562    /// Execute a query, cache the raw data, and return the decoded result.
563    func executeAndCache<T: Decodable>(
564        service: SRHTService,
565        query: String,
566        variables: [String: any Sendable]? = nil,
567        responseType _: T.Type,
568        cacheKey: String
569    ) async throws -> T {
570        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
571            throw SRHTError.unauthorized
572        }
573
574        var request = URLRequest(url: service.url)
575        request.httpMethod = "POST"
576        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
577        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
578        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
579
580        let body = GraphQLRequestBody(
581            query: query,
582            variables: variables?.mapValues { AnyCodable($0) }
583        )
584        request.httpBody = try encoder.encode(body)
585
586        let (data, response): (Data, URLResponse)
587        do {
588            (data, response) = try await session.data(for: request)
589        } catch {
590            throw SRHTError.networkError(error)
591        }
592
593        if let http = response as? HTTPURLResponse {
594            if http.statusCode == 401 {
595                throw SRHTError.unauthorized
596            }
597            if !(200...299).contains(http.statusCode) {
598                try throwGraphQLErrorsIfPresent(in: data)
599                throw SRHTError.httpError(http.statusCode)
600            }
601        }
602
603        // Cache the raw response data before decoding
604        responseCache.set(data, forKey: cacheKey)
605
606        let graphQLResponse: GraphQLResponse<T>
607        do {
608            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
609        } catch {
610            #if DEBUG
611            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
612            let variablesDescription = String(describing: variables)
613            if let decodingError = error as? DecodingError {
614                logger.error(
615                    """
616                    Decoding failed for \(String(describing: T.self), privacy: .public)
617                    service: \(service.rawValue, privacy: .public)
618                    query:
619                    \(query, privacy: .public)
620                    variables:
621                    \(variablesDescription, privacy: .public)
622                    decodingError:
623                    \(String(describing: decodingError), privacy: .public)
624                    response:
625                    \(responseBody, privacy: .public)
626                    """
627                )
628            } else {
629                logger.error(
630                    """
631                    Decoding failed for \(String(describing: T.self), privacy: .public)
632                    service: \(service.rawValue, privacy: .public)
633                    query:
634                    \(query, privacy: .public)
635                    variables:
636                    \(variablesDescription, privacy: .public)
637                    error:
638                    \(String(describing: error), privacy: .public)
639                    response:
640                    \(responseBody, privacy: .public)
641                    """
642                )
643            }
644            #else
645            logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
646            #endif
647            throw SRHTError.decodingError(error)
648        }
649
650        if let errors = graphQLResponse.errors, !errors.isEmpty {
651            throw SRHTError.graphQLErrors(errors)
652        }
653
654        guard let result = graphQLResponse.data else {
655            throw SRHTError.decodingError(
656                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
657            )
658        }
659
660        return result
661    }
662
663    // MARK: - Plain-text fetch
664
665    /// Fetch the contents of a URL as plain text, using the same authorization header.
666    /// Used for build logs and other non-GraphQL resources.
667    func fetchText(url: URL) async throws -> String {
668        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
669            throw SRHTError.unauthorized
670        }
671        guard Self.isTrustedAuthenticatedTextURL(url) else {
672            throw SRHTError.invalidAuthenticatedURL(url)
673        }
674
675        var request = URLRequest(url: url)
676        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
677        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
678
679        let (data, response): (Data, URLResponse)
680        do {
681            (data, response) = try await session.data(for: request)
682        } catch {
683            throw SRHTError.networkError(error)
684        }
685
686        if let http = response as? HTTPURLResponse {
687            if http.statusCode == 401 {
688                throw SRHTError.unauthorized
689            }
690            if !(200...299).contains(http.statusCode) {
691                throw SRHTError.httpError(http.statusCode)
692            }
693        }
694
695        guard let text = String(data: data, encoding: .utf8) else {
696            throw SRHTError.decodingError(
697                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
698            )
699        }
700
701        return text
702    }
703
704    // MARK: - Pagination
705
706    /// Returns an `AsyncSequence` that lazily iterates through all pages of a
707    /// paginated sr.ht GraphQL query.
708    ///
709    /// The query must accept a `$cursor: String` variable and return the standard
710    /// `{ results: [T], cursor: String? }` shape at the given key path.
711    func paginated<T: Decodable & Sendable>(
712        service: SRHTService,
713        query: String,
714        variables: [String: any Sendable]? = nil,
715        resultKeyPath: String
716    ) -> SRHTPaginatedSequence<T> {
717        SRHTPaginatedSequence(
718            client: self,
719            service: service,
720            query: query,
721            variables: variables,
722            resultKeyPath: resultKeyPath
723        )
724    }
725
726    /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
727    /// collected results.
728    func fetchAll<T: Decodable & Sendable>(
729        service: SRHTService,
730        query: String,
731        variables: [String: any Sendable]? = nil,
732        resultKeyPath: String
733    ) async throws -> [T] {
734        var all: [T] = []
735        let pages: SRHTPaginatedSequence<T> = paginated(
736            service: service,
737            query: query,
738            variables: variables,
739            resultKeyPath: resultKeyPath
740        )
741        for try await element in pages {
742            all.append(element)
743        }
744        return all
745    }
746}
747
748// MARK: - Data Helper
749
750private extension SRHTClient {
751    func performGraphQLRequest(
752        service: SRHTService,
753        query: String,
754        variables: [String: any Sendable]?
755    ) async throws -> Data {
756        guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
757            throw SRHTError.unauthorized
758        }
759
760        var request = URLRequest(url: service.url)
761        request.httpMethod = "POST"
762        request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
763        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
764        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
765
766        let body = GraphQLRequestBody(
767            query: query,
768            variables: variables?.mapValues { AnyCodable($0) }
769        )
770        request.httpBody = try encoder.encode(body)
771
772        let (data, response): (Data, URLResponse)
773        do {
774            (data, response) = try await session.data(for: request)
775        } catch {
776            throw SRHTError.networkError(error)
777        }
778
779        if let http = response as? HTTPURLResponse {
780            if http.statusCode == 401 {
781                throw SRHTError.unauthorized
782            }
783            if !(200...299).contains(http.statusCode) {
784                try throwGraphQLErrorsIfPresent(in: data)
785                throw SRHTError.httpError(http.statusCode)
786            }
787        }
788
789        try throwGraphQLErrorsIfPresent(in: data)
790        return data
791    }
792
793    func decodeGraphQLData<T: Decodable>(
794        _ data: Data,
795        service: SRHTService,
796        query: String,
797        variables: [String: any Sendable]?
798    ) throws -> T {
799        let graphQLResponse: GraphQLResponse<T>
800        do {
801            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
802        } catch {
803            #if DEBUG
804            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
805            logger.error(
806                """
807                Decoding failed for \(String(describing: T.self), privacy: .public)
808                service: \(service.rawValue, privacy: .public)
809                query:
810                \(query, privacy: .public)
811                variables:
812                \(String(describing: variables), privacy: .public)
813                error:
814                \(String(describing: error), privacy: .public)
815                response:
816                \(responseBody, privacy: .public)
817                """
818            )
819            #endif
820            throw SRHTError.decodingError(error)
821        }
822
823        if let errors = graphQLResponse.errors, !errors.isEmpty {
824            throw SRHTError.graphQLErrors(errors)
825        }
826
827        guard let result = graphQLResponse.data else {
828            throw SRHTError.decodingError(
829                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
830            )
831        }
832        return result
833    }
834
835    func fetchAndCacheGraphQL<T: Decodable>(
836        service: SRHTService,
837        query: String,
838        variables: [String: any Sendable]?,
839        cacheKey: String,
840        resourceType: CacheResourceType,
841        ttl: TimeInterval
842    ) async throws -> (T, CacheEntryMetadata?) {
843        let data = try await requestCoalescer.value(for: cacheKey) {
844            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
845        }
846        let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
847        responseCache.set(data, forKey: cacheKey)
848        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
849        return (value, metadata)
850    }
851
852    func fetchAndCacheGraphQLData(
853        service: SRHTService,
854        query: String,
855        variables: [String: any Sendable]?,
856        cacheKey: String,
857        resourceType: CacheResourceType,
858        ttl: TimeInterval
859    ) async throws -> CacheEntryMetadata? {
860        let data = try await requestCoalescer.value(for: cacheKey) {
861            try await self.performGraphQLRequest(service: service, query: query, variables: variables)
862        }
863        responseCache.set(data, forKey: cacheKey)
864        return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
865    }
866
867    func fetchAndCacheText(
868        url: URL,
869        cacheKey: String,
870        resourceType: CacheResourceType,
871        ttl: TimeInterval
872    ) async throws -> (String, CacheEntryMetadata?) {
873        let data = try await requestCoalescer.value(for: cacheKey) {
874            let text = try await self.fetchText(url: url)
875            guard let data = text.data(using: .utf8) else {
876                throw SRHTError.decodingError(
877                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8"))
878                )
879            }
880            return data
881        }
882        guard let text = String(data: data, encoding: .utf8) else {
883            throw SRHTError.decodingError(
884                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
885            )
886        }
887        responseCache.set(data, forKey: cacheKey)
888        let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
889        return (text, metadata)
890    }
891
892    static func tokenCacheScope(_ token: String) -> String {
893        let digest = SHA256.hash(data: Data(token.utf8))
894        return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
895    }
896
897    func throwGraphQLErrorsIfPresent(in data: Data) throws {
898        if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
899           let errors = envelope.errors,
900           !errors.isEmpty {
901            throw SRHTError.graphQLErrors(errors)
902        }
903    }
904
905    static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
906        guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
907              let host = url.host?.lowercased() else {
908            return false
909        }
910
911        return host.hasSuffix(".sr.ht")
912    }
913}
914
915private actor RequestCoalescer {
916    private var tasks: [String: Task<Data, Error>] = [:]
917
918    func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data {
919        if let task = tasks[key] {
920            return try await task.value
921        }
922
923        let task = Task {
924            try await operation()
925        }
926        tasks[key] = task
927        defer { tasks.removeValue(forKey: key) }
928        return try await task.value
929    }
930}
931
932private extension Data {
933    mutating func append(_ string: String) {
934        if let data = string.data(using: .utf8) {
935            append(data)
936        }
937    }
938}