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" })
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
を指定すると 200
、void
を指定すると 204
、@error
をつけた Error
を指定すると default
になります。細かく設定することも可能ですが、最低限なら code
と message
があれば実装に困ることはなさそうではあります。また、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;
詳細は以下を確認してください。
おわりに
Zod OpenAPI Honoは型を活用しながらも直感的に書けますが、OpenAPIに特化したTypeSpecはより短いコードで記述することが可能でした。
Hono OpenAPIやoRPCも確認してみたいと思います。
Discussion