OpenAPI を自動生成して AI エージェントに API を理解させる
AI エージェントが API を叩ける状態にするには OpenAPI 仕様の公開が前提です。Hono / tsoa / Stripe 方式の 3 アプローチを比較し、配置位置や落とし穴を実装例つきで解説します。
ChatGPT の Function Calling も、Claude の Tool Use も、Cursor の MCP も、どれも内部的に OpenAPI 仕様を読んで「このエンドポイントは何をするか」「どんなパラメータを取るか」を把握している。SaaS や API プロダクトを運営しているなら、/openapi.json の公開は AI エージェント対応で外せない項目のひとつだ。
本記事では OpenAPI をコードから自動生成する代表的な 3 アプローチを比較しつつ、実装例と落とし穴を見ていく。
AI が OpenAPI を読む流れ
ChatGPT や Claude が API を叩くとき、内部的にはおおむねこんなフローを通る。まずユーザーが「〇〇のサービスで △△ してほしい」と依頼を出すと、AI はそのサービスのドキュメントまたは OpenAPI 仕様を取得する。OpenAPI の paths を読んで該当エンドポイントを特定し、requestBody や parameters のスキーマからリクエストを組み立てて、実際に API を叩く。返ってきたレスポンスは responses のスキーマに基づいて解釈する、という具合だ。
このフローのどこかで OpenAPI が無い、もしくは情報が抜けていると、AI からは「叩けない API」または「推測で叩いて失敗する API」として扱われる。逆に整った OpenAPI を公開していれば、AI 経由で自社プロダクトが使われる確率がぐっと上がる。YomuScore のチェック項目では S15 (ルートに /openapi.json or /openapi.yaml or /swagger.json が存在するか) と P22 (API 系ページから OpenAPI ファイルへのリンクがあるか) がこれを評価している。
アプローチ A: コードから自動生成
ルート定義からスキーマを抽出して OpenAPI を生成するパターン。最小の追加作業で OpenAPI が手に入るので、中規模以下のチームでは第一選択になる。
Hono + zod-openapi
Hono は軽量な TypeScript Web framework で、@hono/zod-openapi を組み合わせると zod スキーマがランタイム検証と OpenAPI 生成の両方を兼ねる構成が組める。同じスキーマで入力検証もドキュメントも済むので、二重管理の無駄がない。
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
const app = new OpenAPIHono()
const ScanRequest = z.object({
url: z.string().url().openapi({ example: 'https://example.com' }),
}).openapi('ScanRequest')
const ScanResponse = z.object({
scanId: z.string().uuid(),
totalScore: z.number().int().min(0).max(100),
grade: z.enum(['A', 'B', 'C', 'D', 'F']),
}).openapi('ScanResponse')
const route = createRoute({
method: 'post',
path: '/api/scan',
request: {
body: { content: { 'application/json': { schema: ScanRequest } } },
},
responses: {
200: {
content: { 'application/json': { schema: ScanResponse } },
description: 'スキャン完了',
},
},
tags: ['Scan'],
summary: 'URL をスキャンして AI エージェント対応度を判定',
})
app.openapi(route, async (c) => {
const body = c.req.valid('json')
// ... 実装 ...
return c.json({ scanId: '...', totalScore: 84, grade: 'B' })
})
// /openapi.json で OpenAPI 仕様を公開
app.doc('/openapi.json', {
openapi: '3.0.0',
info: { title: 'YomuScore API', version: '1.0.0' },
})
これだけで /openapi.json が動的に配信される。zod スキーマが入力検証になり、それがそのままドキュメントにもなる、というのが構成として美しい。
tsoa
tsoa は TypeScript デコレータからルートと OpenAPI を生成するライブラリで、Express / Koa / Fastify と組み合わせる王道のひとつだ。
import { Route, Post, Body, Tags, Summary } from 'tsoa'
interface ScanRequest {
/** @example "https://example.com" */
url: string
}
interface ScanResponse {
scanId: string
totalScore: number
grade: 'A' | 'B' | 'C' | 'D' | 'F'
}
@Route('api/scan')
@Tags('Scan')
export class ScanController {
@Post()
@Summary('URL をスキャンして AI エージェント対応度を判定')
public async scan(@Body() body: ScanRequest): Promise<ScanResponse> {
return { scanId: '...', totalScore: 84, grade: 'B' }
}
}
ビルド時に tsoa spec コマンドを叩くと OpenAPI JSON が生成される。
NestJS
NestJS ユーザーには @nestjs/swagger が組み込み標準。デコレータでメタを付けて SwaggerModule.setup() を呼ぶと、/api-docs と /api-docs-json が自動で立ち上がる。
アプローチ B: スキーマファースト
先にスキーマを書いて、そこから型もバリデーションも OpenAPI も派生させるパターン。フレームワーク選択の自由度が高いのが利点で、ルートとスキーマが分かれてしまうのが欠点だ。
Zod + zod-to-openapi 系を使う場合はこんな感じになる。
import { z } from 'zod'
import { OpenApiGeneratorV3, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
extendZodWithOpenApi(z)
const ScanRequestSchema = z.object({
url: z.string().url(),
}).openapi('ScanRequest')
const generator = new OpenApiGeneratorV3([ScanRequestSchema])
const openapi = generator.generateDocument({
openapi: '3.0.0',
info: { title: 'YomuScore API', version: '1.0.0' },
})
// openapi を /openapi.json に静的書き出し or 動的配信
TypeBox + Fastify の組み合わせも実用的だ。TypeBox は JSON Schema 互換の TypeScript スキーマライブラリで、Fastify が組み込みで JSON Schema を扱えるので、定義をそのまま渡すと OpenAPI 出力まで自動で得られる。
import Fastify from 'fastify'
import { Type } from '@sinclair/typebox'
import swagger from '@fastify/swagger'
const fastify = Fastify()
await fastify.register(swagger, {
swagger: { info: { title: 'YomuScore API', version: '1.0.0' } },
})
fastify.post('/api/scan', {
schema: {
body: Type.Object({ url: Type.String({ format: 'uri' }) }),
response: {
200: Type.Object({
scanId: Type.String(),
totalScore: Type.Integer({ minimum: 0, maximum: 100 }),
}),
},
},
handler: async (req) => { /* ... */ },
})
await fastify.ready()
const openapi = fastify.swagger()
アプローチ C: 手書き
Stripe、GitHub、Twilio のような大規模 API プロバイダは OpenAPI を手書きで管理している。API のドキュメンテーション自体がプロダクトの一部であり、自動生成だと品質が足りないこと、言語別 SDK を生成するためのソースとして OpenAPI を信頼できる仕様書として使いたいこと、バージョニングや破壊的変更の管理を厳密にやる必要があること、あたりが理由だ。
中規模以下のスタートアップや社内 API では基本的にオーバーキルだが、API がプロダクト価値の中核に位置するケースでは検討の価値がある。手書きの場合はこんな構造になる。
openapi: 3.1.0
info:
title: YomuScore API
version: 1.0.0
description: |
AI エージェント対応度を 47 項目でスキャンする SaaS の API。
contact:
url: https://yomuscore.com/contact
servers:
- url: https://yomuscore.com
paths:
/api/scan:
post:
tags: [Scan]
summary: URL をスキャンして AI エージェント対応度を判定
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ScanRequest'
responses:
'200':
description: スキャン完了
content:
application/json:
schema:
$ref: '#/components/schemas/ScanResponse'
components:
schemas:
ScanRequest:
type: object
required: [url]
properties:
url:
type: string
format: uri
example: https://example.com
ScanResponse:
type: object
properties:
scanId: { type: string, format: uuid }
totalScore: { type: integer, minimum: 0, maximum: 100 }
grade: { type: string, enum: [A, B, C, D, F] }
どこに配置するか
YomuScore の S15 は 3 つのパスを順に試行して、いずれかが 200 + 有効な OpenAPI なら pass、という判定をしている。/openapi.json、/openapi.yaml、/swagger.json の順だ。/.well-known/openapi.json に置く流派もあって IETF ドラフトでも提案されているが、現状の AI エージェント主要実装はルート直下を最初に探すので、まずは /openapi.json で配信するのが安全。
API がサブドメインに分かれている場合は両方に置くのが理想で、メインサイトの https://example.com/openapi.json で OpenAPI を見つけられるようにしつつ、API ドメインの https://api.example.com/openapi.json でも直接アクセスできるようにしておく。
API ページから OpenAPI への導線
ドキュメントページや API リファレンスページの <head> に alternate link を入れると P22 が pass する。
<head>
<link rel="alternate" type="application/openapi+json" href="/openapi.json" />
<link rel="alternate" type="application/openapi+yaml" href="/openapi.yaml" />
</head>
加えてページ本文中にも明示的なリンクを置く。
<a href="/openapi.json">OpenAPI 仕様 (JSON)</a>
<a href="/openapi.yaml">OpenAPI 仕様 (YAML)</a>
JSON-LD で WebAPI スキーマを足すとさらに強い。
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebAPI",
"name": "YomuScore API",
"description": "AI エージェント対応度スキャンの REST API",
"documentation": "https://yomuscore.com/docs/api",
"encoding": "https://yomuscore.com/openapi.json"
}
</script>
つまずきやすいポイント
実装ミスでよく見るパターンが 5 つほどある。
1 つ目、Content-Type が間違っているケース。/openapi.json を返したつもりが Content-Type が text/html のままになっていて AI 側がパースできない、というやつ。.json で配信するなら application/json、.yaml なら application/yaml または application/x-yaml を明示する。Nginx の types ディレクティブや Caddy の header ディレクティブで設定するのが普通。
2 つ目、CORS が設定されていないケース。AI エージェントがブラウザ経由で取りに来る場合、CORS がないとフェッチが失敗する。Access-Control-Allow-Origin: * を出しておけばいい。API そのもののエンドポイントはオリジン制限を付けるとしても、OpenAPI 仕様自体は全公開で問題ない。
3 つ目、servers フィールドが開発用のままになっているケース。http://localhost:3000 のままで本番にデプロイしてしまう、というやつ。https://yomuscore.com のような本番 URL を明示する。
4 つ目、認証情報の handling が不明瞭なケース。API キー必須のエンドポイントなのに securitySchemes が定義されていないと、AI 側は「認証不要のオープン API」と誤認してしまう。
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
paths:
/api/scan:
post:
security:
- ApiKeyAuth: []
5 つ目、description がスカスカなケース。summary: Scan description: "" だと AI は何を判断材料にすればいいか分からない。各エンドポイントに 2〜3 文の description を、各パラメータには example を付ける。OpenAPI を「AI 向けの README」として書く意識を持つと、自然と品質が上がる。
関連リンク
- Vercel Agent Readability Spec 完全ガイド — S15 / P22 の詳細
- AGENTS.md とは — API SDK 利用者向けの説明書
- llms.txt とは何か
- OpenAPI Specification (公式)
- Hono OpenAPI
- tsoa
- YomuScore で診断する