📝

Swift OpenAPI Generator+VaporでもVaporのRequestを使いたい

2024/09/23に公開

概要

WWDC2023でAppleが発表したSwift OpenAPI Generatorライブラリは、OpenAPI仕様書からHTTPクライアントコードやサーバーサイドのエンドポイントの型情報を生成できるようにするツールです。これにより、仕様駆動開発が簡単に行えるようになりました。

Server-Side Swiftの主要フレームワークであるVaporにも対応したライブラリが提供されています。

ただし、このライブラリで生成されたエンドポイントの型定義では、VaporのRequestオブジェクトを直接取得できません。VaporではDB接続やファイルI/O、セッション管理、認証、DIなど、豊富な機能がVapor.Requestインスタンスを通じて提供されるため、OpenAPI Generatorを使用するとこれらの便利な機能が利用しづらくなります。

そこで今回は、OpenAPI Generatorを使いつつも、VaporのRequestオブジェクトをハンドラー内で利用する方法を紹介します。

結論

自動生成されたAPIProtocolに準拠したハンドラーに@TaskLocalを用いて作成したプロパティを追加し、そのプロパティにVapor.Requestを注入するミドルウェアを使用することで、ハンドラー内でVapor.Requestインスタンスを利用できるようになります。

以下のコードがその実装例です。

Source/App/Controllers/Handler.swift
import Vapor
import OpenAPIRuntime

struct Handler: APIProtocol {
    
    @TaskLocal static var req: Vapor.Request?
    var req: Vapor.Request { Self.req! }
    
    func Get(_ input: Operations.Get.Input) async throws -> Operations.Get.Output {
        if let value = req.session.data[input.path.key] {
            .ok(.init(body: .plainText(.init(stringLiteral: value))))
        } else {
            .notFound(.init())
        }
    }
    
    func Replace(_ input: Operations.Replace.Input) async throws -> Operations.Replace.Output {
        req.session.data[input.path.key] = req.body.string
        return .noContent(.init())
    }
}
Source/App/Middlewares/InjectVaporRequestMiddleware.swift
import Vapor

struct InjectVaporRequestMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        try await Handler.$req.withValue(request) {
            try await next.respond(to: request)
        }
    }
}

事前準備

VaporとOpenAPI Generatorを使用するサンプルプロジェクトとしてKey-ValueストアのようなWebAPIを作成します。

Vaporプロジェクトの作成

vapor new swift-vapor-openapi-example
cd swift-vapor-openapi-example
open Package.swift

OpenAPI定義を記述する

適当にKey-ValueストアのAPIエンドポイントを定義します。

Source/App/openapi.yaml
openapi: 3.0.3
info:
  version: 1.0.0
  title: KeyValue API
paths:
  /{key}:
    get:
      operationId: Get
      summary: keyに対応する値を取得する
      parameters:
        - $ref: '#/components/parameters/Key'
      security:
        - session: [ ]
      responses:
        200:
          description: 存在している値を返す
          content:
            text/plain:
              schema:
                type: string
        404:
          description: Keyに対応する値が存在しない
    put:
      summary: keyに対して値を格納する
      operationId: Replace
      description: |
        既存の値が存在する場合は置き換える
        既存の値が存在しない場合は、新たに値を設定する
      security:
        - session: [ ]
      parameters:
        - $ref: '#/components/parameters/Key'
      responses:
        204:
          description: 格納完了
components:
  parameters:
    Key:
      name: key
      in: path
      required: true
      schema:
        type: string
  securitySchemes:
    session:
      type: apiKey
      in: cookie
      name: vapor-session

Configファイルを作成する

Server側の設定ファイルを作成します。

Source/App/openapi-generator-config.yaml
generate:
  - types
  - server

OpenAPI Generatorの依存を追加する

依存関係にOpenAPI Generatorを追加し、型情報を生成します。

Package.swift
// swift-tools-version:5.10
import PackageDescription

let package = Package(
    name: "swift-vapor-openapi-example",
    platforms: [
        .macOS(.v13)
    ],
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "4.99.3"),
        // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
        .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
+       // OpenAPI Generator
+       .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"),
    ],
    targets: [
        .executableTarget(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                .product(name: "NIOCore", package: "swift-nio"),
                .product(name: "NIOPosix", package: "swift-nio"),
+               .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
+               .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
            ],
            swiftSettings: swiftSettings,
+           plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
        ),
        .testTarget(
            name: "AppTests",
            dependencies: [
                .target(name: "App"),
                .product(name: "XCTVapor", package: "vapor"),
            ],
            swiftSettings: swiftSettings
        )
    ]
)

var swiftSettings: [SwiftSetting] { [
    .enableUpcomingFeature("DisableOutwardActorInference"),
    .enableExperimentalFeature("StrictConcurrency"),
] }

生成されたAPIProtocolに準拠するHandlerを記述する

