🔥

Hono Takibi というツールを作りました

2024/12/25に公開

Hono Takibi

はじめに

Hono Advent Calendar 2024の最終日の記事です。OpenAPI定義から、Zod OpenAPI Honoのコードを生成するツール、Hono Takibiを紹介します。

OpenAPI Specification(OAS)は、プログラミング言語に依存しないREST API記述フォーマットです。以前はSwagger Specificationと呼ばれていました。

⚠️ 重要な注意点

Hono Takibiを使用するためには、OpenAPI定義(YAMLまたはJSONファイル)が必要です。既存のAPIをZod OpenAPI Honoに移行する場合は、まずOpenAPI定義を用意する必要があります。OpenAPI定義がない場合は、以下のような方法で作成できます。

新規作成

  • Swagger Editorなどのツールを使用して新規作成
  • 生成AIを活用してOpenAPI定義を作成

既存APIの移行

  • FastAPIの自動生成されるOpenAPI定義
  • NestJS@nestjs/swaggerで生成される定義
  • Spring Bootspringdoc-openapiによる定義

参考

Hono Takibiとは?

Hono Takibiは、OpenAPI定義から、Zod OpenAPI Honoのコードを生成するツールです。開発者がビジネスロジックの実装に集中できるよう、定型的なコード生成を自動化することを目的としています。

Hono Takibiを作る上で参考にしたもの

Zodiosは、型安全なAPIクライアントを作成できます。そして、openapi-zod-clientOpenAPI定義からZodiosのコードを生成できます。

Honoでは、RPCという機能があり、Zodiosを使用せずに、型安全なAPIクライアントを実現可能です。

なぜHono Takibiを作ったのか?

 もしかして、openapi-zod-clientZod OpenAPI Hono版があったりするのか?

 もし、あれば、「OpenAPI定義さえあれば、Hono移行できるはず」

 探したところ、なさそうなので、Hono Takibiを作りました。

Hono Takibiという命名について

hono-takibiという名前にしました。

 最初は以下のどれかにしようと思っていました。

  • hono-openapi-codegen
  • openapi-to-hono
  • oas-to-hono
  • hono-oas-gen
  • hono-gen

 名前を募集したら、hono-takibiという案が出たので、使わせていただきました。

開発者の負担軽減

 私自身、命名に苦労しているので、変数名やスキーマ名の自動生成があればと思いました。

REST APIには、制約があります。

  • REST APIを設計する際には、各リソース一意なパスで識別しなければいけない

  • APIの中心にあるのはデータであり、APIを作っているのは、リソース、パラメータ、レスポンス、それらのプロパティ

  • APIの設計は、一貫性のある名前を選ぶことから始まる

 都合が良いことに、OpenAPIは、REST API記述フォーマットです。変数名が一意に定まります

Hono Takibiの機能

hono-takibiは、OpenAPI定義から、Zod OpenAPI Honoのコードを生成します。

Zodスキーマの生成

OpenAPI定義のcomponentsschemasに、ErrorPostを定義します。

components:
  schemas:
    Error:
      type: object
      properties:
        message:
          type: string
      required:
        - message
    Post:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier of the post
        post:
          type: string
          description: Content of the post
          minLength: 1
          maxLength: 140
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the post was created
        updatedAt:
          type: string
          format: date-time
          description: Timestamp when the post was last updated
      required:
        - id
        - post
        - createdAt
        - updatedAt

 キャメルケース形式の変数名と、Zodスキーマが生成されます。

 現在、キャメルケース形式の変数生成のみが対応しています。また、Zod スキーマの生成については、まだ完全には対応できていません。

const errorSchema = z.object({ message: z.string() })

const postSchema = z.object({
  id: z.string().uuid(),
  post: z.string().min(1).max(140),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})

export const schemas = {
  errorSchema,
  postSchema,
}

ルート定義の生成

 投稿APIの例を以下に示します。

paths:
  /posts:
    post:
      tags:
        - Post
      summary: Create a new post
      description: Submit a new post with a maximum length of 140 characters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post:
                  type: string
                  description: Content of the post
                  minLength: 1
                  maxLength: 140
              required:
                - post
            example:
              post: "This is my first post!"
      responses:
        '201':
          description: Post successfully created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Created
        '400':
          description: Invalid request due to bad input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Post content is required and must be between 1 and 140 characters.
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: An unexpected error occurred. Please try again later.

 変数名は、メソッド+パス+Routeで、postPostsRouteとなります。

