krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.1: Hutch/Networking/Pagination.swift · raw
1import Foundation
2
3/// The standard paginated response shape used by all sr.ht GraphQL APIs.
4/// { results: [T], cursor: String? }
5/// A null cursor means the list is exhausted.
6struct CursorPage<Element: Decodable & Sendable>: Decodable, Sendable {
7 let results: [Element]
8 let cursor: String?
9}
10
11/// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL
12/// query. Each element yielded is a single `Element` from the `results` array.
13///
14/// The sequence re-issues the query with an updated `$cursor` variable on each
15/// page until the server returns a null cursor.
16///
17/// Usage:
18/// ```swift
19/// let sequence = SRHTPaginatedSequence<Repository>(
20/// client: client,
21/// service: .git,
22/// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }",
23/// variables: nil,
24/// resultKeyPath: "me.repositories"
25/// )
26/// for try await repo in sequence {
27/// print(repo.name)
28/// }
29/// ```
30struct SRHTPaginatedSequence<Element: Decodable & Sendable>: AsyncSequence, Sendable {
31 let client: SRHTClient
32 let service: SRHTService
33 let query: String
34 let variables: [String: any Sendable]?
35 let resultKeyPath: String
36
37 func makeAsyncIterator() -> Iterator {
38 Iterator(
39 client: client,
40 service: service,
41 query: query,
42 variables: variables,
43 resultKeyPath: resultKeyPath
44 )
45 }
46
47 struct Iterator: AsyncIteratorProtocol {
48 private let client: SRHTClient
49 private let service: SRHTService
50 private let query: String
51 private let baseVariables: [String: any Sendable]?
52 private let resultKeyPath: String
53
54 /// Buffer of elements from the current page.
55 private var buffer: [Element] = []
56 /// Index into the current buffer.
57 private var bufferIndex = 0
58 /// The cursor for the next page. Nil means we haven't started or are done.
59 private var nextCursor: String? = nil
60 /// Whether we've exhausted all pages.
61 private var isFinished = false
62
63 init(
64 client: SRHTClient,
65 service: SRHTService,
66 query: String,
67 variables: [String: any Sendable]?,
68 resultKeyPath: String
69 ) {
70 self.client = client
71 self.service = service
72 self.query = query
73 self.baseVariables = variables
74 self.resultKeyPath = resultKeyPath
75 }
76
77 mutating func next() async throws -> Element? {
78 // Yield buffered elements first.
79 if bufferIndex < buffer.count {
80 let element = buffer[bufferIndex]
81 bufferIndex += 1
82 return element
83 }
84
85 // If we already know there are no more pages, stop.
86 if isFinished {
87 return nil
88 }
89
90 // Fetch the next page.
91 var vars = baseVariables ?? [:]
92 if let cursor = nextCursor {
93 vars["cursor"] = cursor
94 }
95
96 let page = try await fetchPage(variables: vars)
97
98 if let cursor = page.cursor {
99 nextCursor = cursor
100 } else {
101 isFinished = true
102 }
103
104 buffer = page.results
105 bufferIndex = 0
106
107 guard bufferIndex < buffer.count else {
108 return nil
109 }
110
111 let element = buffer[bufferIndex]
112 bufferIndex += 1
113 return element
114 }
115
116 private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage<Element> {
117 // We decode the raw JSON and navigate the key path manually,
118 // since the paginated object can be nested arbitrarily
119 // (e.g. "me.repositories" or just "repositories").
120 let raw = try await client.execute(
121 service: service,
122 query: query,
123 variables: variables.isEmpty ? nil : variables,
124 responseType: RawJSON.self
125 )
126
127 // Walk the key path to find the paginated object.
128 let pathComponents = resultKeyPath.split(separator: ".").map(String.init)
129 var current = raw.value
130 for component in pathComponents {
131 guard let dict = current as? [String: Any],
132 let next = dict[component] else {
133 throw SRHTError.decodingError(
134 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)"))
135 )
136 }
137 current = next
138 }
139
140 // Re-serialize the nested object and decode as CursorPage<Element>.
141 let pageData = try JSONSerialization.data(withJSONObject: current)
142 let decoder = JSONDecoder()
143 decoder.dateDecodingStrategy = .formatted(.srht)
144 return try decoder.decode(CursorPage<Element>.self, from: pageData)
145 }
146 }
147}
148
149// MARK: - RawJSON
150
151/// A Decodable wrapper that preserves the raw JSON structure as Foundation objects
152/// so we can navigate dynamic key paths at runtime.
153struct RawJSON: Decodable, Sendable {
154 let value: Any
155
156 init(from decoder: any Decoder) throws {
157 let container = try decoder.singleValueContainer()
158 if let dict = try? container.decode([String: RawJSON].self) {
159 value = dict.mapValues(\.value)
160 } else if let array = try? container.decode([RawJSON].self) {
161 value = array.map(\.value)
162 } else if let string = try? container.decode(String.self) {
163 value = string
164 } else if let int = try? container.decode(Int.self) {
165 value = int
166 } else if let double = try? container.decode(Double.self) {
167 value = double
168 } else if let bool = try? container.decode(Bool.self) {
169 value = bool
170 } else if container.decodeNil() {
171 value = NSNull()
172 } else {
173 throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
174 }
175 }
176}