🌐

Overview: Swift OpenAPI Generator

2024/01/30に公開

謝辞

この記事は2024年01月25日に開催されたイベント「pixiv App Night〜エディタアプリとOpenAPIと〜」での発表内容を基にした記事です。

https://pixiv.connpass.com/event/305389/

はじめに

Swift OpenAPI Generatorは、Appleが開発したSwift Package Pluginであり、iOSアプリ開発の速度をさらに向上させる可能性を持っています。このツールを利用することで、OpenAPIドキュメントからSwift言語におけるクライアントおよびサーバーコードを自動生成させ、開発プロセスの迅速化と簡素化を実現することができます。

現代のiOSアプリ開発では、APIとの連携が必要になることが多く、それを正確かつ効率的に実装していくための手段としてコードの自動生成が有効です。Swift OpenAPI Generatorは、Type Safeなプロパティ、入出力のエンコード・デコード処理、リクエストの自動生成機能を含めた様々な機能を提供することにより、開発者が直面する多くの課題を解消します。このツールの導入により、開発者はいわゆる「儀式的なコード」の実装から解放され、別の作業により集中できるようになります。

このツールが提供する自動化機能は、最新のOpenAPI仕様(3.0.xおよび3.1.x)に対応しています。

この記事では、Swift OpenAPI Generatorの基本的な概要から、具体的なサーバー側の実装方法およびクライアント側の実装方法、さらに、Swift OpenAPI Generatorの隠蔽やヘッダーの注入方法までのプラクティスを解説していきます。これにより、iOSアプリ開発の現場でSwift OpenAPI Generatorを最大限に活用して頂ければ幸いです。

サーバ側の実装

このセクションでは、クライアント側アプリケーションをサポートするために、Vaporフレームワークを使用してローカルで動作するサーバの実装について詳しく説明します。サーバの設計は、以下に示すOpenAPI.yamlファイルを基にして行われます。

OpenAPIの構成

openapi: '3.1.0'
info:
  title: API For PixivAppNight
  version: 1.0.0
servers:
  - url: https://example.com/api
    description: Example service deployment.
paths:
  /v1/welcome_message:
    get:
      operationId: welcomeMessage
      parameters:
        - name: name
          required: true
          in: query
          description: メッセージを返す
          schema:
            type: string
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PixivAppNightMessage'
components:
  schemas:
    PixivAppNightMessage:
      type: object
      properties:
        message:
          type: string
      required:
        - message

このOpenAPI.yamlファイルには、3.1.0バージョンのOpenAPI仕様に従った、API For PixivAppNightというタイトルのAPIが定義されています。

主要APIの解説

重要な部分は、/v1/welcome_messageというパスに定義されたAPIです。このAPIGETメソッドを使用し、nameという必須のクエリパラメータを受け取ります。このパラメータは、メッセージを返すために使用され、文字列型のデータを受け取ります。レスポンスとしては、200 OKのステータスコードと共にPixivAppNightMessageスキーマを用いてJSON形式のメッセージを返します。

コード自動生成の設定

サーバ側のコードと型を自動生成するためにopenapi-generator-config.yamlが必要です。

generate:
  - types
  - server

typesserverを指定しているので、これにより型とサーバ側のコードが自動生成されます。

PackageDescriptionの内容

以下はサーバ用のPackageDescriptionの内容です。

import PackageDescription

let package = Package(
  name: "PixivAppNightServer",
  platforms: [
    .macOS(.v10_15)
  ],
  dependencies: [
    .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
    .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
    .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.0"),
    .package(url: "https://github.com/vapor/vapor", from: "4.89.0"),
  ],
  targets: [
    .executableTarget(
      name: "PixivAppNightServer",
      dependencies: [
        .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
        .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
        .product(name: "Vapor", package: "vapor"),
      ],
      plugins: [
        .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
      ]
    )
  ]
)

この設定をビルドするとサーバの構築に必要なプロトコルであるAPIProtocolが自動生成ます。

APIProtocolの概要

internal protocol APIProtocol: Sendable {
  func welcomeMessage(
    _ input: Operations.welcomeMessage.Input
  ) async throws -> Operations.welcomeMessage.Output
}

extension APIProtocol {
  internal func welcomeMessage(
    query: Operations.welcomeMessage.Input.Query,
    headers: Operations.welcomeMessage.Input.Headers = .init()
  ) async throws -> Operations.welcomeMessage.Output {
    try await welcomeMessage(Operations.welcomeMessage.Input(
       query: query,
      headers: headers
    ))
  }
}

