🔥

Hono Takibi 0.4.0

2025/02/06に公開

Hono Takibi 0.4.0

npm

https://www.npmjs.com/package/hono-takibi

GitHub

https://github.com/nakita628/hono-takibi

Hono Takibi 0.4.0の変更点

  • スキーマ名の命名規則は、defaultではPascalCaseとなりました。

  • appオプションを追加し、定型的なコード生成をサポートしました。

Hono-Takibi 設定ガイド

 プロジェクトのルートディレクトリに hono-takibi.json ファイルを作成することで、コード生成の動作をカスタマイズできます。

スキーマオプション(Schema Options)

オプション デフォルト 説明
name "PascalCase" | "camelCase" "PascalCase" 生成されるスキーマ変数の命名規則
export boolean false true にすると、すべてのスキーマ定義をエクスポート

型オプション(Type Options)

オプション デフォルト 説明
name "PascalCase" | "camelCase" "PascalCase" 生成される型定義の命名規則
export boolean false true にすると、すべての型定義をエクスポート

アプリオプション(App Options)

オプション デフォルト 説明
output boolean false アプリケーションおよびハンドラーファイルの生成を制御 true にすると、メインのアプリケーションファイルと対応するルートハンドラーを作成
test boolean false API エンドポイントのテストファイルを自動生成
basePath string "" API エンドポイントのベースURLパスを指定
isDev string "process.env.NODE_ENV" 開発モードを判定する環境変数を指定(Swagger UI の有効化などに影響)

入力と出力(Input and Output)

入力と出力のパスは、次の2つの方法で指定できます。

1. コマンドライン引数

2. 設定ファイル(hono-takibi.json

オプション デフォルト 説明
input string "" 入力ファイルのパス
output string "" 出力ファイルのパス

⚠️ 注意: 設定ファイルを使用する場合、コマンドライン引数は不要です。
設定ファイルの設定がコマンドライン引数よりも優先されます。

hono-takibi.json を設定したら、次のコマンドだけで実行できます:

npx hono-takibi

hono-takibi.json のサンプル

 デフォルトの動作(スキーマと型定義の命名は、PascalCase

{
  "input": "src/openapi/openapi.yaml",
  "output": "src/openapi/index.ts",
  "schema": {
    "name": "PascalCase",
    "export": false
  },
  "type": {
    "name": "PascalCase",
    "export": false
  },
  "app": {
    "output": true
  }
}

Hono Takibi0.4.0の新機能

 以下のようなopenapi.yamlを用意します。

openapi.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: true
          schema:
            type: integer
            minimum: 0
            default: 1
            example: 1
          description: The page number to retrieve. Must be a positive integer. Defaults to 1.
        - in: query
          name: rows
          required: true
          schema:
            type: integer
            minimum: 0
            default: 10
            example: 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
            example: 123e4567-e89b-12d3-a456-426614174000
          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.

 このopenapi.yamlを用いて、hono-takibi.jsonを以下のように設定します。

{
  "input": "src/openapi/openapi.yaml",
  "output": "src/openapi/index.ts",
  "schema": {
    "name": "PascalCase",
    "export": false
  },
  "type": {
    "name": "PascalCase",
    "export": false
  },
  "app": {
    "output": true
  }
}

 以下のようなディレクトリ構成にします。

.
├── hono-takibi.json
├── package.json
├── src
│   ├── index.ts
│   └── openapi
│       └── openapi.yaml
└── tsconfig.json

Hono Takibiの実行

npx hono-takibi

生成されたファイル

./index.ts

import { OpenAPIHono } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
import {
  getRoute,
  postPostsRoute,
  getPostsRoute,
  putPostsIdRoute,
  deletePostsIdRoute,
} from './src/openapi'
import { getRouteHandler } from './src/openapi/handler/index_handler.ts'
import {
  postPostsRouteHandler,
  getPostsRouteHandler,
  putPostsIdRouteHandler,
  deletePostsIdRouteHandler,
} from './src/openapi/handler/posts_handler.ts'

const app = new OpenAPIHono()

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

const isDev = process.env.NODE_ENV === 'development'

if (isDev) {
  app
    .doc('/doc', {
      openapi: '3.1.0',
      info: { title: 'Hono API', version: 'v1' },
      tags: [
        { name: 'Hono', description: 'Endpoints related to general Hono operations' },
        {
          name: 'Post',
          description: 'Endpoints for creating, retrieving, updating, and deleting posts',
        },
      ],
    })
    .get('/ui', swaggerUI({ url: '/doc' }))
}

export type AddType = typeof api

export default app

./src/openapi/index.ts

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

const ErrorSchema = z.object({ message: z.string() }).openapi('Error')

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

export const getRoute = createRoute({
  tags: ['Hono'],
  method: 'get',
  path: '/',
  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: z.object({ message: z.string().openapi({ example: 'Hono🔥' }) }),
        },
      },
    },
  },
})

