✈️ How to level up your releases with Runway — Webinar, Feb 8th at 1pm ET — Register
✈️ How to level up your releases with Runway — Webinar, Feb 8th at 1pm ET — Register

A guide to the App Store Connect API and how to automate your iOS App workflow

For years, Apple has provided web services to developers through the Developer Portal and App Store Connect websites to make app releases, manage signing certificates, and gather reports about how much money you’re making from building your apps. It used to be that the browser was the only way to access these services but that has been changing over the last few years with the development of the App Store Connect API, which allows anyone with an App Store Connect account to access much of the functionality that was previously only available via those sites.

The only thing that’s required to get started with the App Store Connect API is an API key, but to really make the most of out it — let’s say, so you can build your own internal tools powered by App Store Connect data — setting up an API client to authenticate with and make requests out to the App Store Connect API is a good idea.

How to started with an API client for App Store Connect?

The simplest way is to use the OpenAPI definitions. OpenAPI is a specification for defining network call schemas — endpoints to hit as well as the payloads they accept and send back. OpenAPI, as a specification, allows for code to be generated to use these network schemas, and just this past summer Apple announced their own OpenAPI generator for Swift code.​

Check out the announcement session Meet Swift OpenAPI Generator from WWDC23.​

In this post, we’ll build a Swift Package which can talk to the App Store Connect API for us using the code generated by the OpenAPI generator.

Let's start with a Package.swift manifest:

let openAPITag: PackageDescription.Version = "1.0.0"
​
let package = Package(
    name: "asc-api-client",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(
            name: "AppStoreConnectClient",
            targets: ["APIClient"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", exact: openAPITag),
        .package(url: "https://github.com/apple/swift-openapi-runtime", exact: openAPITag),
        .package(url: "https://github.com/apple/swift-openapi-urlsession", exact: openAPITag),
    ],
    targets: [
        .target(
            name: "APIClient",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
            ],
            plugins: [
                .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
            ]
        ),
        .testTarget(
            name: "APIClientTests",
            dependencies: ["APIClient"]),
    ]
)

To use the OpenAPI tools there are 3 dependencies that we need to have: 1) the code generator, 2) the runtime types, and 3) the transport layer (in our case <code>URLSession<code> but there are others such as HTTPClient). We then add the code generator plugin to the main <code>APIClient<code> target so that it can run during a build. You don't want to invoke it manually — the files it generates will be copied to your project's Sources directory and also included from the plugin's output. This means the project won't build because it will have identical files in 2 different places.

Using the OpenAPI generator plugin

With this structure in place let's have a look at the OpenAPI generator. This is the plugin which will generate Swift code for us to interact with the App Store Connect API. In our package it is a plugin for the <code>APIClient<code> target, and inside of the Sources file it needs 2 things in the target's Sources:

  1. At file called <code>openapi.json<code>. It can have a <code>.yml<code> or <code>.yaml<code> file extension. This is the manifest where the API's definitions are declared.
  2. A file called <code>openapi-generator-config.yaml<code> This configuration file tells the generator things like which types to create and what their visibility is (public, internal, etc). Here is the full documentation on the generator and what it can configure.

One very important configuration option to call out is filtering. Filtering allows you to take in parts of the OpenAPI document rather than the whole thing, where for example you can filter by Operation Object. This comes in extremely handy for very large definition files like the App Store Connect manifest, where generating without a filter yielded a 20MB file of types, and a 5MB file for the client. These are Swift files that can have a lot of complexity, meaning that it takes a lot of computer resources to parse them in Xcode and they brought a new M3 Max MacBook Pro to a halt when trying to scroll one. Filter is your friend 😀

Once we have these pieces in place we can build the project. This will trigger the generator plugin and get our types and client in place from the OpenAPI manifest. For our purposes, let's try to make an API call to fetch the apps for our account. We can do that using this configuration:

generate:
  - types
  - client
accessModifier: internal
filter:
  paths:
    - v1/apps

So now let's look at our <code>APIClient<code> class and how it can make the call to this endpoint:

