summaryrefslogtreecommitdiff
path: root/Hutch/Networking
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Networking')
-rw-r--r--Hutch/Networking/GraphQLRequest.swift45
-rw-r--r--Hutch/Networking/NetworkMonitor.swift48
-rw-r--r--Hutch/Networking/Pagination.swift176
-rw-r--r--Hutch/Networking/ResponseCache.swift33
-rw-r--r--Hutch/Networking/SRHTClient.swift521
-rw-r--r--Hutch/Networking/SRHTError.swift63
-rw-r--r--Hutch/Networking/SRHTService.swift34
7 files changed, 920 insertions, 0 deletions
diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift
new file mode 100644
index 0000000..bce7b40
--- /dev/null
+++ b/Hutch/Networking/GraphQLRequest.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+/// The JSON body sent with every GraphQL request.
+struct GraphQLRequestBody: Encodable, Sendable {
+ let query: String
+ let variables: [String: AnyCodable]?
+}
+
+/// The top-level shape of every GraphQL response.
+struct GraphQLResponse<T: Decodable>: Decodable {
+ let data: T?
+ let errors: [GraphQLError]?
+}
+
+// MARK: - AnyCodable
+
+/// A type-erased `Codable` wrapper so callers can pass `[String: Any]` variables
+/// without losing type information at the encoding boundary.
+struct AnyCodable: Sendable, Encodable {
+ let value: any Sendable
+
+ init(_ value: any Sendable) {
+ self.value = value
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch value {
+ case let v as String:
+ try container.encode(v)
+ case let v as Int:
+ try container.encode(v)
+ case let v as Double:
+ try container.encode(v)
+ case let v as Bool:
+ try container.encode(v)
+ case let v as [any Sendable]:
+ try container.encode(v.map { AnyCodable($0) })
+ case let v as [String: any Sendable]:
+ try container.encode(v.mapValues { AnyCodable($0) })
+ default:
+ try container.encodeNil()
+ }
+ }
+}
diff --git a/Hutch/Networking/NetworkMonitor.swift b/Hutch/Networking/NetworkMonitor.swift
new file mode 100644
index 0000000..4d5d364
--- /dev/null
+++ b/Hutch/Networking/NetworkMonitor.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Network
+
+/// Observes network connectivity using `NWPathMonitor`.
+/// Shared singleton injected into the environment.
+@Observable
+@MainActor
+final class NetworkMonitor {
+
+ private(set) var isConnected = true
+ private(set) var connectionType: ConnectionType = .unknown
+
+ enum ConnectionType: Sendable {
+ case wifi
+ case cellular
+ case wiredEthernet
+ case unknown
+ }
+
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "net.cleberg.Hutch.NetworkMonitor")
+
+ init() {
+ startMonitoring()
+ }
+
+ private func startMonitoring() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.isConnected = path.status == .satisfied
+ self.connectionType = self.resolveConnectionType(path)
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ private nonisolated func resolveConnectionType(_ path: NWPath) -> ConnectionType {
+ if path.usesInterfaceType(.wifi) { return .wifi }
+ if path.usesInterfaceType(.cellular) { return .cellular }
+ if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
+ return .unknown
+ }
+
+ deinit {
+ monitor.cancel()
+ }
+}
diff --git a/Hutch/Networking/Pagination.swift b/Hutch/Networking/Pagination.swift
new file mode 100644
index 0000000..8adeaee
--- /dev/null
+++ b/Hutch/Networking/Pagination.swift
@@ -0,0 +1,176 @@
+import Foundation
+
+/// The standard paginated response shape used by all sr.ht GraphQL APIs.
+/// { results: [T], cursor: String? }
+/// A null cursor means the list is exhausted.
+struct CursorPage<Element: Decodable & Sendable>: Decodable, Sendable {
+ let results: [Element]
+ let cursor: String?
+}
+
+/// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL
+/// query. Each element yielded is a single `Element` from the `results` array.
+///
+/// The sequence re-issues the query with an updated `$cursor` variable on each
+/// page until the server returns a null cursor.
+///
+/// Usage:
+/// ```swift
+/// let sequence = SRHTPaginatedSequence<Repository>(
+/// client: client,
+/// service: .git,
+/// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }",
+/// variables: nil,
+/// resultKeyPath: "me.repositories"
+/// )
+/// for try await repo in sequence {
+/// print(repo.name)
+/// }
+/// ```
+struct SRHTPaginatedSequence<Element: Decodable & Sendable>: AsyncSequence, Sendable {
+ let client: SRHTClient
+ let service: SRHTService
+ let query: String
+ let variables: [String: any Sendable]?
+ let resultKeyPath: String
+
+ func makeAsyncIterator() -> Iterator {
+ Iterator(
+ client: client,
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath
+ )
+ }
+
+ struct Iterator: AsyncIteratorProtocol {
+ private let client: SRHTClient
+ private let service: SRHTService
+ private let query: String
+ private let baseVariables: [String: any Sendable]?
+ private let resultKeyPath: String
+
+ /// Buffer of elements from the current page.
+ private var buffer: [Element] = []
+ /// Index into the current buffer.
+ private var bufferIndex = 0
+ /// The cursor for the next page. Nil means we haven't started or are done.
+ private var nextCursor: String? = nil
+ /// Whether we've exhausted all pages.
+ private var isFinished = false
+
+ init(
+ client: SRHTClient,
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]?,
+ resultKeyPath: String
+ ) {
+ self.client = client
+ self.service = service
+ self.query = query
+ self.baseVariables = variables
+ self.resultKeyPath = resultKeyPath
+ }
+
+ mutating func next() async throws -> Element? {
+ // Yield buffered elements first.
+ if bufferIndex < buffer.count {
+ let element = buffer[bufferIndex]
+ bufferIndex += 1
+ return element
+ }
+
+ // If we already know there are no more pages, stop.
+ if isFinished {
+ return nil
+ }
+
+ // Fetch the next page.
+ var vars = baseVariables ?? [:]
+ if let cursor = nextCursor {
+ vars["cursor"] = cursor
+ }
+
+ let page = try await fetchPage(variables: vars)
+
+ if let cursor = page.cursor {
+ nextCursor = cursor
+ } else {
+ isFinished = true
+ }
+
+ buffer = page.results
+ bufferIndex = 0
+
+ guard bufferIndex < buffer.count else {
+ return nil
+ }
+
+ let element = buffer[bufferIndex]
+ bufferIndex += 1
+ return element
+ }
+
+ private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage<Element> {
+ // We decode the raw JSON and navigate the key path manually,
+ // since the paginated object can be nested arbitrarily
+ // (e.g. "me.repositories" or just "repositories").
+ let raw = try await client.execute(
+ service: service,
+ query: query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RawJSON.self
+ )
+
+ // Walk the key path to find the paginated object.
+ let pathComponents = resultKeyPath.split(separator: ".").map(String.init)
+ var current = raw.value
+ for component in pathComponents {
+ guard let dict = current as? [String: Any],
+ let next = dict[component] else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)"))
+ )
+ }
+ current = next
+ }
+
+ // Re-serialize the nested object and decode as CursorPage<Element>.
+ let pageData = try JSONSerialization.data(withJSONObject: current)
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .formatted(.srht)
+ return try decoder.decode(CursorPage<Element>.self, from: pageData)
+ }
+ }
+}
+
+// MARK: - RawJSON
+
+/// A Decodable wrapper that preserves the raw JSON structure as Foundation objects
+/// so we can navigate dynamic key paths at runtime.
+struct RawJSON: Decodable, Sendable {
+ let value: Any
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let dict = try? container.decode([String: RawJSON].self) {
+ value = dict.mapValues(\.value)
+ } else if let array = try? container.decode([RawJSON].self) {
+ value = array.map(\.value)
+ } else if let string = try? container.decode(String.self) {
+ value = string
+ } else if let int = try? container.decode(Int.self) {
+ value = int
+ } else if let double = try? container.decode(Double.self) {
+ value = double
+ } else if let bool = try? container.decode(Bool.self) {
+ value = bool
+ } else if container.decodeNil() {
+ value = NSNull()
+ } else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
+ }
+ }
+}
diff --git a/Hutch/Networking/ResponseCache.swift b/Hutch/Networking/ResponseCache.swift
new file mode 100644
index 0000000..a6cc269
--- /dev/null
+++ b/Hutch/Networking/ResponseCache.swift
@@ -0,0 +1,33 @@
+import Foundation
+import os
+
+/// Thread-safe in-memory cache for raw GraphQL response data.
+/// Keyed by a caller-provided string (typically service name + query hash).
+final class ResponseCache: Sendable {
+
+ private let storage: OSAllocatedUnfairLock<[String: Data]>
+
+ init() {
+ self.storage = OSAllocatedUnfairLock(initialState: [:])
+ }
+
+ /// Store raw response data under a cache key.
+ func set(_ data: Data, forKey key: String) {
+ storage.withLock { $0[key] = data }
+ }
+
+ /// Retrieve cached response data. Returns nil on cache miss.
+ func get(forKey key: String) -> Data? {
+ storage.withLock { $0[key] }
+ }
+
+ /// Remove a specific entry.
+ func remove(forKey key: String) {
+ storage.withLock { _ = $0.removeValue(forKey: key) }
+ }
+
+ /// Clear all cached data (e.g. on sign-out).
+ func clear() {
+ storage.withLock { $0.removeAll() }
+ }
+}
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)
+ }
+ }
+}
diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift
new file mode 100644
index 0000000..f148c00
--- /dev/null
+++ b/Hutch/Networking/SRHTError.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+/// Errors produced by the Sourcehut GraphQL client.
+enum SRHTError: LocalizedError, Sendable {
+ /// The server returned one or more GraphQL-level errors.
+ case graphQLErrors([GraphQLError])
+ /// The HTTP response had a non-2xx status code.
+ case httpError(Int)
+ /// The response data could not be decoded.
+ case decodingError(any Error)
+ /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
+ case networkError(any Error)
+ /// 401 or no authentication token configured.
+ case unauthorized
+
+ var errorDescription: String? {
+ switch self {
+ case .graphQLErrors(let errors):
+ let messages = errors.map(\.message).joined(separator: "\n")
+ return "GraphQL error: \(messages)"
+ case .httpError(let code):
+ return "Server returned HTTP \(code)."
+ case .decodingError(let error):
+ return "Failed to decode response: \(error.localizedDescription)"
+ case .networkError(let error):
+ return "Network error: \(error.localizedDescription)"
+ case .unauthorized:
+ return "Authentication required. Please sign in again."
+ }
+ }
+
+ /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
+ var isConnectivityError: Bool {
+ switch self {
+ case .networkError(let error):
+ let nsError = error as NSError
+ let connectivityCodes: Set<Int> = [
+ NSURLErrorNotConnectedToInternet,
+ NSURLErrorNetworkConnectionLost,
+ NSURLErrorTimedOut,
+ NSURLErrorCannotFindHost,
+ NSURLErrorCannotConnectToHost,
+ NSURLErrorDNSLookupFailed,
+ NSURLErrorInternationalRoamingOff,
+ NSURLErrorDataNotAllowed
+ ]
+ return connectivityCodes.contains(nsError.code)
+ default:
+ return false
+ }
+ }
+}
+
+/// A single error entry from the GraphQL `errors` array.
+struct GraphQLError: Decodable, Sendable {
+ let message: String
+ let locations: [GraphQLErrorLocation]?
+}
+
+struct GraphQLErrorLocation: Decodable, Sendable {
+ let line: Int
+ let column: Int
+}
diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift
new file mode 100644
index 0000000..5db06dd
--- /dev/null
+++ b/Hutch/Networking/SRHTService.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Each Sourcehut service exposes its own GraphQL endpoint.
+enum SRHTService: String, Codable, Sendable, CaseIterable {
+ case meta
+ case git
+ case hg
+ case builds
+ case lists
+ case todo
+ case paste
+ case pages
+ case man
+
+ /// The GraphQL endpoint URL for this service.
+ var url: URL {
+ // Force-unwrap is safe here — these are compile-time constant strings.
+ URL(string: "https://\(rawValue).sr.ht/query")!
+ }
+
+ var displayName: String {
+ switch self {
+ case .meta: "Meta"
+ case .git: "Git"
+ case .hg: "Mercurial"
+ case .builds: "Builds"
+ case .lists: "Lists"
+ case .todo: "Todo"
+ case .paste: "Paste"
+ case .pages: "Pages"
+ case .man: "Man"
+ }
+ }
+}