summaryrefslogtreecommitdiff
path: root/Hutch/Networking
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
commit659c76df9ff3926cb68886c16167808c23bd8f75 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Networking
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
parent6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff)
downloadhutch-2.tar.gz
hutch-2.tar.bz2
hutch-2.zip
v2.0: Merge branch 'dev'v2
Diffstat (limited to 'Hutch/Networking')
-rw-r--r--Hutch/Networking/GraphQLRequest.swift2
-rw-r--r--Hutch/Networking/PasteService.swift231
-rw-r--r--Hutch/Networking/ProjectService.swift284
-rw-r--r--Hutch/Networking/SRHTClient.swift101
-rw-r--r--Hutch/Networking/SRHTService.swift11
5 files changed, 627 insertions, 2 deletions
diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift
index bce7b40..8924e6a 100644
--- a/Hutch/Networking/GraphQLRequest.swift
+++ b/Hutch/Networking/GraphQLRequest.swift
@@ -34,6 +34,8 @@ struct AnyCodable: Sendable, Encodable {
try container.encode(v)
case let v as Bool:
try container.encode(v)
+ case let v as [String?]:
+ try container.encode(v)
case let v as [any Sendable]:
try container.encode(v.map { AnyCodable($0) })
case let v as [String: any Sendable]:
diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift
new file mode 100644
index 0000000..b9aa530
--- /dev/null
+++ b/Hutch/Networking/PasteService.swift
@@ -0,0 +1,231 @@
+import Foundation
+
+struct PasteListPage: Decodable, Sendable {
+ let results: [Paste]
+ let cursor: String?
+}
+
+final class PasteService: Sendable {
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ private static let listQuery = """
+ query pastes($cursor: Cursor) {
+ pastes(cursor: $cursor) {
+ results {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ }
+ user {
+ canonicalName
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let detailQuery = """
+ query paste($id: String!) {
+ paste(id: $id) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let createMutation = """
+ mutation createPaste($files: [Upload!]!, $visibility: Visibility!) {
+ create(files: $files, visibility: $visibility) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let updateMutation = """
+ mutation updatePaste($id: String!, $visibility: Visibility!) {
+ update(id: $id, visibility: $visibility) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ contents
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let deleteMutation = """
+ mutation deletePaste($id: String!) {
+ delete(id: $id) {
+ id
+ created
+ visibility
+ files {
+ filename
+ hash
+ }
+ user {
+ canonicalName
+ }
+ }
+ }
+ """
+
+ private static let cacheKey = "paste.pastes"
+
+ func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage {
+ let variables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result: PasteListResponse
+ if useCache, cursor == nil {
+ result = try await client.executeAndCache(
+ service: .paste,
+ query: Self.listQuery,
+ variables: variables,
+ responseType: PasteListResponse.self,
+ cacheKey: Self.cacheKey
+ )
+ } else {
+ result = try await client.execute(
+ service: .paste,
+ query: Self.listQuery,
+ variables: variables,
+ responseType: PasteListResponse.self
+ )
+ }
+ return result.pastes ?? PasteListPage(results: [], cursor: nil)
+ }
+
+ func loadCachedPastes() -> PasteListPage? {
+ guard let data = client.responseCache.get(forKey: Self.cacheKey) else {
+ return nil
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else {
+ return nil
+ }
+ return response.data?.pastes
+ }
+
+ func loadPaste(id: String) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.detailQuery,
+ variables: ["id": id],
+ responseType: PasteDetailResponse.self
+ )
+ return result.paste
+ }
+
+ func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste {
+ let uploadFiles = normalizedUploadFiles(from: files)
+ let variables: [String: any Sendable] = [
+ "files": [String?](repeating: nil, count: uploadFiles.count),
+ "visibility": visibility.rawValue
+ ]
+
+ let result = try await client.executeMultipartFiles(
+ service: .paste,
+ query: Self.createMutation,
+ variables: variables,
+ files: uploadFiles.enumerated().map { index, file in
+ MultipartUploadFile(
+ variablePath: "files.\(index)",
+ fileData: file.data,
+ fileName: file.fileName,
+ mimeType: "text/plain"
+ )
+ },
+ responseType: CreatePasteResponse.self
+ )
+ return result.create
+ }
+
+ func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.updateMutation,
+ variables: ["id": id, "visibility": visibility.rawValue],
+ responseType: UpdatePasteResponse.self
+ )
+ return result.update
+ }
+
+ func deletePaste(id: String) async throws -> Paste? {
+ let result = try await client.execute(
+ service: .paste,
+ query: Self.deleteMutation,
+ variables: ["id": id],
+ responseType: DeletePasteResponse.self
+ )
+ return result.delete
+ }
+
+ func loadContents(from url: URL) async throws -> String {
+ try await client.fetchText(url: url)
+ }
+
+ private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] {
+ files.compactMap { draft in
+ let text = draft.contents
+ guard let data = text.data(using: .utf8) else {
+ return nil
+ }
+
+ return (draft.filename, data)
+ }
+ }
+}
+
+private struct PasteListResponse: Decodable, Sendable {
+ let pastes: PasteListPage?
+}
+
+private struct PasteDetailResponse: Decodable, Sendable {
+ let paste: Paste?
+}
+
+private struct CreatePasteResponse: Decodable, Sendable {
+ let create: Paste
+}
+
+private struct UpdatePasteResponse: Decodable, Sendable {
+ let update: Paste?
+}
+
+private struct DeletePasteResponse: Decodable, Sendable {
+ let delete: Paste?
+}
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
new file mode 100644
index 0000000..2afb699
--- /dev/null
+++ b/Hutch/Networking/ProjectService.swift
@@ -0,0 +1,284 @@
+import Foundation
+
+private struct ProjectPageResponse: Decodable, Sendable {
+ let me: ProjectPageUser
+}
+
+private struct ProjectPageUser: Decodable, Sendable {
+ let projects: ProjectPage
+}
+
+private struct ProjectPage: Decodable, Sendable {
+ let results: [ProjectSummaryPayload]
+ let cursor: String?
+}
+
+private struct ProjectSummaryPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+}
+
+private struct ProjectDetailResponse: Decodable, Sendable {
+ let project: ProjectDetailPayload?
+}
+
+private struct ProjectDetailPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+ let mailingLists: ProjectMailingListPage
+ let sources: ProjectSourcePage
+ let trackers: ProjectTrackerPage
+}
+
+private struct ProjectMailingListPage: Decodable, Sendable {
+ let results: [ProjectMailingListPayload]
+ let cursor: String?
+}
+
+private struct ProjectMailingListPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+}
+
+private struct ProjectSourcePage: Decodable, Sendable {
+ let results: [ProjectSourcePayload]
+ let cursor: String?
+}
+
+private struct ProjectSourcePayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+ let repoType: Project.SourceRepo.RepoType
+}
+
+private struct ProjectTrackerPage: Decodable, Sendable {
+ let results: [ProjectTrackerPayload]
+ let cursor: String?
+}
+
+private struct ProjectTrackerPayload: Decodable, Sendable {
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+}
+
+struct ProjectService: Sendable {
+ private let client: SRHTClient
+
+ private static let projectsQuery = """
+ query meProjects($cursor: Cursor) {
+ me {
+ projects(cursor: $cursor) {
+ results {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let projectDetailQuery = """
+ query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
+ project(rid: $rid) {
+ rid
+ name
+ description
+ website
+ visibility
+ tags
+ mailingLists(cursor: $mailingListsCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ }
+ cursor
+ }
+ sources(cursor: $sourcesCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ repoType
+ }
+ cursor
+ }
+ trackers(cursor: $trackersCursor) {
+ results {
+ rid
+ name
+ description
+ visibility
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func fetchProjects() async throws -> [Project] {
+ let summaries = try await fetchProjectSummaries()
+ guard !summaries.isEmpty else { return [] }
+
+ return try await withThrowingTaskGroup(of: Project.self) { group in
+ for summary in summaries {
+ group.addTask {
+ try await self.fetchProjectDetail(summary: summary)
+ }
+ }
+
+ var projects: [Project] = []
+ for try await project in group {
+ projects.append(project)
+ }
+ return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+ }
+
+ private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
+ var results: [ProjectSummaryPayload] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.projectsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: ProjectPageResponse.self
+ )
+
+ results.append(contentsOf: response.me.projects.results)
+ guard let nextCursor = response.me.projects.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ return results
+ }
+
+ private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
+ var mailingLists: [Project.MailingList] = []
+ var sources: [Project.SourceRepo] = []
+ var trackers: [Project.Tracker] = []
+ var mailingListsCursor: String?
+ var sourcesCursor: String?
+ var trackersCursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = ["rid": summary.rid]
+ if let mailingListsCursor {
+ variables["mailingListsCursor"] = mailingListsCursor
+ }
+ if let sourcesCursor {
+ variables["sourcesCursor"] = sourcesCursor
+ }
+ if let trackersCursor {
+ variables["trackersCursor"] = trackersCursor
+ }
+
+ let response = try await client.execute(
+ service: .hub,
+ query: Self.projectDetailQuery,
+ variables: variables,
+ responseType: ProjectDetailResponse.self
+ )
+
+ guard let project = response.project else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
+ )
+ }
+
+ mailingLists.append(contentsOf: project.mailingLists.results.map {
+ Project.MailingList(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner
+ )
+ })
+ sources.append(contentsOf: project.sources.results.map {
+ Project.SourceRepo(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner,
+ repoType: $0.repoType
+ )
+ })
+ trackers.append(contentsOf: project.trackers.results.map {
+ Project.Tracker(
+ id: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ owner: $0.owner
+ )
+ })
+
+ mailingListsCursor = project.mailingLists.cursor
+ sourcesCursor = project.sources.cursor
+ trackersCursor = project.trackers.cursor
+
+ if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
+ return Project(
+ id: project.rid,
+ name: project.name,
+ description: project.description,
+ website: project.website,
+ visibility: project.visibility,
+ tags: project.tags,
+ mailingLists: deduplicate(mailingLists),
+ sources: deduplicate(sources),
+ trackers: deduplicate(trackers)
+ )
+ }
+ }
+ }
+
+ private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
+ var seen = Set<T.ID>()
+ return items.filter { item in
+ seen.insert(item.id).inserted
+ }
+ }
+}
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 6032cab..4a6b3ca 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -3,6 +3,13 @@ import os
private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
+struct MultipartUploadFile: Sendable {
+ let variablePath: String
+ let fileData: Data
+ let fileName: String
+ let mimeType: String
+}
+
/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
private struct EmptyData: Decodable {}
@@ -298,6 +305,100 @@ final class SRHTClient: Sendable {
return result
}
+ func executeMultipartFiles<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable],
+ files: [MultipartUploadFile],
+ 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")
+
+ let operationsBody = GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ let operationsData = try encoder.encode(operationsBody)
+
+ let mapDict = Dictionary(uniqueKeysWithValues: files.enumerated().map { index, file in
+ (String(index), ["variables.\(file.variablePath)"])
+ })
+ let mapData = try encoder.encode(mapDict)
+
+ var body = Data()
+
+ 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")
+
+ 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")
+
+ for (index, file) in files.enumerated() {
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"\(index)\"; filename=\"\(file.fileName)\"\r\n")
+ body.append("Content-Type: \(file.mimeType)\r\n\r\n")
+ body.append(file.fileData)
+ body.append("\r\n")
+ }
+
+ 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) {
+ 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)
+ }
+ }
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ 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
diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift
index 5db06dd..416b733 100644
--- a/Hutch/Networking/SRHTService.swift
+++ b/Hutch/Networking/SRHTService.swift
@@ -3,6 +3,7 @@ import Foundation
/// Each Sourcehut service exposes its own GraphQL endpoint.
enum SRHTService: String, Codable, Sendable, CaseIterable {
case meta
+ case hub
case git
case hg
case builds
@@ -14,13 +15,19 @@ enum SRHTService: String, Codable, Sendable, CaseIterable {
/// 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")!
+ switch self {
+ case .hub:
+ URL(string: "https://sr.ht/query")!
+ default:
+ // 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 .hub: "Hub"
case .git: "Git"
case .hg: "Mercurial"
case .builds: "Builds"