はじめに
弊社は、ハノイの小さなソフトウェア開発会社だ。あんまり忙しいのは嫌なので、規模は拡大せず、少人数で開発を行っている。私以外は、ベトナム人エンジニアで、スキルはまちまちだ。
受託案件では 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年。内容によって、良しとする完成度を決める(完璧を目指さない)。
まず一般論として、ソフトウェア開発では、何に時間がかかっているのかを確認する。
上図は、日本における一般的なソフトウェア開発を 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
内は、
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 });
}
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
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
には認証をかけないように処理する。
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 を追加する
...
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 の場合、
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
などしないと、エラーになる。例えば、
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 であれば、以下のような感じ。
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
- P1xxx: Common Errors →
- 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/health
→200
-
/api/health/deep
→200
or503
src
└── app/api
└── health
├── deep
│ └── route.ts
└── route.ts
health チェックの API のレスポンスは、
- headers >
'Content-Type': 'application/health+json'
- body >
status: 'pass' or 'fail'
- error >
details: {}
とするのが作法のようだ。例えば、以下のようになる。
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.prisma
に generator
を登録する。
// 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図も生成してくれる。どうせ見られないなら、これでも良い気がする。
API 仕様書のガワを作る
まず、先にガワを作る。以前は、@stoplight/element
を利用したが、今回は、swagger-ui-react
を使うことにする。
├── public
│ └── openapi.json
└── src
├── app
│ └── docs
│ ├── page.tsx
│ └── swagger-ui.css
└ globals.css
App Route ではページは、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番の満足ポイント)
openapi.json を生成する
あとは、openapi.json
を書くだけ…なのだが、正直これが1番面倒くさい。これをなるべく手間をかけずに生成できる必要がある。
Zod を使っているので、それをうまいこと使いたいと思い、今回は 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
を追加する。
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 の部分だけをかいつまんで。
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
を吐き出すようになっている。
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
にする。
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:generate
でpublic/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
が更新される。一覧はブラウザで確認できる。
必ずしも正確に生成できる訳ではないので、適宜 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.ts
に export const dynamic = 'force-dynamic';
とする。
結論
というわけで、完成。これはまだサーバに Deploy して実運用してないので、あれ?となることがあるかも知れないが、運用しながらバージョンアップしていけば良い。
上図の Requirement と Write Codes 以外はほぼ何もやることがなくなる。つまり、API 作成は、定例ミーティングに参加して要件・仕様変更を聞き、src/schemas/config/
以下の設定をするだけになる。チーム開発時のポイントとしては、メンバーに独自のアレンジを許さないようにすることだ。仕組みに正しく乗ることで、工数が減らせるという点をしっかりと説明して。
結果として、工数は激減し、案件が面白くなくなる笑(でも切実)
今回は API というサンプルで説明したが、弊社では他にもこんなような Assets を日々蓄積しており、創業3年目だがいまのところかなりうまく機能している。実際には、この基本形だけで全て済むことはないので、開発することはある。
弊社では、受託案件を含めたソフトウェア開発を行っているが、面白くなくなっちゃったので(笑)、業務プロセス改善のコンサルティングも行っている。興味が湧いた方は、お声がけください。