Next.js14, Prisma5 で作る工数が激減する RESTFull API

## はじめに

弊社は、ハノイの小さなソフトウェア開発会社だ。あんまり忙しいのは嫌なので、規模は拡大せず、少人数で開発を行っている。私以外は、ベトナム人エンジニアで、スキルはまちまちだ。

受託案件では API を作ることが非常に多い。何度も作るので、なるべく工数がかからないように、なるべく品質が一定になるように作りたい。API に限らず、弊社では、**Agile, Assets & Automation** という 3 つの戦略で開発を行っている。Agile と Automation はその名の通りで、Assets は、簡単に言えば再利用可能なモジュール・フレームワーク郡を指す。

今回は、以前作った Next.js + Prisma の RESTfull API が、Next.js で App Router が実装されたり、OpenSSL 1.0 のサポートが終了したり、古くなったので、作り直すことにした。

以前作ったもの:
- [Next.js(TypeScript) で Prisma を使って API を作る](https://qiita.com/kurab/items/efce37b19f4484ae39bc)
- [Next.js + typescript + Prisma の API テンプレートを作る](https://qiita.com/kurab/items/6491961ea56347b48c00)

## 先に結論

https://github.com/TAKT-R-D/nextts14-prisma5-api-sample

- node: 20.10.0
- yarn: 1.22.19
- next.js: 14.0.4
- prisma: 5.7.1
- zod: 3.22.4

による工数が激減する RESTFull API のサンプルを作った。主要な機能は、

- Bearer authentication
- CORS
- Type safe by Zod
- Generate some required documents
  - ER-Diagram
  - DBML
  - API Specification (OpenAPI)
  - Licenses list of dependencies
- Dockerized
  - OpenSSL 3.x support
- Testing by dredd

## 何を仕組み化するか？

仕組み化・自動化は、Web の高速化と似ていて、なんとなくやっても成果が出ないものだ。弊社の Assets & Automation は、以下のポリシーに従い選定している。

- 頻度が高いもの
- ボトルネックになりがちなもの
- モチベーションが下がるもの

これらを FACT ベースでやる・やらないを決める。ソフトウェアのライフサイクルは2年。内容によって、良しとする完成度を決める（完璧を目指さない）。

まず一般論として、ソフトウェア開発では、何に時間がかかっているのかを確認する。

![vmodel_on_time.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/00ba818a-8f0f-0934-8473-215370bd5dcc.png)

上図は、日本における一般的なソフトウェア開発を V Model に当てはめた場合を、時間軸に載せてみたものだ。参照データは、ソフトウェア開発データ白書2018-2019 と、少し古いのだが、それほど大きな変化はないものと思う。開発規模によって違うとか、V Model？とかウォーターフォール？とかは、いま重要ではなく、ざっくり感覚をつかめれば良い。N=1 の私の感覚的にも、全体通したらまぁだいたいこんなものだろうと感じる。

この図で言えるのは、エンジニアがやって楽しいのはコード実装部分の効率化だったりしがちだが、そこだけを自動化しても、全体工数はそんなに減らないということだ。また、この図から言えるかも知れないのは、足らない IT 人材は、エンジニアではなく、優秀な PM だ。たぶん。

API の場合、
- 頻度が高いものは、全部になる（笑）
- ボトルネックになりがちなのは、リーダー的な人が担当するコードレビューやドキュメンテーション
- モチベーションが下がるのは、テストとドキュメンテーション

ドキュメンテーションは、受託開発の場合、必ず納品しなければならないドキュメントがいくつかある。本来、受託かどうかは関係ないが。

そんなことを念頭に、汎用的な API を設計する。

## API 設計方針

API が兼ね備える機能は、概ね以下の通りだ。

- Authentication (Bearer etc)
- CORS etc
- Validation (Params/Methods...)
- Endpoints (CRUD)
- DB Access (incl. Caching)
- (External API Access)
- Formatting Response
- Error Handling
- Logging

脆弱性検査で指摘されやすいポイントとしては、

- Fetch all
- Sequentila Parameter
- Raw Sensitive Information
- Secret Keys Rotation

あたりだ。これらのうち、案件の内容に左右されるのは、

- Authentication (Bearer etc)
- Endpoints (CRUD)
- (External API Access)

だけなので、それ以外はフレームワーク化してしまいたい。

- Authentication (Bearer etc)

は、有効な認証パターンがそんなにたくさんある訳ではないので、ライブラリ化して案件によって選択するようにしたい。

- Endpoints (CRUD)

上記ができてると、これが実装のメインになり、たくさん作ることになるので、基本的なものは、テンプレート化してしまいたい。なるべく工夫の余地をなくし、ササッとテストするだけで良いようにしたい。
ユーザ向けの API と CMS 等で使う API は必要なものが異なるので、そのあたりも考慮して、テンプレート化したい。

- (External API Access)

外部 API 等へのアクセスは、何度も使うようなものは、AWS S3 へのファイルアップロードくらいなので、都度やれば良くて、メンテナンス工数を考え、仕組み化はしない。

その他にも、Logging が案件によって何を/どこにログするの変わったりするが、これも仕組み化はしない。

## モチベーションが下がるドキュメンテーション

とにかく面倒くさくて精神が削られていくのが、

- API 仕様書
- ER 図

の作成などだ。あと、DBのテーブル仕様書など。`openapi.yaml/json` をせっせと書いたり、ER図をチマチマ書くのは、正直実装より時間がかかる。しかも ER図などは、先方がきちんと確認してくれたことがないが、これまで納品してもらっているから、という理由で納品を求められるケースが多い。

しんどい。やりたくない。

なのだけど、実際、概ねこれらはコード内で定義したスキーマ設定の別表現、Visualization なだけであったりする。世の中便利なツールがあるので、それらを活用して自分ではやらなくて良いようにする。

もうひとつ、使用している OSS のライセンス一覧の作成も面倒くさい。面倒くさいだけでなく、便利だけど NO LICENSE だったりするやべーの使ってないかは、あとで気づくと泣きながら修正することになる。こちらはピンとくるツールはあんまりないのだが、全部手動で調べるようなことにはならないようにする。

## その他に API にあると嬉しい機能

- Docker で動く
- リリースバージョンが分かる
- health check のエンドポイント

Production へのリリースは、今どきだと、Container をリリースすることが多いので、Docker で動くことを前提とする

活発に開発をしている際は、Container を push してからサーバに Deploy されるまで時間がかかったり、Deploy に失敗したことが手元では分からなかったりなど、何が動いているかを確かめる必要がある。また、検証環境が複数あって、UAT で上がってくるバグ・CR のレポートの原因を特定するにもバージョンが分かると嬉しい。

health check のエンドポイントも、AWS 等の環境に Deploy するのにあった方が良い。

## Next.js の App Router で作る API の基本型

今回は `Next.js` の `14.0.4` を利用する。エンドポイントは、`/api/users`, `/api/users/{id}` などのようにする。App Router で API を作るには、以下のように `route.ts` を配置する。

```
src
└── app/api
    └── users
        ├── [id]
        │   └── route.ts
        └── route.ts
```

`route.ts` 内は、

```ts:src/app/api/users/route.ts
import { NextResponse, type NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  ...
  return NextResponse.json(res, {
    headers: {
      'x-total-count': String(totalCount._count),
    },
    status: statusCode,
  });
}

export async function POST(request: Request) {
  ...
  return NextResponse.json(res, { status: statusCode });
}
```

```ts:src/app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';

type Props = {
  params: {
    id: string;
  };
};

export async function GET(request: Request, { params: { id } }: Props) {...}
export async function PUT(request: Request, { params: { id } }: Props) {...}
export async function DELETE(request: Request, { params: { id } }: Props) {...}
```

こんな感じで書く。これで

- GET `/api/users`: 一括取得
- POST `/api/users`: 登録
- GET `/api/users/{id}`: 1件取得
- PUT `/api/users/{id}`: 1件更新
- DELETE `/api/users/{id}`: 1件削除

ができる。一括更新、一括削除が欲しい場合は、`src/app/api/users/route.ts` に追加すれば良いがあんまり必要になることがないので、入れていない。

`src/app/api/users/route.ts > GET` で `request: NextRequest` としてあるのは、クエリパラメータ（page, limit, orderby など）を簡単に取得するため。一括取得の場合は、全部で何レコードあるかの情報がないと、使う側が不便なので `response headers` に `x-total-count` として入るようにした。`response body` に入れても良いのだが、深くなるのが嫌だし、情報の種類が違うので、headers で。

ところで、今回は Database に `PostgreSQL`、ORM に `Prisma 5` を利用しており、エンドポイントは、Prisma の各 Model に対応したものを基本としている。上記 `/api/users` 等は、Prisma の User model に対応している。

各メソッド内で、prisma にて処理を行う

- GET `/api/users`: `findMany`
- POST `/api/users`: `create`
- GET `/api/users/{id}`: `findUnique`
- PUT `/api/users/{id}`: `update`
- DELETE `/api/users/{id}`: `delete`

## CORS と Authentication

```
src
├── lib
│   └── BearerAuth.ts
└── middleware.ts
```

共通処理は、`middleware` で行う。API の認証は、前述の通りライブラリ化したいので、別ファイルにする。

### CORS

```ts:src/middleware.ts
import { NextResponse } from 'next/server';

export async function middleware(request: Request) {
  newHeaders.set('Access-Control-Allow-Origin', '*');
  newHeaders.set(
    'Access-Control-Allow-Headers',
    'GET, POST, OPTIONS, DELETE, PUT'
  );
  newHeaders.set('X-FRAME-OPTIONS', 'DENY');
  newHeaders.set('Cache-Control', 'no-cache, no-store, max-age=0');

  return NextResponse.next({
    headers: newHeaders,
    request: {
      headers: newHeaders,
    },
  });
}

export const config = {
  matcher: '/api/:path*',
};

```

`middleware` は全てに適用されてしまうので、`matcher` を使って、適用範囲をいったん `/api/` 配下に限定する。CORS は、ブラウザが API をリクエストする際に、`preflight` リクエストを `OPTION` メソッドで送って来るので、`204` レスポンスを返す必要があるのだが、この処理は、`Next.js` がよしなにやってくれるようになったので、以前は自分で書いていたが、いまは書く必要はない。

### Authentication

今回は、Bearer Auth にした。API 利用の流れとしては、

- `access_token` を取得
- `request header` に `Authorization: Bearer {access_token}`を入れて API を叩く

になる。`access_token` 取得の API は `/api/auth/access_token` にする。このエンドポイントと、後で作る health check の `/api/health` には認証をかけないように処理する。

```ts:src/middleware.ts
import { NextResponse } from 'next/server';
import { getBearerAuthStatusCode } from './lib/BearerAuth';
import { ERROR_MAP } from './lib/ErrorMessages';

export async function middleware(request: Request) {
  ...
  const regexAuth = new RegExp('/api/(auth|health)/*');
  if (!regexAuth.test(request.url)) {
    const bearerAuthStatusCode = await getBearerAuthStatusCode(request);
    if (bearerAuthStatusCode !== 200) {
      return NextResponse.json(
        { error: ERROR_MAP[bearerAuthStatusCode] },
        { status: bearerAuthStatusCode }
      );
    }
  }
  ...
}

export const config = {
  matcher: '/api/:path*',
}
```

`'/api/(auth|health)/*'` にマッチしない場合だけ、認証処理を行う。

Bearer Auth に関しては特に書くこともないのだが、`Next.js 14` では、セキュリティの都合上、`jsonwebtoken` がうまく動かないので、`jose` を利用している。`JWT` を使うのが良いかどうかは議論の余地があるが、API の用途によって token の生成方法は変えれば良い。

## Zod で Validation

`zod`, `zod-prisma-types` をインストールし、`prisma/schema.prisma` に generator を追加する

```ts:prisma/schema.prisma
...
generator zod {
  provider = "zod-prisma-types"
  output  = "../src/schemas/zod"
  useMultipleFiles                 = true
  useTypeAssertions                = true
}
```
`useTypeAssertions = true` を指定しないと、エラーになる。その他各種オプションは、[zod-prisma-types](https://github.com/chrishoermann/zod-prisma-types) を参照。

`npx prisma generate` すると、指定したディレクトリに Zod オブジェクトがドバっとできる。こんなに必要ないのだが、欲しい物だけに絞れなかったので、全部 generate しておいた。

この `zod-prisma-types` を使うと、`prisma/schema.prisma` の 各 model にコメントをつけることで、Zod オブジェクトを細かく指定できる。例えば、最大値や最小値、エラーメッセージなどを指定できる。

`Zod` によるパラメータの validation は、`.parse`, `.safeParse` などがあるが、エラーが起きた時に、エラーレスポンスを返したいので、`.safeParse` を使う。validation するのは、

- GET `/api/users`: query
- POST `/api/users`: request body
- GET `/api/users/{id}`: slug
- PUT `/api/users/{id}`: slug, request body
- DELETE `/api/users/{id}`: slug

になる。例えば、POST の場合、

```ts:src/app/api/users/route.ts
import { UserCreateInputSchema, type User as RequestType } from '@/schemas/zod';
...

export async function POST(request: Request) {
  const requestBody: Partial<RequestType> = await request
    .json()
    .catch(() => {});

  const query = UserCreateInputSchema.safeParse(requestBody);

  if (query.success === false) {
    const { errorCode, errorObject } = handleZodError(query.error);
    return NextResponse.json({ error: errorObject }, { status: errorCode });
  }
...
}
```

こんな感じになる。relation のある `requestBody` は、Prisma 的 `connect` などしないと、エラーになる。例えば、

```zsh:src/schemas/config/models/PostSchema.ts
export function formatPostParams(params: Partial<RequestType>) {
  const { authorId, ...rest } = params;
  let formattedParams: any = { ...rest };
  if (authorId) {
    formattedParams = {
      ...formattedParams,
      author: { connect: { id: authorId } },
    };
  }
  return formattedParams;
}
```

## エラーを考える

まず、プログラム的にエラーが発生する箇所は、

- Auth error: 認証に失敗（基本`401`）
- Zod error: Validation に失敗（基本`400`）
- Prisma error: DB が落ちてる、一位制約違反 etc（基本`500`）

などだ。Validation に失敗した場合は、どう失敗したいのかフィードバックしたいので、Zod のエラーを中心に考える。

```json
{
  "error": {
    "message": "error message(Bad Request など)",
    "errors": [
      {
        "path": "error path(どのパラメータがエラーか)",
        "message": "error message(required など)"
      }
    ]
  }
}
```

```
src
└── lib
    ├── PrismaErrorHandler.ts
    └── ZodErrorHandler.ts
```

これらを、Zod のエラーが発生するところ、Prisma のエラーが発生する箇所に埋め込む。
例えば、先程の POST であれば、以下のような感じ。

```ts:src/app/api/users
export async function POST(request: Request) {
  const requestBody: Partial<RequestType> = await request
    .json()
    .catch(() => {});

  const params = formatUserParams(requestBody);
  const query = CreateInputSchema.safeParse(params);

  if (query.success === false) {
    // Zod のエラー
    const { errorCode, errorObject } = handleZodError(query.error);
    return NextResponse.json({ error: errorObject }, { status: errorCode });
  }

  let statusCode = 200;

  const res = await prisma.user
    .create({
      data: query.data,
    })
    .catch((err) => {
      // Prisma のエラー
      const { errorCode, errorObject } = handlePrismaError(err);
      statusCode = errorCode;
      return { error: errorObject };
    });

  return NextResponse.json(res, { status: statusCode });
}
```

Prisma のエラーには、いくつか種類があって、内容によっては、connection を張りなおす必要があったりする。

- PrismaClientKnownRequestError
    - P1xxx: Common Errors → `500`
    - P2xxx: Prisma Client (Query Engine) Errors → `400`
    - P3xxx: Prisma Migrate (Schema Engine) Errors → `500`
    - P4xxx: Prisma db pull Errors → `500`
    - P5xxx: Data Proxy Error → `500`
- PrismaClientUnknownRequestError → `500`, 再接続が必要
- PrismaClientRustPanicError → `500`, 再接続もしくは再起動が必要
- PrismaClientInitializationError → `500`
- PrismaClientValidationError → `400`

詳細は、[Prisma/Docs Error message reference](https://www.prisma.io/docs/orm/reference/error-reference) を参照。

再接続が必要になることはあんまりないが、AWS Aurora Global Databases などで、リージョン超えのDB構成にするとたまに発生する。

これはテストするのが難しいが、ローカルの場合、今回は使っていない MySQL であれば、docker の command で `--innodb_read_only` のオプションをつけることで、一部エラーを発生させることができる。

```yaml
version: '3.8'
services:
  db:
    image: mysql:5.7
    ports:
      - '3306:3306'
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASS}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASS}
      TZ: 'Asia/Tokyo'
    volumes:
      - ./mysql/data:/var/lib/mysql
      - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./mysql/sql:/docker-entrypoint-initdb.d
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --innodb_read_only
    container_name: ${CONTAINER_NAME_DB}
```

## health チェック

health チェックは、システム自体が立ち上がっているかのチェックと、DB 含めシステムが機能しているかのチェックの2種類がある。1つにしてしまっても良いが、前者は Deploy する AWS 等のインフラ側がたくさんアクセスするので、DB への負荷という面で、2つに分けることにする。

- `/api/health` → `200`
- `/api/health/deep` → `200` or `503`

```
src
└── app/api
    └── health
        ├── deep
        │   └── route.ts
        └── route.ts
```

health チェックの API のレスポンスは、

- headers > `'Content-Type': 'application/health+json'`
- body > `status: 'pass' or 'fail'`
- error > `details: {}`

とするのが作法のようだ。例えば、以下のようになる。

```ts:src/app/api/health/deep/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/Prisma';
import { handlePrismaError } from '@/lib/PrismaErrorHandler';

// health check with database connection
export async function GET(request: Request) {
  let statusCode = 200;
  const res = await prisma.bookmark
    .findMany({ take: 1 })
    .then(() => {
      return {
        status: 'pass',
        message: 'success to connect database',
      };
    })
    .catch((err) => {
      const { errorObject } = handlePrismaError(err);
      statusCode = 503;
      return {
        status: 'fail',
        message: 'failed to connect database',
        details: errorObject,
      };
    });

  return NextResponse.json(res, {
    status: statusCode,
    headers: {
      'Content-Type': 'application/health+json',
    },
  });
}
```

## ER図等のドキュメントを生成する

Prisma から以下を生成する。

- ER図
- DBML

ER図は納品を求められるが、テーブル数が増えていくと正直見るのもしんどいし、先方も見ない。開発者的には、必要な場合には、DBML の方が見やすいのでそちらも生成する。

ER図は、[prisma-erd-generator](https://github.com/keonik/prisma-erd-generator)、DBML は、[prisma-dbml-generator](https://github.com/notiz-dev/prisma-dbml-generator) で生成する。`zod-prisma-types` 同様、`schema.prisma` に `generator` を登録する。

```ts:prisma/schema.prisma
// via. https://github.com/keonik/prisma-erd-generator
generator erd {
  provider     = "prisma-erd-generator"
  output       = "../deliverables/ER-Diagram.svg"
  disableEmoji = true
}

// via. https://github.com/notiz-dev/prisma-dbml-generator
generator dbml {
  provider              = "prisma-dbml-generator"
  output                = "../deliverables"
  outputName            = "schema.dbml"
  projectName           = "Sample Project"
  projectDatabaseType   = "PostgreSQL"
  manyToMany            = false
  includeRelationFields = false
}
```

これで `npx prisma generate` すれば指定のディレクトリに吐き出される。 ただし、ER図の吐き出しは、画像として吐き出そうとすると、docker で build する際に、必要ないのにエラーになってこけるので、普段は、`.env` にて `DISABLE_ERD=true` としておく必要がある。Markdown 形式で出力すればエラーにはならなさそうだが、納品時に一手間かかるので、画像にしておくことにした。

ただ、ER図は Prisma で generate しなくても良いかも知れない。

DB 関連では、テーブル定義書の納品が求められる。作り方は色々あるが、使う DB によってやり方を変えるのもいまいちなので、[tbls](https://github.com/k1LoW/tbls) を使うことにした。docker による方法がうまく行かなかったので、今回は、ローカルマシンで生成することにした。

`tbls` を使うと、見やすいとは言えないが ER図も生成してくれる。どうせ見られないなら、これでも良い気がする。

![Screenshot 2024-01-05 at 12.49.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/b680cb79-17e5-35db-48b9-22caab7f28aa.png)

## API 仕様書のガワを作る

まず、先にガワを作る。以前は、`@stoplight/element` を利用したが、今回は、`swagger-ui-react` を使うことにする。

```
├── public
│   └── openapi.json
└── src
    ├── app
    │   └── docs
    │       ├── page.tsx
    │       └── swagger-ui.css
    └ globals.css
```

App Route ではページは、`page.tsx` を作成する。

```tsx:src/app/docs/page.tsx
'use client';
import dynamic from 'next/dynamic';
import 'swagger-ui-react/swagger-ui.css';
import './swagger-ui.css';

const DynamicSwaggerUI = dynamic(() => import('swagger-ui-react'), {
  ssr: false,
  loading: () => <p>Loading Component...</p>,
});

export default function APISpecification() {
  return (
    <>
      <title>API Specification</title>
      <main>
        <DynamicSwaggerUI url="/openapi.json" />
      </main>
    </>
  );
}
```

`swagger-ui-react` では、読み込むソースは URL で指定して、ローカルファイルが読み込めないので、`public` ディレクトリに `openapi.json` を配置し、`'use client';` とする。
`build` すると動かなくなる。container にしてサーバに Deploy したいので、`Dynamic` Component として、`ssr: false` とする必要がある。

swagger-ui は見慣れてはいるが、私には眩しすぎたので、Dark モードを付け加えた。最近ご執心の日本の伝統色をベースに、目に優しい色使いになっている！（今回1番の満足ポイント）

![Screenshot 2024-01-05 at 13.08.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/86decfb5-ccfa-fbc6-8f20-c8cfbcace16d.png)

## openapi.json を生成する

あとは、`openapi.json` を書くだけ…なのだが、正直これが1番面倒くさい。これをなるべく手間をかけずに生成できる必要がある。

Zod を使っているので、それをうまいこと使いたいと思い、今回は [zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi) を使うことにした。

`zod-prisma-types` で生成したスキーマをそのまま使えれば良かったのだが、`openapi.json` 用には、`description` と `example` が足らないし、`response` の形やクエリパラメータなどは、こちらの実装次第。また、`/api/auth/access_token`, `/api/health` など、特定の model に依存しないものは生成されないので、せっせと書くことにする。

ファイルをどこに置くかは、かなり悩んだ。openapi.json の生成に関連するファイルは、サーバに Deploy する必要がないため、`src` ディレクトリ外に置いておきたいが、前述の生成された zod のスキーマと別の場所というのも整理されていない気もするし、この後 EntryPoint の作成をテンプレート化し、実装ではなく設定にしたいので…

`openapi.json` で書くことは、このシステムの場合、`prisma` クエリの別表現とも言えるので、同じファイルで設定するのが迷子にならなくて良かろう。と一旦結論づけた。

```
├── src/app/schemas
│   ├── config
│   │   ├── models
│   │   │   └── UserSchema.ts
│   │   ├── AuthSchema.ts
│   │   ├── Commons.ts
│   │   ├── ExtendedModels.ts
│   │   ├── HealthSchema.ts
│   │   └── index.ts
│   └── BuildOpenApiSchema.ts
└── tools
    ├── openapi
    │   ├── Configs.ts
    │   └── EntryPoints.ts
    └ GenerateOpenAPI.ts
```

まず、`src/app/schemas/config/ExtendedModels.ts` にて、`description` と `example` を追加する。

```ts:src/schemas/config/ExtendedModels.ts
import { UserSchema } from '@/schemas/zod';
import { Ex } from './Commons';

export const UserModelSchema = UserSchema.extend({
  id: UserSchema.shape.id.describe('user id').openapi(Ex.cuid),
  userName: UserSchema.shape.userName
    .describe('unique user name')
    .openapi(Ex.name),
  imageUrl: UserSchema.shape.imageUrl.describe('user image').openapi(Ex.image),
  createdAt: UserSchema.shape.createdAt
    .describe('created date')
    .openapi(Ex.date),
  updatedAt: UserSchema.shape.updatedAt
    .describe('updated date')
    .openapi(Ex.date),
});
```
こんな感じで、`zod-prisma-types` で生成した各 model スキーマを拡張する。`example` は都度書くのが面倒なので、`src/app/schemas/config/Commons.ts` 内にまとめた。`Commons.ts` には、その他に、GET 時の共通なクエリや、エラーレスポンスに関する設定をまとめた。

`src/schemas/config/models/UserSchema.ts` には、Prisma 用の設定と、OpenAPI 用の設定が含まれる。運用のイメージとしては、新しい EntryPoint を作る際に、このファイルだけをいじるようにし、実装ではなく設定させるようにしたい。そうすることでバグやレビューの手間などが減らせる。

いったん、OpenAPI の部分だけをかいつまんで。

```ts:src/schemas/config/models/UserSchema.ts
import ...

/**
 * PRISMA CONFIGS
 * *PrismaSelect: selected columns. if select all, leave it as {}
 * *PrismaInclude: included columns
 * format*Params: connect etc. if no relational post/put query, just return params.
 */

export const UserPrismaSelect = {};

export const UserPrismaInclude = {...};

export function formatUserParams(params: Partial<RequestType>) {...}

/**
 * OPENAPI CONFIGS
 * add describe & example
 *
 * _requestPostSchema: request body for POST request
 * _requestPutSchema: request body for PUT request. basically all are optional
 * _responseSchema: response body with relations
 */

const _requestPostSchema = ModelSchema.pick({
  userName: true,
  imageUrl: true,
});

const _requestPutSchema = _requestPostSchema.extend({
  userName: _requestPostSchema.shape.userName.optional(),
  imageUrl: _requestPostSchema.shape.imageUrl.optional(),
});

const _responseSchema = ModelSchema.merge(
  z.object({
    posts: z.array(
      PostModelSchema.pick({ id: true, title: true, createdAt: true })
    ),
    bookmarks: z.array(
      BookmarkModelSchema.pick({ postId: true }).merge(
        z.object({
          post: PostModelSchema.pick({ title: true, createdAt: true }).merge(
            z.object({
              author: ModelSchema.pick({
                id: true,
                userName: true,
                imageUrl: true,
              }),
            })
          ),
        })
      )
    ),
    _count: z.object({
      posts: z.number().int().openapi(Ex.number),
      bookmarks: z.number().int().openapi(Ex.number),
    }),
  })
);

/**
 * OPENAPI PATH CONFIG
 * path, summary, description, tags(user/cms), etc
 */

export const UserCreateSchema = builder.getCreateSchema(...);
export const UserFindManySchema = builder.getFindManySchema(...);
export const UserFindUniqueSchema = builder.getFindUniqueSchema(...);
export const UserUpdateSchema = builder.getUpdateSchema(...);
export const UserDeleteSchema = builder.getDeleteSchema(...);
```

`_requestPostSchema` は、Create 時の request body の設定、`_requestPutSchema` は、Update 時の設定で、基本的には任意パラメータに設定する。

`_responseSchema` は、GET 時の response body の設定で、`UserPrismaSelect`, `UserPrismaInclude` で設定した内容と一致するように書く。この作業は、慣れるまで結構面倒くさい。もうちょっと簡単にしたい。

`UserCreateSchema` ~ `UserDeleteSchema` は、OpenAPI の `path` の設定になっている。具体的な内容は、`src/schemas/BuildOpenApiSchema.ts` 参照。これらを、`tools/openapi/EntryPoints.ts` にエントリーすると、`tools/GenerateOpenAPI.ts` が読み込んで `openapi.json` を吐き出すようになっている。

```ts:tools/openapi/EntryPoints.ts
import { type RouteConfig } from '@asteasolutions/zod-to-openapi';
import * as schm from '@/schemas/config';

type EntryType = {
  schema: RouteConfig;
  requireAuth: boolean;
  isMultiLines: boolean;
  slugIdType?: string;
};

export const entries: EntryType[] = [
  { schema: schm.AuthSchema, requireAuth: false, isMultiLines: false },
  { schema: schm.HealthSchema, requireAuth: false, isMultiLines: false },
  { schema: schm.HealthDeepSchema, requireAuth: false, isMultiLines: false },
  { schema: schm.UserCreateSchema, requireAuth: true, isMultiLines: false },
  { schema: schm.UserFindManySchema, requireAuth: true, isMultiLines: true },
  ...
```

`isMultiLines` は、一括取得の場合に、`x-total-count` を `header` に埋め込む判定に使う。FindMany だけ `true` にする。

```ts:tools/GenerateOpenAPI.ts
import ...

const main = async () => {
  require('dotenv').config({
    path: path.resolve(__dirname, '../.env'),
  });
  const registry = new OpenAPIRegistry();
  extendZodWithOpenApi(z);

  // securityScheme
  registry.registerComponent(...);

  // headers
  const headerVersion = registry.registerComponent(...);
  const headerCount = registry.registerComponent(...);
  // params
  const IntIdSchema = registry.registerParameter(
    'IntId',
    z.number().openapi({
      param: {
        name: 'id',
        in: 'path',
      },
      ...Ex.number,
    })
  );
  const CuidIdSchema = registry.registerParameter(
    'CuidId',
    z.string().openapi({
      param: {
        name: 'id',
        in: 'path',
      },
      ...Ex.cuid,
    })
  );

  entries.forEach((entry) => {
    ...
    registry.registerPath(path);
  });

  const generator = new OpenApiGeneratorV3(registry.definitions);

  let obj: any = generator.generateDocument(DocumentConfig);

  if (process.env.API_VERSION) {
    obj.info.version = process.env.API_VERSION;
  }
  // MEMO: manually add example on slugId schema. reconsider when dredd support 3.0.0
  if (obj.components?.parameters && obj.components?.schemas) {
    if (obj.components.parameters.IntId && obj.components.schemas.IntId) {
      obj.components.parameters.IntId.example =
        obj.components.schemas.IntId.example;
    }
    if (obj.components.parameters.CuidId && obj.components.schemas.CuidId) {
      obj.components.parameters.CuidId.example =
        obj.components.schemas.CuidId.example;
    }
  }

  const json = JSON.stringify(obj, null, 2);
  const file = __dirname + '/../public/openapi.json';
  fs.writeFileSync(file, json);
};

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

```

こちらはそれほど特筆することはないのだが、変なことやってる点としては、slug パラメータの exapmle が OpenAPI3.0 をサーポートしていない `dredd` でうまく読み込めないので、マニュアルで追加している点と、API のバージョンを .env ファイルから読み込んで埋め込んでいる点。あとは、[zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi) のドキュメントを読み込んでいただきたい。

あとは、`package.json` に scripts を追加して

```zsh
yarn openapi:generate
```

とすることで、`public/openapi.json` が生成される。あんまり簡単にはなってない気がするが、前より楽になってるし、いまはいったんこれが精一杯ということでおしまい。

## OpenAPI を使った Dredd によるテスト

せっかく OpenAPI を真面目に書いたので、恩恵にあずかる。

```
└── tests
    ├── dredd.sh
    └── hook.js
```

```zsh
yarn test:dredd
```

でテストが実行される。
レコードのないテーブルがあると、テストは失敗するので、実行前に適当にいれておく。

何も追加しなくても、GET（一括取得、1件取得）だけはテストされる。POST, PUT, DELETE をテストしたい場合は、`tests/hook.js` にシナリオを追加する。

GET だけでもテストされていると、API 仕様書と GET の動作が同じになっているかは確認できるので、ないより良いし、レビューも楽になる。

## EntryPoints のテンプレート化

毎度おなじみの `scaffdog` にてテンプレート化する。それに際し、テンプレート化しやすいようにリファクタリングし、それをテンプレートにした: `.scaffdog/template.md`

```zsh
yarn scaffold
```

で動く。

```
├── src
│   ├── app/api
│   │   └── Xxxx
│   │       ├── [id]
│   │       │   └── route.ts
│   │       └── route.ts
│   └── schemas/config
│       ├── models
│       │   └── XxxxSchema.ts
│       ├── ExtendedModels.ts
│       └── index.ts
└── tools/openapi
    └── EntryPoints.ts
```
が更新される。

EntryPoint 新規作成の流れは、

- `prisma/schema.prisma` に model を書く
- `npx prisma migrate dev` して DB に反映する（と共に、諸々生成する）
- `yarn scaffold` してファイルを生成/更新
- `src/schemas/config/ExtendedModels.ts` で description, example を書く
- `src/schemas/config/models/XxxxSchema.ts` で入出力を書く
- `yarn openapi:generate` で `public/openapi.json` を更新する

となる。

## License リストを生成する

API の場合、Web 上で表示することはないが、利用している OSS の License を法務確認のために提出せよと言われることがある。セキュリティのため、package は気軽に更新したいので、自動で生成されるようにしたい。

提出が必要なのは、Production で使うものだけ。依存関係まで含めると、ものすごい量になるので、直接読み込んでいるものだけで良いことが多いので、そうする。

[license-checker](https://github.com/davglass/license-checker) を利用することにする。これは、API の機能には全く関係ないので、本来はアプリケーションに含めるべきではない。今回は説明のため、入れておいた。

```
├── src/app
│   └── licenses
│       ├── licenses.json
│       ├── licenses.module.css
│       └── page.ts
└── tools
    └── LicenseChecker.ts
```

```zsh
yarn license:generate
```

で、`src/app/licenses/licenses.json` が更新される。一覧はブラウザで確認できる。

![Screenshot 2024-01-05 at 15.21.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/6f7b7824-7a2f-2c5f-8411-1c8402f47183.png)

必ずしも正確に生成できる訳ではないので、適宜 `licenses.json` を修正する。これで、やべーのが入ってないかも一目瞭然である。ライセンスの諸々については、[こちら](https://qiita.com/kurab/items/925a9bc3408f1554de10)を確認。

## Docker化 する

```
├── Docker
│   └── Dockerfile
├── prisma
│   └── schema.prisma
├── .dockerignore
├── docker-compose.app-arm64.yml
├── docker-compose.app.yml
├── docker-compose.db.yml
├── docker-compose.yml
└── next.config.js
```

Docker 自体はそんなに気をつけることはなくて、いつも通りの手順だ。

- `next.config.js` で、 `output: 'standalone'` する。
- `schema.prisma` で、 `binaryTargets` を OpenSSL 3.0 に対応したものにする
- `Dockerfile` では、`public` と `.next/static` を手動でコピーする

くらいだ。M1/M2 などの Apple Silicon な ARM64 マシンで docker を build すると、AWS 等の AMD64 プラットフォームに Deploy しても動かないので、`docker-compose.app-arm64.yml` にて `platform: linux/amd64` を追加して、リリース用に使う。

Next.js 14 は、何もしないと build 時に DB にアクセスする。API はアクセスして欲しくないので、各 `route.ts` に `export const dynamic = 'force-dynamic';` とする。

## 結論

https://github.com/TAKT-R-D/nextts14-prisma5-api-sample

というわけで、完成。これはまだサーバに Deploy して実運用してないので、あれ？となることがあるかも知れないが、運用しながらバージョンアップしていけば良い。

![vmodel_on_time.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/00ba818a-8f0f-0934-8473-215370bd5dcc.png)

上図の Requirement と Write Codes 以外はほぼ何もやることがなくなる。つまり、API 作成は、定例ミーティングに参加して要件・仕様変更を聞き、`src/schemas/config/` 以下の設定をするだけになる。チーム開発時のポイントとしては、メンバーに独自のアレンジを許さないようにすることだ。仕組みに正しく乗ることで、工数が減らせるという点をしっかりと説明して。

結果として、工数は激減し、案件が面白くなくなる笑（でも切実）

今回は API というサンプルで説明したが、弊社では他にもこんなような Assets を日々蓄積しており、創業3年目だがいまのところかなりうまく機能している。実際には、この基本形だけで全て済むことはないので、開発することはある。

弊社では、受託案件を含めたソフトウェア開発を行っているが、面白くなくなっちゃったので（笑）、業務プロセス改善のコンサルティングも行っている。興味が湧いた方は、お声がけください。