krz/hutch

an ios client for sourcehut

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

v2.4.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}