🫵

Hono + OpenAPIでクエリパラメーターはstringにするんだぞ!

2024/03/29に公開2

ログラスでエンジニアをしている村本です。
ログラスではKotlinをメインにサーバーサイドの開発やNext.jsでのフロントエンド開発を生業としています。

今回は個人開発で最近話題のHonoを触ってみたときに、型が合わずにひたすら頭を悩ませていたことがあったので、そのエピソードをご紹介しようと思います。

Honoについて

Honoについて言わずもがなかと思いますが、Edge Function向けのとてもイケてる名前をしたフレームワークです。
https://zenn.dev/yusukebe/articles/0c7fed0949e6f7

個人開発のサーバーサイドの環境を何にしようか考えていたときに、このHonoについて知りました。
Edge Function向けなので、軽量で起動が早いだけでなく、Node.jsで動くだけでなく、DenoやBunのようなランタイムにも対応しており、どこでも動かせる素晴らしいフレームワークです。

ドキュメントを読み込んだのですが、ルーターの章などはこだわりがヒシヒシと感じられましたし、実際にベンチマークでも高いパフォーマンスを発揮していて、とても惹かれたことを覚えています。
https://hono.dev/concepts/motivation

最近はHono v4もリリースされ、HonoXというメタフレームワークも登場し、とても活発に開発されているのも安心感に繋がりました。
https://zenn.dev/yusukebe/articles/724940fa3f2450

HonoでもOpenAPIを使いたい!

Loglassでは、バックエンドとフロントエンドのやりとりはOpenAPIのスキーマを用いて行っています。
https://zenn.dev/loglass/articles/open-loglass-tech-stack-2023

springdoc-openapiを利用して、API定義からOpenAPIのスキーマを自動生成しているのですが、この体験がとても楽で、個人開発でも使いたいなーと思っていました。

Kotlin + springdoc-openapiだと、以下のようにアノテーションをつけてControllerを書くだけで、SpringBootのサーバー起動時にOpenAPIのドキュメントを生成してホスティングしてくれます。
(もちろんspringdocの設定は必要です。)

@RestController
@RequestMapping("todos")
class TodosController(
    private val listTodosUseCase: ListTodosUseCase,
) {
    @Operation(summary = "TODOをステータス別にページ単位で取得する")
    @GetMapping("/{status}")
    fun find(
        @PathVariable status: TodoStatus,
        @RequestParam(name = "limit", required = false) limit: Int?,
        @RequestParam(name = "offset", required = false) offset: Int?,
        @RequestParam(name = "searchWord", required = false) searchWord: String?,
    ): Page<TodoView> {
        val dimensionTagsDto = listTodosUseCase.execute(
            status = status,
            searchWord = searchWord,
            limit = limit,
            offset = offset,
        )
    }
}

このような体験をHonoでも実現できないか調べてみたところ、Zodを利用してOpenAPIのスキーマを生成できるライブラリが提供されていました。
https://hono.dev/snippets/zod-openapi

以下のようにリクエストとレスポンスのスキーマをZodで定義することで、HonoでもOpenAPIのドキュメントを自動生成してくれます。
(以下は公式ドキュメントの引用です)

import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi'

const ParamsSchema = z.object({
  id: z
    .string()
    .min(3)
    .openapi({
      param: {
        name: 'id',
        in: 'path',
      },
      example: '1212121',
    }),
})

const UserSchema = z
  .object({
    id: z.string().openapi({
      example: '123',
    }),
    name: z.string().openapi({
      example: 'John Doe',
    }),
    age: z.number().openapi({
      example: 42,
    }),
  })
  .openapi('User')

const route = createRoute({
  method: 'get',
  path: '/users/{id}',
  request: {
    params: ParamsSchema,
  },
  responses: {
    200: {
      content: {
        'application/json': {
          schema: UserSchema,
        },
      },
      description: 'Retrieve the user',
    },
  },
})

const app = new OpenAPIHono()

app.openapi(route, (c) => {
  const { id } = c.req.valid('param')
  return c.json({
    id,
    age: 20,
    name: 'Ultra-man',
  })
})

// The OpenAPI documentation will be available at /doc
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'My API',
  },
})

