Swift OpenAPI Generator を使って、モックサーバーアプリをローカルで起動する

こんにちは、takeshi-p0601 - Qiita です。普段iOSアプリケーションの開発をメインに業務を行っています。

Vision Pro の話が印象深い今年のWWDCでしたが、その他にもSwiftの言語仕様やXcodeの改善、UIフレームワークの機能向上など気になるものがたくさんありました。本記事ではその中の一つである Swift OpenAPI Generator と、それを利用して作成した簡易モックサーバーアプリをローカルで起動することについて書きます。

※ この記事で記載しているソースコードは、Swift OpenAPI GeneratorのWWDCセッション中に使用されたソースコードや、公式のサンプルコードなどを参考にしています

Swift OpenAPI Generator とは

OpenAPIの仕様に準拠したドキュメントファイルの内容から、Swiftソースコードを自動で生成してくれるツールで、ビルド時に実行されるSwift Package Pluginです。 こちらソースコードを確認することができます。※Swift Package PluginとはWWDC2022でも発表のあった比較的新しいツールで、この記事では深掘りしませんが、概要を掴むために下記のリンクが役立ちました。

既存でもOpenAPIドキュメントファイルからソースコードを自動生成するツールはありますが、AppleがSwiftを使用して公式的に開発しているツールであることや、Swift Package Pluginとして提供されていることから、Xcodeとの親和性も高く、将来的に期待できそうです。そのためiOSアプリやMacOSアプリなどを開発する場合に、Web API関連のソースコードを自動生成するツールとして、スタンダードなものになっていくことが予想されます。

ツールの簡単な紹介

詳細な手順は省きますが、ツールがどのようなものか軽く紹介します。

何らかのiOSアプリがAPIリクエストを送信してレスポンスを受け取りたいような、クライアントサイドの処理を実装するとして、 まず下記のようなOpenAPIドキュメントファイルを用意します。

openapi: "3.0.3"
info:
  title: CatService
  version: 1.0.0
servers:
  - url: http://localhost:8080/api
    description: "Localhost cats 🙀"
paths:
  /emoji:
    get:
      operationId: getEmoji
      parameters:
      - name: count
        required: false
        in: query
        description: "The number of cats to return. 😽😽😽"
        schema:
          type: integer
      responses:
        '200':
          description: "Returns a random emoji, of a cat, ofc! 😻"
          content:
            text/plain:
              schema:
                type: string

その後、Xcodeで上記のドキュメントファイルの組み込みやツールの設定ファイルの追加、そのほか必要な手順を済ませると、ビルド時にソースコードを自動生成するツールが実行され、

ソースコードが自動で生成されます。

それにより、iOSアプリのプロダクトコードに自動生成されたAPIリクエスト処理とそのレスポンスを処理できるプログラムを組み込めるようになります。

API通信処理を組み込んだ後でデバックビルドし、そのログを追ってみると

SwiftCompile normal arm64 /Users/takeshikomori/me/iOS/testSwiftOpenAPIGenerator/testSwiftOpenAPIGenerator/testSwiftOpenAPIGeneratorApp.swift (in target 'testSwiftOpenAPIGenerator' from project 'testSwiftOpenAPIGenerator')
    cd /Users/takeshikomori/me/iOS/testSwiftOpenAPIGenerator
    /Applications/Xcode15beta2.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend -c 
    .... /Users/takeshikomori/Library/Developer/Xcode/DerivedData/testSwiftOpenAPIGenerator-himwceovqvysargtcarobfkdexzk/SourcePackages/plugins/testSwiftOpenAPIGenerator.output/testSwiftOpenAPIGenerator/OpenAPIGenerator/GeneratedSources/Client.swift

DerivedData/.../SourcePackages/plugins/testSwiftOpenAPIGenerator.output/testSwiftOpenAPIGenerator/OpenAPIGenerator/GeneratedSources/Client.swift とあるように、 DerivedData/に自動生成されたソースコードが生成され、それを元にコンパイルするので、デフォルトでプロジェクトフォルダ直下に自動生成されることはなく、メインアプリターゲットのソースファイルとして管理することはないようです。

サーバーサイドのコードも自動生成ができる

Swift OpenAPI Generatorのセッション は、前のセクションで説明したようなクライアントサイドアプリで使えるSwiftソースコードを自動生成する話がメインですが、セッションの後半ラスト5分ほどでサーバーサイドアプリのソースコードにおいても、自動生成できる紹介がありました。

