@hono/zod-openapiで型安全なAPI開発

はじめに

こんにちは、Reproで新規事業の開発を行っているエンジニアの兼信です。
今回は @hono/zod-openapi を採用して型安全なAPI開発を行なっている事例をご紹介します。

導入の経緯

私たちが提供する「Repro」は、デジタル領域のマーケターに対し、エンドユーザーとの付加価値の高いコミュニケーション手段を提供するためのSaaSプロダクトです。一方でそのコミュニケーションを次のステージに導くための新規事業も準備しており、そのために新しいプロダクトの開発も行っています。

すでにRepro という規模が大きくなっているプロダクト・ソリューションをもっているため、最初から一定の規模のユーザーに安定したサービスを提供できるケイパビリティを担保しつつも、新規事業であるため早く顧客に価値を体験しただきたいと考え、開発速度も重視しています。

今回新しいプロダクトのバックエンドを開発するにあたり、フロントエンドとサーバー間の型安全性のためにOpenAPIを活用したいと考え検証を進めていました(GraphQLの採用経験もありますが、今回に関してはよりシンプルな仕組みであるRESTを採用することにしました)

OpenAPIを書く上で、バリデーションライブラリとしてデファクトスタンダードとなっているzodを活用したいと考えました。
そこでOpenAPI + zodを利用したミドルウェアとして、 @hono/zod-openapi が上記の要件に合っていたので採用しました。 結果としてHonoを利用しています。

@hono/zod-openapi の例

例として記事IDを指定して記事情報を取得するようなよくあるAPIを考えてみます。
まずAPIのインターフェースのスキーマを定義します。

// レスポンススキーマを定義
export const responseSchema = z
  .object({
    id: z.number(),
    title: z.string(),
    body: z.string(),
  })
  .openapi("ArticleResponse");

export const errorResponseSchema = z
  .object({
    message: z.string(),
  })
  .openapi("ErrorResponse");
  
export const requestParams = z
  .object({
    // 数値のバリデーションとNumberへの変換を行う
    articleId: z.string().min(1).regex(/^\d+$/).transform(Number),
  })
  .openapi("RequestParams");

次にAPIのルートを定義します。

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

// ルートを定義
export const getArticleRoute = createRoute({
  method: "get",
  path: "/articles/{articleId}",
  request: {
    // パスパラメータ
    params: requestParams,
  },
  responses: {
    200: {
      description: "Ok",
      content: {
        "application/json": {
          // 一つ前に定義したレスポンススキーマ
          schema: responseSchema,
        },
      },
    },
    404: {
      description: "Not Found",
      content: {
        "application/json": {
          // 一つ前に定義したエラーレスポンススキーマ
          schema: errorResponseSchema,
        },
      },
    },
  },
});

最後にハンドラーを定義します。

// ここでハンドラの型に先ほど定義したルートの型をセットします
export const getArticleHandler: RouteHandler<typeof getArticleRoute> = async (
  c,
) => {
  // ハンドラーではバリデーションと型変換が行われた状態のパスパラメータを取り出すことができる
  const { articleId } = c.req.valid("param");

  const article = await findArticleById(articleId);

  if (!article) {
    return c.json({ message: "記事がありませんでした" }, 404);
    // 404のレスポンスにはmessageが必要なのでこれは型チェックが失敗する
    // return c.json({ msg: "記事がありませんでした" }, 404);
  }

  // ↓はレスポンスのスキーマを満たさないので型チェックが失敗する
  // return c.json({ id: article.id });
  return c.json(article);
};

今回は記事APIのようなよくあるケースを例として挙げましたが、このような形でリクエスト・レスポンス共に簡潔かつ型安全に開発出来ています。

余談

HonoはCloudflare Workersなどのエッジワーカーで採用されていることが多いイメージですが、今回はHono + Node.js + AWS App Runnerという組み合わせで、旧来と同様にインスタンスベースのWeb APIサーバーとして利用しています。

Node.jsでHonoを利用する上での注意点として、記載時点ではWebSocketサポートが一部のランタイムに限定されており、Node.jsでは利用出来ません。(ワークアラウンドはあり)

その他に強いて言えば、基本的にはより制約の大きいエッジランタイム(=プロセスのTTLが短い)向けにチューニングされているミドルウェアライブラリが多く、立てっぱなしのサーバーで利用する上ではより効率の良い実装が考えられそうなものもありました。
パフォーマンスクリティカルな用途が出てきた場合には、そういった部分に関しては自作するなどの対応も検討したいと考えています。

まとめ

@hono/zod-openapi の採用は狙いどおりの開発速度と体験が得られたと感じています。

HonoはNode.jsやApp Runnerでの事例が少なかったため、運用上ある程度ハマるかもしれないとは思っていたのですが、そのようなこともほとんどありませんでした。
Cloudflareなどエッジワーカー以外でHonoの利用を考えている方の参考になればと思います。

↓ Reproではエンジニアの採用やっているので、よければ見てください! https://herp.careers/v1/repro/requisition-groups/07e6d8e3-4222-45f0-8b02-4cabf43aed4a