🆚

TypeSpec vs Zod OpenAPI Hono

に公開

全体のコード

最初に全体のコードをお見せします。例にはTypeSpecのプロジェクトを新しく作成したときに生成されるコードを少しだけ修正して使用しました。修正点は以下の通りです。

  • POST、PATCHでは id が必要ないため、
    @removeVisibility(Lifecycle.Create, Lifecycle.Update) を追加
  • @route("{id}/analyze")@route("/{id}/analyze") に変更 (生成に影響なし)
TypeSpec
import "@typespec/http";

using Http;
@service(#{ title: "Widget Service" })
namespace DemoService;

model Widget {
  @removeVisibility(Lifecycle.Create, Lifecycle.Update)
  id: string;
  weight: int32;
  color: "red" | "blue";
}

model WidgetList {
  items: Widget[];
}

@error
model Error {
  code: int32;
  message: string;
}

model AnalyzeResult {
  id: string;
  analysis: string;
}

@route("/widgets")
@tag("Widgets")
interface Widgets {
  /** List widgets */
  @get list(): WidgetList | Error;
  /** Read a widget */
  @get read(@path id: string): Widget | Error;
  /** Create a widget */
  @post create(@body body: Widget): Widget | Error;
  /** Update a widget */
  @patch update(@path id: string, @body body: MergePatchUpdate<Widget>): Widget | Error;
  /** Delete a widget */
  @delete delete(@path id: string): void | Error;

  /** Analyze a widget */
  @route("/{id}/analyze") @post analyze(@path id: string): AnalyzeResult | Error;
}
Zod OpenAPI Hono
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";

const Widget = z.object({
  id: z.string(),
  weight: z.number().int(),
  color: z.enum(["red", "blue"]),
}).openapi("Widget");

const WidgetCreate = Widget
  .omit({ id: true })
  .openapi("WidgetCreate");

const WidgetMergePatchUpdate = WidgetCreate
  .partial()
  .openapi("WidgetMergePatchUpdate");

const WidgetList = z.object({
  items: z.array(Widget),
}).openapi("WidgetList");

const Error = z.object({
  code: z.number().int(),
  message: z.string(),
}).openapi("Error");

const AnalyzeResult = z.object({
  id: z.string(),
  analysis: z.string(),
}).openapi("AnalyzeResult");

const listRoute = createRoute({
  tags: ["Widgets"],
  method: "get",
  path: "",
  description: "List widgets",
  responses: {
    200: {
      content: {
        "application/json": {
          schema: WidgetList,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const readRoute = createRoute({
  tags: ["Widgets"],
  method: "get",
  path: "/{id}",
  description: "Read a widget",
  request: {
    params: z.object({
      id: z.string(),
    }),
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: Widget,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const createWidgetRoute = createRoute({
  tags: ["Widgets"],
  method: "post",
  path: "",
  description: "Create a widget",
  request: {
    body: {
      content: {
        "application/json": {
          schema: WidgetCreate,
        },
      },
    },
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: Widget,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const updateRoute = createRoute({
  tags: ["Widgets"],
  method: "patch",
  path: "/{id}",
  description: "Update a widget",
  request: {
    params: z.object({
      id: z.string(),
    }),
    body: {
      content: {
        "application/json": {
          schema: WidgetMergePatchUpdate,
        },
      },
    },
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: Widget,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const deleteRoute = createRoute({
  tags: ["Widgets"],
  method: "delete",
  path: "/{id}",
  description: "Delete a widget",
  request: {
    params: z.object({
      id: z.string(),
    }),
  },
  responses: {
    204: {
      description: "There is no content to send for this request, but the headers may be useful.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const analyzeRoute = createRoute({
  tags: ["Widgets"],
  method: "post",
  path: "/{id}/analyze",
  description: "Analyze a widget",
  request: {
    params: z.object({
      id: z.string(),
    }),
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: AnalyzeResult,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const widgets = new OpenAPIHono()
  .openapi(listRoute, (c) => {
    return c.json({
      items: [{ id: "string", weight: 0, color: "red" as const }],
    }, 200);
  })
  .openapi(readRoute, (c) => {
    return c.json({
      id: "string",
      weight: 0,
      color: "red" as const,
    }, 200);
  })
  .openapi(createWidgetRoute, (c) => {
    return c.json({
      id: "string",
      weight: 0,
      color: "red" as const,
    }, 200);
  })
  .openapi(updateRoute, (c) => {
    return c.json({
      id: "string",
      weight: 0,
      color: "red" as const,
    }, 200);
  })
  .openapi(deleteRoute, (c) => {
    return c.body(null, 204);
  })
  .openapi(analyzeRoute, (c) => {
    return c.json({
      id: "string",
      analysis: "string",
    }, 200);
  });

const app = new OpenAPIHono()
  .route("/widgets", widgets)
  .doc("/doc", {
    openapi: "3.0.0",
    info: {
      version: "1.0.0",
      title: "Widget Service",
    },
  });

解説

TypeSpecのコードを基準に上から比較をしていきます。

Info Object

@service(#{ title: "Widget Service" })
tspconfig.yaml
emit:
  - "@typespec/openapi3"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{output-dir}/schema"
    openapi-versions:
      - 3.1.0
    file-type: "json"
const app = new OpenAPIHono()
  .doc("/doc", {
    openapi: "3.1.0",
    info: {
      version: "0.0.0",
      title: "Widget Service",
    },
  });

TypeSpecでは version はデフォルトで 0.0.0 に設定されます。変更方法は試していません。

Schema Object

model Widget {
  @removeVisibility(Lifecycle.Create, Lifecycle.Update)
  id: string;
  weight: int32;
  color: "red" | "blue";
}

@error
model Error {
  code: int32;
  message: string;
}
const Widget = z.object({
  id: z.string(),
  weight: z.number().int(),
  color: z.enum(["red", "blue"]),
}).openapi("Widget");

const WidgetCreate = Widget
  .omit({ id: true })
  .openapi("WidgetCreate");

const Error = z.object({
  code: z.number().int(),
  message: z.string(),
}).openapi("Error");

TypeSpecではリクエストメソッドごとに使用するスキーマを生成、変更することができます。Zod OpenAPI Honoでも .omit() を使用することで対応できます。

Tag Object, Paths Object

@route("/widgets")
@tag("Widgets")
interface Widgets {
  /** Update a widget */
  @patch update(@path id: string, @body body: MergePatchUpdate<Widget>): Widget | Error;
  /** Delete a widget */
  @delete delete(@path id: string): void | Error;

  /** Analyze a widget */
  @route("/{id}/analyze") @post analyze(@path id: string): AnalyzeResult | Error;
}
const WidgetMergePatchUpdate = WidgetCreate
  .partial()
  .openapi("WidgetMergePatchUpdate");

const updateRoute = createRoute({
  tags: ["Widgets"],
  method: "patch",
  path: "/{id}",
  description: "Update a widget",
  request: {
    params: z.object({
      id: z.string(),
    }),
    body: {
      content: {
        "application/json": {
          schema: WidgetMergePatchUpdate,
        },
      },
    },
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: Widget,
        },
      },
      description: "The request has succeeded.",
    },
    default: {
      content: {
        "application/json": {
          schema: Error,
        },
      },
      description: "An unexpected error response.",
    },
  },
});

const widgets = new OpenAPIHono()
  .openapi(updateRoute, (c) => {
    return c.json({
      id: "string",
      weight: 0,
      color: "red" as const,
    }, 200);
  });

const app = new OpenAPIHono()
  .route("/widgets", widgets);

Zod OpenAPI Honoは行数が多くなるため、PATCHのみ抜き出しました。TypeSpecはスキーマに Widget を指定すると 200void を指定すると 204@error をつけた Error を指定すると default になります。細かく設定することも可能ですが、最低限なら codemessage があれば実装に困ることはなさそうではあります。また、description はデフォルトのものが用意されているようです。Zod OpenAPI Honoでは仮データでも良いので handler を作成しないと仕様書が出力されません。MergePatchUpdate<>.partial() で対応できます。

おまけ (OpenAPI TypeScript)

Zod OpenAPI Honoは戻り値の型を確かめることが可能です。例えば

const widgets = new OpenAPIHono()
  .openapi(readRoute, (c) => {
    return c.json({
      id: "string",
      weight: 0,
    }, 200);
  });

とすると、

プロパティ 'color' は型 '{ id: string; weight: number; }' にありませんが、型 '{ id: string; weight: number; color: "red" | "blue"; }' では必須です。

というエラーが出ます (なぜか自分で環境構築したときは出なかったのでhono-openapi-starterで確認しました)。TypeSpecで生成した場合はそのままでは型の恩恵を受けられないため、OpenAPI TypeScriptを使用して以下のようにできます (Turborepoを使用)。

import { Hono } from "hono";
import {
  components,
  paths,
} from "@workspace/tsp-openapi-ts/openapi-ts-output/schema";
import { cors } from "hono/cors";
import openApiDoc from "@workspace/tsp-openapi-ts/tsp-output/schema/openapi.json";
import { swaggerUI } from "@hono/swagger-ui";

const app = new Hono();

app.use("/*", cors());

app.get("/doc", (c) => c.json(openApiDoc));
app.get("/ui", swaggerUI({ url: "/doc" }));

app.get("/widgets", (c) => {
  try {
    // throw new Error();
    return c.json<
      paths["/widgets"]["get"]["responses"]["200"]["content"]["application/json"]
    >({
      items: [{ id: "string", weight: 0, color: "red" }],
    });
  } catch (err) {
    return c.json<components["schemas"]["Error"]>({
      code: 500,
      message: "エラーが発生しました",
    }, 500);
  }
});

export default app;

詳細は以下を確認してください。
https://openapi-ts.dev/ja/examples#hono
https://github.com/nkfr26/tsp-openapi-ts

おわりに

Zod OpenAPI Honoは型を活用しながらも直感的に書けますが、OpenAPIに特化したTypeSpecはより短いコードで記述することが可能でした。
Hono OpenAPIoRPCも確認してみたいと思います。

Discussion