From 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 17 Mar 2026 23:19:43 -0500 Subject: v1.0 --- Hutch/Networking/GraphQLRequest.swift | 45 +++ Hutch/Networking/NetworkMonitor.swift | 48 ++++ Hutch/Networking/Pagination.swift | 176 ++++++++++++ Hutch/Networking/ResponseCache.swift | 33 +++ Hutch/Networking/SRHTClient.swift | 521 ++++++++++++++++++++++++++++++++++ Hutch/Networking/SRHTError.swift | 63 ++++ Hutch/Networking/SRHTService.swift | 34 +++ 7 files changed, 920 insertions(+) create mode 100644 Hutch/Networking/GraphQLRequest.swift create mode 100644 Hutch/Networking/NetworkMonitor.swift create mode 100644 Hutch/Networking/Pagination.swift create mode 100644 Hutch/Networking/ResponseCache.swift create mode 100644 Hutch/Networking/SRHTClient.swift create mode 100644 Hutch/Networking/SRHTError.swift create mode 100644 Hutch/Networking/SRHTService.swift (limited to 'Hutch/Networking') 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: 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: 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( +/// 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: 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 { + // 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. + let pageData = try JSONSerialization.data(withJSONObject: current) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(.srht) + return try decoder.decode(CursorPage.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 + + /// 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( + 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.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 + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + 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( + 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."] } + 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 + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + 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( + 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.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( + 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 + do { + graphQLResponse = try decoder.decode(GraphQLResponse.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + 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( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + resultKeyPath: String, + type: T.Type + ) -> SRHTPaginatedSequence { + 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( + 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 = [ + 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" + } + } +} -- cgit v1.2.3