恥ずかしながら元々セッションの目次を見ていなかったため、それに辿り着くまでpythonを使った簡易サーバープログラムを起動し、クライアントサイドアプリの自動生成ソースコードの挙動確認を行なっていました。後半のセッションをもとにサーバーサイドの自動生成プログラムの内容も試してみると、Swiftを使って簡単に少ないコードでローカルモックサーバーアプリを起動することができました。

普段クライアントサイドアプリを中心にSwiftをプログラミングするエンジニアとしては、Swiftで簡単にサーバーアプリを起動できたことは良い体験でしたので、その辺りを紹介します。(サーバーサイドの実装には、 Vapor というSwiftで主に実装されたオープンソースのWebフレームワークを前提としています。)

公式のサンプルコードを使って、ローカルにサーバーアプリを起動する

実装方法を紹介する前に、公式にあるサンプルコードを使用してローカルにモックサーバーアプリを起動できるので、下記の手順を踏んで軽く確認をしてみます。 ※具体的な繋ぎ込みの手順はこの次のセクションに記載しています。

試した環境

手順

(1)ソースコードをcloneします

$ git clone https://github.com/apple/swift-openapi-generator.git

(2)clone後、下記のディレクトリに移動しておきます

$ cd swift-openapi-generator/Examples/GreetingService

(3) swift-openapi-generator/Examples/GreetingService/openapi.yamlファイルを開き、ベースURLを下記のようにlocalhostに書き換えます。

   title: GreetingService
   version: 1.0.0
 servers:
-  - url: https://example.com/api
+  - url: http://localhost:8080/api
     description: Example
 paths:
   /greet:

(4)Xcodeの指定を、Xcode14.3 に切り替えます

$ sudo xcode-select -s /Applications/{Xcode14.3}.app/Contents/Developer

(5)ソースコードをビルドし、アプリを起動させます。

$ swift run GreetingService
Building for debugging...
~~~
Build complete! (178.39s)
2023-06-28T05:03:32+0900 notice codes.vapor.application : [Vapor] Server starting on http://127.0.0.1:8080

※Exampleの例としてクライアントサイドアプリのターゲットもPackage.swiftに定義されているため、 run サブコマンドの引数にサーバーサイドアプリのターゲット( GreetingService )を指定してください。Exampleとしてクライアント/サーバー両方のアプリの実行ターゲットが準備されているようです。

(6)curlコマンドで疎通確認します。

下記のようにレスポンスが返されると思います。

$ curl localhost:8080/api/greet
{
  "message" : "Hello, Stranger!"
}

※レスポンス処理の実装参考: https://github.com/apple/swift-openapi-generator/blob/7b97c0e062411ec594fad8d670a25bac1449bc06/Examples/GreetingService/Sources/GreetingService/GreetingService.swift#L29

サーバーサイドアプリの実装方法

先の公式サンプルで示したモックサーバーアプリをlocalで立てるところまでの実装方法を紹介します。https://github.com/takeshi-p0601/TestMockServerApp にもソースコードを載せていますので、そちらもご参照ください。

試した環境

  • Xcode14.3 (Swift5.8)
  • Mac OS 13.4

手順

(1)適当にディレクトリを作成して、そのディレクトリに移動します。

$ mkdir TestMockServerApp && cd TestMockServerApp

(2) swift package initを実行してserverアプリを実装するための Swift Package を作成します。 その際、オプションの type(Package type)executable を指定してください。(デフォルトでは type=library となっています)

$ swift package init --type executable            
Creating executable package: TestMockServerApp
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/main.swift

(3)Package.swift を下記のように編集します。

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "TestMockServerApp",
    platforms: [
        .macOS(.v13)
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-openapi-generator", .upToNextMinor(from: "0.1.0")),
        .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.0")),
        .package(url: "https://github.com/swift-server/swift-openapi-vapor", .upToNextMinor(from: "0.1.0")),
        .package(url: "https://github.com/vapor/vapor", from: "4.76.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .executableTarget(
            name: "TestMockServerApp",
            dependencies: [
                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
                .product(name: "Vapor", package: "vapor"),
            ],
            path: "Sources",
            plugins: [
                .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
            ]
        ),
    ]
)

(4) 下記の内容で、OpenAPIドキュメントファイル( openapi.yaml )をSourcesディレクトリに追加します。

openapi: '3.0.3'
info:
  title: TestMockServerApp
  version: 1.0.0
servers:
  - url: http://localhost:8080/api
    description: Example