このプロトコルは、サーバが提供するAPIの仕様を定義します。具体的には、welcomeMessageメソッドを通じて、クライアントからのリクエストを処理し、適切な値を返します。

PixivAppNightAPIの実装

APIProtocolに準拠したPixivAppNightAPIの実装は以下の通りです。

struct PixivAppNightAPI: APIProtocol {
  func welcomeMessage(
    _ input: Operations.welcomeMessage.Input
  ) async throws -> Operations.welcomeMessage.Output {
    let name = input.query.name
    let welcomeMessage = Components.Schemas.PixivAppNightMessage(message: "\(name)さん、pixivAppNightへようこそ!")
    
    return .ok(.init(body: .json(welcomeMessage)))
  }
}

このクラスは、クライアントからのnameパラメータを受け取り、PixivAppNightMessageを生成してレスポンスとして返します。

サーバの実行

最後に、以下のようなmain関数を実装し、サーバを起動します。

@main struct PixivAppNightServer {
  static func main() async throws {
    let app = Vapor.Application()
    let transport = VaporTransport(routesBuilder: app)
    let handler = PixivAppNightAPI()
    try handler.registerHandlers(
      on: transport,
      serverURL: Servers.server1()
    )
    try await app.execute()
  }
}

この実装により、ローカルでサーバが立ち上がり、APIリクエストの受付と処理が開始されます。

クライアント側の実装

このセクションでは、クライアント側の実装について詳細に説明します。ここでは、ローカル開発環境に特化した設定と実装の手順を紹介します。

OpenAPIの設定

ローカル開発を効率化するために、OpenAPI.yamlにはローカルサーバ用のURLを追加します。

servers:
  - url: https://example.com/api
    description: Example service deployment.
  - url: http://127.0.0.1:8080/api
    description: Localhost deployment.

この設定により、ローカルホスト上で動作するサーバにクライアントがアクセスできるようになります。

コード自動生成の設定

openapi-generator-config.yamlは以下のように設定します。

generate:
  - types
  - server

この設定を用いると、必要な型とクライアント側のコードが自動生成されます。

PackageDescriptionの内容

クライアント側のPackageDescriptionは次の通りです。

import PackageDescription

let package = Package(
  name: "PixivAppNightClient",
  platforms: [.macOS(.v12), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1)],
  dependencies: [
    .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
    .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
    .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
  ],
  targets: [
    .executableTarget(
      name: "PixivAppNightClient",
      dependencies: [
        .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
        .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
      ],
      plugins: [
        .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
      ]
    )
  ]
)

この設定によりビルドを行うと、APIProtocolに準拠したClientという名前の構造体が自動生成されます。

internal struct Client: APIProtocol {
  private let client: UniversalClient
  
  internal init(
    serverURL: Foundation.URL,
    configuration: Configuration = .init(),
    transport: any ClientTransport,
    middlewares: [any ClientMiddleware] = []
  ) {
      // ...
    )
  }
  
  // ...

  internal func welcomeMessage(
    _ input: Operations.welcomeMessage.Input
  ) async throws -> Operations.welcomeMessage.Output {
    try await client.send(
      // ...
    )
  }
}

Client構造体の詳細解説

Client構造体は、サーバとの通信を担う重要なコンポーネントです。この構造体はAPIProtocolに準拠しており、以下のような機能を持っています

初期化処理

Clientinitメソッドは、サーバのURLとクライアントの設定を受け取ります。serverURLパラメータにはAPIリクエストを送信するサーバのアドレスを指定し、configurationではクライアントの動作をカスタマイズします。transport引数にはリクエストの送受信を担うトランスポート層を指定し、必要に応じてミドルウェアをmiddlewares配列に追加できます。

APIリクエストメソッド

welcomeMessageメソッドなどのAPIリクエスト処理は、サーバとの通信を管理します。これらのメソッドは、入力パラメータ(input)を受け取り、サーバにリクエストを送信してレスポンスを待ちます。処理は非同期で行われ、結果はasync throwsを用いて返されます。

APIの利用方法の詳細

クライアントは、以下のコードを用いることでAPIを叩くことができます。

let client = Client(
  serverURL: URL(string: "http://127.0.0.1:8080/api")!,
  transport: URLSessionTransport()
)

let response = try await client.welcomeMessage(
  .init(query: .init(name: "ojun"))
)

switch response {
case let .ok(okResponse):
  switch okResponse.body {
  case let .json(PixivAppNightMessage):
    print(PixivAppNightMessage.message)
  }
case .undocumented(statusCode: let statusCode, _):
    print(statusCode)
}

