← ブログ一覧

OpenAPI を自動生成して AI エージェントに API を理解させる

AI エージェントが API を叩ける状態にするには OpenAPI 仕様の公開が前提です。Hono / tsoa / Stripe 方式の 3 アプローチを比較し、配置位置や落とし穴を実装例つきで解説します。

·2·YomuScore 編集部

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 を読んで該当エンドポイントを特定し、requestBodyparameters のスキーマからリクエストを組み立てて、実際に 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」として書く意識を持つと、自然と品質が上がる。


関連リンク

OpenAPIAPI実装AIエージェント

自社サイトを今すぐ診断

47項目のチェックを約5秒で実行。登録不要・無料。

診断ページへ