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

How to implement fastlane match in Swift using the App Store Connect API, part II

Creating and invalidating signing credentials

In our previous installment we talked about why you might want to use your own API client to supplant some features of fastlane match and we wrote some code, in Swift, to help us do that, starting with fetching provisioning profiles and signing certificates that already exist in App Store Connect.

In this installment we’ll build on top of that to continue fleshing out more of the functionality fastlane match provides: creating and invalidating provisioning profiles and signing certificates, the bulk of fastlane match’s nuke and match actions. Thankfully, the App Store Connect API provides the required endpoints, which we’ll be exploring in this post. Let's dive in!

Creating a signing certificate

To create a certificate we'll be calling POST v1/certificates, which is the first time we’ve used the <code>POST<code> verb. To save some time, let's update our API generator configuration with all the new paths we'll be using (note that the verbs don't matter for this part as the generator creates code for all the supported verbs for a path).

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

A quick build and the code to generate our certificates has been created and we can get down to business. The first thing you'll need is a Certificate Signing Request or CSR. This is used by Apple to generate your certificate. You'll also need to know what type of certificate you want to make. Thankfully we created an enum of these possible values in the last article, and we’ll also be able to reuse the <code>Certificate<code> type that we made last time. Here's how we'll request the certificate's creation:

typealias CreateCertificatePayload = Components.Schemas.CertificateCreateRequest

extension APIClient {
    func createCertificate(csrContent: String, certificateType: CertificateType) async throws -> Certificate {
        guard let schemaCertificate = Components.Schemas.CertificateType(from: certificateType) else {
            throw ClientError(message: "Invalid certificate: \(certificateType)")
        }

        let payload = CreateCertificatePayload(csrContent: csrContent, certificateType: schemaCertificate)
        let response = try await client.certificates_hyphen_create_instance(.init(body: .json(payload)))

        switch response {
        case .created(let payload):
            switch payload.body {
            case .json(let json):
                guard let certificate = Certificate(schema: json.data) else {
                    throw ClientError(message: "Unable to parse successful certificate response")
                }

                return certificate
            }
        default:
            throw ClientError(message: "Unable to create certificate.")
        }
    }
}

private extension Components.Schemas.CertificateType {
    init?(from certType: CertificateType) {
        guard let resolved = Self(rawValue: certType.rawValue) else { return nil }
        self = resolved
    }
}

private extension CreateCertificatePayload {
    init(csrContent: String, certificateType: Components.Schemas.CertificateType) {
        let attributes = CreateCertificatePayload.dataPayload.attributesPayload(
            csrContent: csrContent, 
            certificateType: certificateType
        )

        self.data = .init(_type: .certificates, attributes: attributes)
    }
}

To simplify things, we’ve created a <code>typealias<code> for the certificate payload type (to avoid a lot of nesting), and broken out the creation of the payload to an extension on that type. We’ve also created a helper which converts the <code>Components.Schemas.CertificateType<code> to our own <code>CertificateType<code>.

Our method on the <code>APIClient<code> will take in the contents of the CSR (which is formatted much like an SSH private key) and will pass that along to the API. It also takes in the type of certificate to generate and makes the API call. The result of this is much like the payload type that we got back when we queried the API for existing certificates. The only difference is that the response is the single certificate that was created and not an array of certificates attached to our development team.

Revoking a signing certificate

On the flip side of creation is revoking a certificate. Revoking in App Store Connect parlance is the equivalent of deleting the certificate and only needs an ID mapping to the certificate which needs deletion.

extension APIClient {
    func revokeCertificate(_ certificate: Certificate) async throws {
        let _ = try await client.certificates_hyphen_delete_instance(path: .init(id: certificate.id))
    }
}

Looking at the API client call here, the picture continues to fill out of how the API client generator code names things. <code>certificates_hyphen_delete_instance<code> maps to the <code>DELETE /v1/certificates<code> endpoint, and the <code>path<code> input appends the certificate's ID to the path when the request is formed. You'll notice that we don't do anything with the response, and that's because these <code>DELETE<code> calls can be considered "fire and forget" and for our purposes we don't need to worry about error handling.

However, this doesn't compile because we didn't put an <code>id<code> property on our <code>Certificate<code> type, so let's do that to get our client compiling:

public struct Certificate {
    public let id: String
    // Other properties of our type
    
    init?(schema: Components.Schemas.Certificate) {
	    // Assign the other properties
	    self.id = schema.id
    }
}

Now the library compiles and we can delete a certificate!

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

Creating a provisioning profile

Provisioning profiles require more data than certificates, making them a bit trickier to create. We'll also be working with an incredibly nested set of data types and as a result are going to build this in a slightly different order than we did certificates above. Let's start with building out the data payload that we'll be sending:

typealias ProfilePayload = Components.Schemas.ProfileCreateRequest

private typealias SchemaProfileType = ProfilePayload.dataPayload.attributesPayload.profileTypePayload

private extension SchemaProfileType {
    init?(profileType: ProfileType) {
        guard let resolved = Self(rawValue: profileType.rawValue) else { return nil }
        self = resolved
    }
}

private typealias CertificateRelationship = ProfilePayload.dataPayload.relationshipsPayload.certificatesPayload.dataPayloadPayload
private extension CertificateRelationship {
    init(id: String) {
        self.init(_type: .certificates, id: id)
    }
}

private extension ProfilePayload {
    static func create(name: String, bundleID: BundleID, certificates: [Certificate], profileType: ProfileType) throws -> ProfilePayload {
        guard let schemaProfileType = SchemaProfileType(profileType: profileType) else {
            throw ClientError(message: "Unable to resolve the profile type")
        }

        let attributes: ProfilePayload.dataPayload.attributesPayload = .init(name: name, profileType: schemaProfileType)
        let relationships = ProfilePayload.dataPayload.relationshipsPayload(
            bundleId: .init(data: .init(_type: .bundleIds, id: bundleID.bundleID)),
            certificates: .init(data: certificates.map({ CertificateRelationship(id: $0.id) }))
        )

        return .init(data:
                .init(
                    _type: .profiles,
                    attributes: attributes,
                    relationships: relationships
                )
        )
    }
}

There's a lot going on in this chunk so let's walk through it piece by piece. Firstly the <code>ProfilePayload<code> type alias represents the full body payload of our eventual request. This initial alias is useful to make not only this body type more compact but a couple of other things more compact as well — namely the <code>CertificateRelationship<code> and <code>SchemaProfileType<code> types.

We haven't talked about <code>ProfileType<code> yet but it looks and works much like the <code>CertificateType<code>. <code>ProfileType<code> is analogous to the profileType property on the <code>attributes<code> member of our payload. It's an enum of strings with each case mapping to the possible values of the strings on the documentation link. The initializer there is much like our convenience for certificates and behaves the same way.

Next up is our first foray into relationships. Relationships tell the backend to tie into other types of data rather than including them in the attributes payload. There are 2 required relationships for creating profiles: the certificates used to sign the profile, and the bundle identifier. The certificate relationship value in particular is defined as an array of certificate data (documented here). The convenience above will let us map an array of our <code>Certificate<code> type to this data payload (where there is a hard-coded type <code>.certificates<code> as well). It's a nice thing to have and we deserve nice things.

All this leads to our static function to create a payload. This method is pretty straightforward, where we utilize our newly-created helpers to create the attributes and relationships of the payload, and nest those in the structure of the payload initializer itself.

Doing this legwork at the beginning lets us write an API client method that hopefully will look pretty familiar at this point:

extension APIClient {
    func createProfile(
        name: String,
        bundleID: BundleID,
        certificates: [Certificate],
        profileType: ProfileType) async throws -> Profile
    {
        let payload = try  ProfilePayload.create(name: name, bundleID: bundleID, certificates: certificates, profileType: profileType)
        let response = try await client.profiles_hyphen_create_instance(.init(body: .json(payload)))

        switch response {
        case .created(let created):
            switch created.body {
            case .json(let json):
                guard let profile = Profile(schema: json.data) else {
                    throw ClientError(message: "Unable to parse successful profile response")
                }

                return profile
            }

        default:
            throw ClientError(message: "Unable to verify a profile was created")
        }
    }
}