paths:
  /greet:
    get:
      operationId: getGreeting
      parameters:
      - name: name
        required: false
        in: query
        description: A name used in the returned greeting.
        schema:
          type: string
      responses:
        '200':
          description: A success response with a greeting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Greeting'
components:
  schemas:
    Greeting:
      type: object
      properties:
        message:
          type: string
      required:
        - message

(5) 下記の内容で openapi-generator-config.yaml を作成して、Sourcesディレクトリに追加します。

generate:
  - types
  - server

(6) API関連のソースコードを自動生成するためにビルドします。(Xcodeでビルドすることもできます。)

出力内容が一部異なるかもしれませんが、初回ビルド時に下記のようにOpenAPIGeneratorが実行されていることがわかります。

$ swift build
...Swift Package の Fetch など...
Swift OpenAPI Generator is running with the following configuration:
- OpenAPI document path: /Users/takeshikomori/TestMockServerApp/Sources/openapi.yaml
- Configuration path: /Users/takeshikomori/TestMockServerApp/Sources/openapi-generator-config.yaml
- Generator modes: types, server
- Output file names: Types.swift, Server.swift
- Output directory: /Users/takeshikomori/TestMockServerApp/.build/plugins/outputs/testmockserverapp/TestMockServerApp/OpenAPIGenerator/GeneratedSources
- Diagnostics output path: <none - logs to stderr>
- Current directory: /Users/takeshikomori/TestMockServerApp
- Is plugin invocation: true
- Additional imports: <none>
File Types.swift: changed
File Server.swift: changed
[1876/1876] Linking TestMockServerApp
Build complete! (208.55s)

// 再度ビルド
$ swift build 
Building for debugging...
[4/4] Compiling plugin OpenAPIGenerator
Build complete! (0.56s)

(7) Sources/main.swift を下記のように変更します。

import OpenAPIRuntime
import OpenAPIVapor
import Vapor

struct Handler: APIProtocol {
    func getGreeting(
        _ input: Operations.getGreeting.Input
    ) async throws -> Operations.getGreeting.Output {
        let name = input.query.name ?? "Stranger"
        return .ok(.init(body: .json(.init(message: "Hello, \(name)!"))))
    }
}

let app = Vapor.Application()
let transport = VaporTransport(routesBuilder: app)
let handler = Handler()
try handler.registerHandlers(on: transport, serverURL: Servers.server1())
try app.run()

(8)ビルドして、サーバーアプリを起動させます。(Xcodeでビルド+起動させることもできます。)

$ swift run
Building for debugging...
Build complete! (0.53s)
2023-06-30T08:14:50+0900 notice codes.vapor.application : [Vapor] Server starting on http://127.0.0.1:8080

(9)疎通できるか確認します。

下記のようなレスポンスが返されれば問題ないと思います。

$ curl localhost:8080/api/greet
{
  "message" : "Hello, Stranger!"
}

$ curl -G -d name=world localhost:8080/api/greet
{
  "message" : "Hello, world!"
}

APIの仕様の変更に対応する

APIの仕様が変わる場合も当然あると思います。その際の変更方法を紹介します。 先の手順で定義したOpenAPIの仕様から、imageバイナリデータがが一つ返される [GET] /image エンドポイントが追加される変更があると想定して、確認します。

(10) openapi.yaml ファイルの内容を変更します。

/image エンドポイントを追加してみます。

  /image:
    get:
      operationId: getImage
      responses:
        '200':
          description: "Return image"
          content:
            image/png:
              schema:
                type: string
                format: binary

(11) ビルドします。

ログを見ると、OpenAPIドキュメントファイルが変更されたため、Types.swiftServer.swiftに変更が入ってそうなことがわかります。

