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