Swift OpenAPI Generator+VaporでもVaporのRequestを使いたい
概要
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インスタンスを利用できるようになります。
以下のコードがその実装例です。
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())
}
}
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エンドポイントを定義します。
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側の設定ファイルを作成します。
generate:
- types
- server
OpenAPI Generatorの依存を追加する
依存関係にOpenAPI Generatorを追加し、型情報を生成します。
// 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を記述する
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機能を使用して行います。
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インスタンスに格納されます。
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インスタンスを保持する静的プロパティを作成します。
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インスタンスを注入するミドルウェアを作成します。
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に登録します。
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と同じようにリクエストをハンドリングできます。
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との連携が期待されます。
Discussion