export const postPostsRoute = createRoute({
  tags: ['Post'],
  method: 'post',
  path: '/posts',
  description: 'Submit a new post with a maximum length of 140 characters.',
  request: {
    body: {
      required: true,
      content: { 'application/json': { schema: z.object({ post: z.string().min(1).max(140) }) } },
    },
  },
  responses: {
    201: {
      description: 'Post successfully created.',
      content: { 'application/json': { schema: errorSchema } },
    },
    400: {
      description: 'Invalid request due to bad input.',
      content: { 'application/json': { schema: errorSchema } },
    },
    500: {
      description: 'Internal server error.',
      content: { 'application/json': { schema: errorSchema } },
    },
  },
})

Hono Takibiの使い方

hono-takibiをインストールします。

npm add -D hono-takibi

OpenAPI定義、yamlまたはjsonファイルと、出力先のパスを指定して、hono-takibiを実行します。

npx hono-takibi path/to/openapi.yaml -o path/to/output_hono.ts

Example

 以下のような、OpenAPI定義ファイルを用意します。

hono-rest-example.yaml
openapi: 3.1.0
info:
  title: Hono API
  version: v1

components:
  schemas:
    Error:
      type: object
      properties:
        message:
          type: string
      required:
        - message
    Post:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier of the post
        post:
          type: string
          description: Content of the post
          minLength: 1
          maxLength: 140
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the post was created
        updatedAt:
          type: string
          format: date-time
          description: Timestamp when the post was last updated
      required:
        - id
        - post
        - createdAt
        - updatedAt

tags:
  - name: Hono
    description: Endpoints related to general Hono operations
  - name: Post
    description: Endpoints for creating, retrieving, updating, and deleting posts

paths:
  /:
    get:
      tags:
        - Hono
      summary: Welcome message
      description: Retrieve a simple welcome message from the Hono API.
      responses:
        '200':
          description: Successful response with a welcome message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Hono🔥
                required:
                  - message

  /posts:
    post:
      tags:
        - Post
      summary: Create a new post
      description: Submit a new post with a maximum length of 140 characters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post:
                  type: string
                  description: Content of the post
                  minLength: 1
                  maxLength: 140
              required:
                - post
            example:
              post: "This is my first post!"
      responses:
        '201':
          description: Post successfully created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Created
        '400':
          description: Invalid request due to bad input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Post content is required and must be between 1 and 140 characters.
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: An unexpected error occurred. Please try again later.

    get:
      tags:
        - Post
      summary: Retrieve a list of posts
      description: Retrieve a paginated list of posts. Specify the page number and the number of posts per page.
      parameters:
        - in: query
          name: page
          required: false
          schema:
            type: integer
            minimum: 0
            default: 1
          description: The page number to retrieve. Must be a positive integer. Defaults to 1.
        - in: query
          name: rows
          required: false
          schema:
            type: integer
            minimum: 0
            default: 10
          description: The number of posts per page. Must be a positive integer. Defaults to 10.
      responses:
        '200':
          description: Successfully retrieved a list of posts.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Post'
              example:
                - id: "123e4567-e89b-12d3-a456-426614174000"
                  post: "Hello world!"
                  createdAt: "2024-12-01T12:34:56Z"
                  updatedAt: "2024-12-02T14:20:00Z"
        '400':
          description: Invalid request due to bad input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Invalid page or rows parameter. Both must be positive integers.
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: An unexpected error occurred. Please try again later.

  /posts/{id}:
    put:
      tags:
        - Post
      summary: Update an existing post
      description: Update the content of an existing post identified by its unique ID.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: Unique identifier of the post.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post:
                  type: string
                  description: Updated content for the post
                  minLength: 1
                  maxLength: 140
              required:
                - post
            example:
              post: "Updated post content."
      responses:
        '204':
          description: Post successfully updated.
        '400':
          description: Invalid input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Post content is required and must be between 1 and 140 characters.
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: An unexpected error occurred. Please try again later.

    delete:
      tags:
        - Post
      summary: Delete a post
      description: Delete an existing post identified by its unique ID.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: Unique identifier of the post.
      responses:
        '204':
          description: Post successfully deleted.
        '400':
          description: Invalid input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: Invalid post ID.
        '500':
          description: Internal server error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                message: An unexpected error occurred. Please try again later.

hono-takibiを実行します。

npx hono-takibi example/hono-rest-example.yaml -o routes/index.ts

 以下のような、routes/index.tsが生成されます。

import { createRoute, z } from '@hono/zod-openapi'

const errorSchema = z.object({ message: z.string() })

