krz/hutch

an ios client for sourcehut

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

v3.0.4: 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) ?? .public
 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) ?? .public
 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) ?? .public
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) ?? .public
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) ?? .public
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                    id: project.rid,
376                    name: project.name,
377                    description: project.description,
378                    website: project.website,
379                    visibility: project.visibility,
380                    tags: project.tags,
381                    updated: project.updated,
382                    mailingLists: deduplicate(mailingLists),
383                    sources: deduplicate(sources),
384                    trackers: deduplicate(trackers),
385                    isFullyLoaded: true
386                )
387            }
388        }
389    }
390
391    private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
392        Project(
393            id: summary.rid,
394            name: summary.name,
395            description: summary.description,
396            website: summary.website,
397            visibility: summary.visibility,
398            tags: summary.tags,
399            updated: summary.updated,
400            mailingLists: [],
401            sources: [],
402            trackers: [],
403            isFullyLoaded: false
404        )
405    }
406
407    private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
408        var seen = Set<T.ID>()
409        return items.filter { item in
410            seen.insert(item.id).inserted
411        }
412    }
413}