Skip to content

Commit 72db71c

Browse files
authored
DeveloperServices: support paginated requests (#89)
Realized that we need to paginate due to #87
1 parent efc1ed9 commit 72db71c

9 files changed

Lines changed: 234 additions & 62 deletions
File renamed without changes.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import DeveloperAPI
2+
3+
/// An `AsyncSequence` that unfolds a paginated DeveloperAPI request.
4+
///
5+
/// Some Developer API GET requests are paginated. Each response contains
6+
/// a `links.next` field that points to the next page. This API allows you to
7+
/// consume all pages as an `AsyncSequence`. For example, you can
8+
/// enumerate all devices with
9+
///
10+
/// ```swift
11+
/// let pages = DeveloperAPIPages {
12+
/// try await client.devicesGetCollection().ok.body.json
13+
/// } next: {
14+
/// $0.links.next
15+
/// }
16+
/// for try await page in pages {
17+
/// print(page.data)
18+
/// }
19+
/// ```
20+
public struct DeveloperAPIPages<Page>: AsyncSequence {
21+
// inspired by PagedRequest from
22+
// https://github.com/AvdLee/appstoreconnect-swift-sdk
23+
24+
public var request: @Sendable () async throws -> Page
25+
public var getNext: @Sendable (Page) -> String?
26+
27+
public init(
28+
request: @escaping @Sendable () async throws -> Page,
29+
next getNext: @escaping @Sendable (Page) -> String?
30+
) {
31+
self.request = request
32+
self.getNext = getNext
33+
}
34+
35+
public func makeAsyncIterator() -> AsyncIterator {
36+
AsyncIterator(request: request, getNext: getNext)
37+
}
38+
39+
public struct AsyncIterator: AsyncIteratorProtocol {
40+
fileprivate enum State {
41+
case initial
42+
case hasNext(String)
43+
case end
44+
}
45+
46+
fileprivate let request: @Sendable () async throws -> Page
47+
fileprivate let getNext: @Sendable (Page) -> String?
48+
49+
fileprivate var state: State = .initial
50+
51+
public mutating func next() async throws -> Page? {
52+
guard !Task.isCancelled else { return nil }
53+
54+
let cursor: String?
55+
56+
switch state {
57+
case .initial:
58+
cursor = nil
59+
case .hasNext(let next):
60+
cursor = next
61+
case .end:
62+
return nil
63+
}
64+
65+
let page = try await DeveloperAPIClient.withNextLink(cursor) {
66+
try await request()
67+
}
68+
69+
if let next = getNext(page), next != cursor {
70+
state = .hasNext(next)
71+
} else {
72+
state = .end
73+
}
74+
75+
return page
76+
}
77+
}
78+
}

Sources/XKit/DeveloperServices/DeveloperServices+OpenAPI.swift renamed to Sources/XKit/DeveloperServices/OpenAPI/DeveloperServices+OpenAPI.swift

Lines changed: 56 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import OpenAPIURLSession
66
import Dependencies
77

88
extension DeveloperAPIClient {
9+
@TaskLocal fileprivate static var cursor: [URLQueryItem] = []
10+
911
public init(
1012
auth: DeveloperAPIAuthData
1113
) {
@@ -22,6 +24,39 @@ extension DeveloperAPIClient {
2224
]
2325
)
2426
}
27+
28+
/// Perform a paginated request, starting at the page given by `link`.
29+
///
30+
/// `link` should be a URL that contains a `cursor` query parameter. The
31+
/// cursor will be applied to any requests made inside the closure.
32+
public static func withNextLink<T>(
33+
_ link: String?,
34+
isolation: isolated (any Actor)? = #isolation,
35+
perform action: () async throws -> T
36+
) async throws -> T {
37+
let cursor: [URLQueryItem]
38+
39+
if let link {
40+
guard let components = URLComponents(string: link),
41+
let newOffset = components.queryItems?.first(where: { $0.name == "cursor" }),
42+
let newLimit = components.queryItems?.first(where: { $0.name == "limit" })
43+
else { throw Errors.badNextLink(link) }
44+
// the next value will contain a cursor (offset) *and* a limit even if we didn't
45+
// provide a limit in our initial request. we need to include the limit in subsequent
46+
// requests, otherwise the cursor isn't respected.
47+
cursor = [newOffset, newLimit]
48+
} else {
49+
cursor = []
50+
}
51+
52+
return try await $cursor.withValue(cursor) {
53+
try await action()
54+
}
55+
}
56+
57+
public enum Errors: Error {
58+
case badNextLink(String)
59+
}
2560
}
2661

