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