Site icon AppTractor

Как создать клиент App Store Connect API на Swift с помощью OpenAPI

В течение многих лет Apple предоставляла разработчикам веб-сервисы через портал для разработчиков и App Store Connect, которые позволяли выпускать релизы приложений, управлять сертификатами подписи и собирать отчеты о том, сколько денег вы зарабатываете на создании своих приложений. Раньше единственным способом доступа к этим сервисам был браузер, но в последние несколько лет ситуация изменилась с появлением App Store Connect API, которые позволяют любому человеку, имеющему учетную запись App Store Connect, получить доступ к большей части функциональности, которая раньше была доступна только через эти сайты.

Единственное, что требуется для начала работы с App Store Connect API — это API-ключ, но чтобы действительно извлечь из подключения максимум пользы — скажем, чтобы вы могли создавать собственные внутренние инструменты на основе данных App Store Connect — хорошей идеей будет настройка API-клиента для аутентификации и выполнения запросов к App Store Connect API.

Как начать работу с API-клиентом для App Store Connect?

Самый простой способ — использовать определения OpenAPI. OpenAPI — это спецификация для определения схем сетевых вызовов — конечных точек, к которым нужно обращаться, а также полезной нагрузки, которую они принимают и отправляют обратно. OpenAPI, как спецификация, позволяет генерировать код для использования этих сетевых схем, и только этим летом Apple анонсировала свой собственный генератор OpenAPI для кода Swift.

Ознакомьтесь с сессией Meet Swift OpenAPI Generator с WWDC23.

В этом посте мы создадим Swift-пакет, который сможет общаться с App Store Connect API, используя код, сгенерированный генератором OpenAPI.

Начнем с манифеста Package.swift:

let openAPITag: PackageDescription.Version = "1.0.0"
​
let package = Package(
    name: "asc-api-client",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(
            name: "AppStoreConnectClient",
            targets: ["APIClient"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", exact: openAPITag),
        .package(url: "https://github.com/apple/swift-openapi-runtime", exact: openAPITag),
        .package(url: "https://github.com/apple/swift-openapi-urlsession", exact: openAPITag),
    ],
    targets: [
        .target(
            name: "APIClient",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
            ],
            plugins: [
                .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
            ]
        ),
        .testTarget(
            name: "APIClientTests",
            dependencies: ["APIClient"]),
    ]
)

Для использования инструментов OpenAPI нам необходимо иметь три зависимости: 1) генератор кода, 2) runtime типы

и 3) транспортный уровень (в нашем случае URLSession, но есть и другие, например HTTPClient). Затем мы добавляем плагин генератора кода к основной цели APIClient, чтобы он мог запускаться во время сборки. Не стоит вызывать его вручную — генерируемые им файлы будут скопированы в каталог Sources вашего проекта, а также включены в выходные данные плагина. Это означает, что проект не соберется, потому что в нем будут идентичные файлы в двух разных местах.

Плагин генератора OpenAPI

С этой структурой давайте посмотрим на генератор OpenAPI. Это плагин, который будет генерировать Swift-код для взаимодействия с App Store Connect API. В нашем пакете это плагин для цели APIClient, и внутри ему нужны две вещи:

  1. Файл с именем openapi.json. Он может иметь расширение .yml или .yaml. Это манифест, в котором объявлены определения API.
  2. Файл openapi-generator-config.yaml Этот файл конфигурации указывает генератору, какие типы создавать и какова их видимость (публичные, внутренние и т. д.). Вот полная документация по генератору и тому, что он может конфигурировать.

Следует отметить одну очень важную опцию конфигурации — фильтрацию. Фильтрация позволяет вам брать части документа OpenAPI, а не весь документ, например, вы можете фильтровать по объекту Operation Object. Это очень удобно для очень больших файлов определений, таких как манифест App Store Connect, где генерация без фильтра дает файл типов размером 20 МБ, а файл клиента — 5 МБ. Это файлы Swift, которые могут быть очень сложными, что означает, что для их разбора в Xcode требуется много ресурсов, и при попытке прокрутить один из них новый MacBook Pro M3 Max остановился. Фильтр — ваш друг 😀.

Как только все эти элементы будут собраны, мы сможем создать проект. Это запустит плагин-генератор и мы получим типы и клиента из манифеста OpenAPI. Для наших целей давайте попробуем сделать вызов API, чтобы получить приложения для нашего аккаунта. Мы можем сделать это с помощью данной конфигурации:

generate:
  - types
  - client
accessModifier: internal
filter:
  paths:
    - v1/apps