Client構造体を使用してAPIを叩くプロセスは以下のステップで構成されます:

  1. クライアントの初期化
    • Client構造体のインスタンスを生成します。
    • ここでserverURLにローカルサーバのアドレスを指定し、transportにはURLSessionTransportを設定します。
    • これにより、クライアントは特定のサーバに接続してリクエストを送信する準備が整います。
  2. APIリクエストの送信
    • welcomeMessageメソッドを使用してサーバにリクエストを送信します。
    • このメソッドは、クエリパラメータとしてユーザー名(この例ではojun)を受け取り、サーバからのレスポンスを非同期的に待ちます。
  3. レスポンスの処理
    • switch文を用いて、レスポンスを解析します。
    • 成功レスポンス(.ok)の場合、得られたJSONデータからPixivAppNightMessageのメッセージを抽出し、コンソールに表示します。
    • OpenAPIで定義していないレスポンス(.undocumented)の場合は、ステータスコードを表示します。

アプリ開発におけるSwift OpenAPI Generatorのプラクティス

このセクションでは、Swiftアプリ開発においてSwift OpenAPI Generatorを利用する際のプラクティスを紹介します。

Swift OpenAPI Generatorの隠蔽

現在の実装では、開発者がSwift OpenAPI Generatorに依存したメソッドを直接呼び出しています。これは、利用者がGeneratorの実装の詳細を知る必要があることを意味しています。

この問題を解決するために、Swift OpenAPI Generatorの機能をラップする新しい構造体を導入することが推奨されます。このアプローチにより、Swift OpenAPI Generatorへの直接的な依存関係が隠蔽され、APIの使用がよりシンプルになります。

PixivAppNightAPIClientの導入

PixivAppNightAPIClientという構造体を作成し、APIProtocolをプライベートに保持します。

struct PixivAppNightAPIClient: Sendable {
  private let client: any APIProtocol
  
  init() {
    self.client = Client(
      serverURL: URL(string: "http://127.0.0.1:8080/api")!,
      transport: URLSessionTransport()
    )
  }
}

この構造体は、initメソッド内でClientを初期化し、内部でAPI呼び出しを処理します。これにより、APIの利用がより簡単かつテストしやすくなります。

初期化メソッドの多様性

PixivAppNightAPIClientに複数の初期化メソッドを提供することで、複数の使用シナリオに対応することができます。

struct PixivAppNightAPIClient: Sendable {
  private let client: any APIProtocol
  
  internal init(client: Any APIProtocol) {
    self.client = client
    self.client = 
  }
  
  public init() {
    self.init(
      Client(
        serverURL: URL(string: "http://127.0.0.1:8080/api")!,
        transport: URLSessionTransport()
      )
    }
  }
}

上記の例では、テストの実施において柔軟性を高めるために、異なる初期化メソッドが提供されています。具体的には、テスト時にはinternal initメソッドを使用します。これにより、テスト環境特有の設定やモックオブジェクトを注入することが可能になります。一方、通常のアプリケーション実行時にはpublic initメソッドを使用します。この方法により、テストと実際の実行環境の間でコードの再利用性を高め、よりテストしやすい実装が可能になります。

このアプローチは、テストのための特別な条件を組み込むことなく、アプリケーションのコードベースをそのまま利用できるため、効率的なテストプロセスを実現することができます。

APIメソッドの実装

APIの各メソッドは、PixivAppNightAPIClient内で定義され、その中でAPI呼び出しを行います。

struct PixivAppNightAPIClient: Sendable {
  private let client: any APIProtocol

  // ...

  func getPixivAppNightMessage(
    name: String
  ) async throws -> String {
    let response = try await client.welcomeMessage(
      query: .init(name: name)
    )
    return try response.ok.body.json.message
  }
}

この方法により、APIの利用者はSwift OpenAPI Generatorを直接使っていることを意識する必要がなくなります。

APIの使用例

PixivAppNightAPIClientを使用して、特定のAPIメソッドを呼び出す例は以下の通りです。

struct SomeStruct {
  func fetchPixivAppNightMessage(name: String) -> String {
    pixivAPIClient.getPixivAppNightMessage(
      name: name
    )
  }
}

この構造を採用することで、利用側がAPIの内部実装を意識することなく、APIの結果を受け取ることができます。

APIヘッダーの注入

