krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

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