iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤖

Calling App Store Connect API from Swift

に公開

The App Store Connect API is detailed in the documentation, so please refer to it for the basics.

https://developer.apple.com/documentation/appstoreconnectapi

In this article, I will introduce some points I found tricky: how to create tokens and how to read the contents of gzip data.

How to Create a Token

The documentation for tokens can be found here.

https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests#3878466

App Store Connect API uses JWT.
First, create an API key in App Store Connect.
Download the p8 file and note the Issuer ID and Key ID.

let header: [String: String] = [
  "alg": "ES256",
  "kid": keyID,
  "typ": "jwt",
]

let payload: [String: Any] = [
  "iss": issuerID,
  "iat": Int(Date().timeIntervalSince1970),
  "exp": Int(Date().timeIntervalSince1970 + (20 * 60)),
  "aud": "appstoreconnect-v1",
]

Create the header and payload like this. (The scope is optional, so it is ignored here.)
After converting these into JSON strings, convert them into Base64 (URL encoded) format and join them with ..

let headerJson = try! JSONSerialization.data(withJSONObject: header)
let payloadJson = try! JSONSerialization.data(withJSONObject: payload)

let input = "\(headerJson.base64urlEncodedString()).\(payloadJson.base64urlEncodedString())"
How to convert to URL-encoded Base64
import Foundation

extension Data {
  func base64urlEncodedString() -> String {
    return base64EncodedString()
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "=", with: "")
  }
}

Next, use the downloaded private key to encrypt it in ES256 format and create a signature.
We will use CryptoKit for this.

let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKey)
let data = input.data(using: .utf8)!
let signature = try privateKey.signature(for: data)

It is convenient that you can pass the PEM format string (the format enclosed by -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----) directly to the initializer of P256.Signing.PrivateKey. (As this is @available(iOS 14.0, macOS 11.0, watchOS 7.0, tvOS 14.0, *), you might need to be careful about the version support.)

Finally, the API token is completed by joining the Base64-converted header and payload with the Base64-converted signature using ..

let raw = signature.rawRepresentation
let apiToken = "\(input).\(raw.base64urlEncodedString())"

After that, you can make requests by adding it to the API request header in the form of Authorization: Bearer \(apiToken).

Column

By the way, when I tried to use the private key by saving it in GitHub Actions Secrets, it didn't work for some reason, and I was stuck for about an hour.
The cause was that the newline codes were converted when saved in the GitHub Actions settings.
I wrote an article about this yesterday, so please refer to it.

https://zenn.dev/fromkk/articles/4ee7df3e65c87e

How to View the Contents of gzip-Compressed Data

Most responses from the App Store Connect API are returned in JSON format, but some data is returned compressed in gzip format.
One way to decompress this data is by using a library.

https://github.com/1024jp/GzipSwift

This time, I decided not to use this library. Instead, while researching, I found that it could be decompressed using zlib, so I asked ChatGPT to write the code for me.

https://chatgpt.com/share/ccc06b02-a7ea-46f9-aced-9f4f496add32

Code to decompress gzip-formatted data
import Foundation
import zlib

func gunzip(data: Data) -> Data? {
  guard data.count > 0 else {
    return nil
  }

  var stream = z_stream()
  var status: Int32

  status = inflateInit2_(&stream, 16 + MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
  guard status == Z_OK else {
    return nil
  }

  var decompressedData = Data(capacity: data.count * 2)
  data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
    stream.next_in = UnsafeMutablePointer<Bytef>(
      mutating: bytes.bindMemory(to: Bytef.self).baseAddress!)
    stream.avail_in = uint(data.count)

    while stream.avail_in > 0 {
      if Int(stream.total_out) >= decompressedData.count {
        decompressedData.count += data.count / 2
      }

      stream.next_out = decompressedData.withUnsafeMutableBytes {
        $0.bindMemory(to: Bytef.self).baseAddress! + Int(stream.total_out)
      }
      stream.avail_out = uint(decompressedData.count) - uint(stream.total_out)

      status = inflate(&stream, Z_SYNC_FLUSH)
      guard status == Z_OK || status == Z_STREAM_END else {
        inflateEnd(&stream)
        return
      }
    }
  }

  inflateEnd(&stream)
  decompressedData.count = Int(stream.total_out)

  return decompressedData
}

I am using the code generated by ChatGPT as is, so please let me know if there are any errors.
(It worked fine for me, so I'm using it as it is.)

Summary

In this article, I introduced how to create tokens for use with the App Store Connect API in Swift and how to decompress gzip-formatted data.

I am building a CLI tool that uses these methods to summarize and post daily app installation counts and user reviews to Slack.
Feel free to take a look if you're interested.

https://github.com/fromkk/AppReporter.git

Discussion