LoginSignup
8
4

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

Last updated at Posted at 2024-01-05

はじめに

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

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

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

以前作ったもの:

先に結論

  • 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

上図は、日本における一般的なソフトウェア開発を 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.js14.0.4 を利用する。エンドポイントは、/api/users, /api/users/{id} などのようにする。App Router で API を作るには、以下のように route.ts を配置する。

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

route.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 });
}
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 > GETrequest: NextRequest としてあるのは、クエリパラメータ(page, limit, orderby など)を簡単に取得するため。一括取得の場合は、全部で何レコードあるかの情報がないと、使う側が不便なので response headersx-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

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 headerAuthorization: Bearer {access_token}を入れて API を叩く

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

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 を追加する

prisma/schema.prisma
...
generator zod {
  provider = "zod-prisma-types"
  output  = "../src/schemas/zod"
  useMultipleFiles                 = true
  useTypeAssertions                = true
}

useTypeAssertions = true を指定しないと、エラーになる。その他各種オプションは、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 の場合、

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 などしないと、エラーになる。例えば、

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 のエラーを中心に考える。

{
  "error": {
    "message": "error message(Bad Request など)",
    "errors": [
      {
        "path": "error path(どのパラメータがエラーか)",
        "message": "error message(required など)"
      }
    ]
  }
}
src
└── lib
    ├── PrismaErrorHandler.ts
    └── ZodErrorHandler.ts

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

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 を参照。

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

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

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/health200
  • /api/health/deep200 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: {}

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

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、DBML は、prisma-dbml-generator で生成する。zod-prisma-types 同様、schema.prismagenerator を登録する。

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 を使うことにした。docker による方法がうまく行かなかったので、今回は、ローカルマシンで生成することにした。

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

Screenshot 2024-01-05 at 12.49.30.png

API 仕様書のガワを作る

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

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

App Route ではページは、page.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

openapi.json を生成する

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

Zod を使っているので、それをうまいこと使いたいと思い、今回は zod-to-openapi を使うことにした。

zod-prisma-types で生成したスキーマをそのまま使えれば良かったのだが、openapi.json 用には、descriptionexample が足らないし、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 にて、descriptionexample を追加する。

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 の部分だけをかいつまんで。

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 を吐き出すようになっている。

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-countheader に埋め込む判定に使う。FindMany だけ true にする。

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 のドキュメントを読み込んでいただきたい。

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

yarn openapi:generate

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

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

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

└── tests
    ├── dredd.sh
    └── hook.js
yarn test:dredd

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

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

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

EntryPoints のテンプレート化

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

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:generatepublic/openapi.json を更新する

となる。

License リストを生成する

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

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

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

├── src/app
│   └── licenses
│       ├── licenses.json
│       ├── licenses.module.css
│       └── page.ts
└── tools
    └── LicenseChecker.ts
yarn license:generate

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

Screenshot 2024-01-05 at 15.21.28.png

必ずしも正確に生成できる訳ではないので、適宜 licenses.json を修正する。これで、やべーのが入ってないかも一目瞭然である。ライセンスの諸々については、こちらを確認。

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.tsexport const dynamic = 'force-dynamic'; とする。

結論

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

vmodel_on_time.png

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

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

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

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

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4