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