Skip to content

Hono OpenAPI Valibot

Installation

sh
pnpm add hono-openapi valibot

Scalar for Hono

sh
pnpm add -D @scalar/hono-api-reference

Swagger UI

sh
pnpm add -D @hono/swagger-ui

Hono API

ts
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { openAPISpecs } from 'hono-openapi'
import { apiReference } from '@scalar/hono-api-reference'
import { SwaggerUI } from '@hono/swagger-ui'
import honoHandler from './handler/hono_handler.js'
import postsHandler from './handler/posts_handler.js'

const app = new Hono()

const port = 3000

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

app.get(
  '/openapi',
  openAPISpecs(app, {
    documentation: {
      info: { title: 'Hono API', version: '1.0.0', description: 'Greeting API' },
      servers: [{ url: 'http://localhost:3000', description: 'Local Server' }],
    },
  }),
)

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

// swagger
app.get('/ui', (c) => {
  return c.html(`
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="description" content="Custom Swagger" />
        <title>Custom Swagger</title>
        <script>
          // custom script
        </script>
        <style>
          /* custom style */
        </style>
      </head>
      ${SwaggerUI({ url: '/openapi' })}
    </html>
  `)
})

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

const api = app.route('/', honoHandler).route('/posts', postsHandler)

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

export default api

Handler

honoHandler

ts
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { resolver } from 'hono-openapi/valibot'
import * as v from 'valibot'

const responseSchema = v.string()

const honoHandler = new Hono().get(
  '/',
  describeRoute({
    description: 'Hono Valibot🔥',
    responses: {
      200: {
        description: 'Hono Valibot🔥',
        content: {
          'application/json': {
            schema: resolver(responseSchema),
          },
        },
      },
    },
  }),
  (c) => {
    return c.json({ message: 'Hono Valibot🔥' }, 200)
  },
)

export default honoHandler

postsHandler

ts
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { resolver, validator as vValidator } from 'hono-openapi/valibot'
import { deletePostsId, getPosts, postPosts, putPostsId } from '@packages/service'
import * as v from 'valibot'

const postSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  post: v.pipe(v.string(), v.minLength(1), v.maxLength(140)),
  createdAt: v.pipe(v.string(), v.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/)),
  updatedAt: v.pipe(v.string(), v.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/)),
})

const errorResponseSchema = v.object({ message: v.string() })

const postsHandler = new Hono()
  .post(
    '/',
    describeRoute({
      tags: ['posts'],
      description: 'Submit a new post with a maximum length of 140 characters.',
      responses: {
        201: {
          description: 'Post successfully created.',
          content: { 'application/json': { schema: resolver(postSchema) } },
        },
        400: {
          description: 'Invalid request due to bad input.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
        500: {
          description: 'Internal server error.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
      },
    }),
    vValidator('json', v.object({ post: v.pipe(v.string(), v.minLength(1), v.maxLength(140)) })),
    async (c) => {
      const { post } = c.req.valid('json')
      await postPosts(post)
      return c.json({ message: 'Created' }, 201)
    },
  )
  .get(
    '/',
    describeRoute({
      tags: ['posts'],
      description:
        'Retrieve a paginated list of posts. Specify the page and number of posts per page.',
      responses: {
        200: {
          description: 'Successfully retrieved a list of posts.',
          content: { 'application/json': { schema: resolver(v.array(postSchema)) } },
        },
        400: {
          description: 'Invalid request due to bad input.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
        500: {
          description: 'Internal server error.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
      },
    }),
    vValidator(
      'query',
      v.object({
        page: v.string(),
        rows: v.string(),
      }),
    ),
    async (c) => {
      const { page, rows } = c.req.valid('query')
      const pageNumber = parseInt(page)
      const rowsPerPage = parseInt(rows)
      if (isNaN(pageNumber) || isNaN(rowsPerPage) || pageNumber < 1 || rowsPerPage < 1) {
        return c.json({ message: 'Bad Request' }, 400)
      }
      const limit = rowsPerPage
      const offset = (pageNumber - 1) * rowsPerPage
      const posts = await getPosts(limit, offset)
      return c.json(posts, 200)
    },
  )
  .put(
    '/:id',
    describeRoute({
      tags: ['posts'],
      description: 'Update a post by ID.',
      responses: {
        204: {
          description: 'Post successfully updated.',
        },
        400: {
          description: 'Invalid request due to bad input.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
        500: {
          description: 'Internal server error.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
      },
    }),
    vValidator('param', v.object({ id: v.pipe(v.string(), v.uuid()) })),
    vValidator('json', v.object({ post: v.pipe(v.string(), v.minLength(1), v.maxLength(140)) })),
    async (c) => {
      const { id } = c.req.valid('param')
      const { post } = c.req.valid('json')
      await putPostsId(id, post)
      return new Response(null, { status: 204 })
    },
  )
  .delete(
    '/:id',
    describeRoute({
      tags: ['posts'],
      description: 'Delete a post by ID.',
      responses: {
        204: {
          description: 'Post successfully deleted.',
        },
        400: {
          description: 'Invalid request due to bad input.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
        500: {
          description: 'Internal server error.',
          content: { 'application/json': { schema: resolver(errorResponseSchema) } },
        },
      },
    }),
    vValidator('param', v.object({ id: v.pipe(v.string(), v.uuid()) })),
    async (c) => {
      const { id } = c.req.valid('param')
      await deletePostsId(id)
      return new Response(null, { status: 204 })
    },
  )

export default postsHandler