krz/hutch

an ios client for sourcehut

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

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