APIリクエストを行う際には、ヘッダーに様々な値を追加することが一般的です。Swift OpenAPI Generatorを用いたクライアント実装でも、同様にヘッダーへの値の追加が必要になります。

必要なヘッダーの定義

今回の実装では、次のようなヘッダーを追加することを考えます。

{
  "App-OS": "iOS"
  "Client-Id": "pixiv-app-night"
}

これらのヘッダーをAPIリクエストに注入するためには、ClientMiddlewareプロトコルの理解が重要です。

ClientMiddlewareプロトコル

ClientMiddlewareは、リクエストの前処理や後処理を行うためのプロトコルです。

public protocol ClientMiddleware: Sendable {
  func intercept(
    _ request: HTTPRequest,
    body: HTTPBody?,
    baseURL: URL,
    operationID: String,
    next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
  ) async throws -> (HTTPResponse, HTTPBody?)
}

このプロトコルを使用することで、リクエストに対して必要な変更を加えた後、次の処理(next)に進むことができます。

HeaderInjectionMiddlewareの定義

今回はHeaderInjectionMiddlewareという名前でClientMiddlewareを実装します。

struct HeaderInjectionMiddleware: ClientMiddleware {
  func intercept(
    _ request: HTTPTypes.HTTPRequest,
    body: OpenAPIRuntime.HTTPBody?,
    baseURL: URL,
    operationID: String,
    next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?)
  ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {

    // ここに処理を書く

    return try await next(request, body, baseURL)
  }
}

このミドルウェア内で、ヘッダーへの値の追加を行います。

struct HeaderInjectionMiddleware: ClientMiddleware {
  func intercept(
    /* 略 */
  ) /* 略 */ {
    var request = request
    request.headerFields[/* 略 */] = /* 略 */

    return try await next(request, body, baseURL)
  }
}

headerFieldsの省略された部分(ヘッダー名を指定する部分)にはHttpFields.Nameを指定する必要があります。

HttpFields.Nameとは?

HttpFields.Nameは、ヘッダーのフィールド名を定義するための構造体で、以下のように定義されています。

extension HTTPField {
  public struct Name: Sendable {
    public let rawName: String
    public let canonicalName: String

    // ...
  }
}

この構造体を使用して、標準的なヘッダー名やカスタムヘッダー名を定義することができます。
HttpFields.Nameはデフォルトで以下のような標準的に利用されるヘッダー名がすでに用意されています。ゆえに、指定したいヘッダー名が既に定義されている場合はそちらを指定することで解決することができます。

extension HTTPField.Name {
  // ...
  public static var accept: Self {
    .init(rawName: "Accept", canonicalName: "accept") 
  }
  // ...
  public static var cookie: Self { 
    .init(rawName: "Cookie", canonicalName: "cookie") 
  }
  // ...
}

しかし今回追加したいヘッダー名であるApp-OSおよびClient-Idはデフォルトでは存在していないです。なので、この2つのカスタムなヘッダー名は以下のようにして開発者自身で定義する必要があります。

private extension HTTPField.Name {
  static let appOS = Self("App-OS")!
  static let clientID = Self("Client-Id")!
}

ヘッダーの追加

HttpFields.Nameの拡張定義を行った後、ヘッダーへの値の追加を行います。指定は、.appOS.clientIDのようにします。

struct HeaderInjectionMiddleware: ClientMiddleware {
  func intercept(
    /* 略 */
  ) /* 略 */ {
    var request = request

    request.headerFields[.appOS] = "iOS"
    request.headerFields[.appOS] = "pixiv-app-night"

    return try await next(request, body, baseURL)
  }
}

今回の例ではiOSpixiv-app-nightのような値をメソッド内で直接設定していますが、実際にはHeaderFactoryのような構造体を定義し値の設定は作成したFactory経由で行うとより良い実装になるかと思います。

作成したClientMiddlewareの指定

最後に、作成したHeaderInjectionMiddlewareClientに適用します。

struct PixivAppNightAPIClient: Sendable {
  private let client: any APIProtocol
  
  init() {
    self.client = Client(
      serverURL: URL(string: "http://127.0.0.1:8080/api")!,
      transport: URLSessionTransport(),
      middlewares: [                 // 追加
        HeaderInjectionMiddleware()  // 追加
      ]
    )
  }
}

この初期化時にmiddlewares引数にHeaderInjectionMiddlewareを指定することで、すべてのリクエストに対して自動的にヘッダーが追加されます。middlewaresは配列であるため、別のMiddlewareを作成し、指定することで複数のMiddlewareを実行することができます。

