弊社内では OpenAPI 定義の管理をかなり長い間 multi-file-swagger を使って行っていました。
yamlを分割して管理するのですが制約や課題も多く、この管理方法にはずっと不満があったのですが、別の何かに乗り換えようにも書き換えのコストがのしかかり、ずっと後回しになっていました。
しかし、Claude Code といった開発者の良き相棒が登場したこともあり、書き換えのコストに対するハードルはグッと下がったと言えます。ちょうど新規にAPI定義を生やすタイミングであったこともあり、これを契機と考え、思い切って管理方針を変えることにしました。
multi-file-swagger は分割して書いた yaml や json を、後で一枚の OpenAPI 定義ファイルに結合するライブラリです。
かなり古く機能もシンプルであり、これでそこそこ以上の規模の OpenAPI 定義を管理するには課題が多いです。
yamlを生で書かなくてはならない、仕様的に正しいか検証する機能がない、OpenAPI の仕様に沿っていなくても、また $ref
や他の yaml ファイルへの参照が正しくなくてもエラーを吐かずに崩れたファイルを出力してしまう、共通スキーマのファイルが肥大化するなどです。
cuelang で OpenAPI 仕様の型定義を書いて検証だけでも担保しようと試みたりしたこともありますが、やはり開発体験を考えると、生の yaml を直接書くのではなく、TypeScript のような書き慣れた、かつ強力な型システムをもつ言語でそもそものスキーマ定義を書きたいと思いました。
代替ライブラリとして、zod-to-openapi を使うことにしました。
Zod は TypeScript のスキーマ宣言兼バリデーションライブラリです。
Zod に関する情報はかなりたくさん溢れているので詳細は省きますが、便利なので使ったことのない方はぜひ調べてみてください。
zod-to-openapi はこの Zod を拡張し OpenAPI 用のAPIを追加することで、スキーマ定義を TypeScript コードとして書き、そこから yaml や json の形式で定義ファイルを出力するライブラリです。
実装
OpenAPI Specの例としてお馴染み、ペットストアAPIで実装例を書くとこんな感じに書けます。
(Claude Code 君に書いてもらいました)
export const PetStatusSchema = z.enum(['available', 'pending', 'sold']);
export const CategorySchema = z.object({
id: z.number().int().optional(),
name: z.string().optional(),
}).openapi('Category');
export const TagSchema = z.object({
id: z.number().int().optional(),
name: z.string().optional(),
}).openapi('Tag');
export const PetSchema = z.object({
id: z.number().int().optional(),
category: CategorySchema.optional(),
name: z.string(),
photoUrls: z.array(z.string()),
tags: z.array(TagSchema).optional(),
status: PetStatusSchema.optional().describe('pet status in the store'),
}).openapi('Pet');
特筆すべきは、別で定義した PetStatusSchema
や TagSchema
、CategorySchema
を参照するために、$ref
を使用しなくて良いことでしょう。TypeScript で書いているのですから当然ですが、他のスキーマを参照したかったら、そのオブジェクトを直接参照すれば良いだけになります。
また、Zod の API を使用して TypeScript で記述していますから、論理的に誤った書き方はそもそもできません。yaml で直接書く場合よりもミスが減ることは明白でしょう。
ちなみに、openapi()
メソッドで名前を設定すれば、オブジェクトスキーマを components/schemas/ に記述できます。
components:
schemas:
Pet:
type: object
properties:
id:
type: integer
category:
$ref: '#/components/schemas/Category'
name:
type: string
photoUrls:
type: array
items:
type: string
tags:
type: array
items:
$ref: '#/components/schemas/Tag'
status:
type: string
enum:
- available
- pending
- sold
description: pet status in the store
required:
- name
- photoUrls
また、単純に yaml で書くと実現が難しい書き方もできます。例えば、TypeScript のユーティリティ型に Pick
やOmit
など、ある型から一部だけを取り出す型がありますが、Zod にもこれと役割を同じくする pick
、omit
メソッドがあります。これを使用すると、他のスキーマからプロパティを引き算して新しいスキーマを定義できます。
例えば、CreatePetRequest
は新規に Pet を登録する際のリクエストを定義した型です。登録して初めて ID が割り当てられるわけなので、リクエストに id
は不要です。しかし、他のプロパティは Pet
と同じです。そこで、Pet
のスキーマから id
を取り除くことで定義しています。
export const CreatePetRequestSchema = PetSchema.omit({ id: true }).openapi('CreatePetRequest');
export const UpdatePetRequestSchema = PetSchema.openapi('UpdatePetRequest');
Path Object の登録は、OpenAPIRegistry
オブジェクトを使用して行います。
export function registerPetEndpoints(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'post',
path: '/pet',
summary: 'Add a new pet to the store',
description: 'Add a new pet to the store',
tags: ['pet'],
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
'application/json': {
schema: CreatePetRequestSchema,
},
'application/xml': {
schema: CreatePetRequestSchema,
},
},
required: true,
},
},
responses: {
200: {
description: 'Successful operation',
content: {
'application/json': {
schema: PetResponseSchema,
},
'application/xml': {
schema: PetResponseSchema,
},
},
},
405: {
description: 'Invalid input',
content: {
'application/json': {
schema: PetErrorResponseSchema,
},
},
},
},
});
ただ、Path Object の登録関数を作成するたびに、生成処理を担当するファイルなどに毎回関数呼び出しの記述を追加するのは面倒です。そこで、一定のルールを設けた上で、src/
以下にある登録用関数を探して import するようにしました。この例では、register
と名前のついた関数であればそれを import して、OpenAPIRegistry
オブジェクトを渡して呼び出しています。
const loadEndpoints = () => {
const srcPath = path.join(__dirname, 'src');
const endpointFiles = fs.globSync('**/*.ts', {
cwd: srcPath
});
for (const file of endpointFiles) {
const fullPath = path.join(srcPath, file);
try {
const module = require(fullPath);
const registerFunctions = Object.keys(module).filter(key =>
key.startsWith('register') &&
typeof module[key] === 'function'
);
for (const funcName of registerFunctions) {
console.log(`Executing: ${funcName}`);
module[funcName](registry);
}
} catch (error) {
console.error(`Error loading ${file}:`, error);
}
}
}
あとは、Info Object などの情報を足した上で、生成した OpenAPIObject
を yaml や json に書き出すだけです。
ちなみに、OpenAPI3.1用のジェネレータも用意されています。これも3.1向けに書き換えたくなったらジェネレータを入れ替えるだけで良いということですから、ありがたいですね。
function main() {
loadEndpoints();
const generator = new OpenApiGeneratorV3(registry.definitions);
const document = generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'PetStore API',
description: 'PetStore API',
},
servers: [
{
url: 'petstore.swagger.io/v2/',
description: 'Production server',
},
],
});
fs.writeFileSync(
path.join(__dirname, 'openapi.yaml'),
yaml.dump(document, { lineWidth: -1 })
);
console.log('\nOpenAPI document generated successfully!');
}
zod-to-openapi を使えば完璧かというと、そういうわけでもありません。課題はまだあります。
例えば、operationId や component/schemas に定義したスキーマ名など、定義全体でユニークな名前でなければならないものを重複して登録しても、残念ながら特にエラーは出ません。前者はそのまま重複して書かれ、後者は後から登録されたものが無視されるようです。これは予期しない不具合に繋がる可能性がある振る舞いです。
そのため、別で静的解析ツールを使ってチェックするといった対応が必要でしょう。
また、ディレクトリ構造が自由なので、設計方針を決めるコストも、本格的なアプリケーションの設計ほどではないにせよ、多少はありそうです。開発チーム全体で迷わないように取り決めておく必要があるでしょう。
openapi-typescript などのツールを使用して、OpenAPI 定義からコードを生成して使用する場合、yaml の書き方によって微妙に生成結果が変わる可能性があるため、ツールとの兼ね合いも気にしておきたいところです。
既存の yaml 定義から Zod による定義に起こすのは、Claude Code 君に軽く指示を出すだけですんなりいきました。こういうのはやはりAIの得意分野ですね。
AIの登場によって、作業コスト故に後回しにされやすかったタスクに着手しやすくなったことは、素直に喜ばしいことだなぁと思います。
Views: 0