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