🔀 How to untangle and manage build distribution — Webinar, May 16th — Register
🔀 How to untangle and manage build distribution — Webinar, May 16th — Register

Guide to the App Store Connect API: Calculate your iOS app's average user rating for each version

Recently we wrote about the App Store Connect API and how to build on top of it using an API client written in Swift. With the basics in place, we can start to look at more use cases where we extend this API client to take advantage of more of what the App Store Connect API has to offer. First up: user ratings!

Computing average, version-specific user ratings with the App Store Connect API

While your iOS app’s overall average user rating generally won’t change a ton from release to release, what can change significantly is the average user rating for a given release. Maybe you just shipped a bad release and there’s a drop in user ratings for that version, given some slowness/bugginess/crashiness that was introduced. Keeping a close eye on version-specific ratings is a good way to catch and fix problems before too many users encounter them.

But version-specific average ratings are not available from any built-in iOS frameworks, nor are they available using fastlane or even the App Store Connect API directly (at least not out-of-the-box from a single endpoint). Thankfully, we can leverage our API client and extend it to provide this functionality ourselves, calculating an average rating for any given release. (You can also use tools like Runway’s Rollouts to track these ratings averages without doing any work yourself, but that’s besides the point of this post.)

Let's start by creating a class that we can use to manage our <code>App<code> type.

public final class AppManager {
    private let client: APIClient
    public let app: App
    public private(set) var releases: [Release] = []
    public init(app: App, client: APIClient = APIClient()) {
        self.app = app
        self.client = client
    }
    public func fetchReleases() async throws {
        self.releases = try await client.fetchVersions(for: app)
    }
}

In our last post we created <code>App<code> and <code>Release<code> types – now, we’ve added a manager to sit on top of them. Here the <code>AppManager<code> class is initialized with an <code>App<code> and an instance of the <code>APIClient<code>. We want the client injected in the initializer so that tests can be more easily mocked out.

We'll be hitting the <code>v1/appStoreVersions/{id}/customerReviews<code> endpoint (which is documented here) to fetch all reviews for a given app version That API call requires an <code>id<code> for a given release as part of the path, which we didn't have in our <code>Release<code> type before so let's add that:

public struct Release {
    public let version: String
    let appStoreState: String
    let id: 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
        self.id = schema.id
    }
}

Thankfully the property was already on the schema that we get returned so adding it to the type is straightforward. We can now turn our gaze to building out the API client additions that will be required to call our new reviews endpoint.

Building the API client additions

The first thing we need to do is add a new filter to our OpenAPI configuration:

generate:
  - types
  - client
accessModifier: internal
filter:
  operations:
    - bundleIds-get_collection
  paths:
    - v1/apps
    - v1/apps/{id}/appStoreVersions
    - v1/appStoreVersions/{id}/customerReviews

The endpoint gets added to the <code>paths<code> array on our filter and we can build the project. This will generate some new Swift code for us to communicate with the endpoint because we are using the OpenAPI generator as a Swift Package Manager build plugin. We could generate the code manually by writing a script to call the OpenAPI generator as needed (and we'd need to check in the generated code to the repository as well once it's done), but we’ll leave code generation as an automated thing for now – the generated code will live in the DerivedData for our package.

Now that we have the generated code we can wire up an addition to our <code>APIClient<code>:

public struct Review {
    public let rating: Int
    init?(schema: Components.Schemas.CustomerReview) {
        guard let rating = schema.attributes?.rating else { return nil }
        self.rating = rating
    }
}
extension APIClient {
    func fetchReviewsForRelease(_ release: Release) async throws -> [Review] {
        let response = try await client.appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related(.init(path: .init(id: release.id)))
        switch response {
        case .ok(let ok):
            switch ok.body {
            case .json(let json):
                return json.data.compactMap({ Review(schema: $0) })
            }
        default:
            print("Bad response")
        }
        return []
    }
}

