diff --git a/README.md b/README.md index c8311d0aaa..9b2f35735b 100644 --- a/README.md +++ b/README.md @@ -71,19 +71,22 @@ The `ok` property throws for any other response. To handle those, switch over th ### Fetching More Pages -Lists are paged. Pass a page's `links.nextCursor` as the `cursor` of the next request: +Lists are paged. Iterate a `PagedSequence` to fetch each page in turn: ```swift -var apps: [Components.Schemas.App] = [] -var cursor: String? +let pages = PagedSequence(links: \.links) { cursor in + try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json +} -repeat { - let page = try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json +var apps: [Components.Schemas.App] = [] + +for try await page in pages { apps += page.data - cursor = page.links.nextCursor -} while cursor != nil +} ``` +To fetch pages yourself, pass a page's `links.nextCursor` as the `cursor` of the next request. + ### Handling Rate Limits Add `RateLimitMiddleware` before the authentication middleware to retry `429 Too Many Requests` responses: diff --git a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md index 2ba24103b1..f624eef728 100644 --- a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md +++ b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md @@ -48,19 +48,22 @@ The `ok` property throws for any other response. To handle those, switch over th ### Fetching More Pages -Lists are paged. Pass a page's ``Components/Schemas/PagedDocumentLinks/nextCursor`` as the `cursor` of the next request: +Lists are paged. Iterate a ``PagedSequence`` to fetch each page in turn: ```swift -var apps: [Components.Schemas.App] = [] -var cursor: String? +let pages = PagedSequence(links: \.links) { cursor in + try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json +} -repeat { - let page = try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json +var apps: [Components.Schemas.App] = [] + +for try await page in pages { apps += page.data - cursor = page.links.nextCursor -} while cursor != nil +} ``` +To fetch pages yourself, pass a page's ``Components/Schemas/PagedDocumentLinks/nextCursor`` as the `cursor` of the next request. + ### Handling Rate Limits Add ``RateLimitMiddleware`` before the authentication middleware to retry `429 Too Many Requests` responses: @@ -84,6 +87,10 @@ middlewares: [ - ``BearerAuthMiddleware`` - ``APIKeyTokenProvider`` +### Pagination + +- ``PagedSequence`` + ### Rate Limiting - ``RateLimitMiddleware`` diff --git a/Sources/ASConnectService/Sources/Public/Sequences/PagedSequence.swift b/Sources/ASConnectService/Sources/Public/Sequences/PagedSequence.swift new file mode 100644 index 0000000000..aa3e5bb4aa --- /dev/null +++ b/Sources/ASConnectService/Sources/Public/Sequences/PagedSequence.swift @@ -0,0 +1,117 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the ASConnectService open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of ASConnectService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +/// An asynchronous sequence of the pages of a paged operation. +/// +/// Each iteration fetches the next page, until a page has no next link: +/// +/// ```swift +/// let pages = PagedSequence(links: \.links) { cursor in +/// try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json +/// } +/// +/// var apps: [Components.Schemas.App] = [] +/// +/// for try await page in pages { +/// apps += page.data +/// } +/// ``` +public struct PagedSequence: AsyncSequence, Sendable { + // MARK: Type aliases + + public typealias Element = Page + + // MARK: Properties + + /// The closure that returns the links of a page. + private let links: @Sendable (Page) -> Components.Schemas.PagedDocumentLinks + + /// The closure that fetches the page at a cursor. + private let fetch: @Sendable (String?) async throws -> Page + + // MARK: Initializers + + /// Creates a sequence of the pages of a paged operation. + /// - Parameters: + /// - links: A closure that returns the links of a page. + /// - fetch: A closure that fetches the page at a cursor, or the first page if the cursor is `nil`. + public init( + links: @escaping @Sendable (Page) -> Components.Schemas.PagedDocumentLinks, + fetch: @escaping @Sendable (_ cursor: String?) async throws -> Page + ) { + self.links = links + self.fetch = fetch + } + + // MARK: Functions + + public func makeAsyncIterator() -> Iterator { + Iterator( + links: links, + fetch: fetch + ) + } +} + +// MARK: - Iterator + +extension PagedSequence { + /// An iterator that fetches one page at a time. + public struct Iterator: AsyncIteratorProtocol { + // MARK: Properties + + /// The closure that returns the links of a page. + private let links: @Sendable (Page) -> Components.Schemas.PagedDocumentLinks + + /// The closure that fetches the page at a cursor. + private let fetch: @Sendable (String?) async throws -> Page + + /// The cursor of the next page, or `nil` for the first page. + private var cursor: String? + + /// A Boolean value that indicates whether there are no more pages to fetch. + private var isFinished = false + + // MARK: Initializers + + init( + links: @escaping @Sendable (Page) -> Components.Schemas.PagedDocumentLinks, + fetch: @escaping @Sendable (String?) async throws -> Page + ) { + self.links = links + self.fetch = fetch + } + + // MARK: Functions + + /// Fetches the next page. + /// - Returns: The next page, or `nil` after the last page. + /// - Throws: The error thrown by the fetch closure, after which the iterator returns `nil`. + public mutating func next() async throws -> Page? { + guard !isFinished else { + return nil + } + + // Finishes the iteration if fetching the page throws. + isFinished = true + + let page = try await fetch(cursor) + + cursor = links(page).nextCursor + isFinished = cursor == nil + + return page + } + } +} diff --git a/Tests/ASConnectService/Cases/Public/Sequences/PagedSequenceTests.swift b/Tests/ASConnectService/Cases/Public/Sequences/PagedSequenceTests.swift new file mode 100644 index 0000000000..206a3ed57f --- /dev/null +++ b/Tests/ASConnectService/Cases/Public/Sequences/PagedSequenceTests.swift @@ -0,0 +1,173 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the ASConnectService open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of ASConnectService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +// A non-testable import ensures that the sequence can be created from outside the module. +import ASConnectService + +@Suite("PagedSequence") +struct PagedSequenceTests { + // MARK: Properties + + private let server = Server(pages: 3) + + // MARK: Tests + + @Test("Fetches every page, passing the cursor of each next link") + func fetchesEveryPage() async throws { + var pages: [Int] = [] + + for try await page in makeSequence() { + pages.append(page.number) + } + + #expect(pages == [1, 2, 3]) + #expect(await server.cursors == [nil, "2", "3"]) + } + + @Test("Does not fetch any page until the sequence is iterated") + func fetchesLazily() async throws { + _ = makeSequence() + + #expect(await server.cursors.isEmpty) + } + + @Test("Finishes the iteration after a fetch throws") + func finishesAfterError() async throws { + await server.fail(atPage: 2) + + var iterator = makeSequence().makeAsyncIterator() + + #expect(try await iterator.next()?.number == 1) + + do { + _ = try await iterator.next() + + Issue.record("Fetching the second page should throw") + } catch { + #expect(error is ServerError) + } + + #expect(try await iterator.next() == nil) + #expect(await server.cursors == [nil, "2"]) + } + + @Test("Pages through a generated operation, sending the cursor as a query parameter") + func pagesThroughGeneratedOperation() async throws { + let transport = PagedTransport() + let client = Client( + serverURL: try Servers.Server1.url(), + transport: transport + ) + + let pages = PagedSequence(links: \.links) { cursor in + try await client.appsGetCollection(query: .init(limit: 200, cursor: cursor)).ok.body.json + } + + var count = 0 + + for try await _ in pages { + count += 1 + } + + #expect(count == 2) + #expect(await transport.paths == ["/v1/apps?limit=200", "/v1/apps?limit=200&cursor=Mg"]) + } +} + +// MARK: - Helpers + +private extension PagedSequenceTests { + func makeSequence() -> PagedSequence { + let server = server + + return PagedSequence(links: \.links) { cursor in + try await server.page(at: cursor) + } + } +} + +/// A page of a paged operation. +private struct TestPage: Sendable { + let number: Int + let links: Components.Schemas.PagedDocumentLinks +} + +/// An actor that serves a number of pages, and records the cursors it is asked for. +private actor Server { + private let pages: Int + private var failingPage: Int? + + private(set) var cursors: [String?] = [] + + init(pages: Int) { + self.pages = pages + } + + func fail(atPage page: Int) { + failingPage = page + } + + func page(at cursor: String?) throws -> TestPage { + cursors.append(cursor) + + let number = cursor.flatMap { Int($0) } ?? 1 + + if number == failingPage { + throw ServerError.unavailable + } + + let base = "https://api.appstoreconnect.apple.com/v1/apps" + + return TestPage( + number: number, + links: .init( + _self: base, + next: number < pages ? "\(base)?cursor=\(number + 1)" : nil + ) + ) + } +} + +/// An error thrown by the server. +private enum ServerError: Error { + case unavailable +} + +/// A transport that serves two pages of apps, and records the paths of the requests it sends. +private actor PagedTransport: ClientTransport { + private(set) var paths: [String?] = [] + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + paths.append(request.path) + + let isFirstPage = paths.count == 1 + let next = isFirstPage ? #", "next": "https://api.appstoreconnect.apple.com/v1/apps?cursor=Mg&limit=200""# : "" + let json = #"{ "data": [], "links": { "self": "https://api.appstoreconnect.apple.com/v1/apps"\#(next) } }"# + + return ( + HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), + HTTPBody(json) + ) + } +}