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