🔥

Hono RPC OpenAPI🔥

2024/11/23に公開

Generate RPC code from OpenAPI definition

OpenAPIの定義から、HonoRPCで、使用できるコードを自動生成させてみる。

Demo

gif

OpenAPI YAML

openapi.yaml
info:
  title: Hono API
  version: v1
openapi: 3.0.0
tags:
  - name: Hono
    description: Hono API
  - name: Post
    description: Post API
components:
  schemas: {}
  parameters: {}
paths:
  /:
    get:
      tags:
        - Hono
      responses:
        '200':
          description: Hono🔥
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Hono🔥
                required:
                  - message
  /posts:
    post:
      tags:
        - Post
      description: create a new post
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post:
                  type: string
                  minLength: 1
                  maxLength: 140
              required:
                - post
      responses:
        '201':
          description: Created
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Bad Request
                required:
                  - message
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Internal Server Error
                required:
                  - message
    get:
      tags:
        - Post
      description: get PostList posts with optional pagination
      parameters:
        - schema:
            type: string
          required: true
          name: page
          in: query
        - schema:
            type: string
          required: true
          name: rows
          in: query
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    post:
                      type: string
                      minLength: 1
                      maxLength: 140
                    createdAt:
                      type: string
                    updatedAt:
                      type: string
                  required:
                    - id
                    - post
                    - createdAt
                    - updatedAt
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Bad Request
                required:
                  - message
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Internal Server Error
                required:
                  - message
  /posts/{id}:
    put:
      tags:
        - Post
      description: update Post
      parameters:
        - schema:
            type: string
            format: uuid
          required: true
          name: id
          in: path
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                post:
                  type: string
                  minLength: 1
                  maxLength: 140
              required:
                - post
      responses:
        '204':
          description: No Content
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Bad Request
                required:
                  - message
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Internal Server Error
                required:
                  - message
    delete:
      tags:
        - Post
      description: delete post
      parameters:
        - schema:
            type: string
            format: uuid
          required: true
          name: id
          in: path
      responses:
        '204':
          description: No Content
        '400':
          description: Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Bad Request
                required:
                  - message
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Internal Server Error
                required:
                  - message

Generate

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

export const get = createRoute({
  tags: ['Hono'],
  method: 'get',
  path: '/',
  description: 'undefined',

  responses: {
    200: {
      description: 'Hono🔥',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
})

export const postPosts = createRoute({
  tags: ['Post'],
  method: 'post',
  path: '/posts',
  description: 'create a new post',
  request: {
    body: {
      required: true,
      content: {
        'application/json': {
          schema: z.object({
            post: z.string().min(1).max(140),
          }),
        },
      },
    },
  },
  responses: {
    201: {
      description: 'Created',
    },

    400: {
      description: 'Bad Request',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },

    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
})

export const getPosts = createRoute({
  tags: ['Post'],
  method: 'get',
  path: '/posts',
  description: 'get PostList posts with optional pagination',
  request: {
    query: z.object({
      page: z.string(),
      rows: z.string(),
    }),
  },
  responses: {
    200: {
      description: 'OK',
      content: {
        'application/json': {
          schema: z.array(
            z.object({
              id: z.string().uuid(),
              post: z.string().min(1).max(140),
              createdAt: z.string(),
              updatedAt: z.string(),
            }),
          ),
        },
      },
    },

    400: {
      description: 'Bad Request',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },

    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
})

export const putPostsId = createRoute({
  tags: ['Post'],
  method: 'put',
  path: '/posts/{id}',
  description: 'update Post',
  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: 'No Content',
    },

    400: {
      description: 'Bad Request',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },

    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
})

export const deletePostsId = createRoute({
  tags: ['Post'],
  method: 'delete',
  path: '/posts/{id}',
  description: 'delete post',
  request: {
    params: z.object({
      id: z.string().uuid(),
    }),
  },
  responses: {
    204: {
      description: 'No Content',
    },

    400: {
      description: 'Bad Request',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },

    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: z.object({
            message: z.string(),
          }),
        },
      },
    },
  },
})

Test

  • 可能な限り、純粋関数にしている。

  • Vitestを使用。

const generateRouteNameTestCases = [
  {
    method: 'get',
    path: '/posts',
    expected: 'getPosts',
  },
  {
    method: 'get',
    path: '/posts/{id}',
    expected: 'getPostsId',
  },
  {
    method: 'post',
    path: '/posts/{id}',
    expected: 'postPostsId',
  },
  {
    method: 'put',
    path: '/posts/{id}',
    expected: 'putPostsId',
  },
  {
    method: 'delete',
    path: '/posts/{id}',
    expected: 'deletePostsId',
  },
]

it.concurrent.each(generateRouteNameTestCases)(
  'generateRouteName($method, $path) -> $expected',
  async ({ method, path, expected }) => {
    const result = generateRouteName(method, path)
    expect(result).toBe(expected)
  },
)

Features

  • 変数の名前をAPIパスに応じて、自動生成。

  • Zodスキーマを自動生成。

  • OpenAPIの定義から、コードを自動生成。

Sample

REST APIを作成する。

App

import { serve } from '@hono/node-server'
import { OpenAPIHono } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/swagger-ui'
import { logger } from 'hono/logger'
import { get, getPosts, postPosts, putPostsId, deletePostsId } from '@packages/hono-rpc'
import { getHandler } from '../handler/openapi_hono_handler'
import { postPostsHandler, getPostsHandler, putPostsIdHandler, deletePostsIdHandler } from '../handler/post_handler'

export class App {
  static init() {
    const app = new OpenAPIHono()

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

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

    app.use('*', logger())
    app.use('*', (c, next) => {
      console.log(`  ::: ${c.req.method} ${c.req.url}`)
      return next()
    })

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

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

    return this.applyRoutes(app)
  }

  static applyRoutes(app: OpenAPIHono) {
    return app
      .openapi(get, getHandler)
      .openapi(postPosts, postPostsHandler)
      .openapi(getPosts, getPostsHandler)
      .openapi(putPostsId, putPostsIdHandler)
      .openapi(deletePostsId, deletePostsIdHandler)
  }
}

Handler

Hono🔥を返す。

import { type RouteHandler } from '@hono/zod-openapi'
import { get } from '@packages/hono-rpc'

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

Prismaを使用。

import { type RouteHandler } from '@hono/zod-openapi'
import { postPosts, getPosts, putPostsId, deletePostsId } from '@packages/hono-rpc'
import { Post } from '@packages/prisma'
import { PostService } from '@packages/service'
import { PostDomain } from '@packages/domain'

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

export const getPostsHandler: RouteHandler<typeof getPosts> = async (c) => {
  const valid = c.req.valid('query')
  const { page, rows } = PostDomain.convertNumberQueryParams(valid)
  if (isNaN(page) || isNaN(rows) || page < 1 || rows < 1) {
    return c.json({ message: 'Bad Request' }, 400)
  }
  const limit = rows
  const offset = (page - 1) * rows
  const res: Post[] = await PostService.getPosts(limit, offset)
  return c.json(res, 200)
}

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

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

The end

HonoRPCは、お気に入りです。

OpenAPIの定義から、Zod OpenAPIを自動生成し、開発を楽にできるようにしたい。🔥

Discussion