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