$ swift build
Building for debugging...
Swift OpenAPI Generator is running with the following configuration:
- OpenAPI document path: /Users/takeshikomori/TestMockServerApp/Sources/openapi.yaml
- Configuration path: /Users/takeshikomori/TestMockServerApp/Sources/openapi-generator-config.yaml
- Generator modes: types, server
- Output file names: Types.swift, Server.swift
- Output directory: /Users/takeshikomori/TestMockServerApp/.build/plugins/outputs/testmockserverapp/TestMockServerApp/OpenAPIGenerator/GeneratedSources
- Diagnostics output path: <none - logs to stderr>
- Current directory: /Users/takeshikomori/TestMockServerApp
- Is plugin invocation: true
- Additional imports: <none>
File Types.swift: changed
File Server.swift: changed
error: emit-module command failed with exit code 1 (use -v to see invocation)
/Users/takeshikomori/TestMockServerApp/Sources/main.swift:5:8: error: type 'Handler' does not conform to protocol 'APIProtocol'
struct Handler: APIProtocol {
       ^
/Users/takeshikomori/TestMockServerApp/.build/plugins/outputs/testmockserverapp/TestMockServerApp/OpenAPIGenerator/GeneratedSources/Types.swift:16:10: note: protocol requires function 'getImage' with type '(Operations.getImage.Input) async throws -> Operations.getImage.Output'
    func getImage(_ input: Operations.getImage.Input) async throws -> Operations.getImage.Output
         ^
/Users/takeshikomori/TestMockServerApp/Sources/main.swift:5:8: error: type 'Handler' does not conform to protocol 'APIProtocol'
struct Handler: APIProtocol {
       ^
/Users/takeshikomori/TestMockServerApp/.build/plugins/outputs/testmockserverapp/TestMockServerApp/OpenAPIGenerator/GeneratedSources/Types.swift:16:10: note: protocol requires function 'getImage' with type '(Operations.getImage.Input) async throws -> Operations.getImage.Output'
    func getImage(_ input: Operations.getImage.Input) async throws -> Operations.getImage.Output
         ^
[9/10] Compiling TestMockServerApp main.swift

下の方でエラーが出てしまっているのは、プロダクトコードの実装が新たに変更されたAPIProtocolに準拠してないためです。

上記はCLIでの確認ですが、Xcodeで確認すると添付のようにエラーメッセージが表示されるので、そのFixボタンを押してメソッドを自動で追加します。

(12) 先ほど追加したメソッドの中身の実装と、imageバイナリをレスポンスとして返すために少し修正を適用させます。

こちらの変更履歴参考にしてください。

(13) 再度ビルドしてアプリを起動し、疎通確認をします。

$ swift run
Building for debugging...
[5/5] Linking TestMockServerApp
Build complete! (2.75s)
2023-06-30T08:24:54+0900 notice codes.vapor.application : [Vapor] Server starting on http://127.0.0.1:8080

ブラウザで http://127.0.0.1:8080/api/image にアクセスすると添付のような画像が表示されます。

==

今示したように、APIの仕様変更に対応して挙動を確認するには下記の手順になります。

  1. OpenAPIドキュメントを更新する
  2. ビルドして、変更後のOpenAPIドキュメントファイルに則ったソースコードを生成する
  3. 新しく生成されたソースコードを使って、プロダクトコードに繋ぎ込む
  4. 再度ビルドして、アプリを起動する

良かった点

  • 普段使い慣れたエディタ + 言語でプログラミングできるため、特にChatGPTに頼ることなく実装できたこと。(pythonを使った簡易サーバープログラムは、簡単な文字列の操作から標準ライブラリのimportなどもすぐエラーになってしまうため、ややストレスに感じました。)
  • 書くべきソースコードはそこまで多くなかったこと。
  • APIProtocolインターフェースに定義された実装が強制されるので、実装もれが少なくなりそうなこと。

気になる点

一方でこのツールを使う際の気になる点として、個人的には下記がありました。

  • OpenAPIドキュメントファイルは準備する必要があるので、ない場合は準備が手間なこと。
  • 自動生成されたソースコードは、デフォルトで管理するような設計になってないためソースコードの変更を追従することがやや面倒です。APIの規格自体はそこまで頻繁な変更があるわけでもないように思いますが、最初のうちはバージョンごとでどういうソースコードが生成されるかや、どういった差分はあるかは確認したり、gitで管理するのもいいかもと思いました。

今後試したいこと/調べてみたいこと

今回試せなかったことで、今後下記のようなことを試したり調べてみたいです。

  • Swift OpenAPI Generator を使ってAPIの結合確認の際に、簡単なレスポンスを返すモックサーバーアプリの作成
    • サーバーアプリがデプロイされる前の、簡易的な動作確認用のレスポンス
    • 異常なケース(大量のデータなど)のレスポンス
  • Vaporの理解を深める
    • 今回のGeneratorツールを利用した手順でないと、Vaporを利用することができないため。

最後に

Swift Package のエコシステムを利用しながら、クライアントサイドにとどまらない改善を進めていることを知ることができて、今後Swiftで開発するモチベーションにつながりました。WWDCのセッションはまだ視聴できてないものもあるので、引き続き確認していけたらと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proにご興味のある方はこちらから akerun.com