🍙

@hono/openapi-zod と openapi2aspida で楽々 api 開発

2023/10/25に公開

TL;DR

今回は簡単のために自分自身にリクエストを送る方法でやったが cloudflare workers で外部 worker とやり取りするときに有用だと思われる。

これが

const handler: RouteHandler<typeof route, HonoEnv> = (c) => {
  return c.env.SELF_SERVICE.fetch(new URL("/", c.req.url));
};

こうできる

const handler: RouteHandler<typeof route, HonoEnv> = async (c) => {
  const res = await c.get("client").$get();
  return c.jsonT(res);
};

https://github.com/naporin0624/openapi-hono-aspida

@hono/openapi-zod と openapi2aspida を使ったら面白そう

@hono/openapi-zod は簡単に path parameter, request header, request body の validation ができる上に openapi schema もできる優れものです。今日はこれと openapi から TS の型生成を行い client を作ることができる openapi2aspida を使って自分自身にリクエストを型安全に送る機構を考えていこうと思います。

@hono/openapi-zod で schema と handler を定義する

これだけで openapi schema と api が作れます。これに対して openapi2aspida から型生成して client を作り handler 内で使用します。

import { OpenAPIHono, RouteHandler, createRoute } from "@hono/zod-openapi";
import { z } from "zod";
import { prettyJSON } from "hono/pretty-json";

type Bindings = {
  //
};

type Variables = {
  //
};

type HonoEnv = {
  Bindings: Bindings;
  Variables: Variables;
};

const route = createRoute({
  path: "/",
  method: "get",
  description: "@hono/openapi-zod はすごい!",
  responses: {
    200: {
      description: "成功時のレスポンスは success: true です!",
      content: {
        "application/json": {
          schema: z.object({ success: z.literal(true) }),
        },
      },
    },
  },
});
const handler: RouteHandler<typeof route, HonoEnv> = (c) => {
  return c.json({ success: true });
};

const app = new OpenAPIHono();

app.use("/doc/*", prettyJSON());
app.doc("/doc", {
  openapi: "3.0.3",
  info: { title: "openapi doc", version: "0.0.1" },
  servers: [{ url: "/api" }],
});

app.openapi(route, handler);

export default app;
/doc の内容

openapi schema json

自分自身にリクエストを送る client を作る

まず service bindings を wrangler.toml にかき、Hono から参照できるように Bindings の型を変更します。cloudfalre workers はほかの workers にリクエストを送るには service bindings が必要ですが、自分自身にリクエストを送るときも必要です。

+ services = [
+   { binding = "SELF_SERVICE", service = "openapi-hono", environment = "production" }
+ ]
type Bindings = {
-  //
+  SELF_SERVICE: Fetcher;
};

つぎに openapi2aspida の設定をしていきます。まず aspida.config.cjs を作ります。

module.exports = {
  input: "src/config/api",
  openapi: {
    inputFile: "http://localhost:8787/doc",
  },
};

wrangler dev をしている状態で npx openapi2aspida -c aspida.config.cjs を実行。以下のような内容のファイルが出力されます

config/api の出力内容

$api.ts

import type { AspidaClient, BasicHeaders } from "aspida";
import type { Methods as Methods_by08hd } from ".";

const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? "http://localhost/api" : baseURL).replace(/\/$/, "");
  const GET = "GET";

  return {
    /**
     * @hono/openapi-zod はすごい!
     * @returns 成功時のレスポンスは success: true です!
     */
    get: (option?: { config?: T | undefined } | undefined) =>
      fetch<Methods_by08hd["get"]["resBody"], BasicHeaders, Methods_by08hd["get"]["status"]>(
        prefix,
        "",
        GET,
        option,
      ).json(),
    /**
     * @hono/openapi-zod はすごい!
     * @returns 成功時のレスポンスは success: true です!
     */
    $get: (option?: { config?: T | undefined } | undefined) =>
      fetch<Methods_by08hd["get"]["resBody"], BasicHeaders, Methods_by08hd["get"]["status"]>(prefix, "", GET, option)
        .json()
        .then((r) => r.body),
    $path: () => `${prefix}`,
  };
};

export type ApiInstance = ReturnType<typeof api>;
export default api;

index.ts

/* eslint-disable */
export type Methods = {
  /** @hono/openapi-zod はすごい! */
  get: {
    status: 200;

    /** 成功時のレスポンスは success: true です! */
    resBody: {
      success: true;
    };
  };
};

client を作る

ここまでできたところで client を生成する関数を作ります。これを middleware として実装し、hono の variables として登録して handler 内で使います。

import aspida, { FetchConfig } from "@aspida/fetch";
import api from "./config/api/$api";

const createClient = (env: Bindings, config?: FetchConfig) =>
  api(aspida((...args: Parameters<typeof fetch>) => env.SELF_SERVICE.fetch(...args), config));

Variables の内容を変更

type Variables = {
-  //
+  client: ReturnType<typeof createClient>;
};

variable にセットする middleware を作る

baseURL だけ注意が必要です。c.req.url をそのまま渡してしまうと client が実行される handler ごとに baseURL が変わってしまうので new URL("/", c.req.url).href で固定します。

const clientMiddleware: MiddlewareHandler<HonoEnv> = (c, next) => {
  const client = createClient(c.env, {
    baseURL: new URL("/", c.req.url).href,
  });
  c.set("client", client);

  return next();
};

これを app.use("/doc/*", prettyJSON()) の上あたりにおいておけばよいです。

app.use("*", clientMiddleware);

新しい endpoint を作って自分自身にリクエストを送ってみる

const route2 = createRoute({
  path: "/route2",
  method: "get",
  description: "/ の内容をそのまま返します",
  responses: {
    200: {
      description: "成功です",
      content: {
        "application/json": {
          schema: z.object({ success: z.literal(true) }),
        },
      },
    },
  },
});
const handler2: RouteHandler<typeof route2, HonoEnv> = async (c) => {
  const res = await c.get("client").$get();
  return c.jsonT(res);
};

app.openapi(route2, handler2);

できた

~/openapi-hono ❯❯❯ curl http://localhost:8787/route2
{"success":true}%

これをやると service binding のテストが行いたくなる
https://zenn.dev/naporin24690/articles/1229650b8fd991

Discussion