The method takes as inputs the things needed to build up the payload (notice that the payload creation <code>throws<code> and that lets us create it with a <code>try<code>, and in the event of a thrown error we don't need to handle it in the API client method, it will be forwarded along to our caller). From there we create the request, wait for it to complete, and unwrap the profile from the successful response.

We have a brand new shiny profile!

Deleting a provisioning profile

Deleting a profile that is not needed anymore is thankfully just as easy as deleting a certificate.

extension APIClient {
    func deleteProfile(_ profile: Profile) async throws {
        let _ = try await client.profiles_hyphen_delete_instance(path: .init(id: profile.id))
    }
}

It is once again a fire-and-forget operation, and much like certificates we've also added an <code>id<code> property to the <code>Profile<code> type we made before.

We have arrived

We've come a long way in this fastlane match mini-series. We can create, retrieve, and delete signing certificates and provisioning profiles using Swift code alone. This code can run as a step in your build process or as a pre-flight on your Continuous Integration systems. In an earlier article we talked about how we can use fastane and its match_nuke action to automate the renewal of your code signing credentials. Using our App Store Connect API client we can replicate that custom lane using exclusively Swift code. We'll start with a new <code>SigningManager<code> which can have a method to rebuild distribution signing and go from there.

 public final class SigningManager {
    private let client: APIClient
    private let profileManager: ProfileManager
    private let certificateManager: CertificateManager

    init(client: APIClient = APIClient()) {
        self.client = client
        self.profileManager = ProfileManager(client: client)
        self.certificateManager = CertificateManager(client: client)
    }

    public func rebuildDistributionSigning() async throws {
        // Fetch all the distribution certs we know about
        let distributionCertificates = certificateManager.certificates.filter({ $0.isDistribution })
        // Fetch all the profiles which are signed by those certs
        let profiles = profileManager.profiles.filter({ $0.certificates.contains(distributionCertificates) })

        await withThrowingTaskGroup(of: Void.self) { [profileManager, certificateManager] group in
            for profile in profiles {
                group.addTask {
                    try await profileManager.deleteProfile(profile)
                }
            }

            for certificate in distributionCertificates {
                group.addTask {
                    try await certificateManager.revokeCertificate(certificate)
                }
            }
        }

        var renewedCertificates = [Certificate]()
        for certificate in distributionCertificates {
            let csr = "" // To be filled in – unique per certificate
            let cert = try await certificateManager.addCertificate(csrContent: csr, certificateType: certificate.type)
            renewedCertificates.append(cert)
            certificateManager.writeCertificateToKeychain(cert)
        }

        var regeneratedProfiles = [Profile]()
        for profile in profiles {
            guard profile.bundleID != nil else { continue }

            let certTypes = profile.certificates.map { $0.type }
            let profileCerts = renewedCertificates.filter({ certTypes.contains($0.type) })
            let newProfile = try await profileManager.createProfile(
                name: profile.name,
                bundleID: profile.bundleID!,
                certificates: profileCerts,
                profileType: profile.profileType
            )
            regeneratedProfiles.append(newProfile)
            profileManager.writeProfileToDisk(newProfile)
        }

        // We now have arrays of renewed signing certificates and profisioning profiles!
    }
 }

The method here looks complicated but is broken down in a few logical steps:

  1. This class has instances of the <code>ProfileManager<code> and <code>CertificateManager<code> classes that we made in earlier articles. We'll use those to get the distribution certificates first, and then filter just the profiles that use those certificates.
  2. We make a task group, which lets us run async tasks concurrently, so that we can make tasks to delete the profiles and certificates from App Store Connect. This will give us the clean slate we need to create the new credentials.
  3. The first thing we'll remake is the distribution certificates. We'll create an array of the new certificates and loop through our local copies of the ones we just deleted to create the new ones and add them to the array. You'll have to supply a CSR for each one, and this is doable using the <code>openssl<code> process (but is outside of the scope of the article).
  4. Lastly we'll remake the provisioning profiles. Like certificates we'll loop over the local copies of the profiles we deleted and remake them. Note how we're finding the correct renewed certificates to include in the profile request. And from there we use the rest of the data from the old profile to create the new one.

And with this method we can now rebuild our distribution signing using our own Swift code, just like we did using fastlane!

Bonus: troubleshooting the App Store Connect API

In building allthis out, we've done a lot with the App Store Connect API. We've talked a little bit about the quirks of the API when it comes to date formatters, but there's another “quirk” you may run into (and it is very applicable to our topic). There can be times when the OpenAPI spec and documentation are wrong. Not a little wrong — completely wrong!

For example, the API call for fetching provisioning profiles returns a profile response that does not include things we need when making our new profile, such as the bundle ID and the certificates used to make the old profile. To get this data we have to make 2 other API calls, to Read the BundleID in a Profile and to List All Certificates in a Profile. Those documentation pages will list the URLs to be called as well as the expected response objects, like we've seen before.

The problem is that they don't return those types. At all. The certificates response says that a successful call will return something called the CertificatesWithoutIncludesResponse and inside that object is an array of profiles, only our call was expecting certificates! So how do we get the certificates we want? Well, it turns out that we do get them in the API call we make to the <code>v1/profiles/{id}/certificates<code> endpoint, but the documentation and OpenAPI spec are wrong. We're just going to have to do this parsing ourselves.

extension APIClient {
    func fetchURL(_ url: URL) async throws -> T {
        let auth = RequestAuth()
        let token = try createJWT(from: auth)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .custom({ decoder in
            let container = try decoder.singleValueContainer()
            let wireValue = try container.decode(String.self)

            let transcoder = APIDateTranscoder()
            return try transcoder.decode(wireValue)
        })

        var request = URLRequest(url: url)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

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

Firstly we'll make a method on our API client to handle fetching a URL and decoding its response. This method does all the auth that we need and handles the date oddities we looked at before. At the end of it if we have a successful payload decode then we can return it and be on our way. But what kind of payload do we decode if everything to this point has been generated?

extension ProfileManager {
    public func fetchCertificates(for profile: Profile) async throws -> [Certificate] {
        struct CertificateDTO: Decodable {
            var data: [Components.Schemas.Certificate]
        }

        let url = URL(string: "https://api.appstoreconnect.apple.com/v1/profiles/\(profile.id)/certificates")!
        let dto: CertificateDTO = try await client.fetchURL(url)
        let certs = dto.data.compactMap({ Certificate(schema: $0) })
        return certs
    }
}

Here we have a method on our <code>ProfileManager<code> to grab the associated certificates. It constructs the URL based on the identifier of the profile and the payload it's expecting is one of our own creation — kind of. It turns out that the generated <code>Components.Schemas.Certificate<code> type is what is actually returned by the API, it's just wrapped in a different outer shell. This is where the power of Swift comes in really handy and we can make a disposable <code>Decodable<code> type which mirrors the API response we get back from App Store Connect, and this will decode perfectly! We already have the ability to make a certificate by the response schema, so we can map the array of those schemas to our certificates and return them from our method.

Likewise we need to have a similar method for fetching the bundle IDs. This will give us the bundle ID associated with the existing profile, so that we can use it to make our new profile:

 extension ProfileManager {
    public func fetchBundleID(for profile: Profile) async throws -> BundleID {
        struct BundleIDDTO: Decodable {
            var data: Components.Schemas.BundleId
        }

        let url = URL(string: "https://api.appstoreconnect.apple.com/v1/profiles/\(profile.id)/bundleId")!
        let dto: BundleIDDTO = try await client.fetchURL(url)
        let bundleID = BundleID(id: dto.data.id, bundleID: dto.data.attributes?.identifier ?? "NONE")
        return bundleID
    }
}

Learning how to troubleshoot these issues can be difficult. It's very helpful to have a tool like Proxyman to help out when making an API client of any kind. Proxyman can sit between your code and the traffic going in and out of your Mac, and actually show you the requests and responses in real-time. That way you can see if you're getting responses like the documentation and specs say you are (because if you run into decoding errors there’s a decent chance the response is not what you'd expect it to be).

OK, now we have arrived

We have now built out in Swift an API client to talk with App Store Connect, and all the pieces we need to rebuild our signing credentials just like we did in fastlane match. We've also looked at ways the API specification and documentation may differ from the responses that are actually returned to you. You're now ready to go and conquer the App Store Connect API!

See even earlier posts in our App Store Connect API series: 

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.