Итак, теперь давайте рассмотрим наш класс APIClient и то, как он может выполнять вызов этой конечной точки:

import OpenAPIRuntime
import OpenAPIURLSession
​
public final class APIClient {
    private let client: Client
​
    public init() {
        self.client = try! Client(
            serverURL: Servers.server1(),
            transport: URLSessionTransport(),
            middlewares: [
                JWTMiddleware(),
            ]
        )
    }
}
​
extension APIClient {
    public func fetchBundleIdentifiers() async throws -> [String] {
        let response = try await client.apps_hyphen_get_collection()
        switch response {
        case .ok(let okResponse):
            switch okResponse.body {
            case .json(let json):
                return json.data.compactMap({ $0.attributes?.bundleId })
            }
        default:
            break
        }
        
        return []
    }
}

Первое, что следует отметить, — это странные имена сгенерированных методов, и если вы просто посмотрите на возвращаемый тип метода, он не станет менее странным: Operations.apps_hyphen_get_collection.Output.

Здесь очень много вложенных типов, и будет полезно обратиться к документации API для любого конкретного вызова, который вы хотите выполнить, чтобы понять, что должно быть отправлено в качестве аргумента и что можно ожидать в ответ. Навигация по сгенерированному коду на Swift не будет для вас увлекательным занятием. Поэтому давайте сделаем простой юнит-тест для нашего вызова API, поставим точку останова в ответе и посмотрим, что придет в ответ:

func testFetchBundleIDs() async throws {
    let client = APIClient()
    let bundleIDs = try await client.fetchBundleIdentifiers()
    XCTAssertEqual([], bundleIDs)
}

Фактические возвращаемые значения и утверждения здесь пока не имеют значения, поскольку мы хотим увидеть, что возвращает сеть. А это означает, что вызов API возвращает неавторизованный запрос. Это подводит нас к аутентификации.

Аутентификация вызова App Store Connect API

Теперь нам нужно сообщить API, кто мы такие и к какой учетной записи App Store Connect принадлежим. Для этого нужно выполнить несколько шагов:

  1. Сгенерировать ключ API‍
  2. Сгенерировать токен аутентификации для данного запроса 
  3. Добавить токен к каждому запросу

Пункты 2 и 3 могут быть выполнены с помощью созданного нами API-клиента. Мы можем создать тип «промежуточного ПО», который будет находиться между отправкой запроса и получением ответа, чтобы изменить его, и это промежуточное ПО может вводить наши учетные данные аутентификации в виде JSON Web Token (JWT). Для этого требуется специальное кодирование и криптография. К счастью, команда, создавшая бэкенд-фреймворк Vapor, позаботилась о библиотеке JWT. Так что давайте добавим эту зависимость:

.package(url: "https://github.com/vapor/jwt-kit", exact: "4.13.1"),

И добавьте ее в качестве зависимости к нашей цели APIClient. Затем мы можем создать функцию для генерации JWT и использовать ее в качестве промежуточного ПО:

func createJWT(from request: RequestAuth) throws -> String {
    let signers = JWTSigners()
    // privateKey needs to be fetched from a hard-coded string, some file, or
    // the environment
    try signers.use(.es256(key: .private(pem: privateKey)))
    // same for the key ID
    let jwkID = JWKIdentifier(string: keyID)
    // same for the kid (Key ID) value
    let jwt = try signers.sign(request, kid: jwkID)
    return jwt
}
​
struct JWTMiddleware: ClientMiddleware {
    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        baseURL: URL,
        operationID: String,
        next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
        var request = request
        let auth = RequestAuth()
        let jwt = try createJWT(from: auth)
        request.headerFields[.authorization] = "Bearer \(jwt)"
        return try await next(request, body, baseURL)
    }
}

Поместив это в инициализатор Client, мы можем гарантировать, что наши запросы будут подписаны правильно:

// Inside APIClient.init
self.client = try! Client(
    serverURL: Servers.server1(),
    transport: URLSessionTransport(),
    middlewares: [ JWTMiddleware() ]
)

Перезапустив тест, вы можете увидеть, что он, скорее всего, не сработает! По иронии судьбы это именно то, что нам нужно, потому что наш метод на самом деле возвращает идентификаторы пакетов для ваших приложений, а тест утверждал, что массив будет пустым.

У нас есть рабочий вызов API App Store Connect! Что теперь?

Теперь, когда у нас есть рабочий вызов, давайте сделаем еще один шаг вперед и создадим еще один вызов API на основе того, который мы только что сделали. Сначала немного отрефакторим:

