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:
javier authored and Javier Cicchelli committed 2026-10-25 17:52:37 +00:00
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)
)
}
}