krz/hutch

an ios client for sourcehut

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

v3.0.4: 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 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    ///   - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar").
168    ///   - fileData: The raw file data (e.g. JPEG).
169    ///   - fileName: The file name to send (e.g. "avatar.jpg").
170    ///   - mimeType: The MIME type (e.g. "image/jpeg").
171    ///   - responseType: The expected `Decodable` type nested under `data`.
172    func executeMultipart<T: Decodable>(
173        service: SRHTService,
174        query: String,
175        variables: [String: any Sendable],
176        fileVariablePath: String,
177        fileData: Data,
178        fileName: String,
179        mimeType: String,
180        responseType: T.Type
181    ) async throws -> T {
182        guard let token = _token.withLock({ $0 }), !token.isEmpty else {
183            throw SRHTError.unauthorized
184        }
185
186        let boundary = "Boundary-\(UUID().uuidString)"
187
188        var request = URLRequest(url: service.url)
189        request.httpMethod = "POST"
190        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
191        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
192
193        // Build the operations JSON (file variable mapped to null)
194        let operationsBody = GraphQLRequestBody(
195            query: query,
196            variables: variables.mapValues { AnyCodable($0) }
197        )
198        let operationsData = try encoder.encode(operationsBody)
199
200        // Build the map JSON: { "0": ["variables.<fileVariablePath>"] }
201        let mapDict = ["0": ["variables.\(fileVariablePath)"]]
202        let mapData = try encoder.encode(mapDict)
203
204        // Assemble multipart body
205        var body = Data()
206
207        // Part: operations
208        body.append("--\(boundary)\r\n")
209        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
210        body.append("Content-Type: application/json\r\n\r\n")
211        body.append(operationsData)
212        body.append("\r\n")
213
214        // Part: map
215        body.append("--\(boundary)\r\n")
216        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
217        body.append("Content-Type: application/json\r\n\r\n")
218        body.append(mapData)
219        body.append("\r\n")
220
221        // Part: file
222        body.append("--\(boundary)\r\n")
223        body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n")
224        body.append("Content-Type: \(mimeType)\r\n\r\n")
225        body.append(fileData)
226        body.append("\r\n")
227
228        // Closing boundary
229        body.append("--\(boundary)--\r\n")
230
231        request.httpBody = body
232
233        let (data, response): (Data, URLResponse)
234        do {
235            (data, response) = try await session.data(for: request)
236        } catch {
237            throw SRHTError.networkError(error)
238        }
239
240        if let http = response as? HTTPURLResponse {
241            if http.statusCode == 401 {
242                throw SRHTError.unauthorized
243            }
244            if !(200...299).contains(http.statusCode) {
245                try throwGraphQLErrorsIfPresent(in: data)
246                throw SRHTError.httpError(http.statusCode)
247            }
248        }
249
250        try throwGraphQLErrorsIfPresent(in: data)
251
252        let graphQLResponse: GraphQLResponse<T>
253        do {
254            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
255        } catch {
256            #if DEBUG
257            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
258            let variablesDescription = String(describing: variables)
259            if let decodingError = error as? DecodingError {
260                logger.error(
261                    """
262                    Decoding failed for \(String(describing: T.self), privacy: .public)
263                    service: \(service.rawValue, privacy: .public)
264                    query:
265                    \(query, privacy: .public)
266                    variables:
267                    \(variablesDescription, privacy: .public)
268                    decodingError:
269                    \(String(describing: decodingError), privacy: .public)
270                    response:
271                    \(responseBody, privacy: .public)
272                    """
273                )
274            } else {
275                logger.error(
276                    """
277                    Decoding failed for \(String(describing: T.self), privacy: .public)
278                    service: \(service.rawValue, privacy: .public)
279                    query:
280                    \(query, privacy: .public)
281                    variables:
282                    \(variablesDescription, privacy: .public)
283                    error:
284                    \(String(describing: error), privacy: .public)
285                    response:
286                    \(responseBody, privacy: .public)
287                    """
288                )
289            }
290            #else
291            logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
292            #endif
293            throw SRHTError.decodingError(error)
294        }
295
296        if let errors = graphQLResponse.errors, !errors.isEmpty {
297            throw SRHTError.graphQLErrors(errors)
298        }
299
300        guard let result = graphQLResponse.data else {
301            throw SRHTError.decodingError(
302                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
303            )
304        }
305
306        return result
307    }
308
309    func executeMultipartFiles<T: Decodable>(
310        service: SRHTService,
311        query: String,
312        variables: [String: any Sendable],
313        files: [MultipartUploadFile],
314        responseType: T.Type
315    ) async throws -> T {
316        guard let token = _token.withLock({ $0 }), !token.isEmpty else {
317            throw SRHTError.unauthorized
318        }
319
320        let boundary = "Boundary-\(UUID().uuidString)"
321
322        var request = URLRequest(url: service.url)
323        request.httpMethod = "POST"
324        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
325        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
326
327        let operationsBody = GraphQLRequestBody(
328            query: query,
329            variables: variables.mapValues { AnyCodable($0) }
330        )
331        let operationsData = try encoder.encode(operationsBody)
332
333        let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
334            (String(index), ["variables.\(file.variablePath)"])
335        })
336        let mapData = try encoder.encode(mapDict)
337
338        var body = Data()
339
340        body.append("--\(boundary)\r\n")
341        body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
342        body.append("Content-Type: application/json\r\n\r\n")
343        body.append(operationsData)
344        body.append("\r\n")
345
346        body.append("--\(boundary)\r\n")
347        body.append("Content-Disposition: form-data; name=\"map\"\r\n")
348        body.append("Content-Type: application/json\r\n\r\n")
349        body.append(mapData)
350        body.append("\r\n")
351
352        for (index, file) in files.enumerated() {
353            body.append("--\(boundary)\r\n")
354            body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
355            body.append("Content-Type: \(file.mimeType)\r\n\r\n")
356            body.append(file.fileData)
357            body.append("\r\n")
358        }
359
360        body.append("--\(boundary)--\r\n")
361        request.httpBody = body
362
363        let (data, response): (Data, URLResponse)
364        do {
365            (data, response) = try await session.data(for: request)
366        } catch {
367            throw SRHTError.networkError(error)
368        }
369
370        if let http = response as? HTTPURLResponse {
371            if http.statusCode == 401 {
372                throw SRHTError.unauthorized
373            }
374            if !(200...299).contains(http.statusCode) {
375                try throwGraphQLErrorsIfPresent(in: data)
376                throw SRHTError.httpError(http.statusCode)
377            }
378        }
379
380        try throwGraphQLErrorsIfPresent(in: data)
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                try throwGraphQLErrorsIfPresent(in: data)
468                throw SRHTError.httpError(http.statusCode)
469            }
470        }
471
472        // Cache the raw response data before decoding
473        responseCache.set(data, forKey: cacheKey)
474
475        let graphQLResponse: GraphQLResponse<T>
476        do {
477            graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
478        } catch {
479            #if DEBUG
480            let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
481            let variablesDescription = String(describing: variables)
482            if let decodingError = error as? DecodingError {
483                logger.error(
484                    """
485                    Decoding failed for \(String(describing: T.self), privacy: .public)
486                    service: \(service.rawValue, privacy: .public)
487                    query:
488                    \(query, privacy: .public)
489                    variables:
490                    \(variablesDescription, privacy: .public)
491                    decodingError:
492                    \(String(describing: decodingError), privacy: .public)
493                    response:
494                    \(responseBody, privacy: .public)
495                    """
496                )
497            } else {
498                logger.error(
499                    """
500                    Decoding failed for \(String(describing: T.self), privacy: .public)
501                    service: \(service.rawValue, privacy: .public)
502                    query:
503                    \(query, privacy: .public)
504                    variables:
505                    \(variablesDescription, privacy: .public)
506                    error:
507                    \(String(describing: error), privacy: .public)
508                    response:
509                    \(responseBody, privacy: .public)
510                    """
511                )
512            }
513            #else
514            logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
515            #endif
516            throw SRHTError.decodingError(error)
517        }
518
519        if let errors = graphQLResponse.errors, !errors.isEmpty {
520            throw SRHTError.graphQLErrors(errors)
521        }
522
523        guard let result = graphQLResponse.data else {
524            throw SRHTError.decodingError(
525                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
526            )
527        }
528
529        return result
530    }
531
532    // MARK: - Plain-text fetch
533
534    /// Fetch the contents of a URL as plain text, using the same authorization header.
535    /// Used for build logs and other non-GraphQL resources.
536    func fetchText(url: URL) async throws -> String {
537        guard let token = _token.withLock({ $0 }), !token.isEmpty else {
538            throw SRHTError.unauthorized
539        }
540        guard Self.isTrustedAuthenticatedTextURL(url) else {
541            throw SRHTError.invalidAuthenticatedURL(url)
542        }
543
544        var request = URLRequest(url: url)
545        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
546
547        let (data, response): (Data, URLResponse)
548        do {
549            (data, response) = try await session.data(for: request)
550        } catch {
551            throw SRHTError.networkError(error)
552        }
553
554        if let http = response as? HTTPURLResponse {
555            if http.statusCode == 401 {
556                throw SRHTError.unauthorized
557            }
558            if !(200...299).contains(http.statusCode) {
559                throw SRHTError.httpError(http.statusCode)
560            }
561        }
562
563        guard let text = String(data: data, encoding: .utf8) else {
564            throw SRHTError.decodingError(
565                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
566            )
567        }
568
569        return text
570    }
571
572    // MARK: - Pagination
573
574    /// Returns an `AsyncSequence` that lazily iterates through all pages of a
575    /// paginated sr.ht GraphQL query.
576    ///
577    /// The query must accept a `$cursor: String` variable and return the standard
578    /// `{ results: [T], cursor: String? }` shape at the given key path.
579    func paginated<T: Decodable & Sendable>(
580        service: SRHTService,
581        query: String,
582        variables: [String: any Sendable]? = nil,
583        resultKeyPath: String,
584        type: T.Type
585    ) -> SRHTPaginatedSequence<T> {
586        SRHTPaginatedSequence(
587            client: self,
588            service: service,
589            query: query,
590            variables: variables,
591            resultKeyPath: resultKeyPath
592        )
593    }
594
595    /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
596    /// collected results.
597    func fetchAll<T: Decodable & Sendable>(
598        service: SRHTService,
599        query: String,
600        variables: [String: any Sendable]? = nil,
601        resultKeyPath: String,
602        type: T.Type
603    ) async throws -> [T] {
604        var all: [T] = []
605        for try await element in paginated(
606            service: service,
607            query: query,
608            variables: variables,
609            resultKeyPath: resultKeyPath,
610            type: type
611        ) {
612            all.append(element)
613        }
614        return all
615    }
616}
617
618// MARK: - Data Helper
619
620private extension SRHTClient {
621    func throwGraphQLErrorsIfPresent(in data: Data) throws {
622        if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
623           let errors = envelope.errors,
624           !errors.isEmpty {
625            throw SRHTError.graphQLErrors(errors)
626        }
627    }
628
629    static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
630        guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
631              let host = url.host?.lowercased() else {
632            return false
633        }
634
635        return host.hasSuffix(".sr.ht")
636    }
637}
638
639private extension Data {
640    mutating func append(_ string: String) {
641        if let data = string.data(using: .utf8) {
642            append(data)
643        }
644    }
645}