// 1. Create a new type to represent an App
public struct App {
    public let id: String
    public let bundleID: String

    // 3. This is the payload DTO returned from the API
    init?(schema: Components.Schemas.App) {
        guard let bundleID = schema.attributes?.bundleId else { return nil }
        self.id = schema.id
        self.bundleID = bundleID
    }
}

// 2. Update the API client method to return an array of the new App type
public func fetchApps() async throws -> [App] {
            switch okResponse.body {
            case .json(let json):
                // 4. Instead of unpacking the bundle IDs here, create our `App` instances.
                return json.data.compactMap({ App(schema: $0) })
}

Здесь очень много всего, особенно важно следующее:

  1. Создаем новый тип, который мы сможем использовать в API нашего пакета (API в смысле настоящего интерфейса программирования приложений, а не в смысле сетевых вызовов). Мы хотим сделать это так, чтобы скрыть от потребителей любые детали сетевого App Store Connect API.
  2. Обновляем наш клиентский метод API, чтобы он возвращал новый тип App вместо массива строк, и изменяем название, чтобы отразить намерения метода.
  3. В инициализаторе типа App попросим его принять полезную нагрузку ответа от вызова App Store Connect API. Полезная нагрузка, с которой мы взаимодействуем в клиенте OpenAPI, называется DTO — Data Transfer Objects. Считайте их посредниками между нашими приложениями и сетевыми вызовами, которые можно использовать безопасным для типов способом.
  4. Обновим часть разбора JSON в нашем API-клиенте, чтобы вернуть тип App, передав схему.

Далее мы возьмем приложение, которое мы извлекли, и захватим связанные с ним релизы. Для этого вызовем конечную точку v1/apps/{id}/appStoreVersions. Обратите внимание, что в середине пути находится токен {id}, обозначающий идентификатор приложения. Обратите особое внимание на то, как свойство app.id используется ниже для заполнения токена:

public struct Release {
    public let version: String
    let appStoreState: 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
    }
}

extension APIClient {
    public func fetchVersions(for app: App) async throws -> [Release] {
        let response = try await client.apps_hyphen_appStoreVersions_hyphen_get_to_many_related(path: .init(id: app.id))
        switch response {
        case .ok(let okResponse):
            switch okResponse.body {
            case .json(let json):
                return json.data.compactMap({ Release(schema: $0) })
            }

        default:
            print("bad response")
        }

        return []
    }
}

Как и в случае с типом App, мы создаем здесь тип Release, чтобы он служил нашим собственным типом, который мы контролируем. Он создается с помощью DTO-представления релиза в сетевом вызове. Интересно отметить, что в определении Components.Schemas.AppStoreVersion мы должны получить rawValue свойства appStoreState. Это потому, что генератор OpenAPI создал для нас перечисление! Это может быть очень полезно при работе с собственными схемами API, поскольку они предоставляют исчерпывающий список допустимых значений.

Далее мы создадим метод API-клиента для получения всех версий приложения. Метод, сгенерированный OpenAPI, называется apps_hyphen_appStoreVersions_hyphen_get_to_many_related и принимает аргумент пути. Мы можем создать этот аргумент с помощью .init(path: app.id) и попросить Swift вывести тип, который мы создаем. Существуют некоторые споры о преимуществах использования голого .init в коде Swift, но это одно из мест, где это кажется более приемлемым, потому что полный тип пути здесь — apps_hyphen_appStoreVersions_hyphen_get_to_many_related.Path. Лучше позволить компилятору разобраться с этим!

Как и раньше, мы сделаем сетевой запрос и проверим его ответ. Если все в порядке, то мы соберем массив объектов Release и вернем их. После этого мы сможем сделать наш тест для проверки следующим образом:

func testFetchingReleases() async throws {
    let client = APIClient()
    let apps = try await client.fetchApps()
    let releases = try await client.fetchVersions(for: apps.first!)
    XCTAssertEqual(0, releases.count)
}

Вы можете поставить точку останова на строке XCTAssert и просмотреть результирующий вывод нашего метода, а также узнать, что вернул API.

Какое путешествие мы совершили! Мы рассмотрели компоненты пакетов OpenAPI, которые Apple недавно выпустила, создали API-клиент для вызова App Store Connect API, сделали небольшой крюк, чтобы запустить аутентификацию, и сделали два вызова API, которые мы действительно можем разобрать и получить полезные данные. API App Store Connect может сделать для нас гораздо больше, и это только начало.

Источник

Exit mobile version