krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.7: Hutch/Networking/ProjectService.swift · raw
1import Foundation
2
3private struct ProjectPageResponse: Decodable, Sendable {
4 let me: ProjectPageUser
5}
6
7private struct ProjectPageUser: Decodable, Sendable {
8 let projects: ProjectPage
9}
10
11private struct ProjectPage: Decodable, Sendable {
12 let results: [ProjectSummaryPayload]
13 let cursor: String?
14
15 private enum CodingKeys: String, CodingKey {
16 case results
17 case cursor
18 }
19
20 init(from decoder: any Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22 results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
23 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
24 }
25}
26
27private struct ProjectSummaryPayload: Decodable, Sendable {
28 let rid: String
29 let name: String
30 let description: String?
31 let website: String?
32 let visibility: Visibility
33 let tags: [String]
34 let updated: Date
35
36 private enum CodingKeys: String, CodingKey {
37 case rid
38 case name
39 case description
40 case website
41 case visibility
42 case tags
43 case updated
44 }
45
46 init(from decoder: any Decoder) throws {
47 let container = try decoder.container(keyedBy: CodingKeys.self)
48 rid = try container.decode(String.self, forKey: .rid)
49 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
50 description = try container.decodeIfPresent(String.self, forKey: .description)
51 website = try container.decodeIfPresent(String.self, forKey: .website)
52 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
53 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
54 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
55 }
56}
57
58private struct ProjectDetailResponse: Decodable, Sendable {
59 let project: ProjectDetailPayload?
60}
61
62private struct ProjectDetailPayload: Decodable, Sendable {
63 let rid: String
64 let name: String
65 let description: String?
66 let website: String?
67 let visibility: Visibility
68 let tags: [String]
69 let updated: Date
70 let mailingLists: ProjectMailingListPage
71 let sources: ProjectSourcePage
72 let trackers: ProjectTrackerPage
73
74 private enum CodingKeys: String, CodingKey {
75 case rid
76 case name
77 case description
78 case website
79 case visibility
80 case tags
81 case updated
82 case mailingLists
83 case sources
84 case trackers
85 }
86
87 init(from decoder: any Decoder) throws {
88 let container = try decoder.container(keyedBy: CodingKeys.self)
89 rid = try container.decode(String.self, forKey: .rid)
90 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
91 description = try container.decodeIfPresent(String.self, forKey: .description)
92 website = try container.decodeIfPresent(String.self, forKey: .website)
93 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
94 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
95 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
96 mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
97 sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
98 trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
99 }
100}
101
102private struct ProjectMailingListPage: Decodable, Sendable {
103 let results: [ProjectMailingListPayload]
104 let cursor: String?
105
106 static let empty = ProjectMailingListPage(results: [], cursor: nil)
107}
108
109private struct ProjectMailingListPayload: Decodable, Sendable {
110 let rid: String
111 let name: String
112 let description: String?
113 let visibility: Visibility
114 let owner: Entity
115
116 private enum CodingKeys: String, CodingKey {
117 case rid
118 case name
119 case description
120 case visibility
121 case owner
122 }
123
124 init(from decoder: any Decoder) throws {
125 let container = try decoder.container(keyedBy: CodingKeys.self)
126 rid = try container.decode(String.self, forKey: .rid)
127 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
128 description = try container.decodeIfPresent(String.self, forKey: .description)
129 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
130 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
131 }
132}
133
134private struct ProjectSourcePage: Decodable, Sendable {
135 let results: [ProjectSourcePayload]
136 let cursor: String?
137
138 static let empty = ProjectSourcePage(results: [], cursor: nil)
139}
140
141private struct ProjectSourcePayload: Decodable, Sendable {
142 let rid: String
143 let name: String
144 let description: String?
145 let visibility: Visibility
146 let owner: Entity
147 let repoType: Project.SourceRepo.RepoType
148
149 private enum CodingKeys: String, CodingKey {
150 case rid
151 case name
152 case description
153 case visibility
154 case owner
155 case repoType
156 }
157
158 init(from decoder: any Decoder) throws {
159 let container = try decoder.container(keyedBy: CodingKeys.self)
160 rid = try container.decode(String.self, forKey: .rid)
161 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
162 description = try container.decodeIfPresent(String.self, forKey: .description)
163 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
164 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
165 repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
166 }
167}
168
169private struct ProjectTrackerPage: Decodable, Sendable {
170 let results: [ProjectTrackerPayload]
171 let cursor: String?
172
173 static let empty = ProjectTrackerPage(results: [], cursor: nil)
174}
175
176private struct ProjectTrackerPayload: Decodable, Sendable {
177 let rid: String
178 let name: String
179 let description: String?
180 let visibility: Visibility
181 let owner: Entity
182
183 private enum CodingKeys: String, CodingKey {
184 case rid
185 case name
186 case description
187 case visibility
188 case owner
189 }
190
191 init(from decoder: any Decoder) throws {
192 let container = try decoder.container(keyedBy: CodingKeys.self)
193 rid = try container.decode(String.self, forKey: .rid)
194 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
195 description = try container.decodeIfPresent(String.self, forKey: .description)
196 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
197 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
198 }
199}
200
201struct ProjectService: Sendable {
202 private let client: SRHTClient
203
204 private static let projectsQuery = """
205 query meProjects($cursor: Cursor) {
206 me {
207 projects(cursor: $cursor) {
208 results {
209 rid
210 name
211 description
212 website
213 visibility
214 tags
215 updated
216 }
217 cursor
218 }
219 }
220 }
221 """
222
223 private static let projectDetailQuery = """
224 query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
225 project(rid: $rid) {
226 rid
227 name
228 description
229 website
230 visibility
231 tags
232 updated
233 mailingLists(cursor: $mailingListsCursor) {
234 results {
235 rid
236 name
237 description
238 visibility
239 owner { canonicalName }
240 }
241 cursor
242 }
243 sources(cursor: $sourcesCursor) {
244 results {
245 rid
246 name
247 description
248 visibility
249 owner { canonicalName }
250 repoType
251 }
252 cursor
253 }
254 trackers(cursor: $trackersCursor) {
255 results {
256 rid
257 name
258 description
259 visibility
260 owner { canonicalName }
261 }
262 cursor
263 }
264 }
265 }
266 """
267
268 init(client: SRHTClient) {
269 self.client = client
270 }
271
272 func fetchProjects() async throws -> [Project] {
273 try await fetchProjectSummaries().map(Self.makeSummaryProject)
274 }
275
276 func fetchProjectDetail(rid: String) async throws -> Project {
277 try await fetchProjectDetailPayload(rid: rid)
278 }
279
280 private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
281 var results: [ProjectSummaryPayload] = []
282 var cursor: String?
283
284 while true {
285 var variables: [String: any Sendable] = [:]
286 if let cursor {
287 variables["cursor"] = cursor
288 }
289
290 let response = try await client.execute(
291 service: .hub,
292 query: Self.projectsQuery,
293 variables: variables.isEmpty ? nil : variables,
294 responseType: ProjectPageResponse.self
295 )
296
297 results.append(contentsOf: response.me.projects.results)
298 guard let nextCursor = response.me.projects.cursor else {
299 break
300 }
301 cursor = nextCursor
302 }
303
304 return results.sorted { $0.updated > $1.updated }
305 }
306
307 private func fetchProjectDetailPayload(rid: String) async throws -> Project {
308 var mailingLists: [Project.MailingList] = []
309 var sources: [Project.SourceRepo] = []
310 var trackers: [Project.Tracker] = []
311 var mailingListsCursor: String?
312 var sourcesCursor: String?
313 var trackersCursor: String?
314
315 while true {
316 var variables: [String: any Sendable] = ["rid": rid]
317 if let mailingListsCursor {
318 variables["mailingListsCursor"] = mailingListsCursor
319 }
320 if let sourcesCursor {
321 variables["sourcesCursor"] = sourcesCursor
322 }
323 if let trackersCursor {
324 variables["trackersCursor"] = trackersCursor
325 }
326
327 let response = try await client.execute(
328 service: .hub,
329 query: Self.projectDetailQuery,
330 variables: variables,
331 responseType: ProjectDetailResponse.self
332 )
333
334 guard let project = response.project else {
335 throw SRHTError.decodingError(
336 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
337 )
338 }
339
340 mailingLists.append(contentsOf: project.mailingLists.results.map {
341 Project.MailingList(
342 id: $0.rid,
343 name: $0.name,
344 description: $0.description,
345 visibility: $0.visibility,
346 owner: $0.owner
347 )
348 })
349 sources.append(contentsOf: project.sources.results.map {
350 Project.SourceRepo(
351 id: $0.rid,
352 name: $0.name,
353 description: $0.description,
354 visibility: $0.visibility,
355 owner: $0.owner,
356 repoType: $0.repoType
357 )
358 })
359 trackers.append(contentsOf: project.trackers.results.map {
360 Project.Tracker(
361 id: $0.rid,
362 name: $0.name,
363 description: $0.description,
364 visibility: $0.visibility,
365 owner: $0.owner
366 )
367 })
368
369 mailingListsCursor = project.mailingLists.cursor
370 sourcesCursor = project.sources.cursor
371 trackersCursor = project.trackers.cursor
372
373 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
374 return Project(
375 metadata: .init(
376 id: project.rid,
377 name: project.name,
378 description: project.description,
379 website: project.website,
380 visibility: project.visibility,
381 tags: project.tags,
382 updated: project.updated
383 ),
384 resources: .init(
385 mailingLists: deduplicate(mailingLists),
386 sources: deduplicate(sources),
387 trackers: deduplicate(trackers),
388 isFullyLoaded: true
389 )
390 )
391 }
392 }
393 }
394
395 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
396 Project(
397 metadata: .init(
398 id: summary.rid,
399 name: summary.name,
400 description: summary.description,
401 website: summary.website,
402 visibility: summary.visibility,
403 tags: summary.tags,
404 updated: summary.updated
405 ),
406 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
407 )
408 }
409
410 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
411 var seen = Set<T.ID>()
412 return items.filter { item in
413 seen.insert(item.id).inserted
414 }
415 }
416}