krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.1: 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(forceRefresh: Bool = false) async throws -> [Project] {
273 try await fetchProjectSummaries(forceRefresh: forceRefresh).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(forceRefresh: Bool) 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 cached = try await client.executeCached(
291 service: .hub,
292 query: Self.projectsQuery,
293 variables: variables.isEmpty ? nil : variables,
294 responseType: ProjectPageResponse.self,
295 cacheKey: APICacheKeys.projects(cursor: cursor),
296 resourceType: .userProfile,
297 ttl: APICacheTTLs.projectList,
298 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
299 )
300 let response = cached.value
301
302 results.append(contentsOf: response.me.projects.results)
303 guard let nextCursor = response.me.projects.cursor else {
304 break
305 }
306 cursor = nextCursor
307 }
308
309 return results.sorted { $0.updated > $1.updated }
310 }
311
312 private func fetchProjectDetailPayload(rid: String) async throws -> Project {
313 var mailingLists: [Project.MailingList] = []
314 var sources: [Project.SourceRepo] = []
315 var trackers: [Project.Tracker] = []
316 var mailingListsCursor: String?
317 var sourcesCursor: String?
318 var trackersCursor: String?
319
320 while true {
321 var variables: [String: any Sendable] = ["rid": rid]
322 if let mailingListsCursor {
323 variables["mailingListsCursor"] = mailingListsCursor
324 }
325 if let sourcesCursor {
326 variables["sourcesCursor"] = sourcesCursor
327 }
328 if let trackersCursor {
329 variables["trackersCursor"] = trackersCursor
330 }
331
332 let cached = try await client.executeCached(
333 service: .hub,
334 query: Self.projectDetailQuery,
335 variables: variables,
336 responseType: ProjectDetailResponse.self,
337 cacheKey: APICacheKeys.projectDetail(
338 rid: rid,
339 mailingListsCursor: mailingListsCursor,
340 sourcesCursor: sourcesCursor,
341 trackersCursor: trackersCursor
342 ),
343 resourceType: .userProfile,
344 ttl: APICacheTTLs.projectDetail,
345 policy: .cacheFirstThenRefresh
346 )
347 let response = cached.value
348
349 guard let project = response.project else {
350 throw SRHTError.decodingError(
351 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
352 )
353 }
354
355 mailingLists.append(contentsOf: project.mailingLists.results.map {
356 Project.MailingList(
357 id: $0.rid,
358 name: $0.name,
359 description: $0.description,
360 visibility: $0.visibility,
361 owner: $0.owner
362 )
363 })
364 sources.append(contentsOf: project.sources.results.map {
365 Project.SourceRepo(
366 id: $0.rid,
367 name: $0.name,
368 description: $0.description,
369 visibility: $0.visibility,
370 owner: $0.owner,
371 repoType: $0.repoType
372 )
373 })
374 trackers.append(contentsOf: project.trackers.results.map {
375 Project.Tracker(
376 id: $0.rid,
377 name: $0.name,
378 description: $0.description,
379 visibility: $0.visibility,
380 owner: $0.owner
381 )
382 })
383
384 mailingListsCursor = project.mailingLists.cursor
385 sourcesCursor = project.sources.cursor
386 trackersCursor = project.trackers.cursor
387
388 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
389 return Project(
390 metadata: .init(
391 id: project.rid,
392 name: project.name,
393 description: project.description,
394 website: project.website,
395 visibility: project.visibility,
396 tags: project.tags,
397 updated: project.updated
398 ),
399 resources: .init(
400 mailingLists: deduplicate(mailingLists),
401 sources: deduplicate(sources),
402 trackers: deduplicate(trackers),
403 isFullyLoaded: true
404 )
405 )
406 }
407 }
408 }
409
410 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
411 Project(
412 metadata: .init(
413 id: summary.rid,
414 name: summary.name,
415 description: summary.description,
416 website: summary.website,
417 visibility: summary.visibility,
418 tags: summary.tags,
419 updated: summary.updated
420 ),
421 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
422 )
423 }
424
425 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
426 var seen = Set<T.ID>()
427 return items.filter { item in
428 seen.insert(item.id).inserted
429 }
430 }
431}