MiddlewareTransportの役割

Swift OpenAPI Generatorを利用する際に、Clientの初期化でTransportとMiddlewareを設定していますが、それらの役割と目的を解説します。

init() {
  Client(
    serverURL: /* ... */
    transport: URLSessionTransport(),
    middlewares: [
      HeaderInjectionMiddleware(),
      FugaMiddleware(),
      PiyoMiddleware()
    ]
  )
}

Transportについて

Transportは、ネットワーク操作の中心的なコンポーネントであり、HTTP操作を実行する基本となるHTTPライブラリを抽象化する役割を持ちます。これにより、実際のネットワーク通信を担当し、サーバとのデータのやり取りを可能にします。

特に、使用したいHTTPライブラリに対応するクライアントトランスポートがまだ存在しない場合や、テストで珍しいネットワーク条件をシミュレートする必要がある場合に、Transportの使用が重要になります。このような状況では、カスタムなTransportの実装を検討することが推奨されます。

Middlewareについて

Middlewareは、リクエストとレスポンスの処理において非常に汎用的な役割を果たします。Transportに渡される前のリクエストと、Transportから返された後のレスポンスを読み取り、変更することが可能です。Middlewareは、HTTPリクエストとレスポンスを処理するものの、自身ではHTTPの操作を行う責務を持っていません。

Middlewareは認証・ログ記録・メトリクス・トレーシング・カスタムヘッダの注入などに適しており、実際のサーバーへリクエスト時にランダムな失敗を注入したり、リトライやエラーハンドリングのロジックをテストする際にも適しています。

処理の流れ

APIリクエストを送信する際、処理は以下の順序で行われます。

  1. Middlewareが順番に実行される。
  2. Middlewareの処理が完了した後、Transportが実行される。

以下の例では、HeaderInjectionMiddlewareFugaMiddlewarePiyoMiddlewareの順でMiddlewareが実行され、その後URLSessionTransportが処理を引き継ぎます。

init() {
  Client(
    serverURL: /* ... */
    transport: URLSessionTransport(),  // 4番目
    middlewares: [
      HeaderInjectionMiddleware(),     // 1番目
      FugaMiddleware(),                // 2番目
      PiyoMiddleware()                 // 3番目
    ]
  )
}

プロトコルClientMiddlewareで定義されていたメソッドinterceptでは引数にnextを取り、returnを行う際に引数で取ったnextを指定していました。これは、nextを呼び出すことにより、次のMiddlewareの内容、もしくは、Transportの内容を実行することを意味しています。

struct HeaderInjectionMiddleware: ClientMiddleware {
  func intercept(
    /* 略 */
    next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?)
  ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {

    // `next`を呼び出すことで次のMiddlewareかTransportの内容を実行する
    return try await next(request, body, baseURL)
  }
}

おわりに

本記事では、Swift OpenAPI Generatorの使用方法とプラクティスについて、解説しました。このツールを使うことで、iOSアプリ開発においてAPIとの連携がより容易になり、開発プロセスが効率化されます。Swift OpenAPI Generatorは、OpenAPI仕様に対応し、自動的にクライアントおよびサーバーサイドのコードを生成する能力を持っています。

サーバ側とクライアント側の両方で、OpenAPIドキュメントから直接コードを生成し、Vaporフレームワークと組み合わせることにより、iOSアプリのバックエンドとの連携を効率化しました。また、Swift OpenAPI Generatorを利用する際のプラクティスについても詳しく説明しました。これには、API呼び出しの隠蔽や、ヘッダーの注入、MiddlewareTransportの役割についての理解が含まれます。

このツールの導入により、開発者は煩雑なコード記述から解放され、より別の作業に集中できるようになります。さらに、自動化されたコード生成は、最新のAPI変更に迅速に対応することができ、継続的なメンテナンスとアップデートの負担を軽減します。

Swift OpenAPI Generatorは、iOSアプリ開発をより良くするツールであり、その可能性は非常に大きいと考えています。本記事を通じて、Swift OpenAPI Generatorの基本的な使用方法から実践的なプラクティスに至るまでが活用され、iOSアプリ開発の品質と生産性の向上に貢献できれば幸いです。

開発環境

  • Swift compiler version info:
    • Apple Swift version 5.9.2 (swiftlang-5.9.2.2.56 clang-1500.1.0.2.5)
  • Xcode version info:
    • Xcode 15.2 Build version 15C500b
  • Swift OpenAPI Generator version info:
    • 1.2.0 (b18bece)

参考引用文献等

Discussion