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