← Engineering blog

I changed my direct App Store Connect API calls to Parsequerade

My desktop app had a JWT signer so it could show me a list of iOS apps. It also had two pagination implementations and UI code that knew how Apple represents a category relationship. None of that was the part of DeployerCoaster Studio I wanted to spend time on.

I moved its App Store Connect reads to Parsequerade, the open-source facade we're building over Apple's API. The app-list loader went from 63 lines to 23. Apple JWT signing left the client entirely. Reading a field now looks like reading a field.

The previous article explained how the facade works. This is what changed in a client using it, including the awkward bits. Parsequerade currently implements core metadata reads; writes and sales reports are still planned. This migration disables Studio's copy/paste and live sales, so it isn't feature parity yet.

The JSON stopped leaking into the UI

Apple wraps ordinary fields inside a typed resource envelope. If Studio wants an app's name, it reads through attributes. A collection puts those resources inside data. Relationships have another shape again. The code is easy enough to write, but the vocabulary follows you from the HTTP response into your view code and test fixtures.

Here's the body of Studio's shared string-field helper, before and after:

Direct Apple API

resource["attributes"][field]
    .as_str()
    .unwrap_or_default()

Parsequerade

resource[field]
    .as_str()
    .unwrap_or_default()

One fewer index isn't much of a refactor on its own. The category renderer is a better example of how the surrounding code changes. It used to read info["relationships"][field]["data"]["id"], with field set to primaryCategory or secondaryCategory. Now those fields are primaryCategoryId and secondaryCategoryId, and the lookup is just info[field]. The view knows it has a category ID. It can forget Apple's relationship representation.

The app-list model needed very little surgery. I kept its existing AppAttributes struct and changed the field annotation to #[serde(flatten)], so Serde reads those fields from the flat object. The separate AppPage and PageLinks structs went away. Existing display code could keep using the same Rust fields.

The detail fixtures became ordinary objects too. A test about which iOS draft to display can supply an ID, platform, state, and creation date without recreating Apple's envelope. That's a small improvement I get every time I add another case.

Parsequerade's adapter checks the upstream resource type and copies reviewed fields with type checks. It turns supported relationships into named ID fields and preserves nulls. Studio's display helper still renders missing strings as empty, but that is a display choice; the API hasn't collapsed null, missing, and empty into the same value.

For a client that wants just one locale, the facade goes further. Its exact-locale route, /apps/{appId}/versions/{versionId}/localizations/en-US, resolves Apple's localization ID and verifies the version belongs to the app. A missing locale stays missing; it won't quietly return another language. Studio currently uses the collection route because its screen shows multiple English localizations. I can choose the collection or the exact read without implementing Apple's ID lookup in either caller.

One list reader instead of two

The old app-list loader mixed several jobs: send the request, check the status, decode Apple's page, validate the next-page URL, detect repeated pages, then sort and deduplicate the apps. The detail reader had its own pagination loop.

Now both use api::list(). All collections have an items array and a nextCursor, so the app-specific part starts like this. Sorting and deduplication follow this excerpt:

let values = api::list(transport, "/apps?limit=200", cancelled)?;
let mut apps: Vec<AppleApp> = values
    .into_iter()
    .map(|value| {
        serde_json::from_value(value)
            .map_err(|_| "Parsequerade returned an invalid app list.".to_owned())
    })
    .collect::<Result<_, _>>()?;

The complete list_apps function is 23 lines, down from 63, counting signatures and blank lines in both versions. It still sorts and deduplicates apps. Those 40 lines left the function because a shared reader now handles pages, not because I dropped the rest of the list behavior. The three-commit diff grew overall: connection migration, validation, and tests add code. This is a reduction in what each caller has to understand.

The cursor also gives that shared reader less authority. Studio used to receive Apple's next-page URL and check whether it was safe to follow. Now it rebuilds the same facade route and filters, adding the cursor as query data. Even a cursor that looks like a URL stays a parameter; it never becomes the destination for the bearer token.

Parsequerade handles Apple's pagination links behind that interface. Its pagination code validates the upstream origin and path and restores the original filters if a next link omits them. The facade cursor is signed and bound to the connection, credentials, permissions, route, and query. Changing the filter means starting a new list.

There is still a loop in Studio. It checks cancellation, validates returned IDs, rejects repeated cursors, and stops with an error after 100 pages. Responses are capped at 4 MiB. A simpler contract gives me one place to keep those checks.

