🧊

zod-to-openapiで、既存のAPI実装にOpenAPIドキュメントを後付けする

昔々あるところに、既存のWeb APIの実装がありました。

それなりに実装を進めた後に、天の声が言いました。「OpenAPIのドキュメントを公開したい」と。

さて、あなたならどうする?っていうニッチな問いに対する一つの答えとして。

ルーターごと乗り換える?

たとえば今回でいうと、元のAPIはCloudflare Workersにデプロイされてた。

ので、たとえばhonoとかitty-routerとか、OpenAPIのドキュメント生成ができるエコシステムが整ってるルーターに乗り換えてしまうという手がある。

もちろん最初からこの要件がわかってるなら、そうするつもりだったなら、有効な選択肢だと思う。

が、個人的には、こういう特定の実装に依存したソリューションがあまり好きじゃなくて・・・。

もっと自由に部品を組み合わせたいし、いつでも捨て去れるようにしたいのです。何より書き直すのが面倒くさすぎる。

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で埋めて返すだけ。

お手軽だわ。