import OpenAPIRuntime
import OpenAPIURLSession
​
public final class APIClient {
    private let client: Client
​
    public init() {
        self.client = try! Client(
            serverURL: Servers.server1(),
            transport: URLSessionTransport(),
            middlewares: [
                JWTMiddleware(),
            ]
        )
    }
}
​
extension APIClient {
    public func fetchBundleIdentifiers() async throws -> [String] {
        let response = try await client.apps_hyphen_get_collection()
        switch response {
        case .ok(let okResponse):
            switch okResponse.body {
            case .json(let json):
                return json.data.compactMap({ $0.attributes?.bundleId })
            }
        default:
            break
        }
        
        return []
    }
}

The first thing to note is that these generated method names are weird, and if you just look at the return type on that method it doesn't get any less weird:

<code>Operations.apps_hyphen_get_collection.Output<code>. There's a lot of usage of nested types here, and it will be helpful to consult the API documentation for any specific call you want to perform to see what is supposed to be sent in as an argument and what you can expect back. Navigating the generated Swift code will not be a fun exercise for you. ​So let's make a simple unit test for our API call and put a breakpoint in the response and see what comes back:

func testFetchBundleIDs() async throws {
    let client = APIClient()
    let bundleIDs = try await client.fetchBundleIdentifiers()
    XCTAssertEqual([], bundleIDs)
}

The actual return values and assertions don't matter here just yet as we are only after seeing what the network returns. And that the API call returns an unauthorized request. That brings us to authentication.

Don’t have a CI/CD pipeline for your mobile app yet? Struggling with a flaky one?
Try Runway Quickstart CI/CD to quickly autogenerate an end-to-end workflow for major CI/CD providers.
Try our free tool ->
The App Store Connect API is very powerful, but it can quickly become a time sink.
Runway offers a lot of the functionality you might be looking for — and more — out-of-the-box and maintenance-free.
Learn more

Authenticating an App Store Connect API call

Now we need to tell the API who we are and what App Store Connect account we belong to. There are a few steps to getting this done:

  1. Generate an API key
  2. Generate an auth token for a given request
  3. Add the token to each request

Points 2 and 3 can be helped out by our generated API client. We can create a "middleware" type which sits in between sending the request and receiving the response to modify it, and that middleware can inject our authentication credentials as a JSON Web Token (JWT). This requires some special encoding and cryptography to be done right. Thankfully the team behind the Vapor backend framework has us covered with a JWT library. So let's add this dependency:

.package(url: "https://github.com/vapor/jwt-kit", exact: "4.13.1"),

and add it as a dependency to our <code>APIClient<code> target. We can then create a function to generate the JWT and use it as middleware:

func createJWT(from request: RequestAuth) throws -> String {
    let signers = JWTSigners()
    // privateKey needs to be fetched from a hard-coded string, some file, or
    // the environment
    try signers.use(.es256(key: .private(pem: privateKey)))
    // same for the key ID
    let jwkID = JWKIdentifier(string: keyID)
    // same for the kid (Key ID) value
    let jwt = try signers.sign(request, kid: jwkID)
    return jwt
}
​
struct JWTMiddleware: ClientMiddleware {
    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        baseURL: URL,
        operationID: String,
        next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
        var request = request
        let auth = RequestAuth()
        let jwt = try createJWT(from: auth)
        request.headerFields[.authorization] = "Bearer \(jwt)"
        return try await next(request, body, baseURL)
    }
}

By putting this into our <code>Client<code> initializer we can ensure that our requests get signed correctly:

// Inside APIClient.init
self.client = try! Client(
    serverURL: Servers.server1(),
    transport: URLSessionTransport(),
    middlewares: [ JWTMiddleware() ]
)

With this in place you can re-run our test and see that it probably fails! Ironically this is exactly what we want because our method is actually returning the bundle identifiers for your apps and the test asserted that the array was going to be empty.

We have a working App Store Connect API call! Now what?

Now that we have a working call, let's take this one step further and make another API call building off the one we just made. First, a little refactor:

// 1. Create a new type to represent an App
public struct App {
    public let id: String
    public let bundleID: String

    // 3. This is the payload DTO returned from the API
    init?(schema: Components.Schemas.App) {
        guard let bundleID = schema.attributes?.bundleId else { return nil }
        self.id = schema.id
        self.bundleID = bundleID
    }
}

