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.
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:
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:
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>:
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>.
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.
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:
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:
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.