We're going to make an API on the client to take in an app's <code>Release<code>, and use the new <code>id<code> property on it to create the full path needed for the URL request. Notice once again the really odd generated code from the OpenAPI generator Swift package – we're putting a much nicer interface on our <code>APIClient<code> type by using our own types and hiding the generated code as an implementation detail of the client.

If you look at the documentation for the <code>/customerReviews<code> endpoint, the <code>200<code> response returns a <code>CustomerReviewsResponse<code> type. That type contains an <code>Attributes<code> sub-type which holds the rating for that review. So we've created a <code>Review<code> struct with a simple <code>rating<code> property on it and we can extract the rating from the returned schema. The <code>attributes<code> property is optional on the schema so the initializer is failable to guarantee that the number we need is present.

To call the new API client method we'll add one more thing to our <code>AppManager<code>.

public func computeRatings(for release: Release) async throws -> Float {
    let reviews = try await client.fetchReviewsForRelease(release)
    guard reviews.count > 0 else { return 0 }
    let ratingSum = reviews.map(\.rating).reduce(0, +)
    return Float(ratingSum) / Float(reviews.count)
}

This method takes in a given release (selected by something outside of the manager and presumably outside of the package all together), fetches all the reviews for a release, and does a bit of math to sum up the ratings then divides by the number of reviews. Notice there's no formatting here and we have to convert our <code>Int<code> types to <code>Floats<code> for the division to include any decimal places. The output formatting for display should be done at the view layer and handled before populating in a label or text view.

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 ->
Sign up for the Flight Deck — our monthly newsletter.
We'll share our perspectives on the mobile landscape, peeks into how other mobile teams and developers get things done, technical guides to optimizing your app for performance, and more. (See a recent issue here)
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

Supporting multiple pages of App Store Connect API responses

There's one more piece to this that we should explore. From time to time the App Store Connect API will have a larger dataset which can't be delivered through a single response. To handle cases like this there is a system called paging which lets the API communicate to us that there is more to fetch. App Store Connect's API uses a type called PagedDocumentLinks, which has a property called <code>next<code>, containing a full URL (interestingly, as a <code>String?<code> type); if present it contains the URL to fetch the next page of data. When the <code>next<code> property comes back <code>nil<code> then we know we have fetched the very last page of data.

> One thing to note is that while the OpenAPI Spec documents the <code>Links<code> type it is not yet implemented in the Swift OpenAPI generator. As such we'll need to approach this a bit differently and without the use of the Swift OpenAPI-generated code for sending and receiving requests.

First we're going to add a new method to <code>APIClient<code> to handle fetching pages for any Decodable data type:

func fetchPages(startingWith initial: String?, transformer: (T) -> String?) async throws {
    let auth = RequestAuth()
    let token = try createJWT(from: auth)
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601

    var nextPage: String? = initial

    while nextPage != nil {
        var request = URLRequest(url: URL(string: nextPage!)!)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (data, _) = try await session.data(for: request)
        let decoded = try decoder.decode(T.self, from: data)
        nextPage = transformer(decoded)
    }
}

The method here is generic over some <code>Decodable<code> type (which is the body of our response). It will get created with an initial URL to hit – though the method brings in a <code>String?<code> because that is what comes from the API response. We also provide a closure which can transform that <code>Decodable<code> and returns the next page to fetch from. When the closure returns <code>nil<code> then we know that the API is exhausted and we're all set.

The advantage of using a method like this is that we can fetch the authorization token once and set up our JSON decoder once. It also means we're not baking this otherwise reusable behavior into our API to fetch all the reviews. When adding another API call which requires pagination, we'll have this method ready to go.

One thing to note is that App Store Connect encodes its dates as <code>ISO8601<code> strings, so we need to set the date decoding strategy accordingly to avoid decoding errors. And, we also have to use a standard <code>URLSession<code> call to send the request for now (though this may change if and when the OpenAPI Swift generator adds support for links properly).

With our generic pagination function in place,  we can now add paging support to our review fetching method so it looks like this:

private typealias ReviewsSchema = Components.Schemas.CustomerReviewsResponse

