🧊

@cloudflare/itty-router-openapiを試す

cloudflare/itty-router-openapi: OpenAPI 3 schema generator and validator for Cloudflare Workers https://github.com/cloudflare/itty-router-openapi

これなに

  • itty-routerをベースにしていて
  • 独自のスキーマでハンドラを実装すれば
  • OpenAPI 3のスキーマを自動生成してくれる

とまあ、実装がドキュメントにそのままなる今風やつ。

特徴といえば、Cloudflare Workersで動く・・・ってところで、それで言うとHonoにもそれ用のミドルウェアがあるので、どっちを使うかは好みか。

https://github.com/honojs/middleware/tree/main/packages/zod-openapi

内部的に、@asteasolutions/zod-to-openapiが使われてるところまで一緒。

コード例

// main.ts
import { OpenAPIRouter, OpenAPIRoute } from "@cloudflare/itty-router-openapi";
import { router as tasksRouter } from "./tasks-routes";

export type Env = { DB: D1Database };

const router = OpenAPIRouter();

// Simple
router.get(
  "/api/healthcheck",
  class extends OpenAPIRoute {
    static schema = {
      tags: ["Healthcheck"],
      summary: "Healthcheck endpoint",
      responses: {
        "200": {
          description: "Healthcheck response",
          schema: { status: "ok" },
        },
      },
    };

    async handle() {
      return { status: "ok" };
    }
  },
);

// Nested routes
router.all("/api/tasks/*", tasksRouter);

// Plain itty routes
router.all("*", () => new Response("Not Found", { status: 404 }));

export default { fetch: router.handle };

コードの書き味としては、とにかく既存のitty-router@v4プロジェクトを壊さないようになってるのが特徴的だった。

  • OpenAPIRouterRouterから差し替えるだけで動く
  • 既存のハンドラもそのまま動く

ただしその分、DXやらがちょっと損なわれる感じの印象。

// tasks-routes.ts
import {
  OpenAPIRouter,
  OpenAPIRoute,
  Path,
} from "@cloudflare/itty-router-openapi";
import { z } from "zod";
import type { Env } from "./main";

export const router = OpenAPIRouter({
  base: "/api/tasks",
});

const TaskSchema = z.object({
  name: z.string().openapi({ example: "Buy milk" }),
  slug: z.string().openapi({ example: "task:1234" }),
  description: z.string().optional().openapi({ example: "My task" }),
  completed: z.boolean(),
  due_date: z.date(),
});

router.get(
  "/:taskSlug",
  class extends OpenAPIRoute {
    static schema = {
      tags: ["Tasks"],
      summary: "Get a single Task by slug",
      parameters: {
        taskSlug: Path(z.string().openapi({ example: "12" })),
      },
      responses: {
        "200": {
          description: "Single Task object with metadata",
          schema: {
            metaData: {},
            task: TaskSchema,
          },
        },
      },
    };

    // XXX: Not typed :(
    async handle(
      req: Request,
      env: Env,
      ctx: ExecutionContext,
      data: { params: { taskSlug: string } },
    ) {
      const { taskSlug } = data.params;

      req;
      env.DB;
      ctx;

      return {
        metaData: { meta: "data" },
        // XXX: Need to ensure manually
        task: TaskSchema.parse({
          name: "my task",
          slug: "task:" + taskSlug,
          completed: false,
          due_date: new Date(),
        }),
      };
    }
  },
);

router.post(
  "/attachment",
  class extends OpenAPIRoute {
    static schema = {
      tags: ["Tasks"],
      summary: "Binary attachment",
      responses: {
        "200": {
          description: "Binary attachment",
          contentType: "application/pdf",
          // See https://github.com/cloudflare/itty-router-openapi/issues/99
          // schema: new Str({ format: "binary" }),
        },
      },
    };

    async handle() {
      return new Response(new Blob(["ABC"]), {
        headers: { "content-type": "application/pdf" },
      });
    }
  },
);

という感じで、

  • Inputはバリデーションされるけど
  • Outputはバリデーションされないし、型もつかない
  • 各ハンドラも型付けは自前

このあたりが仕方がないとはいえ、惜しいなって。

おまけ

せっかくitty-routerで軽量路線なのに、zodにべったりなのも惜しい。

Support for Valibot types · Issue #96 · cloudflare/itty-router-openapi https://github.com/cloudflare/itty-router-openapi/issues/96

Oh…😢

Zodのサイズが気になるからあちこち放浪してるのに、本当にどこいってもZodばっかなので、一周回ってある日突然Zodが軽くなるよう念じてる今日この頃。

でもXtoOpenAPIの実装がいっぱい生まれるのも微妙なのは理解できるし、悩ましい。

https://github.com/decs/typeschema

こういう、雨後の筍な様相のバリデーションライブラリの互換性を保つやつがあるくらいやし。