2762
public enum DeveloperAPIAuthData: Sendable {
@@ -126,9 +161,13 @@ public struct DeveloperAPIXcodeAuthMiddleware: ClientMiddleware {
126161

127162
let path = request.path ?? "/"
128163
var components = URLComponents(string: path) ?? .init()
129-
components.queryItems = (components.queryItems ?? []) + [
164+
165+
var items = components.queryItems ?? []
166+
items.upsertQueryItems(DeveloperAPIClient.cursor + [
130167
URLQueryItem(name: "teamId", value: authData.teamID.rawValue)
131-
]
168+
])
169+
components.queryItems = items
170+
132171
let query = components.percentEncodedQuery ?? ""
133172

134173
components.query = nil
@@ -214,69 +253,28 @@ public struct DeveloperAPIASCAuthMiddleware: ClientMiddleware {
214253
_ baseURL: URL
215254
) async throws -> (HTTPResponse, HTTPBody?)
216255
) async throws -> (HTTPResponse, HTTPBody?) {
217-
let jwt = try await generator.generate()
218256
var request = request
219-
request.headerFields[.authorization] = "Bearer \(jwt)"
220-
return try await next(request, body, baseURL)
221-
}
222-
}
223-
224-
struct LoggingMiddleware: ClientMiddleware {
225-
static let regex: NSRegularExpression? = {
226-
guard let pat = ProcessInfo.processInfo.environment["XTL_DEV_LOG"] else { return nil }
227-
return try? NSRegularExpression(pattern: pat)
228-
}()
229257

230-
func intercept(
231-
_ request: HTTPRequest,
232-
body: HTTPBody?,
233-
baseURL: URL,
234-
operationID: String,
235-
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
236-
) async throws -> (HTTPResponse, HTTPBody?) {
237-
var (response, body) = try await next(request, body, baseURL)
238-
239-
guard Self.regex?.firstMatch(
240-
in: operationID,
241-
range: NSRange(operationID.startIndex..., in: operationID)
242-
) != nil
243-
else { return (response, body) }
244-
245-
print("\n\(operationID) response status -> \(response.status)")
246-
247-
if let unwrapped = body {
248-
let data = try await Data(collecting: unwrapped, upTo: .max)
249-
// body may only be consumable once, replace it with the collected data
250-
body = .init(data)
258+
let jwt = try await generator.generate()
259+
request.headerFields[.authorization] = "Bearer \(jwt)"
251260

252-
let text = String(decoding: data, as: UTF8.self)
253-
print("\(operationID) response body -> \(text)")
261+
let cursor = DeveloperAPIClient.cursor
262+
if !cursor.isEmpty {
263+
var components = URLComponents(string: request.path ?? "") ?? .init()
264+
var items = components.queryItems ?? []
265+
items.upsertQueryItems(cursor)
266+
components.queryItems = items
267+
request.path = components.string
254268
}
255269

256-
return (response, body)
270+
return try await next(request, body, baseURL)
257271
}
258272
}
259273

260-
// syntactic sugar to make it nicer to work with `anyOf:` types
261-
public protocol OpenAPIExtensibleEnum {
262-
associatedtype Value1Payload: RawRepresentable<String>, Codable, Hashable, Sendable, CaseIterable
263-
var value1: Value1Payload? { get set }
264-
var value2: String? { get set }
265-
266-
init(value1: Value1Payload?, value2: String?)
267-
}
268-
269-
extension OpenAPIExtensibleEnum {
270-
public var rawValue: String {
271-
value2!
272-
}
273-
274-
public init(_ value: Value1Payload) {
275-
self.init(value1: value, value2: nil)
274+
extension [URLQueryItem] {
275+
fileprivate mutating func upsertQueryItems(_ items: [URLQueryItem]) {
276+
let newNames = Set(items.map(\.name))
277+
removeAll { newNames.contains($0.name) }
278+
append(contentsOf: items)
276279
}
277280
}
278-
279-
extension Components.Schemas.BundleIdPlatform: OpenAPIExtensibleEnum {}
280-
extension Components.Schemas.CapabilityType: OpenAPIExtensibleEnum {}
281-
extension Components.Schemas.CertificateType: OpenAPIExtensibleEnum {}
282-
extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload: OpenAPIExtensibleEnum {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
import OpenAPIRuntime
3+
import HTTPTypes
4+
5+
struct LoggingMiddleware: ClientMiddleware {
6+
static let regex: NSRegularExpression? = {
7+
guard let pat = ProcessInfo.processInfo.environment["XTL_DEV_LOG"] else { return nil }
8+
return try? NSRegularExpression(pattern: pat)
9+
}()
10+
11+
func intercept(
12+
_ request: HTTPRequest,
13+
body: HTTPBody?,
14+
baseURL: URL,
15+
operationID: String,
16+
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
17+
) async throws -> (HTTPResponse, HTTPBody?) {
18+
var (response, body) = try await next(request, body, baseURL)
19+
20+
guard Self.regex?.firstMatch(
21+
in: operationID,
22+
range: NSRange(operationID.startIndex..., in: operationID)
23+
) != nil
24+
else { return (response, body) }
25+
26+
print("\n\(operationID) response status -> \(response.status)")
27+
28+
if let unwrapped = body {
29+
let data = try await Data(collecting: unwrapped, upTo: .max)
30+
// body may only be consumable once, replace it with the collected data
31+
body = .init(data)
32+
33+
let text = String(decoding: data, as: UTF8.self)
34+
print("\(operationID) response body -> \(text)")
35+
}
36+
37+
return (response, body)
38+
}
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import DeveloperAPI
2+
3+
// syntactic sugar to make it nicer to work with `anyOf:` types
4+
public protocol OpenAPIExtensibleEnum {
5+
associatedtype Value1Payload: RawRepresentable<String>, Codable, Hashable, Sendable, CaseIterable
6+
var value1: Value1Payload? { get set }
7+
var value2: String? { get set }
8+
9+
init(value1: Value1Payload?, value2: String?)
10+
}
11+
12+
extension OpenAPIExtensibleEnum {
13+
public var rawValue: String {
14+
value2!
15+
}
16+
17+
public init(_ value: Value1Payload) {
18+
self.init(value1: value, value2: nil)
19+
}
20+
}
21+
22+
extension Components.Schemas.BundleIdPlatform: OpenAPIExtensibleEnum {}
23+
extension Components.Schemas.CapabilityType: OpenAPIExtensibleEnum {}
24+
extension Components.Schemas.CertificateType: OpenAPIExtensibleEnum {}
25+
extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload: OpenAPIExtensibleEnum {}

Sources/XToolSupport/DSCommands/DSCertificatesCommand.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ struct DSCertificatesListCommand: AsyncParsableCommand {
2222

2323
func run() async throws {
2424
let client = DeveloperAPIClient(auth: try AuthToken.saved().authData())
25-
let certificates = try await client.certificatesGetCollection().ok.body.json.data
25+
26+
let certificates = try await DeveloperAPIPages {
27+
try await client.certificatesGetCollection().ok.body.json
28+
} next: {
29+
$0.links.next
30+
}
31+
.map(\.data)
32+
.reduce(into: [], +=)
33+
2634
for certificate in certificates {
2735
print("- id: \(certificate.id)")
2836
guard let attributes = certificate.attributes else {

Sources/XToolSupport/DSCommands/DSDevicesCommand.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ struct DSDevicesListCommand: AsyncParsableCommand {
2222

2323
func run() async throws {
2424
let client = DeveloperAPIClient(auth: try AuthToken.saved().authData())
25-
let devices = try await client.devicesGetCollection().ok.body.json.data
25+
26+
let devices = try await DeveloperAPIPages {
27+
try await client.devicesGetCollection().ok.body.json
28+
} next: {
29+
$0.links.next
30+
}
31+
.map(\.data)
32+
.reduce(into: [], +=)
33+
2634
for device in devices {
2735
print("- id: \(device.id)")
2836
guard let attributes = device.attributes else {

Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ struct DSIdentifiersListCommand: AsyncParsableCommand {
2222

2323
func run() async throws {
2424
let client = DeveloperAPIClient(auth: try AuthToken.saved().authData())
25-
let bundleIDs = try await client.bundleIdsGetCollection().ok.body.json.data
25+
26+
let bundleIDs = try await DeveloperAPIPages {
27+
try await client.bundleIdsGetCollection().ok.body.json
28+
} next: {
29+
$0.links.next
30+
}
31+
.map(\.data)
32+
.reduce(into: [], +=)
33+
2634
for bundleID in bundleIDs {
2735
print("- id: \(bundleID.id)")
2836
guard let attributes = bundleID.attributes else {

Sources/XToolSupport/DSCommands/DSProfilesCommand.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ struct DSProfilesListCommand: AsyncParsableCommand {
2424

2525
func run() async throws {
2626
let client = DeveloperAPIClient(auth: try AuthToken.saved().authData())
27-
let profiles = try await client.profilesGetCollection().ok.body.json.data
27+
28+
let profiles = try await DeveloperAPIPages {
29+
try await client.profilesGetCollection().ok.body.json
30+
} next: {
31+
$0.links.next
32+
}
33+
.map(\.data)
34+
.reduce(into: [], +=)
35+
2836
for profile in profiles {
2937
print("- id: \(profile.id)")
3038
guard let attributes = profile.attributes else {

0 commit comments

Comments
 (0)