aboutsummaryrefslogtreecommitdiff
path: root/Hutch/Networking/SRHTClient.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Networking/SRHTClient.swift
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Networking/SRHTClient.swift')
-rw-r--r--Hutch/Networking/SRHTClient.swift521
1 files changed, 521 insertions, 0 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
new file mode 100644
index 0000000..a24fe24
--- /dev/null
+++ b/Hutch/Networking/SRHTClient.swift
@@ -0,0 +1,521 @@
+import Foundation
+import os
+
+private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
+
+/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
+private struct EmptyData: Decodable {}
+
+/// A lightweight GraphQL client for Sourcehut services.
+/// All requests require a personal access token set via ``token``.
+final class SRHTClient: Sendable {
+
+ private let session: URLSession
+ private let decoder: JSONDecoder
+ private let encoder: JSONEncoder
+
+ /// The personal access token used for `Authorization: Bearer` headers.
+ /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
+ private let _token: OSAllocatedUnfairLock<String?>
+
+ /// In-memory response cache for stale-while-revalidate pattern.
+ let responseCache = ResponseCache()
+
+ var hasToken: Bool {
+ _token.withLock { $0 != nil }
+ }
+
+ init(session: URLSession = .shared, token: String? = nil) {
+ self.session = session
+ self.decoder = JSONDecoder()
+ self.decoder.dateDecodingStrategy = .srhtFlexible
+ self.encoder = JSONEncoder()
+ self._token = OSAllocatedUnfairLock(initialState: token)
+ }
+
+ /// Update the stored token (e.g. after the user saves a new one in Keychain).
+ func setToken(_ token: String?) {
+ _token.withLock { $0 = token }
+ }
+
+ /// Execute a GraphQL query or mutation against a Sourcehut service.
+ ///
+ /// - Parameters:
+ /// - service: The target Sourcehut service (determines the endpoint URL).
+ /// - query: The GraphQL query or mutation string.
+ /// - variables: Optional dictionary of GraphQL variables.
+ /// - responseType: The expected `Decodable` type nested under `data`.
+ /// - Returns: The decoded `data` payload.
+ func execute<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ // Build request
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = GraphQLRequestBody(
+ query: query,
+ variables: variables?.mapValues { AnyCodable($0) }
+ )
+ request.httpBody = try encoder.encode(body)
+
+ // Execute
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ // Check HTTP status
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ // Try to extract GraphQL errors from the response body even on non-2xx
+ if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
+ let errors = gqlResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ // Decode GraphQL response envelope
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ // Surface GraphQL-level errors
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Multipart Upload
+
+ /// Execute a GraphQL mutation with a file upload using the
+ /// graphql-multipart-request-spec (multipart/form-data).
+ ///
+ /// - Parameters:
+ /// - service: The target Sourcehut service.
+ /// - query: The GraphQL mutation string.
+ /// - variables: Variables dict; the file variable should be set to `nil`.
+ /// - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar").
+ /// - fileData: The raw file data (e.g. JPEG).
+ /// - fileName: The file name to send (e.g. "avatar.jpg").
+ /// - mimeType: The MIME type (e.g. "image/jpeg").
+ /// - responseType: The expected `Decodable` type nested under `data`.
+ func executeMultipart<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable],
+ fileVariablePath: String,
+ fileData: Data,
+ fileName: String,
+ mimeType: String,
+ responseType: T.Type
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ let boundary = "Boundary-\(UUID().uuidString)"
+
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+
+ // Build the operations JSON (file variable mapped to null)
+ let operationsBody = GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ let operationsData = try encoder.encode(operationsBody)
+
+ // Build the map JSON: { "0": ["variables.<fileVariablePath>"] }
+ let mapDict = ["0": ["variables.\(fileVariablePath)"]]
+ let mapData = try encoder.encode(mapDict)
+
+ // Assemble multipart body
+ var body = Data()
+
+ // Part: operations
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(operationsData)
+ body.append("\r\n")
+
+ // Part: map
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"map\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(mapData)
+ body.append("\r\n")
+
+ // Part: file
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n")
+ body.append("Content-Type: \(mimeType)\r\n\r\n")
+ body.append(fileData)
+ body.append("\r\n")
+
+ // Closing boundary
+ body.append("--\(boundary)--\r\n")
+
+ request.httpBody = body
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Cached Execute
+
+ /// Execute a query and cache the raw response data. Returns cached data
+ /// immediately on cache hit, then refreshes in the background via the
+ /// `onRefresh` callback.
+ func executeCached<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type,
+ cacheKey: String
+ ) async throws -> T {
+ // Try cache first
+ if let cachedData = responseCache.get(forKey: cacheKey) {
+ if let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
+ let data = cached.data {
+ return data
+ }
+ }
+
+ // No cache hit — fetch normally
+ return try await executeAndCache(
+ service: service,
+ query: query,
+ variables: variables,
+ responseType: responseType,
+ cacheKey: cacheKey
+ )
+ }
+
+ /// Execute a query, cache the raw data, and return the decoded result.
+ func executeAndCache<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type,
+ cacheKey: String
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = GraphQLRequestBody(
+ query: query,
+ variables: variables?.mapValues { AnyCodable($0) }
+ )
+ request.httpBody = try encoder.encode(body)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ // Cache the raw response data before decoding
+ responseCache.set(data, forKey: cacheKey)
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Plain-text fetch
+
+ /// Fetch the contents of a URL as plain text, using the same authorization header.
+ /// Used for build logs and other non-GraphQL resources.
+ func fetchText(url: URL) async throws -> String {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ guard let text = String(data: data, encoding: .utf8) else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
+ )
+ }
+
+ return text
+ }
+
+ // MARK: - Pagination
+
+ /// Returns an `AsyncSequence` that lazily iterates through all pages of a
+ /// paginated sr.ht GraphQL query.
+ ///
+ /// The query must accept a `$cursor: String` variable and return the standard
+ /// `{ results: [T], cursor: String? }` shape at the given key path.
+ func paginated<T: Decodable & Sendable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ resultKeyPath: String,
+ type: T.Type
+ ) -> SRHTPaginatedSequence<T> {
+ SRHTPaginatedSequence(
+ client: self,
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath
+ )
+ }
+
+ /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
+ /// collected results.
+ func fetchAll<T: Decodable & Sendable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ resultKeyPath: String,
+ type: T.Type
+ ) async throws -> [T] {
+ var all: [T] = []
+ for try await element in paginated(
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath,
+ type: type
+ ) {
+ all.append(element)
+ }
+ return all
+ }
+}
+
+// MARK: - Data Helper
+
+private extension Data {
+ mutating func append(_ string: String) {
+ if let data = string.data(using: .utf8) {
+ append(data)
+ }
+ }
+}