「TypeScriptで型がわかっているのに、Zodでスキーマ定義しないといけないのかー」と思って色々調べてみましたが、そもそもTypeScriptはDecoratorをつけた上でオプションを有効にしないとトランスパイル後に型情報が残らないのですよね。
(この辺は全然知らなかったのでとても勉強になりました。)

型が合わない!

@hono/zod-openapi を使って早速APIを定義してみましょう。
TODOを検索してページ単位で取得するAPIを書いてみました。

import { z, createRoute, OpenAPIHono } from "@hono/zod-openapi";

const query = z.object({
  searchParam: z.string().optional(),
  limit: z.number().int().optional(),
  offset: z.number().int().optional(),
}).openapi("ListQuestionQuery");

const route = createRoute({
  method: "get",
  path: "/",
  request: { query },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: z.object({
            offset: z.number(),
            count: z.number(),
            items: z.array(
              z.object({
                id: z.string(),
                title: z.string(),
                status: z.string(),
                createdAt: z.string().datetime(),
              }),
            ),
          }),
        },
      },
      description: "Retrive todos list",
    },
  },
});

const app = new OpenAPIHono();

app.openapi(route, (c) => {
  const query = c.req.valid('query');

  return c.json({
    offset: query.offset,
    count: 0,
    items: [],
  });
});

問題なさそうに見えますよね?
でも、これ型が合わずに怒られてしまうのです。

エラーの内容としては以下になります。さて、どこが悪いのでしょう?

Argument of type 'string' is not assignable to parameter of type 'never'.deno-ts(2345)

このエラーからは全くわからないですね。

答え合わせ

Honoの作者のyusukebeさんが答えてくれています。
https://github.com/honojs/middleware/issues/200#issuecomment-1773428171

そう、クエリパラメータにはstring以外指定できないのです。
クエリパラーメーターなのにnumberで定義したため、型が合わずにエラーになっていたという訳です。

const query = z.object({
  searchParam: z.string().optional(),
  limit: z.number().int().optional(), // ここがnumberになっている
  offset: z.string().optional(), // stringにすると治る
}).openapi("ListQuestionQuery");

考えてみればクエリパラメーターはURLに乗ってくるわけで必ず文字列になるのは当たり前ですね。

とはいえ、、、

とはいえ、受け取りたいのはstringではなくnumber型のoffsetが欲しい訳です。
毎回parseIntを書くのも微妙だし、良い方法がないか探してみました。
(もちろんPOSTリクエストにすればnumberも受け取れますが、URLリソース定義を厳密にしないといけなくなったりするので、この方法は除外しています。)

最終的に行き着いたのはこの方法でした。

import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";

const query = z.object({
  searchParam: z.string().optional(),
  limit: z.string().pipe(z.coerce.number().int().min(0)).openapi({ type: "integer", default: 10 }),
  offset: z.string().pipe(z.coerce.number().int().min(0)).openapi({ type: "integer", default: 0 }),
}).openapi("ListQuestionQuery");

pipeとcoerceを使いstringからnumberへ変換をしつつ、取得時は valid を利用することでstringでスキーマ定義しつつ、number型で受け取ることができました。
また、OpenAPIのドキュメント上もstringではなくinteger型になるように明示的に型の指定をしています。

これをopenapi-generator-cliなどで自動生成すれば、フロントもバックも安全にやり取りすることが可能になります。

おわりに

実はログラスはつい先日にオフィス移転をしました🎉
300人は入れる規模のオフラインイベント用のスペースもできたので、イベント開催し放題です。

エンジニア採用もオープンしております!
ご興味のある方はぜひTwitter等でお声がけください!
https://job.loglass.jp/開発系職種募集一覧

株式会社ログラス テックブログ

Discussion

yusukebeyusukebe

こんにちわ!

そう、クエリパラメータにはstring以外指定できないのです。

この件ですが、最近の変更でnumberも受け付けるようになったので、honoを最新版にして試してみてください!たぶん上記のエラーがでなくなるはず!

urmoturmot

本当ですか!それはとても嬉しい変更です!
v4から追えてなかったので、試してみます!