🔥
Hono Takibi のこだわりポイント🔥
Hono Takibi
npm
GitHub
はじめに
Hono Advent Calendar 2024以降も、Hono Takibi
を開発を継続しています。その中での、こだわりを持って開発した点を紹介します。
参考
クエリパラメータ
参考
例えば以下のような、ルート定義を用意します。
import { createRoute, z } from '@hono/zod-openapi'
export const getFizzbuzzRoute = createRoute({
method: 'get',
path: '/fizzbuzz',
summary: 'Get FizzBuzz result',
description: 'Returns the FizzBuzz result for the given number.',
request: { query: z.object({ number: z.number().int().min(1) }) },
responses: {
200: {
description: 'FizzBuzz result',
content: { 'application/json': { schema: z.object({ result: z.string() }) } },
},
400: {
description: 'Invalid input',
content: { 'application/json': { schema: z.object({ error: z.string() }).partial() } },
},
},
})
APIを作ります。
import type { RouteHandler } from '@hono/zod-openapi'
import type { getFizzbuzzRoute } from '../route.ts'
export const getFizzbuzzRouteHandler: RouteHandler<typeof getFizzbuzzRoute> = async (c) => {
const { number } = c.req.valid('query')
const result = fizzBuzz(number)
return c.json({ result }, 200)
}
function fizzBuzz(number: number): string {
if (number % 15 === 0) return 'FizzBuzz'
if (number % 3 === 0) return 'Fizz'
if (number % 5 === 0) return 'Buzz'
return number.toString()
}
RPCで、呼び出します。
const res = await apiClient.fizzbuzz.$get({ query: { number: 15 } })
しかし、エラーが起きます。
Response {
status: 400,
statusText: 'Bad Request',
headers: Headers {
'content-type': 'application/json',
'content-length': '183',
date: '***, ** *** **** **:**:** ***',
connection: 'keep-alive',
'keep-alive': 'timeout=5'
},
body: ReadableStream { locked: false, state: 'readable', supportsBYOB: true },
bodyUsed: false,
ok: false,
redirected: false,
type: 'basic',
url: 'http://localhost:3000/fizzbuzz?number=15'
}
クエリパラメータにはstring以外指定できないため、型が合わずエラーが起きます。
回避策として、query
をstring
に指定します。
import { createRoute, z } from '@hono/zod-openapi'
export const getFizzbuzzRoute = createRoute({
method: 'get',
path: '/fizzbuzz',
summary: 'Get FizzBuzz result',
description: 'Returns the FizzBuzz result for the given number.',
// query に string を指定
request: { query: z.object({ number: z.string() }) },
responses: {
200: {
description: 'FizzBuzz result',
content: { 'application/json': { schema: z.object({ result: z.string() }) } },
},
400: {
description: 'Invalid input',
content: { 'application/json': { schema: z.object({ error: z.string() }).partial() } },
},
},
})
parseInt
を指定して数値を変えることで、動作します。
import type { RouteHandler } from '@hono/zod-openapi'
import type { getFizzbuzzRoute } from '../route.ts'
export const getFizzbuzzRouteHandler: RouteHandler<typeof getFizzbuzzRoute> = async (c) => {
const { number } = c.req.valid('query')
// parseInt
const parsedNumber = parseInt(number)
const result = fizzBuzz(parsedNumber)
return c.json({ result }, 200)
}
function fizzBuzz(number: number): string {
if (number % 15 === 0) return 'FizzBuzz'
if (number % 3 === 0) return 'Fizz'
if (number % 5 === 0) return 'Buzz'
return number.toString()
}
しかし、受け取りたいものはstring
ではなく、number
です。
Hono Takibi では、自動的に変換する
こだわった点です。以下のようなOpenAPIを定義し、hono-takibi
コマンドを実行します。
openapi: 3.1.0
info:
title: FizzBuzz API
version: 1.0.0
paths:
/fizzbuzz:
get:
summary: Get FizzBuzz result
description: Returns the FizzBuzz result for the given number.
parameters:
- name: number
in: query
required: true
schema:
type: integer
minimum: 1
description: An integer (1 or greater) to evaluate using FizzBuzz.
responses:
'200':
description: FizzBuzz result
content:
application/json:
schema:
type: object
properties:
result:
type: string
description: The FizzBuzz result ("Fizz", "Buzz", "FizzBuzz", or the number as a string)
required:
- result
'400':
description: Invalid input
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message describing the issue
query: z.object({ number: z.string().pipe(z.coerce.number().int().min(1)) })
のように自動で変換するようにしています。
import { createRoute, z } from '@hono/zod-openapi'
export const getFizzbuzzRoute = createRoute({
method: 'get',
path: '/fizzbuzz',
summary: 'Get FizzBuzz result',
description: 'Returns the FizzBuzz result for the given number.',
request: { query: z.object({ number: z.string().pipe(z.coerce.number().int().min(1)) }) },
responses: {
200: {
description: 'FizzBuzz result',
content: { 'application/json': { schema: z.object({ result: z.string() }) } },
},
400: {
description: 'Invalid input',
content: { 'application/json': { schema: z.object({ error: z.string() }).partial() } },
},
},
})
parseInt
を使用する必要がなくなります。
import type { RouteHandler } from '@hono/zod-openapi'
import type { getFizzbuzzRoute } from '../route.ts'
export const getFizzbuzzRouteHandler: RouteHandler<typeof getFizzbuzzRoute> = async (c) => {
const { number } = c.req.valid('query')
const result = fizzBuzz(number)
return c.json({ result }, 200)
}
function fizzBuzz(number: number): string {
if (number % 15 === 0) return 'FizzBuzz'
if (number % 3 === 0) return 'Fizz'
if (number % 5 === 0) return 'Buzz'
return number.toString()
}
リクエストも成功します。
const res = await apiClient.fizzbuzz.$get({ query: { number: '15' } })
レスポンス
Response {
status: 200,
statusText: 'OK',
headers: Headers {
'content-type': 'application/json',
'content-length': '21',
date: '***, ** *** **** **:**:** ***',
connection: 'keep-alive',
'keep-alive': 'timeout=5'
},
body: ReadableStream { locked: false, state: 'readable', supportsBYOB: true },
bodyUsed: false,
ok: true,
redirected: false,
type: 'basic',
url: 'http://localhost:3000/fizzbuzz?number=15'
}
テスト
APIをテストする。
import { describe, expect, it, beforeAll } from 'vitest'
import { testClient } from 'hono/testing'
import { serve } from '@hono/node-server'
import { api } from '../index.ts'
// test client
const test = testClient(api)
// test case
const fizzBuzzApiTestCases = [
{
number: 1,
expected: { result: '1' },
},
{
number: 2,
expected: { result: '2' },
},
{
number: 3,
expected: { result: 'Fizz' },
},
{
number: 4,
expected: { result: '4' },
},
{
number: 5,
expected: { result: 'Buzz' },
},
{
number: 6,
expected: { result: 'Fizz' },
},
{
number: 7,
expected: { result: '7' },
},
{
number: 8,
expected: { result: '8' },
},
{
number: 9,
expected: { result: 'Fizz' },
},
{
number: 10,
expected: { result: 'Buzz' },
},
{
number: 11,
expected: { result: '11' },
},
{
number: 12,
expected: { result: 'Fizz' },
},
{
number: 13,
expected: { result: '13' },
},
{
number: 14,
expected: { result: '14' },
},
{
number: 15,
expected: { result: 'FizzBuzz' },
},
]
// // test client
describe('FizzBuzz API', () => {
it.concurrent.each(fizzBuzzApiTestCases)(
'path fizzbuzz get ({ query: { number: $number } }) -> $expected',
async ({ number, expected }) => {
const res = await test.fizzbuzz.$get({ query: { number: number.toString() } })
expect(res.status).toBe(200)
const input = await res.json()
expect(input).toEqual(expected)
},
)
})
describe('FizzBuzz API Fail Test for Test Client', () => {
it('path fizzbuzz get ({ query: { number: 0 } }) -> ZodError', async () => {
const res = await test.fizzbuzz.$get({ query: { number: '0' } })
expect(res.status).toBe(400)
const input = await res.json()
const expected = {
success: false,
error: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'number',
inclusive: true,
exact: false,
message: 'Number must be greater than or equal to 1',
path: ['number'],
},
],
name: 'ZodError',
},
}
expect(input).toEqual(expected)
})
})
// fetch
describe('FizzBuzz API Fetch Test', () => {
beforeAll(async () => {
serve({
fetch: api.fetch,
port: 3000,
})
})
it.concurrent.each(fizzBuzzApiTestCases)(
'path fizzbuzz get fizzbuzz?number=$number -> $expected',
async ({ number, expected }) => {
const res = await fetch(`http://localhost:3000/fizzbuzz?number=${number}`)
expect(res.status).toBe(200)
const input = await res.json()
expect(input).toEqual(expected)
},
)
})
describe('FizzBuzz API Fail Test for Fetch', () => {
it('path fizzbuzz get fizzbuzz?number=0 -> ZodError', async () => {
const res = await fetch(`http://localhost:3000/fizzbuzz?number=0`)
expect(res.status).toBe(400)
const input = await res.json()
const expected = {
success: false,
error: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'number',
inclusive: true,
exact: false,
message: 'Number must be greater than or equal to 1',
path: ['number'],
},
],
name: 'ZodError',
},
}
expect(input).toEqual(expected)
})
})
テスト結果
✓ src/handler/fizzbuzz_handler.test.ts (32 tests) 34ms
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 1 } }) -> { result: '1' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 2 } }) -> { result: '2' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 3 } }) -> { result: 'Fizz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 4 } }) -> { result: '4' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 5 } }) -> { result: 'Buzz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 6 } }) -> { result: 'Fizz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 7 } }) -> { result: '7' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 8 } }) -> { result: '8' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 9 } }) -> { result: 'Fizz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 10 } }) -> { result: 'Buzz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 11 } }) -> { result: '11' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 12 } }) -> { result: 'Fizz' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 13 } }) -> { result: '13' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 14 } }) -> { result: '14' }
✓ FizzBuzz API > path fizzbuzz get ({ query: { number: 15 } }) -> { result: 'FizzBuzz' }
✓ FizzBuzz API Fail Test for Test Client > path fizzbuzz get ({ query: { number: 0 } }) -> ZodError
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=1 -> { result: '1' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=2 -> { result: '2' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=3 -> { result: 'Fizz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=4 -> { result: '4' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=5 -> { result: 'Buzz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=6 -> { result: 'Fizz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=7 -> { result: '7' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=8 -> { result: '8' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=9 -> { result: 'Fizz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=10 -> { result: 'Buzz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=11 -> { result: '11' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=12 -> { result: 'Fizz' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=13 -> { result: '13' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=14 -> { result: '14' }
✓ FizzBuzz API Fetch Test > path fizzbuzz get fizzbuzz?number=15 -> { result: 'FizzBuzz' }
✓ FizzBuzz API Fail Test for Fetch > path fizzbuzz get fizzbuzz?number=0 -> ZodError
その他
おわりに
Hono Takibi
のこだわりポイントを記事にまとめました。今後も、メンテナンスを継続し、精度が高いライブラリを目指します。
Discussion