export const postPostsRoute = createRoute({
  tags: ['Post'],
  method: 'post',
  path: '/posts',
  summary: 'Create a new post',
  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',
  summary: 'Retrieve a list of 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).default(1).openapi({ example: 1 })),
      rows: z.string().pipe(z.coerce.number().int().min(0).default(10).openapi({ example: 10 })),
    }),
  },
  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}',
  summary: 'Update an existing post',
  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}',
  summary: 'Delete a post',
  description: 'Delete an existing post identified by its unique ID.',
  request: {
    params: z.object({
      id: z
        .string()
        .uuid()
        .openapi({
          param: { name: 'id', in: 'path' },
          example: '123e4567-e89b-12d3-a456-426614174000',
        }),
    }),
  },
  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 } },
    },
  },
})

./src/openapi/handler/index_handler.ts

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

export const getRouteHandler: RouteHandler<typeof getRoute> = async (c) => {}

./src/openapi/handler/posts_handler.ts

import type { RouteHandler } from '@hono/zod-openapi'
import type {
  postPostsRoute,
  getPostsRoute,
  putPostsIdRoute,
  deletePostsIdRoute,
} from '../index.ts'

export const postPostsRouteHandler: RouteHandler<typeof postPostsRoute> = async (c) => {}

export const getPostsRouteHandler: RouteHandler<typeof getPostsRoute> = async (c) => {}

export const putPostsIdRouteHandler: RouteHandler<typeof putPostsIdRoute> = async (c) => {}

export const deletePostsIdRouteHandler: RouteHandler<typeof deletePostsIdRoute> = async (c) => {}

 ディレクトリ構成は以下のようになります。

.
├── hono-takibi.json
├── index.ts
├── package.json
├── src
│   ├── index.ts
│   └── openapi
│       ├── handler
│       │   ├── index_handler.ts
│       │   └── posts_handler.ts
│       ├── index.ts
│       └── openapi.yaml
└── tsconfig.json

OpenAPI定義から、雛形を生成できるようになりました。

 残りは、./src/index.tsの設定と、ビジネスロジックの実装のみです。

import { serve } from '@hono/node-server'
import app from '..'

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

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

Viteとの組み合わせ

 これは、実験的な機能です。他のPluginとの組み合わせで、動作しない可能性があります。

  • Viteが、立ち上がっている間、OpenAPIの定義を編集すると、ルート定義やスキーマも更新されるという機能です。
import { defineConfig } from 'vite'
import honoTakibiPlugin from 'hono-takibi/vite-plugin'

export default defineConfig({
  plugins: [
    honoTakibiPlugin({
      input: 'src/openapi/openapi.yaml',
      output: 'src/openapi/index.ts',
      packageManager: 'npm',
    }),
  ],
})

⚠️ 注意: 0.4.1以降では、以下のように設定してください。他のPluginとの組み合わせで、動作しない可能性は解決していません。

import { defineConfig } from 'vite'
import honoTakibiPlugin from 'hono-takibi/vite-plugin'

export default defineConfig({
  plugins: [honoTakibiPlugin()],
})

Demo

おわりに

Hono Takibi0.4.0の新機能を紹介しました。実験的な機能もあるため、使用には注意が必要です。

 また、趣味で開発しているため、バグや不具合、未実装があるかもしれません。その場合は、GitHubでIssueを報告してください。

 より良いツールにしていくため、フィードバックやコントリビューションをお待ちしています。

参考

Discussion