// 2. Update the API client method to return an array of the new App type
public func fetchApps() async throws -> [App] {
            switch okResponse.body {
            case .json(let json):
                // 4. Instead of unpacking the bundle IDs here, create our `App` instances.
                return json.data.compactMap({ App(schema: $0) })
}

There's a lot going on here, especially conceptually:

  1. Create a new type that we can use in the API of our package (API in the true Application Programming Interface sense rather than the network call sense). We want to do this so that we can hide any details from the App Store Connect network API from consumers.
  2. Update our API client method to return the new <code>App<code> type instead of an array of strings and change the name to reflect the method's intentions.
  3. In the initializer of the <code>App<code> type, have it accept the response payload from the App Store Connect API call. The kind of payloads that we interact with in the OpenAPI client are called DTOs — Data Transfer Objects. Think of them as intermediaries between our apps and the network calls which can be used in a type-safe way.
  4. Update the JSON parsing part of our API client to return the <code>App<code> type, passing along the schema.

Next we'll take an app that we fetched and grab its associated releases. This will call the v1/apps/{id}/appStoreVersions endpoint. Notice that in the middle of the path is the <code>{id}<code> token for the app's ID in the middle of the path. Pay particular attention to how the <code>app.id<code> property is used below to populate the token:

public struct Release {
    public let version: String
    let appStoreState: String

    init?(schema: Components.Schemas.AppStoreVersion) {
        guard let state = schema.attributes?.appStoreState,
              let version = schema.attributes?.versionString else { return nil }

        self.appStoreState = state.rawValue
        self.version = version
    }
}

extension APIClient {
    public func fetchVersions(for app: App) async throws -> [Release] {
        let response = try await client.apps_hyphen_appStoreVersions_hyphen_get_to_many_related(path: .init(id: app.id))
        switch response {
        case .ok(let okResponse):
            switch okResponse.body {
            case .json(let json):
                return json.data.compactMap({ Release(schema: $0) })
            }

        default:
            print("bad response")
        }

        return []
    }
}

Like we did with the App type, we're creating a Release type here to serve as our own type that we control. It gets created with the network call's DTO representation of a release. One interesting thing to note in the <code>Components.Schemas.AppStoreVersion<code> definition is that we have to grab the <code>rawValue<code> of the <code>appStoreState<code> property. That's because the OpenAPI generator built out an enum for us! This can be really nice when working with your own API schemas as they provide an exhaustive list of acceptable values.

Next we make the API client method to fetch all the versions for an app. The OpenAPI generated method is called <code>apps_hyphen_appStoreVersions_hyphen_get_to_many_related<code> and it takes in a path argument. We can create that argument with <code>.init(path: app.id)<code> and have Swift infer the type we are creating. There's some debate about the merits of using bare .init in Swift code but this is one place where it seems more acceptable because the full type of the path here is <code>apps_hyphen_appStoreVersions_hyphen_get_to_many_related.Path<code>. Best to let the compiler figure this one out!

Just as before we'll make the network request and examine its response. If things are all good then we assemble the array of Release objects and return them. We can then make our test for this like so:

func testFetchingReleases() async throws {
    let client = APIClient()
    let apps = try await client.fetchApps()
    let releases = try await client.fetchVersions(for: apps.first!)
    XCTAssertEqual(0, releases.count)
}

You can put a breakpoint on the <code>XCTAssert<code> line and examine the resulting releases from our method and see what the API returned.

What a journey we've been on! We looked at the components of the OpenAPI packages Apple has recently released, built out an API client to call the App Store Connect APIs, took a brief detour to get authentication up and running, and made two API calls that we could actually parse and get useful data from. There's a lot more that the App Store Connect API can do for us and this is only the beginning.

App Development

Release better with Runway.

Runway integrates with all the tools you’re already using to level-up your release coordination and automation, from kickoff to release to rollout. No more cat-herding, spreadsheets, or steady drip of manual busywork.

Release better with Runway.

Runway integrates with all the tools you’re already using to level-up your release coordination and automation, from kickoff to release to rollout. No more cat-herding, spreadsheets, or steady drip of manual busywork.

Don’t have a CI/CD pipeline for your mobile app yet? Struggling with a flaky one?

Try Runway Quickstart CI/CD to quickly autogenerate an end-to-end workflow for major CI/CD providers.

Looking for a better way to distribute all your different flavors of builds, from one-offs to nightlies to RCs?

Give Build Distro a try! Sign up for Runway and see it in action for yourself.

Release better with Runway.

What if you could get the functionality you're looking for, without needing to use the ASC API at all? Runway offers you this — and more — right out-of-the-box, with no maintenance required.