昔々あるところに、既存のWeb APIの実装がありました。
それなりに実装を進めた後に、天の声が言いました。「OpenAPIのドキュメントを公開したい」と。
さて、あなたならどうする?っていうニッチな問いに対する一つの答えとして。
ルーターごと乗り換える?
たとえば今回でいうと、元のAPIはCloudflare Workersにデプロイされてた。
ので、たとえばhono
とかitty-router
とか、OpenAPIのドキュメント生成ができるエコシステムが整ってるルーターに乗り換えてしまうという手がある。
- https://github.com/honojs/middleware/tree/main/packages/zod-openapi
hono
好きなあなたに
- https://github.com/cloudflare/itty-router-openapi/
itty-router
好きなあなたに- ただし型が・・・
もちろん最初からこの要件がわかってるなら、そうするつもりだったなら、有効な選択肢だと思う。
が、個人的には、こういう特定の実装に依存したソリューションがあまり好きじゃなくて・・・。
もっと自由に部品を組み合わせたいし、いつでも捨て去れるようにしたいのです。何より書き直すのが面倒くさすぎる。
zod-to-openapi
を直接使う
先述のエコシステムも、蓋を開ければコレをラップしてるだけ。
asteasolutions/zod-to-openapi: A library that generates OpenAPI (Swagger) docs from Zod schemas https://github.com/asteasolutions/zod-to-openapi
(Zodしか選択肢がないのはこの際なので諦める。本当はもっと軽いやつがいい。)
というわけで、これを直接使うことで、既存実装に後付けする形で実装することにした。
0. 既存の実装
この時点では、
// main
import { handler as getTodos } from "./handlers/get-todos";
import { handler as postTodos } from "./handlers/post-todos";
// ...
router.get("/todos", getTodos);
router.post("/todos", postTodos);
// handlers/get-todos
export const handler = async (req) => {
// ...
return Response.json(todos);
};
という素朴な状態。
1. ZodでI/Oをバリデーションする
OpenAPI関係なしに、これはやっておいてもいいやつ。
import { z } from "zod";
const InputSchema = z.object({ /* ... */ });
const OutputSchema = z.array(z.object({ /* ... */ }));
export const handler = async (req) => {
const input = await req.json().catch(() => ({}));
const parsed = InputSchema.parse(input);
// ...
return Response.json(OutputSchema.parse(todos));
};
というように、InputとOutputをそれぞれ固める。
2. OpenAPIの定義を書く
この状態で、OpenAPIのドキュメント生成のための実装を足す。
import { z } from "zod";
import type { RouteConfig } from "@asteasolutions/zod-to-openapi";
const InputSchema = z.object({ /* ... */ });
const OutputSchema = z.array(z.object({ /* ... */ }));
export const handler = async (req) => {
const input = await req.json().catch(() => ({}));
const parsed = InputSchema.parse(input);
// ...
return Response.json(OutputSchema.parse(todos));
};
// 追加
export const schema: RouteConfig = {
method: "get",
path: "/todos",
request: {
body: {
content: {
"application/json": { schema: InputSchema },
},
},
},
responses: {
200: {
description: "Success",
content: {
"application/json": { schema: OutputSchema },
},
},
},
};
3. ドキュメントを生成する
定義したスキーマを登録して、ドキュメント自体のエンドポイントを増やす。
// main
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import * as getTodos from "./handlers/get-todos";
// ...
router.get("/todos", getTodos.handler);
// or
// router[getTodos.schema.method](getTodos.schema.path, getTodos.handler);
// 追加
const registry = new OpenAPIRegistry();
registry.registerPath(getTodos.schema);
const docs = new OpenApiGeneratorV3(registry.definitions).generateDocument({
openapi: "3.0.0",
info: { title: "API", version: "1.0.0" },
});
router.get("/openapi.json", () => Response.json(docs));
という風にすることで、完全オプトインでOpenAPIのドキュメント生成ができたよという話。
OpenAPIのドキュメントとしての質を向上させたい場合、
example
とか$ref
で参照したいとか
z.string().openapi({})
のように拡張されたZodのインスタンスを使う必要があり、そこの取り回しだけオプトイン的な観点からは手間。
https://github.com/asteasolutions/zod-to-openapi#the-openapi-method
そういう意味で、万能なソリューションではないけど、懇意にしてるルーターの趣味が変わっても困らないようにはなる。
おまけ: Swagger UIも見たい
JSONだけじゃわからん!GUIを見せろ!というご要望にお応えして。
Swagger UIは、HTML/CSS/JSのセットを公開してるので、それをCDN経由で使うようにして返せばいい。
swagger-ui-dist - npm https://www.npmjs.com/package/swagger-ui-dist
コードとしてはこんな風に。
const renderSwaggerUI = (jsonUrl: string) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css"/>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({
url: '${jsonUrl}',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis]
});
</script>
</body>
</html>
`;
このHTML文字列を、さっき生成したopenapi.json
のURLで埋めて返すだけ。
お手軽だわ。