krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.11.0: Hutch/Networking/PasteService.swift · raw
1import Foundation
2
3struct PasteListPage: Decodable, Sendable {
4 let results: [Paste]
5 let cursor: String?
6}
7
8final class PasteService: Sendable {
9 private let client: SRHTClient
10
11 init(client: SRHTClient) {
12 self.client = client
13 }
14
15 private static let listQuery = """
16 query pastes($cursor: Cursor) {
17 pastes(cursor: $cursor) {
18 results {
19 id
20 created
21 visibility
22 files {
23 filename
24 hash
25 }
26 user {
27 canonicalName
28 }
29 }
30 cursor
31 }
32 }
33 """
34
35 private static let detailQuery = """
36 query paste($id: String!) {
37 paste(id: $id) {
38 id
39 created
40 visibility
41 files {
42 filename
43 hash
44 contents
45 }
46 user {
47 canonicalName
48 }
49 }
50 }
51 """
52
53 private static let createMutation = """
54 mutation createPaste($files: [Upload!]!, $visibility: Visibility!) {
55 create(files: $files, visibility: $visibility) {
56 id
57 created
58 visibility
59 files {
60 filename
61 hash
62 contents
63 }
64 user {
65 canonicalName
66 }
67 }
68 }
69 """
70
71 private static let updateMutation = """
72 mutation updatePaste($id: String!, $visibility: Visibility!) {
73 update(id: $id, visibility: $visibility) {
74 id
75 created
76 visibility
77 files {
78 filename
79 hash
80 contents
81 }
82 user {
83 canonicalName
84 }
85 }
86 }
87 """
88
89 private static let deleteMutation = """
90 mutation deletePaste($id: String!) {
91 delete(id: $id) {
92 id
93 created
94 visibility
95 files {
96 filename
97 hash
98 }
99 user {
100 canonicalName
101 }
102 }
103 }
104 """
105
106 private static let cacheKey = "paste.pastes"
107
108 func listPastes(cursor: String?, useCache: Bool) async throws -> PasteListPage {
109 let variables = cursor.map { ["cursor": $0 as any Sendable] }
110 let result: PasteListResponse
111 if useCache, cursor == nil {
112 let cached = try await client.executeCached(
113 service: .paste,
114 query: Self.listQuery,
115 variables: variables,
116 responseType: PasteListResponse.self,
117 cacheKey: APICacheKeys.pasteList(cursor: cursor),
118 resourceType: .pasteList,
119 ttl: APICacheTTLs.ticketList,
120 policy: .cacheFirstThenRefresh
121 )
122 result = cached.value
123 } else {
124 result = try await client.execute(
125 service: .paste,
126 query: Self.listQuery,
127 variables: variables,
128 responseType: PasteListResponse.self
129 )
130 }
131 return result.pastes ?? PasteListPage(results: [], cursor: nil)
132 }
133
134 func loadCachedPastes() async -> PasteListPage? {
135 guard let data = await client.cachedPayload(forKey: APICacheKeys.pasteList()) ?? client.responseCache.get(forKey: Self.cacheKey) else {
136 return nil
137 }
138
139 let decoder = JSONDecoder()
140 decoder.dateDecodingStrategy = .srhtFlexible
141 guard let response = try? decoder.decode(GraphQLResponse<PasteListResponse>.self, from: data) else {
142 return nil
143 }
144 return response.data?.pastes
145 }
146
147 func loadPaste(id: String) async throws -> Paste? {
148 let result = try await client.execute(
149 service: .paste,
150 query: Self.detailQuery,
151 variables: ["id": id],
152 responseType: PasteDetailResponse.self
153 )
154 return result.paste
155 }
156
157 func createPaste(files: [PasteUploadDraft], visibility: Visibility) async throws -> Paste {
158 let uploadFiles = normalizedUploadFiles(from: files)
159 let variables: [String: any Sendable] = [
160 "files": [String?](repeating: nil, count: uploadFiles.count),
161 "visibility": visibility.rawValue
162 ]
163
164 let result = try await client.executeMultipartFiles(
165 service: .paste,
166 query: Self.createMutation,
167 variables: variables,
168 files: uploadFiles.enumerated().map { index, file in
169 MultipartUploadFile(
170 variablePath: "files.\(index)",
171 fileData: file.data,
172 fileName: file.fileName,
173 mimeType: "text/plain"
174 )
175 },
176 responseType: CreatePasteResponse.self
177 )
178 await invalidatePasteCaches()
179 return result.create
180 }
181
182 func updateVisibility(id: String, visibility: Visibility) async throws -> Paste? {
183 let result = try await client.execute(
184 service: .paste,
185 query: Self.updateMutation,
186 variables: ["id": id, "visibility": visibility.rawValue],
187 responseType: UpdatePasteResponse.self
188 )
189 await invalidatePasteCaches()
190 return result.update
191 }
192
193 func deletePaste(id: String) async throws -> Paste? {
194 let result = try await client.execute(
195 service: .paste,
196 query: Self.deleteMutation,
197 variables: ["id": id],
198 responseType: DeletePasteResponse.self
199 )
200 await invalidatePasteCaches()
201 return result.delete
202 }
203
204 func loadContents(from url: URL) async throws -> String {
205 try await client.fetchText(url: url)
206 }
207
208 private func normalizedUploadFiles(from files: [PasteUploadDraft]) -> [(fileName: String, data: Data)] {
209 files.compactMap { draft in
210 let text = draft.contents
211 guard let data = text.data(using: .utf8) else {
212 return nil
213 }
214
215 return (draft.filename, data)
216 }
217 }
218
219 private func invalidatePasteCaches() async {
220 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.paste.rawValue, "pastes"))
221 }
222}
223
224private struct PasteListResponse: Decodable, Sendable {
225 let pastes: PasteListPage?
226}
227
228private struct PasteDetailResponse: Decodable, Sendable {
229 let paste: Paste?
230}
231
232private struct CreatePasteResponse: Decodable, Sendable {
233 let create: Paste
234}
235
236private struct UpdatePasteResponse: Decodable, Sendable {
237 let update: Paste?
238}
239
240private struct DeletePasteResponse: Decodable, Sendable {
241 let delete: Paste?
242}