const postSchema = z.object({
  id: z.string().uuid(),
  post: z.string().min(1).max(140),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})

export const schemas = {
  errorSchema,
  postSchema,
}

export const getRoute = createRoute({
  tags: ['Hono'],
  method: 'get',
  path: '/',
  description: 'Retrieve a simple welcome message from the Hono API.',
  responses: {
    200: {
      description: 'Successful response with a welcome message.',
      content: { 'application/json': { schema: z.object({ message: z.string() }) } },
    },
  },
})

export const postPostsRoute = createRoute({
  tags: ['Post'],
  method: 'post',
  path: '/posts',
  description: 'Submit a new post with a maximum length of 140 characters.',
  request: {
    body: {
      required: true,
      content: { 'application/json': { schema: z.object({ post: z.string().min(1).max(140) }) } },
    },
  },
  responses: {
    201: {
      description: 'Post successfully created.',
      content: { 'application/json': { schema: errorSchema } },
    },
    400: {
      description: 'Invalid request due to bad input.',
      content: { 'application/json': { schema: errorSchema } },
    },
    500: {
      description: 'Internal server error.',
      content: { 'application/json': { schema: errorSchema } },
    },
  },
})

export const getPostsRoute = createRoute({
  tags: ['Post'],
  method: 'get',
  path: '/posts',
  description:
    'Retrieve a paginated list of posts. Specify the page number and the number of posts per page.',
  request: {
    query: z.object({
      page: z.string().pipe(z.coerce.number().int().min(0)).optional(),
      rows: z.string().pipe(z.coerce.number().int().min(0)).optional(),
    }),
  },
  responses: {
    200: {
      description: 'Successfully retrieved a list of posts.',
      content: { 'application/json': { schema: z.array(postSchema) } },
    },
    400: {
      description: 'Invalid request due to bad input.',
      content: { 'application/json': { schema: errorSchema } },
    },
    500: {
      description: 'Internal server error.',
      content: { 'application/json': { schema: errorSchema } },
    },
  },
})

export const putPostsIdRoute = createRoute({
  tags: ['Post'],
  method: 'put',
  path: '/posts/{id}',
  description: 'Update the content of an existing post identified by its unique ID.',
  request: {
    body: {
      required: true,
      content: { 'application/json': { schema: z.object({ post: z.string().min(1).max(140) }) } },
    },
    params: z.object({ id: z.string().uuid() }),
  },
  responses: {
    204: { description: 'Post successfully updated.' },
    400: {
      description: 'Invalid input.',
      content: { 'application/json': { schema: errorSchema } },
    },
    500: {
      description: 'Internal server error.',
      content: { 'application/json': { schema: errorSchema } },
    },
  },
})

export const deletePostsIdRoute = createRoute({
  tags: ['Post'],
  method: 'delete',
  path: '/posts/{id}',
  description: 'Delete an existing post identified by its unique ID.',
  request: { params: z.object({ id: z.string().uuid() }) },
  responses: {
    204: { description: 'Post successfully deleted.' },
    400: {
      description: 'Invalid input.',
      content: { 'application/json': { schema: errorSchema } },
    },
    500: {
      description: 'Internal server error.',
      content: { 'application/json': { schema: errorSchema } },
    },
  },
})

REST APIの作成

hono-takibiのできるところは、ここまでです。実装は、開発者が行います。

ディレクトリ構造

.
├── prisma
│   ├── migrations
│   └── schema.prisma
└── src
    ├── handler
    │   ├── hono_handler.ts
    │   └── posts_handler.ts
    ├── index.ts
    ├── infra
    │   └── index.ts
    ├── openapi
    │   └── index.ts
    └── service
        └── posts_service.ts

Handler

 hono_handler.ts

import type { RouteHandler } from '@hono/zod-openapi'
import type { getRoute } from '../openapi/index.js'

export const getHandler: RouteHandler<typeof getRoute> = async (c) => {
  return c.json({ message: 'Hono🔥' }, 200)
}

 posts_handler.ts

import type { RouteHandler } from '@hono/zod-openapi'
import type {
  deletePostsIdRoute,
  getPostsRoute,
  postPostsRoute,
  putPostsIdRoute,
} from '../openapi/index.js'
import { deletePostsId, getPosts, postPosts, putPostsId } from '../service/posts_service.js'
import type { Post } from '@prisma/client'

export const postPostsRouteHandler: RouteHandler<typeof postPostsRoute> = async (c) => {
  const { post } = c.req.valid('json')
  await postPosts(post)
  return c.json({ message: 'Created' }, 201)
}

