-
Notifications
You must be signed in to change notification settings - Fork 146
Description
Motivation
Like many APIs, App Store Connect API supports pagination. The ASC API in particular provides a links construct which includes 'self', 'first', and 'next' page links.
However, when using tools like Swift OpenAPI Generator, because the 'cursor' element is not provided in the spec, one cannot take the 'next' url and construct a new request.
I want to be able to use the 'next' link url and create a request, pass it to the client, and reuse the serializer and deserializer generated code. At present because the serialize and deserialize are contained within the client functions there is no way to invoke it and reuse the code.
While it would change the generated code significantly, one could expose a static function serialize and another deserialize on the operation that would enable construction and invocation of a 'next' pagination style API call.
Proposed solution
I will use one of the most basic ASC APIs, Apps Get Collection.
When calling this with a sort order by name, and a limit by 2, the API produces a 'next' link for me.
po json.links.next
https://api.appstoreconnect.apple.com/v1/apps?sort=name&cursor=<REDACTED-OPAQUE-STRING>%3D%3D&limit=2
Great, so now I need to traverse the next page, but there is no way to hit the API and benefit from all of the type safe, status-code safe, content-type and deserialization logic.
I removed a bunch of the query parameter code to reduce the sample.
/// - Remark: HTTP `GET /v1/apps`.
/// - Remark: Generated from `#/paths//v1/apps/get(apps_getCollection)`.
public func appsGetCollection(_ input: Operations.AppsGetCollection.Input) async throws -> Operations.AppsGetCollection.Output {
try await client.send(
input: input,
forOperation: Operations.AppsGetCollection.id,
serializer: { input in
let path = try converter.renderedPath(
template: "/v1/apps",
parameters: []
)
var request: HTTPTypes.HTTPRequest = .init(
soar_path: path,
method: .get
)
suppressMutabilityWarning(&request)
try converter.setQueryItemAsURI(
in: &request,
style: .form,
explode: false,
name: "filter[name]",
value: input.query.filterName
)
// Lots of removed code to simplify listing ...
try converter.setQueryItemAsURI(
in: &request,
style: .form,
explode: true,
name: "limit[subscriptionGroups]",
value: input.query.limitSubscriptionGroups
)
converter.setAcceptHeader(
in: &request.headerFields,
contentTypes: input.headers.accept
)
return (request, nil)
},
deserializer: { response, responseBody in
switch response.status.code {
case 200:
let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
let body: Operations.AppsGetCollection.Output.Ok.Body
let chosenContentType = try converter.bestContentType(
received: contentType,
options: [
"application/json"
]
)
switch chosenContentType {
case "application/json":
body = try await converter.getResponseBodyAsJSON(
Components.Schemas.AppsResponse.self,
from: responseBody,
transforming: { value in
.json(value)
}
)
default:
preconditionFailure("bestContentType chose an invalid content type.")
}
return .ok(.init(body: body))
case 400:
let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
let body: Operations.AppsGetCollection.Output.BadRequest.Body
let chosenContentType = try converter.bestContentType(
received: contentType,
options: [
"application/json"
]
)
switch chosenContentType {
case "application/json":
body = try await converter.getResponseBodyAsJSON(
Components.Schemas.ErrorResponse.self,
from: responseBody,
transforming: { value in
.json(value)
}
)
default:
preconditionFailure("bestContentType chose an invalid content type.")
}
return .badRequest(.init(body: body))
case 401:
let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
let body: Operations.AppsGetCollection.Output.Unauthorized.Body
let chosenContentType = try converter.bestContentType(
received: contentType,
options: [
"application/json"
]
)
switch chosenContentType {
case "application/json":
body = try await converter.getResponseBodyAsJSON(
Components.Schemas.ErrorResponse.self,
from: responseBody,
transforming: { value in
.json(value)
}
)
default:
preconditionFailure("bestContentType chose an invalid content type.")
}
return .unauthorized(.init(body: body))
case 403:
let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
let body: Operations.AppsGetCollection.Output.Forbidden.Body
let chosenContentType = try converter.bestContentType(
received: contentType,
options: [
"application/json"
]
)
switch chosenContentType {
case "application/json":
body = try await converter.getResponseBodyAsJSON(
Components.Schemas.ErrorResponse.self,
from: responseBody,
transforming: { value in
.json(value)
}
)
default:
preconditionFailure("bestContentType chose an invalid content type.")
}
return .forbidden(.init(body: body))
case 429:
let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
let body: Operations.AppsGetCollection.Output.TooManyRequests.Body
let chosenContentType = try converter.bestContentType(
received: contentType,
options: [
"application/json"
]
)
switch chosenContentType {
case "application/json":
body = try await converter.getResponseBodyAsJSON(
Components.Schemas.ErrorResponse.self,
from: responseBody,
transforming: { value in
.json(value)
}
)
default:
preconditionFailure("bestContentType chose an invalid content type.")
}
return .tooManyRequests(.init(body: body))
default:
return .undocumented(
statusCode: response.status.code,
.init(
headerFields: response.headerFields,
body: responseBody
)
)
}
}
)
}
So in order to reuse either the serialization or deserialization code, it needs to be ripped out of this generated code. My initial thought would be to add an extension on the operation. This would be done in the generated client code, not the types code.
For brevity, I am just providing a screenshot of the model code for this API.
Excerpt from the generated Types.swift file

Proposed extensions in the Client.swift file
extension Operations.AppsGetCollection {
static func serializeInput(_ input: Operations.AppsGetCollection.Input) throws -> (HTTPRequest, HTTPBody?) {
// generated code goes here
}
static func deserializeOutput(response: HTTPResponse, body: HTTPBody) async throws -> Operations.AppGetCollection.Output {
// generated code goes here
}
}
An alternative would be to extend the input and output types with serialize and deserialize functions.
Alternatives considered
- Modify the OpenAPI Spec file to add a 'cursor' parameter, however, it isn't documented, and doesn't solve the problem at scale.
- Copy, paste, modify code. That defeats the whole purpose of using generated code
- Modify the generated code to expose the UniversalClient, make a new request, assume happy path status code response, deserialize the type, reducing the copy, paste, modify code.
These are not good options. Not enabling pagination is also a non-starter.
Additional information
As a frame of reference, other generators structure the generated output such that one can easily implement pagination.
Ref: https://github.com/AvdLee/appstoreconnect-swift-sdk/blob/master/Sources/PagedRequest.swift