diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:18:38 -0500 |
| commit | 659c76df9ff3926cb68886c16167808c23bd8f75 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Networking | |
| parent | ed48e72520c51e4635cd3840dbc569bdd74c950f (diff) | |
| parent | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff) | |
| download | hutch-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.swift | 2 | ||||
| -rw-r--r-- | Hutch/Networking/PasteService.swift | 231 | ||||
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 284 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 101 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTService.swift | 11 |
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" |
