api-design-practices
リポジトリ: anomalyco/opencode 分析日: 2026-03-05
概要
opencode のサーバー API 設計を分析する。このプロジェクトは Hono + hono-openapi + Zod v4 でサーバーを構築し、OpenAPI 3.1 スペックを自動生成し、@hey-api/openapi-ts で型安全な SDK を自動生成するパイプラインを持つ。ドメインモジュールで定義した Zod スキーマがバリデーション・OpenAPI ドキュメント・SDK 型生成の単一ソースとなっている点が注目に値する。サーバー/クライアント間の型共有を手動同期なしに実現する実用的なアーキテクチャである。
背景にある原則
スキーマ単一ソース原則: バリデーションスキーマ(Zod)を API ドキュメント(OpenAPI)と SDK 型の唯一の真実の源泉にすべき。なぜなら手動で OpenAPI スペックを書くとサーバー実装と乖離し、型定義を手動同期するとバグの温床になるため。opencode では Zod スキーマ → hono-openapi が OpenAPI JSON を生成 → @hey-api/openapi-ts が SDK を生成、という一方向パイプラインでこれを実現している(
script/generate.ts,packages/sdk/js/script/build.ts)。ドメインスキーマの API 層再利用原則: API のリクエスト/レスポンス型はドメインモジュールが所有すべき。ルート定義で独自にスキーマを作るのではなく、ドメインモジュールの
Infoスキーマやfn()で束縛されたスキーマをresolver()/validator()に直接渡すことで、ドメイン変更が自動的に API スペックに反映される(Session.Info,Session.create.schema等)。operationId 駆動の SDK 構造化原則: operationId に
<resource>.<action>という命名規則を適用し、SDK クライアントのメソッド階層を自動的に構造化すべき。これにより SDK 利用者はclient.session.create()のような直感的な API を得る(server/routes/session.tsの operationId 定義 →sdk/js/src/gen/sdk.gen.tsのクラス階層)。エラー型の構造化原則: エラーレスポンスも Zod スキーマで定義し、OpenAPI スペックに含めるべき。opencode では
NamedError.create()で型安全なエラーを定義し、errors()ヘルパーで各ルートに一貫したエラースキーマを付与している(server/error.ts:34)。
実例と分析
Zod → OpenAPI → SDK の自動生成パイプライン
パイプラインの全体像は以下の通り:
- ドメインモジュールが Zod スキーマを定義(
.meta({ ref: "..." })で OpenAPI の$ref名を指定) - ルート定義が
describeRoute()+validator()+resolver()でスキーマをルートに結合 - CLI の
generateコマンドがServer.openapi()→generateSpecs()で OpenAPI JSON を出力 - SDK ビルドスクリプトが
@hey-api/openapi-tsで TypeScript SDK を自動生成
// packages/opencode/src/session/index.ts:119-161
export const Info = z
.object({
id: Identifier.schema("session"),
slug: z.string(),
projectID: z.string(),
// ...
title: z.string(),
version: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
}),
})
.meta({
ref: "Session",
});
export type Info = z.output<typeof Info>;.meta({ ref: "Session" }) がキーポイントで、OpenAPI スペックの #/components/schemas/Session として $ref 化される。これにより SDK 側で Session 型が独立した名前付き型として生成される。
fn() ユーティリティによるスキーマとビジネスロジックの共置
// packages/opencode/src/util/fn.ts:1-11
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input);
return cb(parsed);
};
result.force = (input: z.infer<T>) => cb(input);
result.schema = schema;
return result;
}fn() はスキーマとコールバックを束ねた関数を返し、.schema プロパティでスキーマに外部からアクセスできる。ルート定義側で Session.create.schema や Session.fork.schema.omit({ sessionID: true }) のように参照することで、ドメインロジックとAPI定義のスキーマが自動的に同期する。
ルート定義パターン: describeRoute + validator + resolver
全ルートが以下の一貫した構造を持つ:
// packages/opencode/src/server/routes/session.ts:24-67
.get(
"/",
describeRoute({
summary: "List sessions",
description: "Get a list of all OpenCode sessions...",
operationId: "session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
},
}),
validator("query", z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions" }),
// ...
})),
async (c) => {
const query = c.req.valid("query")
// ...
},
)describeRoute が OpenAPI メタデータ、validator がリクエストバリデーション、resolver がレスポンススキーマを担う。三者すべてが Zod スキーマを入力とする。
エラーレスポンスの構造化と再利用
// packages/opencode/src/server/error.ts:1-36
export const ERRORS = {
400: {
description: "Bad request",
content: {
"application/json": {
schema: resolver(
z.object({
data: z.any(),
errors: z.array(z.record(z.string(), z.any())),
success: z.literal(false),
}).meta({ ref: "BadRequestError" }),
),
},
},
},
404: {/* ... NotFoundError.Schema ... */},
} as const;
export function errors(...codes: number[]) {
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]));
}errors(400, 404) のようにスプレッドするだけでエラーレスポンスを各ルートに追加できる。全ルートでエラー形式が統一され、SDK 側でもエラー型が型安全に扱える。
SDK 自動生成とクライアントファクトリ
// packages/sdk/js/script/build.ts:14-39
await createClient({
input: "./openapi.json",
output: { path: "./src/v2/gen", clean: true },
plugins: [
{ name: "@hey-api/typescript", exportFromIndex: false },
{
name: "@hey-api/sdk",
instance: "OpencodeClient",
paramsStructure: "flat",
},
{ name: "@hey-api/client-fetch", baseUrl: "http://localhost:4096" },
],
});生成された SDK は階層化されたクラス構造を持ち、OpencodeClient がドメインリソースごとのサブクライアントを公開する:
// packages/sdk/js/src/gen/sdk.gen.ts:1157-1197
export class OpencodeClient extends _HeyApiClient {
session = new Session({ client: this._client });
project = new Project({ client: this._client });
config = new Config({ client: this._client });
// ...
}lazy() によるルート初期化の遅延
// packages/opencode/src/server/routes/session.ts:22
export const SessionRoutes = lazy(() => new Hono().get(...).post(...))全ルートファイルが lazy() でラップされている。これにより import 時にルート定義が即座に実行されず、実際に App() が呼ばれるまで初期化が遅延する。テスト時やCLI の generate コマンドで OpenAPI スペックだけ取得する場合に不要な副作用を防ぐ。
SSE イベントストリームの型安全な定義
// packages/opencode/src/bus/bus-event.ts:12-42
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = { type, properties };
registry.set(type, result);
return result;
}
export function payloads() {
return z.discriminatedUnion(
"type",
registry.entries().map(([type, def]) =>
z.object({
type: z.literal(type),
properties: def.properties,
}).meta({ ref: "Event" + "." + def.type })
).toArray() as any,
).meta({ ref: "Event" });
}BusEvent.define() でイベントを登録し、BusEvent.payloads() で全イベントの discriminated union を動的に構築する。この union が SSE エンドポイントのレスポンススキーマに渡され、OpenAPI スペックに全イベント型が含まれる。
パターンカタログ
Registry パターン (分類: 振る舞い)
- 解決する問題: 分散定義されたイベント型を一箇所に集約して OpenAPI スキーマとして出力する
- 適用条件: 多数のモジュールが独立にイベントやエラーを定義し、それらを API スキーマに反映する必要がある場合
- コード例:
packages/opencode/src/bus/bus-event.ts:10-42 - 注意点:
as anyキャストが必要になるため、登録漏れは型チェックでは検出できない
Facade パターン (分類: 構造)
- 解決する問題: 生成された低レベルの HTTP クライアントを、ドメイン指向の API で包む
- 適用条件: 自動生成 SDK にカスタム初期化ロジック(ヘッダー注入、タイムアウト設定等)を追加する場合
- コード例:
packages/sdk/js/src/client.ts:8-30(createOpencodeClientがOpencodeClientをラップ)
Good Patterns
- ドメインスキーマの直接参照: ルート定義で
resolver(Session.Info.array())のようにドメインモジュールのスキーマを直接使う。スキーマのコピーが存在しないため、ドメイン変更が即座に API に反映される。
// packages/opencode/src/server/routes/session.ts:34-36
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},- fn().schema によるバリデーションスキーマの外部公開: ビジネスロジック関数にスキーマを添付し、ルート側で
.schema.omit()等の変換を適用する。URL パラメータとボディの分離が宣言的に行える。
// packages/opencode/src/server/routes/session.ts:315
validator("json", Session.initialize.schema.omit({ sessionID: true })),- errors() スプレッドヘルパー: エラーレスポンス定義をワンライナーで追加できる。
...errors(400, 404)だけで済み、エラー形式の一貫性が保証される。
// packages/opencode/src/server/routes/session.ts:108
...errors(400, 404),- operationId の命名規約:
<resource>.<action>形式(session.list,session.create,mcp.auth.start)により、SDK のクラス階層が自動的に意味のある構造になる。
Anti-Patterns / 注意点
- ルートチェーンの型推論爆発: Hono のルートチェーンが長くなると TypeScript の型推論が破綻する。opencode では
as unknown as Honoキャストで対処している。
// Bad: 型推論が失敗する長大なチェーン
// packages/opencode/src/server/server.ts:576
// ...
// ) as unknown as Hono,
// Better: ルートをドメインごとに分割し、.route() でマウントする
app.route("/session", SessionRoutes());
app.route("/project", ProjectRoutes());opencode はルート分割を進めているが、server.ts にまだ多数のルートが残っている。ドメイン単位の分割を徹底すれば型推論問題を回避できる。
- validator 未使用のパラメータ参照: 一部のルートで
c.req.param("name")を直接使い、validator("param", ...)を経由していない箇所がある。これだとバリデーションが行われず、OpenAPI スペックにパラメータ定義が出力されない。
// Bad: packages/opencode/src/server/routes/mcp.ts:86
const name = c.req.param("name");
// Better: validator 経由でパラメータを取得する
validator("param", z.object({ name: z.string() })), async (c) => {
const { name } = c.req.valid("param");
};導出ルール
[MUST]API のリクエスト/レスポンス型はバリデーションスキーマ(Zod 等)を単一ソースとし、OpenAPI スペックと SDK 型はそこから自動生成する- 根拠: opencode では全ルートが
resolver(DomainSchema)+validator("json", DomainSchema)で Zod スキーマを直接参照し、手動の型同期を排除している(server/routes/*.ts全体)
- 根拠: opencode では全ルートが
[MUST]ドメインモジュールがスキーマを所有し、API 層はそれを参照するだけにする(API 層で独自にスキーマを定義しない)- 根拠:
Session.Info,Session.create.schema,Config.Info等のドメインスキーマがルート定義から直接参照されており、スキーマの重複が存在しない
- 根拠:
[SHOULD]operationId に<resource>.<action>形式の命名規約を適用し、SDK クライアントの階層構造を自動的に導出する- 根拠:
session.list,mcp.auth.start等の operationId が @hey-api/openapi-ts によりOpencodeClient.session.list(),OpencodeClient.mcp.auth.start()のメソッド階層に変換されている
- 根拠:
[SHOULD]エラーレスポンスのスキーマを一箇所で定義し、ヘルパー関数で全ルートにスプレッドする- 根拠:
errors()ヘルパー(server/error.ts:34)により、全ルートで...errors(400, 404)と一行で統一エラー定義を追加している
- 根拠:
[SHOULD]ビジネスロジック関数にバリデーションスキーマを添付し(fn()パターン)、API 層で.schema.omit()等の変換を適用してリクエストスキーマを導出する- 根拠:
Session.create.schema,Session.fork.schema.omit({ sessionID: true })等の利用パターン(server/routes/session.ts)
- 根拠:
[SHOULD]Zod スキーマに.meta({ ref: "TypeName" })を付与して、OpenAPI スペックで$refとして名前付き型を生成する- 根拠:
Session.Info.meta({ ref: "Session" })により、SDK 側でSession型が独立した名前付き型として利用可能になっている
- 根拠:
[AVOID]ルート定義内でc.req.param()やc.req.query()を直接使う(validator()を経由しない参照)- 根拠:
mcp.ts:86でc.req.param("name")を直接使っている箇所があり、バリデーション未適用かつ OpenAPI パラメータ定義が欠落する
- 根拠:
適用チェックリスト
- [ ] バリデーションライブラリ(Zod 等)のスキーマを API ドキュメントと SDK 型の単一ソースにしているか
- [ ] ドメインモジュールにスキーマを定義し、API 層はそれを
importして参照するだけにしているか - [ ] OpenAPI スペック生成がサーバーコードから自動化されているか(手書き OpenAPI YAML を排除)
- [ ] SDK がスペックから自動生成されるパイプラインが CI に組み込まれているか
- [ ] operationId に一貫した命名規約(
resource.action)を適用しているか - [ ] エラーレスポンスのスキーマが一箇所で定義され、全ルートに統一的に適用されているか
- [ ] スキーマに
$ref名(Zod なら.meta({ ref }), 他なら相当する機構)を付与し、生成される型名を制御しているか - [ ] ルートハンドラ内のパラメータ取得がすべて validator 経由か(直接の
req.param()参照がないか)