export const getPostsRouteHandler: RouteHandler<typeof getPostsRoute> = async (c) => {
  const { page = 1, rows = 10 } = c.req.valid('query')
  const limit = rows ?? 10
  const offset = (page - 1) * rows
  const posts: Post[] = await getPosts(limit, offset)
  return c.json(posts, 200)
}

export const putPostsIdRouteHandler: RouteHandler<typeof putPostsIdRoute> = async (c) => {
  const { id } = c.req.valid('param')
  const { post } = c.req.valid('json')
  await putPostsId(id, post)
  return new Response(null, { status: 204 })
}

export const deletePostsIdRouteHandler: RouteHandler<typeof deletePostsIdRoute> = async (c) => {
  const { id } = c.req.valid('param')
  await deletePostsId(id)
  return new Response(null, { status: 204 })
}

Service

model Post {
  id        String   @id @default(uuid())
  post      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

PrismaというORMを使用して、操作を行います。

import type { Post } from '@prisma/client'
import prisma from '../infra/index.js'

export async function postPosts(post: string): Promise<Post> {
  return await prisma.post.create({
    data: {
      post,
    },
  })
}

export async function getPosts(limit: number, offset: number): Promise<Post[]> {
  return await prisma.post.findMany({
    take: limit,
    skip: offset,
    orderBy: {
      createdAt: 'desc',
    },
  })
}

export async function putPostsId(id: string, post: string): Promise<Post> {
  return await prisma.post.update({
    where: { id },
    data: { post },
  })
}

export async function deletePostsId(id: string): Promise<Post> {
  return await prisma.post.delete({
    where: { id },
  })
}

hono-takibiの場合、OpenAPIの定義から、コードを生成します。

 開発しながら、OpenAPIの定義を作成していくことも可能です。

import { serve } from '@hono/node-server'
import { OpenAPIHono } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
import { apiReference } from '@scalar/hono-api-reference'
import {
  deletePostsIdRoute,
  getPostsRoute,
  getRoute,
  postPostsRoute,
  putPostsIdRoute,
} from './openapi/index.js'
import { getHandler } from './handler/hono_handler.js'
import {
  deletePostsIdRouteHandler,
  getPostsRouteHandler,
  postPostsRouteHandler,
  putPostsIdRouteHandler,
} from './handler/posts_handler.js'

const app = new OpenAPIHono()

const api = app
  .openapi(getRoute, getHandler)
  .openapi(postPostsRoute, postPostsRouteHandler)
  .openapi(getPostsRoute, getPostsRouteHandler)
  .openapi(putPostsIdRoute, putPostsIdRouteHandler)
  .openapi(deletePostsIdRoute, deletePostsIdRouteHandler)

api.use('*', async (c, next) => {
  try {
    await next()
  } catch (e) {
    return c.json({ error: (e as Error).message }, 500)
  }
})

// swagger
app
  .doc('/doc', {
    info: {
      title: 'Hono Sample API',
      version: 'v1',
    },
    openapi: '3.0.0',
    tags: [
      {
        name: 'Hono',
        description: 'Hono API',
      },
      {
        name: 'Post',
        description: 'Post API',
      },
    ],
  })
  .get('/ui', swaggerUI({ url: '/doc' }))

// scalar
app.get(
  '/docs',
  apiReference({
    theme: 'saturn',
    spec: {
      url: '/doc',
    },
  }),
)

const port = 3000
console.log(`Server is running on http://localhost:${port}`)

serve({
  fetch: app.fetch,
  port,
})

export default api


swagger


scalar

期待できる効果

 以下のようなケースで特に効果を発揮することを期待して、作成しました。

  1. レガシーシステムの移行

    • RubyやPHP、Perlなどで開発された既存APIのモダナイズ
    • OpenAPI/Swagger定義が存在する古いRESTful APIのリプレース
  2. 新規開発の効率化

    • APIファーストな開発アプローチにおける実装工数の削減
    • 型安全性を担保したAPIの素早い実装
  3. 開発者の負担軽減

    • 変数名の自動生成
    • Zodスキーマの自動生成
    • ルート定義の自動生成

生成AIと組み合わせる

 生成AIにOpenAPI定義を作成させ、hono-takibiでコードを生成することも可能です。ただし、ビジネスロジックは自動生成の対象外となるため、開発者が実装する必要があります。

プロンプト

あなたは「REST APIのOpenAPI仕様」を生成する専門家です。以下の要件をすべて満たすOpenAPIドキュメントを YAML形式 で作成してください。なお、OpenAPIのバージョンは3.0以上 とし、誤りや不足がないよう注意深く出力してください。
generated.yaml
openapi: 3.0.3
info:
  title: Todo API
  description: これはシンプルなToDoリストを管理するためのAPIです。
  version: 1.0.0
servers:
  - url: https://api.example.com/v1
    description: 本番サーバー

tags:
  - name: Todos
    description: Todoアイテムに関する操作

paths:
  /todos:
    get:
      tags:
        - Todos
      summary: Todo一覧の取得
      description: 登録されているTodoをすべて取得します。
      operationId: getAllTodos
      responses:
        '200':
          description: Todoの配列が返却されます。
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Todo'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      tags:
        - Todos
      summary: Todoの新規作成
      description: Todoを新しく登録します。
      operationId: createTodo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoCreateRequest'
            examples:
              validRequest:
                summary: バリデーションを通るリクエスト例
                value:
                  title: "買い物へ行く"
                  description: "牛乳、パン、卵を購入"
                  completed: false
      responses:
        '201':
          description: 新規作成されたTodoを返却します。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '400':
          description: リクエスト不正
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /todos/{todoId}:
    get:
      tags:
        - Todos
      summary: Todoの取得
      description: 指定したIDのTodoを取得します。
      operationId: getTodoById
      parameters:
        - name: todoId
          in: path
          description: TodoのID
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Todoオブジェクトを返却します。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '404':
          description: 指定したIDのTodoが存在しません。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      tags:
        - Todos
      summary: Todoの更新
      description: 指定したIDのTodoを更新します。
      operationId: updateTodo
      parameters:
        - name: todoId
          in: path
          description: TodoのID
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoUpdateRequest'
            examples:
              validRequest:
                summary: バリデーションを通る更新用リクエスト例
                value:
                  title: "買い物リスト更新"
                  description: "牛乳、パン、卵、チーズを購入"
                  completed: false
      responses:
        '200':
          description: 更新されたTodoを返却します。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '400':
          description: リクエスト不正
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: 指定したIDのTodoが存在しません。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      tags:
        - Todos
      summary: Todoの削除
      description: 指定したIDのTodoを削除します。
      operationId: deleteTodo
      parameters:
        - name: todoId
          in: path
          description: TodoのID
          required: true
          schema:
            type: string
      responses:
        '204':
          description: 削除に成功しました(レスポンスボディはありません)。
        '404':
          description: 指定したIDのTodoが存在しません。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

components:
  schemas:
    Todo:
      type: object
      properties:
        id:
          type: string
          example: "123e4567-e89b-12d3-a456-426614174000"
        title:
          type: string
          example: "買い物へ行く"
        description:
          type: string
          example: "牛乳、パン、卵を購入"
        completed:
          type: boolean
          example: false
        createdAt:
          type: string
          format: date-time
          example: "2024-01-01T12:00:00Z"
        updatedAt:
          type: string
          format: date-time
          example: "2024-01-02T09:15:00Z"
      required:
        - id
        - title

    TodoCreateRequest:
      type: object
      properties:
        title:
          type: string
          example: "買い物へ行く"
        description:
          type: string
          example: "牛乳、パン、卵を購入"
        completed:
          type: boolean
          default: false
      required:
        - title

    TodoUpdateRequest:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        completed:
          type: boolean
      required:
        - title

    ErrorResponse:
      type: object
      properties:
        code:
          type: integer
          example: 400
        message:
          type: string
          example: "Invalid request"
      required:
        - code
        - message

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

security:
  - BearerAuth: []

hono-takibiで、生成させてみます。

import { createRoute, z } from '@hono/zod-openapi'

const todoSchema = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string().optional(),
  completed: z.boolean().optional(),
  createdAt: z.string().datetime().optional(),
  updatedAt: z.string().datetime().optional(),
})

const todoCreateRequestSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
  completed: z.boolean().optional(),
})

const todoUpdateRequestSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
  completed: z.boolean().optional(),
})

const errorResponseSchema = z.object({ code: z.number().int(), message: z.string() })

export const schemas = {
  todoSchema,
  todoCreateRequestSchema,
  todoUpdateRequestSchema,
  errorResponseSchema,
}

export const getTodosRoute = createRoute({
  tags: ['Todos'],
  method: 'get',
  path: '/todos',
  description: '登録されているTodoをすべて取得します。',
  responses: {
    200: {
      description: 'Todoの配列が返却されます。',
      content: { 'application/json': { schema: z.array(todoSchema) } },
    },
    500: {
      description: 'サーバーエラー',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
  },
})

export const postTodosRoute = createRoute({
  tags: ['Todos'],
  method: 'post',
  path: '/todos',
  description: 'Todoを新しく登録します。',
  request: {
    body: { required: true, content: { 'application/json': { schema: todoCreateRequestSchema } } },
  },
  responses: {
    201: {
      description: '新規作成されたTodoを返却します。',
      content: { 'application/json': { schema: todoSchema } },
    },
    400: {
      description: 'リクエスト不正',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
    500: {
      description: 'サーバーエラー',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
  },
})

export const getTodosTodoIdRoute = createRoute({
  tags: ['Todos'],
  method: 'get',
  path: '/todos/{todoId}',
  description: '指定したIDのTodoを取得します。',
  request: { params: z.object({ todoId: z.string() }) },
  responses: {
    200: {
      description: 'Todoオブジェクトを返却します。',
      content: { 'application/json': { schema: todoSchema } },
    },
    404: {
      description: '指定したIDのTodoが存在しません。',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
    500: {
      description: 'サーバーエラー',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
  },
})

export const putTodosTodoIdRoute = createRoute({
  tags: ['Todos'],
  method: 'put',
  path: '/todos/{todoId}',
  description: '指定したIDのTodoを更新します。',
  request: {
    body: { required: true, content: { 'application/json': { schema: todoUpdateRequestSchema } } },
    params: z.object({ todoId: z.string() }),
  },
  responses: {
    200: {
      description: '更新されたTodoを返却します。',
      content: { 'application/json': { schema: todoSchema } },
    },
    400: {
      description: 'リクエスト不正',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
    404: {
      description: '指定したIDのTodoが存在しません。',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
    500: {
      description: 'サーバーエラー',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
  },
})

export const deleteTodosTodoIdRoute = createRoute({
  tags: ['Todos'],
  method: 'delete',
  path: '/todos/{todoId}',
  description: '指定したIDのTodoを削除します。',
  request: { params: z.object({ todoId: z.string() }) },
  responses: {
    204: { description: '削除に成功しました(レスポンスボディはありません)。' },
    404: {
      description: '指定したIDのTodoが存在しません。',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
    500: {
      description: 'サーバーエラー',
      content: { 'application/json': { schema: errorResponseSchema } },
    },
  },
})

Hono Takibiの目指すもの

レガシーからモダンへの橋渡し

OpenAPIが言語に依存しない仕様であることは、レガシーシステムのモダン化において以下の重要な価値を提供すると考えています。

  1. 言語非依存

    • YAML/JSONベースの記述
    • 実装言語に縛られない設計
    • 普遍的なAPI表現
  2. 資産としての価値

    • 既存APIの資産を活かす
  1. 移行の容易性

    • 既存の仕様を活かす
    • 段階的な移行を可能にする
    • 新旧システムの共存期間における一貫性の維持
  2. 開発者の負担軽減

    • 変数名の自動生成
    • Zodスキーマの自動生成
    • ルート定義の自動生成
    • 自動生成による実装工数の削減
    • 型安全性による品質の担保
    • ビジネスロジックへの集中が可能
    • 生成AIによる作成されたOpenAPI定義を、hono-takibiでコード生成

その他

Honoで、開発しながらOpenAPI定義を作成することも可能です。最近、Valibotが、サポートされはじめ、Zodではなく、Valibotを使用することも可能です。

Hono OpenAPIのアプローチ

Zod OpenAPI Honoのアプローチ

Hono Takibiのアプローチ

おわりに

Hono Takibiは、まだ発展途上のツールです。以下のような課題や改善点があります。

現在の制限事項

  • キャメルケースのみの変数名生成

  • 一部のZodスキーマ生成に未対応

  • その他

Hono Takibiの今後について考えていること

  • 型定義の自動生成機能として、z.infer()を使用したコード生成の追加を検討中

  • シンプルさを保つため、多機能すぎるツールにはせず、必要最小限の機能に絞る予定

  • メンテナンス性を考慮し、新たな外部ライブラリへの依存は最小限に抑える方針

 より良いツールにしていくため、フィードバックやコントリビューションをお待ちしています。GitHubでのIssue報告や機能改善の提案、プルリクエストを歓迎します。

 私自身、開発経験が浅く、現在のソースコードにはコメントを多く残している段階です。経験豊富な開発者の方々からのアドバイスをいただけると幸いです。

参考リンク

Discussion