krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

remove-splash-highlighter: 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}