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