krz/hutch

an ios client for sourcehut

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

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