Overhauled the library with several new features (#5)
## Summary Updates the library to v4.5.1 of the App Store Connect API, and ships it as a breaking 2.0.0 release with pagination, API key authentication, rate limiting, and a lighter install for consumers. ## Breaking changes - **Minimum platforms:** iOS 14, macOS 11, tvOS 14, watchOS 7, and visionOS 1. - **Toolchain:** Swift 6.0+ (Xcode 16+). The package builds in the Swift 6 language mode. - **Runtime:** requires `swift-openapi-runtime` 1.11.0 or later. ## Changes - **API:** updated the OpenAPI specification to v4.5.1, covering 1,303 operations and 1,473 schemas. - **Pagination:** the spec patch adds the undocumented `cursor` query parameter to the 465 paged operations. `PagedDocumentLinks.nextCursor` reads the cursor from a page, and `PagedSequence` iterates every page. - **Authentication:** `APIKeyTokenProvider` signs ES256 tokens from an API key and reuses each one until a minute before it expires. `BearerAuthMiddleware` now accepts a token provider. - **Rate limiting:** `RateLimitMiddleware` retries `429` responses, waiting for `Retry-After` or backing off exponentially. - **Generated code:** committed to `Sources/ASConnectService/Sources/Public/Generated` instead of generated by a build plugin. Consumers now resolve 4 packages instead of 12, and Xcode no longer asks to trust a plugin. - **Tooling:** - `make oas-download` now downloads, patches, generates, and checks the docs in one go. - New `make oas-generate`, `make oas-validate`, and `make doc-check` targets. - The generator is pinned in `Tools/`, and the DocC plugin is only resolved by the documentation targets. - **Documentation:** the DocC catalog now lists every operation and schema in the spec, and the catalog and README are rewritten for the new APIs. - **Housekeeping:** Apache 2.0 license headers on every Swift file, including `Package.swift` and the tests. Reviewed-on: #5 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #5.
This commit is contained in:
1 parent
3aaf5cd7a5
commit
0431fb5133
27 files changed
+1046471
-284
No files matched your search
@@ -0,0 +1,65 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// 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 Testing
|
||||
|
||||
// A non-testable import ensures that the extension can be used from outside the module.
|
||||
import ASConnectService
|
||||
|
||||
@Suite("PagedDocumentLinks")
|
||||
struct PagedDocumentLinksTests {
|
||||
// MARK: Tests
|
||||
|
||||
@Test("Returns the cursor from the next link")
|
||||
func returnsNextCursor() {
|
||||
let links = Components.Schemas.PagedDocumentLinks(
|
||||
_self: .Link.current,
|
||||
next: "https://api.appstoreconnect.apple.com/v1/apps?cursor=Mg.ANmP3sY&limit=2"
|
||||
)
|
||||
|
||||
#expect(links.nextCursor == "Mg.ANmP3sY")
|
||||
}
|
||||
|
||||
@Test("Returns nil when there is no next link")
|
||||
func returnsNilWithoutNextLink() {
|
||||
let links = Components.Schemas.PagedDocumentLinks(_self: .Link.current)
|
||||
|
||||
#expect(links.nextCursor == nil)
|
||||
}
|
||||
|
||||
@Test("Returns nil when the next link has no cursor")
|
||||
func returnsNilWithoutCursor() {
|
||||
let links = Components.Schemas.PagedDocumentLinks(
|
||||
_self: .Link.current,
|
||||
next: "https://api.appstoreconnect.apple.com/v1/apps?limit=2"
|
||||
)
|
||||
|
||||
#expect(links.nextCursor == nil)
|
||||
}
|
||||
|
||||
@Test("Passes the cursor to the query of a paged operation")
|
||||
func passesCursorToQuery() {
|
||||
let query = Operations.AppsGetCollection.Input.Query(limit: 2, cursor: "Mg.ANmP3sY")
|
||||
|
||||
#expect(query.cursor == "Mg.ANmP3sY")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Samples
|
||||
|
||||
private extension String {
|
||||
enum Link {
|
||||
static let current: String = "https://api.appstoreconnect.apple.com/v1/apps?limit=2"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// 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
|
||||
|
||||
@testable import ASConnectService
|
||||
// A non-testable import ensures that the middleware can be created from outside the module.
|
||||
import ASConnectService
|
||||
|
||||
@Suite("BearerAuthMiddleware")
|
||||
struct BearerAuthMiddlewareTests {
|
||||
@@ -32,17 +47,14 @@ struct BearerAuthMiddlewareTests {
|
||||
let middleware = BearerAuthMiddleware(token: .Token.jwt)
|
||||
let requestBody: HTTPBody = HTTPBody("request-body")
|
||||
|
||||
var receivedBody: HTTPBody?
|
||||
|
||||
_ = try await middleware.intercept(
|
||||
// The next handler echoes the body back, so the test can read it.
|
||||
let (_, receivedBody) = try await middleware.intercept(
|
||||
.get,
|
||||
body: requestBody,
|
||||
baseURL: .base,
|
||||
operationID: "createApp",
|
||||
next: { _, body, _ in
|
||||
receivedBody = body
|
||||
|
||||
return (HTTPResponse(status: .created), nil)
|
||||
(HTTPResponse(status: .created), body)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -112,6 +124,67 @@ struct BearerAuthMiddlewareTests {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Asks the token provider for a token on every request")
|
||||
func asksTokenProviderOnEveryRequest() async throws {
|
||||
let tokens = TokenSequence()
|
||||
let middleware = BearerAuthMiddleware {
|
||||
await tokens.next()
|
||||
}
|
||||
|
||||
for expectedToken in ["token-1", "token-2"] {
|
||||
_ = try await middleware.intercept(
|
||||
.get,
|
||||
body: nil,
|
||||
baseURL: .base,
|
||||
operationID: "listApps",
|
||||
next: { interceptedRequest, _, _ in
|
||||
#expect(interceptedRequest.headerFields[.authorization] == "Bearer \(expectedToken)")
|
||||
|
||||
return (HTTPResponse(status: .ok), nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rethrows the token provider error without calling the next handler")
|
||||
func rethrowsTokenProviderError() async throws {
|
||||
let middleware = BearerAuthMiddleware {
|
||||
throw TokenError.expired
|
||||
}
|
||||
|
||||
await #expect(throws: TokenError.expired) {
|
||||
_ = try await middleware.intercept(
|
||||
.get,
|
||||
body: nil,
|
||||
baseURL: .base,
|
||||
operationID: "listApps",
|
||||
next: { _, _, _ in
|
||||
Issue.record("The next handler should not be called when the token provider throws")
|
||||
|
||||
return (HTTPResponse(status: .ok), nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// An actor that returns a new token every time it is asked for one.
|
||||
private actor TokenSequence {
|
||||
private var count = 0
|
||||
|
||||
func next() -> String {
|
||||
count += 1
|
||||
|
||||
return "token-\(count)"
|
||||
}
|
||||
}
|
||||
|
||||
/// An error thrown by a token provider.
|
||||
private enum TokenError: Error {
|
||||
case expired
|
||||
}
|
||||
|
||||
// MARK: - HTTPRequest+Samples
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// 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 middleware can be created from outside the module.
|
||||
import ASConnectService
|
||||
|
||||
@Suite("RateLimitMiddleware")
|
||||
struct RateLimitMiddlewareTests {
|
||||
// MARK: Properties
|
||||
|
||||
private let recorder = Recorder()
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
@Test("Returns a response that is not rate limited without retrying")
|
||||
func returnsResponseWithoutRetrying() async throws {
|
||||
let response = try await intercept(responses: [.ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.calls == 1)
|
||||
#expect(await recorder.delays.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Retries rate-limited requests with an exponential backoff")
|
||||
func retriesWithBackoff() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests, .tooManyRequests, .ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.calls == 3)
|
||||
#expect(await recorder.delays == [1, 2])
|
||||
}
|
||||
|
||||
@Test("Waits for the seconds in the Retry-After header")
|
||||
func waitsForRetryAfter() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests(retryAfter: "5"), .ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.delays == [5])
|
||||
}
|
||||
|
||||
@Test("Returns the rate-limited response when the retries run out")
|
||||
func returnsResponseWhenRetriesRunOut() async throws {
|
||||
let response = try await intercept(
|
||||
responses: [.tooManyRequests, .tooManyRequests, .tooManyRequests],
|
||||
maximumRetries: 2
|
||||
)
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 3)
|
||||
#expect(await recorder.delays == [1, 2])
|
||||
}
|
||||
|
||||
@Test("Returns the rate-limited response when the delay exceeds the maximum")
|
||||
func returnsResponseWhenDelayExceedsMaximum() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests(retryAfter: "3600"), .ok])
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 1)
|
||||
#expect(await recorder.delays.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Does not retry a request whose body can only be sent once")
|
||||
func doesNotRetrySingleIterationBody() async throws {
|
||||
let body = HTTPBody(
|
||||
AsyncStream<HTTPBody.ByteChunk> { $0.finish() },
|
||||
length: .unknown,
|
||||
iterationBehavior: .single
|
||||
)
|
||||
|
||||
let response = try await intercept(responses: [.tooManyRequests, .ok], body: body)
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RateLimitMiddlewareTests {
|
||||
func intercept(
|
||||
responses: [HTTPResponse],
|
||||
body: HTTPBody? = nil,
|
||||
maximumRetries: Int = 3
|
||||
) async throws -> HTTPResponse {
|
||||
let recorder = recorder
|
||||
let middleware = RateLimitMiddleware(
|
||||
maximumRetries: maximumRetries,
|
||||
baseDelay: 1,
|
||||
maximumDelay: 60,
|
||||
sleep: { await recorder.record(delay: $0) }
|
||||
)
|
||||
|
||||
let (response, _) = try await middleware.intercept(
|
||||
.get,
|
||||
body: body,
|
||||
baseURL: .base,
|
||||
operationID: "apps_getCollection",
|
||||
next: { _, _, _ in
|
||||
(await recorder.response(from: responses), nil)
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that records the calls to the next handler, and the delays the middleware waits for.
|
||||
private actor Recorder {
|
||||
private(set) var calls = 0
|
||||
private(set) var delays: [TimeInterval] = []
|
||||
|
||||
func response(from responses: [HTTPResponse]) -> HTTPResponse {
|
||||
defer { calls += 1 }
|
||||
|
||||
return responses[calls]
|
||||
}
|
||||
|
||||
func record(delay: TimeInterval) {
|
||||
delays.append(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPRequest+Samples
|
||||
|
||||
private extension HTTPRequest {
|
||||
static let get = HTTPRequest(
|
||||
method: .get,
|
||||
scheme: "https",
|
||||
authority: "api.appstoreconnect.apple.com",
|
||||
path: "/v1/apps"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - HTTPResponse+Samples
|
||||
|
||||
private extension HTTPResponse {
|
||||
static let ok = HTTPResponse(status: .ok)
|
||||
static let tooManyRequests = HTTPResponse(status: .tooManyRequests)
|
||||
|
||||
static func tooManyRequests(retryAfter: String) -> HTTPResponse {
|
||||
HTTPResponse(status: .tooManyRequests, headerFields: [.retryAfter: retryAfter])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL+Samples
|
||||
|
||||
private extension URL {
|
||||
static let base = URL(string: "https://api.appstoreconnect.apple.com")!
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
// A non-testable import ensures that the provider can be created from outside the module.
|
||||
import ASConnectService
|
||||
|
||||
@Suite("APIKeyTokenProvider")
|
||||
struct APIKeyTokenProviderTests {
|
||||
// MARK: Properties
|
||||
|
||||
private let key = P256.Signing.PrivateKey()
|
||||
private let clock = TestClock()
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
@Test("Signs a token for a team key")
|
||||
func signsTeamKeyToken() async throws {
|
||||
let token = try await makeProvider(issuerID: .Sample.issuerID).token()
|
||||
let (header, payload) = try decode(token)
|
||||
|
||||
#expect(header == ["alg": "ES256", "kid": String.Sample.keyID, "typ": "JWT"])
|
||||
#expect(payload["iss"] as? String == .Sample.issuerID)
|
||||
#expect(payload["sub"] == nil)
|
||||
#expect(payload["aud"] as? String == "appstoreconnect-v1")
|
||||
#expect(payload["iat"] as? Int == Int(clock.date.timeIntervalSince1970))
|
||||
#expect(payload["exp"] as? Int == Int(clock.date.timeIntervalSince1970) + 20 * 60)
|
||||
}
|
||||
|
||||
@Test("Signs a token for an individual key")
|
||||
func signsIndividualKeyToken() async throws {
|
||||
let token = try await makeProvider(issuerID: nil).token()
|
||||
let (_, payload) = try decode(token)
|
||||
|
||||
#expect(payload["iss"] == nil)
|
||||
#expect(payload["sub"] as? String == "user")
|
||||
}
|
||||
|
||||
@Test("Signs the token with the private key")
|
||||
func signsWithPrivateKey() async throws {
|
||||
let token = try await makeProvider(issuerID: .Sample.issuerID).token()
|
||||
let parts = token.split(separator: ".").map(String.init)
|
||||
let signature = try P256.Signing.ECDSASignature(rawRepresentation: try #require(Data(base64URL: parts[2])))
|
||||
|
||||
#expect(key.publicKey.isValidSignature(signature, for: Data("\(parts[0]).\(parts[1])".utf8)))
|
||||
}
|
||||
|
||||
@Test("Returns the current token until it is about to expire")
|
||||
func returnsCurrentToken() async throws {
|
||||
let provider = try makeProvider(issuerID: .Sample.issuerID)
|
||||
let token = try await provider.token()
|
||||
|
||||
clock.advance(by: 18 * 60)
|
||||
|
||||
#expect(try await provider.token() == token)
|
||||
}
|
||||
|
||||
@Test("Signs a new token when the current one is about to expire")
|
||||
func signsNewToken() async throws {
|
||||
let provider = try makeProvider(issuerID: .Sample.issuerID)
|
||||
let token = try await provider.token()
|
||||
|
||||
clock.advance(by: 19 * 60 + 1)
|
||||
|
||||
#expect(try await provider.token() != token)
|
||||
}
|
||||
|
||||
@Test("Throws when the private key is not valid")
|
||||
func throwsWithInvalidPrivateKey() {
|
||||
#expect(throws: (any Error).self) {
|
||||
try APIKeyTokenProvider(
|
||||
issuerID: .Sample.issuerID,
|
||||
keyID: .Sample.keyID,
|
||||
privateKey: "not-a-private-key"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension APIKeyTokenProviderTests {
|
||||
func makeProvider(issuerID: String?) throws -> APIKeyTokenProvider {
|
||||
try APIKeyTokenProvider(
|
||||
issuerID: issuerID,
|
||||
keyID: .Sample.keyID,
|
||||
privateKey: key.pemRepresentation,
|
||||
now: { [clock] in clock.date }
|
||||
)
|
||||
}
|
||||
|
||||
func decode(_ token: String) throws -> (header: [String: String], payload: [String: Any]) {
|
||||
let parts = token.split(separator: ".").map(String.init)
|
||||
|
||||
try #require(parts.count == 3)
|
||||
|
||||
let header = try JSONDecoder().decode([String: String].self, from: try #require(Data(base64URL: parts[0])))
|
||||
let payloadData = try #require(Data(base64URL: parts[1]))
|
||||
let payload = try #require(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any])
|
||||
|
||||
return (header, payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clock whose date only changes when it is advanced.
|
||||
private final class TestClock: @unchecked Sendable {
|
||||
private(set) var date = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
func advance(by interval: TimeInterval) {
|
||||
date = date.addingTimeInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data+Base64URL
|
||||
|
||||
private extension Data {
|
||||
init?(base64URL: String) {
|
||||
var base64 = base64URL
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
|
||||
self.init(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Samples
|
||||
|
||||
private extension String {
|
||||
enum Sample {
|
||||
static let issuerID: String = "57246542-96fe-1a63-e053-0824d011072a"
|
||||
static let keyID: String = "2X9R4HXF34"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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<TestPage> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user