The desktop app stopped signing Apple JWTs

Previously, Studio kept the issuer ID, key ID, and private key as its working credentials. It parsed the PEM, built ES256 headers and claims, read the system clock, set an expiry, and signed an Apple token. The generic transport called that signer for each request.

Now setup exchanges the Apple credentials for a connection ID, base URL, and facade bearer key. A resource read uses the saved key:

self.http
    .get(url)
    .bearer_auth(&self.credentials.api_key)
    .header(reqwest::header::ACCEPT, "application/json")

The JWT claims struct, signer, and jsonwebtoken dependency are gone from Studio. The service now reuses short-lived Apple tokens, shares concurrent signing work, and can refresh a rejected token once within its read retry budget. It also handles bounded retries for transient Apple failures. The old desktop transport signed and sent; it didn't have that recovery logic.

This moves a credential to a server. Hosted Parsequerade stores the Apple private key encrypted, and Studio saves the facade connection instead. The hosted operator can sign Apple requests, while the facade key grants read access to all apps accessible through that Apple key. That trust is part of choosing the hosted service. Parsequerade can be self-hosted, although this Studio migration pins the hosted origin and would need a configuration change to use another deployment.

I'm also adding a network hop. Nested reads can require extra Apple requests to prove that a resource belongs to the selected app. These commits don't establish a latency improvement; a smaller client doesn't imply one.

A created connection still has to be saved

The two commits after the initial migration tightened the settings flow. This is where the integration needed more code. Creating a hosted connection and saving its bearer key locally are separate operations, and either can fail.

If creation succeeds but the local save fails, Studio keeps the new connection in pending_save and leaves the previous credentials active. “Retry save connection” retries the disk write using the key it already has. It doesn't create another hosted connection just because the disk failed.

Time moves downward across Parsequerade, Studio, and the local settings file. Connection creation succeeds, but saving its key fails. Studio keeps the pending key and retries only the local save. It makes the new connection available to readers after saving succeeds.

Only after persistence succeeds does Studio publish the replacement credentials and clear the issuer ID, key ID, and private key from its settings fields. The old Apple fields are accepted when reading legacy settings, but marked skip_serializing, so subsequent saves omit them. That removes the stored copy in this settings file; it doesn't delete the original .p8 download.

The last commit also distinguishes credentials loaded from the old file from credentials someone has just typed. A complete legacy key can start one automatic migration attempt per load. Completing the new-key form doesn't start a background connection by itself. A failed migration isn't retried on every UI poll.

Errors became more useful too. The facade distinguishes an invalid facade key from Apple rejecting its own connection credentials. Studio maps upstream_authentication_failed to instructions to check the issuer ID, key ID, and private key and reconnect, without displaying the raw upstream message. Loading a saved connection only validates its local shape and trusted base URL; it doesn't prove Apple is reachable. The settings text now says “Saved Parsequerade connection.”

Writes and sales are still missing

I don't want to pass off feature removal as an API improvement. The current service implements twelve core resource GET routes, plus hosted connection setup and revocation. Its much larger reference includes proposed endpoints with implementation labels.

Studio featureAfter this migration
App list and metadataRead through Parsequerade
Copy App and PasteDisabled; copy workflows and writes aren't implemented
Live sales reportsUnavailable; report support is still planned
Previously cached salesStill readable for the matching account, vendor, and period

The flate2 dependency disappeared too, because Studio no longer downloads and decompresses live sales reports. I wouldn't count that as a win for Parsequerade's report handling: there is no replacement report adapter yet. The TSV parser remains for cached reports, and a cache miss reports that live sales are unavailable.

For the reads it supports, the facade has made the client much nicer to work on. Studio still chooses the iOS version to display, prefers editable metadata, and selects English localizations with en-US first. Those decisions stay in preferred() and english(). Parsequerade requires an explicit version ID, so it doesn't add a competing guess about which release I meant.

The migration gives Studio less of Apple's API to carry around and leaves more of its code concerned with what to show me about my apps. Parsequerade takes on the response formats, upstream pagination, and signing, along with the responsibility of holding the Apple key. That has worked well for metadata reads, but copy/paste and live sales still need to come back before this is a complete replacement. The next test is whether writes and reports can fit that same interface without pushing Apple's API details back into Studio.