public func fetchReviewsForRelease(_ release: Release) async throws -> [Review] {
    var reviews = [Review]()
    var nextPage: String?

    let response = try await client.appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related(.init(path: .init(id: release.id)))
    switch response {
    case .ok(let ok):
        switch ok.body {
        case .json(let json):
            reviews = json.data.compactMap({ Review(schema: $0) })
            nextPage = json.links.next
        }
    default:
        return []
    }

    try await fetchPages(startingWith: nextPage) { (response: ReviewsSchema) in
        let page = response.data.compactMap({ Review(schema: $0) })
        reviews.append(contentsOf: page)
        return response.links.next
    }

    return reviews
}

The big changes here are that instead of returning from the initial response parsing (which is done through the generated ASC API client), we extract the first batch of reviews and the <code>next<code> link (if it's there). Then below the initial API call we'll use our new <code>fetchPages<code> method to grab any paged responses that come in and append those to our <code>reviews<code> variable and return that at the end. Notice the <code>(response: ReviewsSchema)<code> input to the <code>transformer<code> closure. Typically Swift can infer types for us but in this case because the method is generic we need to explicitly give <code>response<code> the type we are expecting from the API's response body. Finding that type can be tricky:

  • Command click on the <code>appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related<code> method
  • Command click on the method's return type (<code>.Output<code>)
  • Navigate down to the nested <code>Ok<code> type in the output
  • In our case inside that <code>Ok<code> type you'll notice <code>internal var json: Components.Schemas.CustomerReviewsResponse<code. So we can pick off the schema type from here.

With all this now in place we can fetch all app reviews for a given version, and compute the average rating. Our API client now supports paging both for fetching reviews, and for any other operations we may want to perform later on as well. We've had to subvert the out-of-the-box generated OpenAPI client a little bit in order to implement pagination, but it’s nice that our solution is generalizable across any future paginated requests.

With the ability to track the average user rating for specific versions of our app, we now have a powerful new way to  track app health during each new release, allowing you to catch issues in their tracks and take quick action if you notice any unexpected jumps in negative user feedback.

Mobile DevOps

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.

Guide to the App Store Connect API: Calculate your iOS app's average user rating for each version

Recently we wrote about the App Store Connect API and how to build on top of it using an API client written in Swift. With the basics in place, we can start to look at more use cases where we extend this API client to take advantage of more of what the App Store Connect API has to offer. First up: user ratings!

Computing average, version-specific user ratings with the App Store Connect API

While your iOS app’s overall average user rating generally won’t change a ton from release to release, what can change significantly is the average user rating for a given release. Maybe you just shipped a bad release and there’s a drop in user ratings for that version, given some slowness/bugginess/crashiness that was introduced. Keeping a close eye on version-specific ratings is a good way to catch and fix problems before too many users encounter them.

But version-specific average ratings are not available from any built-in iOS frameworks, nor are they available using fastlane or even the App Store Connect API directly (at least not out-of-the-box from a single endpoint). Thankfully, we can leverage our API client and extend it to provide this functionality ourselves, calculating an average rating for any given release. (You can also use tools like Runway’s Rollouts to track these ratings averages without doing any work yourself, but that’s besides the point of this post.)

Let's start by creating a class that we can use to manage our <code>App<code> type.

public final class AppManager {
    private let client: APIClient
    public let app: App
    public private(set) var releases: [Release] = []
    public init(app: App, client: APIClient = APIClient()) {
        self.app = app
        self.client = client
    }
    public func fetchReleases() async throws {
        self.releases = try await client.fetchVersions(for: app)
    }
}

In our last post we created <code>App<code> and <code>Release<code> types – now, we’ve added a manager to sit on top of them. Here the <code>AppManager<code> class is initialized with an <code>App<code> and an instance of the <code>APIClient<code>. We want the client injected in the initializer so that tests can be more easily mocked out.

We'll be hitting the <code>v1/appStoreVersions/{id}/customerReviews<code> endpoint (which is documented here) to fetch all reviews for a given app version That API call requires an <code>id<code> for a given release as part of the path, which we didn't have in our <code>Release<code> type before so let's add that:

public struct Release {
    public let version: String
    let appStoreState: String
    let id: 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
        self.id = schema.id
    }
}

