🔥

Hono Takibi のこだわりポイント🔥

に公開

Hono Takibi

npm

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

GitHub

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

はじめに

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以外指定できないため、型が合わずエラーが起きます。

 回避策として、querystringに指定します。

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