🔥
Hono RPC OpenAPI🔥
Generate RPC code from OpenAPI definition
OpenAPIの定義から、HonoのRPCで、使用できるコードを自動生成させてみる。
Demo
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
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
OpenAPIの定義から、Zod OpenAPIを自動生成し、開発を楽にできるようにしたい。🔥
Discussion