Thankfully the property was already on the schema that we get returned so adding it to the type is straightforward. We can now turn our gaze to building out the API client additions that will be required to call our new reviews endpoint.

Building the API client additions

The first thing we need to do is add a new filter to our OpenAPI configuration:

generate:
  - types
  - client
accessModifier: internal
filter:
  operations:
    - bundleIds-get_collection
  paths:
    - v1/apps
    - v1/apps/{id}/appStoreVersions
    - v1/appStoreVersions/{id}/customerReviews

The endpoint gets added to the <code>paths<code> array on our filter and we can build the project. This will generate some new Swift code for us to communicate with the endpoint because we are using the OpenAPI generator as a Swift Package Manager build plugin. We could generate the code manually by writing a script to call the OpenAPI generator as needed (and we'd need to check in the generated code to the repository as well once it's done), but we’ll leave code generation as an automated thing for now – the generated code will live in the DerivedData for our package.

Now that we have the generated code we can wire up an addition to our <code>APIClient<code>:

public struct Review {
    public let rating: Int
    init?(schema: Components.Schemas.CustomerReview) {
        guard let rating = schema.attributes?.rating else { return nil }
        self.rating = rating
    }
}
extension APIClient {
    func fetchReviewsForRelease(_ release: Release) async throws -> [Review] {
        let response = try await client.appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related(.init(path: .init(id: release.id)))
        switch response {
        case .ok(let ok):
            switch ok.body {
            case .json(let json):
                return json.data.compactMap({ Review(schema: $0) })
            }
        default:
            print("Bad response")
        }
        return []
    }
}

We're going to make an API on the client to take in an app's <code>Release<code>, and use the new <code>id<code> property on it to create the full path needed for the URL request. Notice once again the really odd generated code from the OpenAPI generator Swift package – we're putting a much nicer interface on our <code>APIClient<code> type by using our own types and hiding the generated code as an implementation detail of the client.

If you look at the documentation for the <code>/customerReviews<code> endpoint, the <code>200<code> response returns a <code>CustomerReviewsResponse<code> type. That type contains an <code>Attributes<code> sub-type which holds the rating for that review. So we've created a <code>Review<code> struct with a simple <code>rating<code> property on it and we can extract the rating from the returned schema. The <code>attributes<code> property is optional on the schema so the initializer is failable to guarantee that the number we need is present.

To call the new API client method we'll add one more thing to our <code>AppManager<code>.

public func computeRatings(for release: Release) async throws -> Float {
    let reviews = try await client.fetchReviewsForRelease(release)
    guard reviews.count > 0 else { return 0 }
    let ratingSum = reviews.map(\.rating).reduce(0, +)
    return Float(ratingSum) / Float(reviews.count)
}

This method takes in a given release (selected by something outside of the manager and presumably outside of the package all together), fetches all the reviews for a release, and does a bit of math to sum up the ratings then divides by the number of reviews. Notice there's no formatting here and we have to convert our <code>Int<code> types to <code>Floats<code> for the division to include any decimal places. The output formatting for display should be done at the view layer and handled before populating in a label or text view.

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 ->
Sign up for the Flight Deck — our monthly newsletter.
We'll share our perspectives on the mobile landscape, peeks into how other mobile teams and developers get things done, technical guides to optimizing your app for performance, and more. (See a recent issue here)
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

Supporting multiple pages of App Store Connect API responses

There's one more piece to this that we should explore. From time to time the App Store Connect API will have a larger dataset which can't be delivered through a single response. To handle cases like this there is a system called paging which lets the API communicate to us that there is more to fetch. App Store Connect's API uses a type called PagedDocumentLinks, which has a property called <code>next<code>, containing a full URL (interestingly, as a <code>String?<code> type); if present it contains the URL to fetch the next page of data. When the <code>next<code> property comes back <code>nil<code> then we know we have fetched the very last page of data.

