【Honoがすごい】APIドキュメントを自動生成&単体テスト

# はじめに
どうも、web系からシステム開発にジョブチェンしたばかりの[@kaiparu](https://qiita.com/kaiparu)です。
今回は名前のとおり激アツな **Hono** というフレームワークを使って爆速で開発する方法について書いていきます。

# Honoとは
Honoとは高速、軽量かつWeb標準APIのみを使用したWebアプリケーションフレームワークです。開発者は日本人で、2022年にプロジェクトが始動したばかりのフレッシュなフレームワークです。
詳しくは公式ドキュメントや開発者のブログをお読みください。

**Hono公式ドキュメント**

https://hono.dev/docs/

**開発者のブログ**

https://zenn.dev/yusukebe/articles/0c7fed0949e6f7

# 環境

- **bun:** v1.1.4
- **hono:** v4.4.8
- **@hono/zod-openapi:** v0.14.5
- **@hono/swagger-ui:** v0.3.0

# プロジェクトの作成
今回はBunを使ってサクッとHonoのプロジェクトを作っていきます。
（基礎的な部分は大丈夫だから本題に進みたい、という方は[ドキュメントの自動生成](https://qiita.com/drafts/9b19b7f278c27d9f25d9/edit#%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%E3%81%AE%E8%87%AA%E5%8B%95%E7%94%9F%E6%88%90)まで飛ばしてください。）

作成手順の詳細は公式ドキュメントを見てください。

https://hono.dev/docs/getting-started/bun

## Bunのインストール
すでにbunが入っている人は飛ばしてOKです。
```bash
curl -fsSL https://bun.sh/install | bash
```

## Honoプロジェクトの作成
いくつか質問されるので好みのものを答えてください。テンプレートはbunを選択します。
私の回答を参考に載せておきます。
```bash
# hono-app は任意の名称
bun create hono hono-app

# Which template do you want to use?
# > bun
# Do you want to install project dependencies?
# > Y
# Which package manager do you want to use?
# > bun
```

これだけで、Honoがあらかじめインストールされた状態でプロジェクトを開始できます。作成されたプロジェクトの`package.json`は下記のようになっています。
```json:package.json
{
  "name": "hono-app",
  "scripts": {
    "dev": "bun run --hot src/index.ts"
  },
  "dependencies": {
    "hono": "^4.4.8"
  },
  "devDependencies": {
    "@types/bun": "latest"
  }
}
```

さらに`src/index.ts`が用意されていて、ルートへアクセスすると'Hello Hono!'と表示されるAPIが定義されています。
```ts:src/index.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app
```

## 起動してみる
Bunのプロジェクトを立ち上げてみます。
```bash
# プロジェクトのディレクトリへ移動
cd hono-app

# run
bun run dev

# $ bun run --hot src/index.ts
# Started server http://localhost:3000
```
`http://localhost:3000`にアクセスして下記のように表示されればOKです。
![スクリーンショット 2024-06-27 9.54.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/8f19f16a-8d9f-4b3e-ed0e-c5e0f476cc98.png)

# ドキュメントの自動生成
今回、Honoの提供する3rd-party Middlewareの`@hono/zod-openapi`と`@hono/swagger-ui`を使ってドキュメントを自動生成します。

**Zod OpenAPI**

https://hono.dev/examples/zod-openapi

**Swagger UI**

https://hono.dev/examples/swagger-ui

## 準備
まずこれらを使うためにインストールします。
```bash
bun install @hono/zod-openapi
bun install @hono/swagger-ui
```
例として今回は`/user/:id`へGETリクエストを投げると、「id・年齢・名前・電話番号」のjsonデータが返ってくるAPIを定義します。例なので年齢・名前・電話番号は固定値としますが、実際のシステム等ではDBに問い合わせて情報を取ってきてると思ってください。
```ts:src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/user/:id", (c) => {
  const id = c.req.param("id");
  return c.json({
    id,
    age: 25,
    name: "Kaeya Alberich",
    tel: "0123-456-789",
  });
});

export default app;
```

## ルーティング
わざわざ分ける必要はないですが、APIが複数ある場合はファイル内のコードが長くなり、視認性が悪くなってしまいます。実用性を考えて、ルーティング用ファイルとAPI処理用ファイルで分けます。
さらに巨大なコードになる場合は、必要に応じてスキーマ定義も別ファイルに切り出すなどして工夫しましょう。

`src`ディレクトリ配下に`index.route.ts`を作成します。
```ts:src/index.route.ts
import { z, createRoute } from "@hono/zod-openapi";

// -------- Schemas --------
const ParamsSchema = z.object({
  id: z
    .string()
    .min(1)
    .max(3)
    .openapi({
      param: {
        name: "id",
        in: "path",
      },
      example: "100",
    }),
});

const UserOutputSchema = z
  .object({
    id: z.string().min(1).max(3).openapi({
      example: "100",
    }),
    age: z.number().min(1).max(3).openapi({
      example: 25,
    }),
    name: z.string().openapi({
      example: "Kaeya Alberich",
    }),
    tel: z
      .string()
      .max(13)
      .regex(/^\d+-\d+-\d+$/)
      .optional()
      .openapi({
        example: "0123-456-789",
      }),
  })
  .openapi("User");

// -------- GET /users/{id} --------
export const getUserIdRoute = createRoute({
  method: "get" as const,
  path: "/users/{id}",
  summary: "get user info",
  description: "idでユーザーの情報を取得する",
  request: {
    params: ParamsSchema,
  },
  responses: {
    200: {
      description: "success",
      content: {
        "application/json": {
          schema: UserOutputSchema,
        },
      },
    },
    400: {
      description: "Invalid Id",
      content: {
        "application/json": {
          schema: z.object({
            message: z.string().openapi({
              example: "Invalid Id",
            }),
          }),
        },
      },
    },
    404: {
      description: "Not Found",
      content: {
        "application/json": {
          schema: z.object({
            message: z.string().openapi({
              example: "User not found",
            }),
          }),
        },
      },
    },
  },
});
export type GetUserIdRouteResponse200 = z.infer<
  (typeof getUserIdRoute.responses)["200"]["content"]["application/json"]["schema"]
>;
export type GetUserIdRouteResponse400 = z.infer<
  (typeof getUserIdRoute.responses)["400"]["content"]["application/json"]["schema"]
>;
export type GetUserIdRouteResponse404 = z.infer<
  (typeof getUserIdRoute.responses)["404"]["content"]["application/json"]["schema"]
>;
```
各所に書いてある`.openapi("User")` `z.object()` `z.string()` `.min()` `.optional()`などで、スキーマ定義とバリデーション定義を一緒にできてしまうのがzodの便利なところです。

## API処理
次に`index.ts`を下記のように修正します。
```ts:src/index.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { getUserIdRoute } from "./index.route";

const app = new OpenAPIHono();

// -------- GET /users/{id} --------
app.openapi(getUserIdRoute, (c) => {
  const { id } = c.req.valid("param");
  if (id.length > 3) {
    return c.json({ message: "Invalid Id" }, 400);
  } else if (id === "999") {
    return c.json({ message: "Not Found" }, 404);
  } else {
    return c.json(
      {
        id,
        age: 25,
        name: "Kaeya Alberich",
        tel: "0123-456-789",
      },
      200
    );
  }
});

// -------- ドキュメント生成 --------
app.doc("/docs/json", {
  openapi: "3.0.0",
  info: {
    version: "1.0.0",
    title: "APIドキュメント",
    description: `## 概要
APIを爆速でドキュメント化するためのサンプルプロジェクトです。
`,
  },
});
app.get("/docs/UI", swaggerUI({ url: "/docs/json" }));

export default app;
```

## 生成されたドキュメントを確認してみる
`bun run dev`で起動した状態で、[http://localhost:3000/docs/json](http://localhost:3000/docs/json)にアクセスしてみます。jsonデータが返ってきていればOKです。

次に[http://localhost:3000/docs/UI](http://localhost:3000/docs/UI)にアクセスしてみます。SwaggerUIのドキュメントが表示されればOKです。
![swagger-ui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/1dc030d6-0418-6515-f608-dbe127eddddf.png)

# 単体テスト
jestを使っている方が多いと思うので、今回はjestを使います。
Honoのドキュメントでもjestを使ったテストを紹介しています。

https://hono.dev/docs/guides/testing

## パッケージのインストール
`TypeScript`でjestを使うには下記の３つのパッケージをインストールします。
```bash
bun add jest ts-jest @types/jest --dev
```
## jest.config.jsを作成
ルートディレクトリに`jest.config.js`を作成します。
```js:jest.config.js
/** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
};
```
## package.jsonにスクリプトを追加
`package.json`に`"test": "jest --detectOpenHandles"`を追記します。
```json:package.json
{
  "name": "hono-app",
  "scripts": {
    "dev": "bun run --hot src/index.ts",
    "test": "jest --detectOpenHandles"
  },
  "dependencies": {
    "@hono/swagger-ui": "^0.3.0",
    "@hono/zod-openapi": "^0.14.5",
    "hono": "^4.4.8"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "@types/jest": "^29.5.12",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.5"
  }
}
```

## テストファイルを作成
`src`ディレクトリ配下に`index.test.ts`を作成します。
今回は「正常に取得できた場合」「不正なidだった場合」「存在しないidだった場合」の３パターンをテストします。
```ts:src/index.test.ts
import { describe, test, expect } from "@jest/globals";
import app from "./index";
import {
  GetUserIdRouteResponse200,
  GetUserIdRouteResponse400,
  GetUserIdRouteResponse404,
} from "./index.route";

// -------- GET /users/{id} --------
describe("GET /users/{id}", () => {
  test("[success] idを指定してユーザーの情報を取得する", async () => {
    const userId = "100";
    const res = await app.request(`/users/${userId}`, {
      method: "GET",
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as GetUserIdRouteResponse200;
    expect(body).toEqual({
      id: userId,
      age: 25,
      name: "Kaeya Alberich",
      tel: "0123-456-789",
    });
  });
  test("[error] idが不正", async () => {
    const invalidUserId = "1000";
    const res = await app.request(`/users/${invalidUserId}`, {
      method: "GET",
    });
    expect(res.status).toBe(400);
  });
  test("[error] idが存在しない", async () => {
    const noExistUserId = "999";
    const res = await app.request(`/users/${noExistUserId}`, {
      method: "GET",
    });
    expect(res.status).toBe(404);
    const body = (await res.json()) as GetUserIdRouteResponse404;
    expect(body).toEqual({
      message: "Not Found",
    });
  });
});
```

## テストの実施
下記のコマンドでテストを実行してみます。
```bash
bun run test ./src/index.test.ts
```
下記のようにテストが通ってくれればOKです。
![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529398/af65aae5-1343-2c3f-42ef-89c4a82c0408.png)

# まとめ
いかがだったでしょうか。

- APIのルーティングや定義を書きながら
- ドキュメントも自動生成できて
- テストも簡単に書ける

を実感してもらえたかなと思います。
私自身もこれからもっとHonoを使いこなしていきたいですね〜

今回の記事で作成したサンプルコードを貼っておきますので、ご活用ください。

https://github.com/kaiharu/hono-app/