krz/hutch

an ios client for sourcehut

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

v2.9.0: 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}