> One thing to note is that while the OpenAPI Spec documents the <code>Links<code> type it is not yet implemented in the Swift OpenAPI generator. As such we'll need to approach this a bit differently and without the use of the Swift OpenAPI-generated code for sending and receiving requests.

First we're going to add a new method to <code>APIClient<code> to handle fetching pages for any Decodable data type:

func fetchPages(startingWith initial: String?, transformer: (T) -> String?) async throws {
    let auth = RequestAuth()
    let token = try createJWT(from: auth)
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601

    var nextPage: String? = initial

    while nextPage != nil {
        var request = URLRequest(url: URL(string: nextPage!)!)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (data, _) = try await session.data(for: request)
        let decoded = try decoder.decode(T.self, from: data)
        nextPage = transformer(decoded)
    }
}

The method here is generic over some <code>Decodable<code> type (which is the body of our response). It will get created with an initial URL to hit – though the method brings in a <code>String?<code> because that is what comes from the API response. We also provide a closure which can transform that <code>Decodable<code> and returns the next page to fetch from. When the closure returns <code>nil<code> then we know that the API is exhausted and we're all set.

The advantage of using a method like this is that we can fetch the authorization token once and set up our JSON decoder once. It also means we're not baking this otherwise reusable behavior into our API to fetch all the reviews. When adding another API call which requires pagination, we'll have this method ready to go.

One thing to note is that App Store Connect encodes its dates as <code>ISO8601<code> strings, so we need to set the date decoding strategy accordingly to avoid decoding errors. And, we also have to use a standard <code>URLSession<code> call to send the request for now (though this may change if and when the OpenAPI Swift generator adds support for links properly).

With our generic pagination function in place,  we can now add paging support to our review fetching method so it looks like this:

private typealias ReviewsSchema = Components.Schemas.CustomerReviewsResponse

public func fetchReviewsForRelease(_ release: Release) async throws -> [Review] {
    var reviews = [Review]()
    var nextPage: String?

    let response = try await client.appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related(.init(path: .init(id: release.id)))
    switch response {
    case .ok(let ok):
        switch ok.body {
        case .json(let json):
            reviews = json.data.compactMap({ Review(schema: $0) })
            nextPage = json.links.next
        }
    default:
        return []
    }

    try await fetchPages(startingWith: nextPage) { (response: ReviewsSchema) in
        let page = response.data.compactMap({ Review(schema: $0) })
        reviews.append(contentsOf: page)
        return response.links.next
    }

    return reviews
}

The big changes here are that instead of returning from the initial response parsing (which is done through the generated ASC API client), we extract the first batch of reviews and the <code>next<code> link (if it's there). Then below the initial API call we'll use our new <code>fetchPages<code> method to grab any paged responses that come in and append those to our <code>reviews<code> variable and return that at the end. Notice the <code>(response: ReviewsSchema)<code> input to the <code>transformer<code> closure. Typically Swift can infer types for us but in this case because the method is generic we need to explicitly give <code>response<code> the type we are expecting from the API's response body. Finding that type can be tricky:

  • Command click on the <code>appStoreVersions_hyphen_customerReviews_hyphen_get_to_many_related<code> method
  • Command click on the method's return type (<code>.Output<code>)
  • Navigate down to the nested <code>Ok<code> type in the output
  • In our case inside that <code>Ok<code> type you'll notice <code>internal var json: Components.Schemas.CustomerReviewsResponse<code. So we can pick off the schema type from here.

With all this now in place we can fetch all app reviews for a given version, and compute the average rating. Our API client now supports paging both for fetching reviews, and for any other operations we may want to perform later on as well. We've had to subvert the out-of-the-box generated OpenAPI client a little bit in order to implement pagination, but it’s nice that our solution is generalizable across any future paginated requests.

With the ability to track the average user rating for specific versions of our app, we now have a powerful new way to  track app health during each new release, allowing you to catch issues in their tracks and take quick action if you notice any unexpected jumps in negative user feedback.