Hutch/Networking/SRHTClient.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Networking/SRHTClient.swift history · blame · raw

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}