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}