Source/App/Controllers/Handler.swift
import Vapor
import OpenAPIRuntime

struct Handler: APIProtocol {
    func Get(_ input: Operations.Get.Input) async throws -> Operations.Get.Output {
        <#code#>
    }

    func Replace(_ input: Operations.Replace.Input) async throws -> Operations.Replace.Output {
        <#code#>
    }
}

transportでVaporにHandlerのルーティングを登録する

APIProtocolに準拠したルーティングをVaporのApplicationに登録します。これはOpneAPIVaporライブラリのtransport機能を使用して行います。

Source/App/configure.swift
import Vapor
+ import OpenAPIRuntime
+ import OpenAPIVapor

// configures your application
public func configure(_ app: Application) async throws {
    // uncomment to serve files from /Public folder
    // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    // register routes
    
+   let transport = VaporTransport(routesBuilder: app)
+   try Handler().registerHandlers(on: transport)
    
    try routes(app)
}

VaporのSessionMiddlewareを有効化する

Key-ValueストアのWeb APIサーバーを作成するため、オンメモリのセッションを有効化します。セッションの値はVapor.Requestインスタンスに格納されます。

Source/App/configure.swift
import Vapor
import OpenAPIRuntime
import OpenAPIVapor

// configures your application
public func configure(_ app: Application) async throws {
    // uncomment to serve files from /Public folder
    // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    // register routes
    
+   app.middleware.use(app.sessions.middleware)
    
    let transport = VaporTransport(routesBuilder: app)
    try Handler().registerHandlers(on: transport)
    
    try routes(app)
}

Vapor.RequestをHandler内で使用可能にする

ここから本題です。Vapor.Requestをハンドラーに注入していきます。

HandlerにTaskLocalプロパティラッパーを追加する

@TaskLocalを用いて、特定のリクエストタスクに対応するVapor.Requestインスタンスを保持する静的プロパティを作成します。

Source/App/Controllers/Handler.swift
import Vapor
import OpenAPIRuntime

struct Handler: APIProtocol {
    
+   @TaskLocal static var req: Vapor.Request?
    
    func Get(_ input: Operations.Get.Input) async throws -> Operations.Get.Output {
        <#code#>
    }

    func Replace(_ input: Operations.Replace.Input) async throws -> Operations.Replace.Output {
        <#code#>
    }
}

VaporのMiddlewareでVapor.RequestをHandler.reqに注入する

Handlerのreqプロパティにrequestインスタンスを注入するミドルウェアを作成します。

Source/App/Middlewares/InjectVaporRequestMiddleware.swift
import Vapor

struct InjectVaporRequestMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        try await Handler.$req.withValue(request) {
            // セットしたreqはこのブロック内でのみ有効
            // ハンドラは↓のnext.respond(to: request)の中で呼び出される
            try await next.respond(to: request)
        }
    }
}

作成したMiddlewareをApplicationに登録します。

Source/App/configure.swift
import Vapor
import OpenAPIRuntime
import OpenAPIVapor

// configures your application
public func configure(_ app: Application) async throws {
    // uncomment to serve files from /Public folder
    // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    // register routes
    
    app.middleware.use(app.sessions.middleware)
+   app.middleware.use(InjectVaporRequestMiddleware())
    
    let transport = VaporTransport(routesBuilder: app)
    try Handler().registerHandlers(on: transport)
    
    try routes(app)
}

Handlerの実装

reqプロパティを使うことで、通常のVaporと同じようにリクエストをハンドリングできます。

Source/App/Controllers/Handler.swift
import Vapor
import OpenAPIRuntime

struct Handler: APIProtocol {
    
    @TaskLocal static var req: Vapor.Request?
+   var req: Vapor.Request { Self.req! }
    
    func Get(_ input: Operations.Get.Input) async throws -> Operations.Get.Output {
+       if let value = req.session.data[input.path.key] {
+           .ok(.init(body: .plainText(.init(stringLiteral: value))))
+       } else {
+           .notFound(.init())
+       }
    }
    
    func Replace(_ input: Operations.Replace.Input) async throws -> Operations.Replace.Output {
+       req.session.data[input.path.key] = req.body.string
+       return .noContent(.init())
    }
}

まとめ

@TaskLocalが非常に便利です。これを使うことで、OpenAPI Generatorを利用してもVaporの機能がほとんど損なわれません。また、サーバー側のJSON型定義も自動生成されるため、手動での型管理が不要になります。

もちろん、Middlewareの設定がパスごとに個別対応しづらい点や、バリデーションやエラーハンドリングの手法が変わる点、APIProtocolの肥大化など、いくつかの課題は残ります。しかし、特に小規模なWeb API開発では、仕様駆動開発を実用レベルで導入できるでしょう。

Vapor 5では、より強固なOpenAPI Generatorとの連携が期待されます。

nextbeat Tech Blog

Discussion