krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: 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}