【スキーマ駆動開発】OpenAPIから型定義を生成し、APIバリデーションとしても活用する
はじめに
OpenAPI Specification(以下、OAS)は、一般的にAPI仕様のドキュメンテーションとして活用されるケースが多いと思います。
しかしOASには、ドキュメンテーション以外にも、様々な活用方法が存在しています。
本記事では、その活用の一部について述べようと思います。
私が携わっている開発では、OASが開発初期からドキュメンテーション(一部バリデーション)として利用していました。
OASは、サードパーティ製のツールも豊富です。
今回新たなサードパーティ製のツールを導入して、OASの下記のような活用を図りました。
- OASからtypescript型定義を生成し、バックエンド・フロントエンドで共用する
- APIのバリデーションにOASを用いる
プロダクトの技術構成はおおまかに下記の通りです。
フロントエンド:React/Next.js
バックエンド:Typescript/Express.js
まずは、OASからのtypescript型定義の生成について述べようと思います。
OASからtypescript型定義を生成し、バックエンド・フロントエンドで共用する
解決したかった課題
開発の課題として、フロントエンドのAPIレスポンスの型定義が実態とそぐわず、バグの温床や開発効率を低下させるというものがありました。ドキュメンテーションとして存在するはずのOASと実際の型定義に不整合が発生していました。
そこでOASから生成した型をバックエンド・フロントエンドで共用することで、型の整合性の担保を狙いました。
型生成のライブラリとしては、openapi-typescriptを選択しています。
openapi-typescriptの選定理由
より一般的なライブラリとして、OpenAPI Generatorがあります。
しかし以下の理由から、openapi-typescriptを選択しました。
- typescript製のライブラリであり、プロダクトの技術構成と相性が良く、内部実装の把握やメンテナンスの面でメリットがある
- transformで柔軟に型を生成できる ※後述
アーキテクチャ
openapi-typescript導入後のイメージです。
openapiディレクトリを新規で作成しています。
ディレクトリ構成
下記はopenapiディレクトリの構成です。
※実際のディレクトリ及びファイル名とは一部異なります。
.
└── openapi
├── bin
│ ├── generatedTypes.js #src/docs配下の定義から、generatedTypes配下に型ファイルを生成する。
│ └── merge.js #define配下の定義を結合し、src/docs配下のファイルを生成する。generatedTypes.jsの中で呼ばれる。
├── define
│ ├── auth
│ │ ├── components.yml
│ │ ├── path.yml
│ │ └── index.yml
│ └── users
│ ├── components.yml
│ ├── path.yml
│ └── index.yml
└── src
├── docs #後述のexpress-openapi-validatorで読み込んで使用されるOAS。
│ ├── auth.yml
│ └── users.yml
├── generatedTypes #bin/generatedTypes.jsを実行すると生成される。
│ ├── auth.ts
│ └── users.ts
├── types #実際にバックエンド/フロントエンドで使用される型ファイル群。
│ ├── auth.ts
│ └── users.ts
└── utils
└── helper.ts #src/types配下のファイルで利用される。
特徴としては、ドメイン単位(auth, users)でディレクトリを設けている点です。
OASやそこから生成する型ファイルを1ファイルで管理しようとすると、あまりにファイルが肥大化してしまいます。
開発フローは下記の通りです。
-
openapi/define/users
ディレクトリを作成し、ymlファイル(OAS)を作成する。 -
openapi/bin/generatedTypes.js
スクリプトを実行する。 - 2の実行により。
openapi/src/docs/users
とopenapi/src/generatedTypes/users/ts
が作成される。 -
openapi/src/types/users.ts
を作成し、バックエンド・フロントエンドで利用する。
スクリプト化
openapi/bin/generatedTypes.js
は、openapi-typescriptが用意するAPIを実行します。そして、openapi/src/generatedTypes
及びopenapi/src/docs
配下のファイル群を生成します。
openapi/bin/generatedTypes.js
の概要です。
一部掲載用に変更を加えています。
//openapi/bin/generatedTypes.js
import * as fs from "fs";
import yaml from "js-yaml"
import openapiTS from "openapi-typescript";
import mergeDefine from "./merge.js"
const makeDir = async (dir) => {
//docs配下に既存のディレクトリがない場合は作成する。
const path = `openapi/src/docs/${dir}`
if (!fs.existsSync(path)) {
fs.mkdirSync(path);
}
}
const callMergeDefine = async (dir) => {
//bin/merge.jsを実行する。define配下の定義を結合し、src/docs配下のファイルを生成する
//openapi-typescriptを実行するために、ymlを結合しておく必要がある
const input = `openapi/define/${dir}/index.yml`
const output = `openapi/src/docs/${dir}/index.yml`
mergeDefine(input, output)
}
const openapiTSFunc = async (data) => {
//型をtransformする。
const output = await openapiTS(data, {
formatter(node) {
if (node.format === "date-time") {
return "Date";
}
}
})
return output
}
const generateTypes = async (dir) => {
const filePath = `openapi/src/docs/${dir}/index.yml`
const fileData = fs.readFileSync(filePath, "utf-8")
const data = yaml.load(fileData)
//型を生成し、ファイルに書き込む。
const output = await openapiTSFunc(data)
fs.writeFileSync(`openapi/src/generatedTypes/${dir}.ts`, output)
}
const main = async () => {
const defineDirLists = fs.readdirSync('openapi/define')
await Promise.all(defineDirLists.map(async dir => {
await makeDir(dir)
await callMergeDefine(dir)
await generateTypes(dir)
}))
}
main()
このスクリプトは、huskyとlint-stagedを用いて、commit時に自動で実行されるようになっています。
これにより、開発者間でのOASや生成される型ファイルの差異をなくしています。
transform
既存プロダクトへの導入ということもあって、自動で生成される型を任意に設定する必要がありました。
その点、openapi-typescriptのtransformは非常に便利です。
type: object
properties:
id:
type: integer
format: number-bigint #任意に命名
example: 1
このようにformat
を任意に命名しておき、上記スクリプトのように記述することで、生成される型を自由に設定できます。
helper
openapi-typescriptの残念な点は、生成される型が複雑にネストされており、そのままでは利用しづらいところです。
そこでopenapi/src/types
配下において、openapi/utils/helper.ts
を用いて、利用しやすい型を再定義しています。
生成される型の例
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/auth": {
get: {
responses: {
200: {
content: {
"application/json": {
key: string
}[];
};
};
/** Not logged in */
401: unknown;
/** Unauthorized */
403: unknown;
};
};
patch: {
responses: {
200: unknown;
/** Not logged in */
401: unknown;
/** Unauthorized */
403: unknown;
};
requestBody: {
content: {
"application/json": {
/** @example 1 */
id: number;
}[];
};
};
};
};
}
export interface components {}
export interface operations {}
export interface external {}
openapi/src/utils/helper.ts
type UrlPaths<paths> = keyof paths & string
type HttpMethods = 'patch' | 'get'
export type ResponseBodyType<
paths extends {
[k in Path]: {
[k in Method]: {
responses: {
200: {
content: {
"application/json": object | Array<object>
}
}
}
}
}
},
Path extends UrlPaths<paths>,
Method extends HttpMethods
> = paths[Path][Method]['responses'][200]['content']["application/json"]
export type RequestBodyType<
paths extends {
[k in Path]: {
[k in Method]: {
requestBody: {
content: {
"application/json": object | Array<object>
}
}
}
}
},
Path extends UrlPaths<paths>,
Method extends HttpMethods
> = paths[Path][Method]['requestBody']['content']["application/json"]
openapi/src/types/auth.ts
import { RequestBodyType, ResponseBodyType } from '../utils/helper'
import { paths } from '../generatedTypes/settings'
export type getSettingsResponseType = ResponseBodyType<paths, '/auth', 'get'>
export type patchSettingsRequestType = RequestBodyType<paths, '/auth', 'patch'>
openapi/src/types
配下が実際に使用されるファイル群です。
getSettingsResponseType
やpatchSettingsRequestType
をバックエンド・フロントエンドでimportして使用します。
APIのバリデーションにOASを用いる
OASを用いたAPIのバリデーションについて述べます。
express-openapi-validatorは、手軽かつ強力にバリデーションとしてOASを利用できます。
express-openapi-validator
使い方は簡単です。
下記のように関数を用意し、ミドルウェアとして各APIで使用します。
// api/src/middlewares/openapiValidator.ts
import { Request, Response, NextFunction } from 'express'
import * as OpenApiValidator from 'express-openapi-validator'
import { OpenApiRequestHandler } from 'express-openapi-validator/dist/framework/types'
export const openApiValidator = (path: string): OpenApiRequestHandler[] => {
return OpenApiValidator.middleware({
apiSpec: `../openapi/src/docs/${path}/index.yml`, //読み込むOASファイルのパス
validateResponses: {
removeAdditional: true, //OASに存在しないキーがパラメータに含まれる場合は取り除く。
},
validateResponses: true,
})
}
// api/src/router/auth.ts
router.use(openApiValidator('auth'))
openApiValidator
はオプションの指定により、定義外のリクエスト/レスポンスが発生した場合の挙動を多様に設定できます。
例えば、付与なパラメータが存在した場合取り除く、或いはエラーにするといった指定が可能です。
まとめ
本記事では、OASを用いた下記2点の活用について述べました。
- openapi-typescriptを用いて、型を生成し、バックエンド・フロントエンドで共用する
- express-openapi-validatorを用いて、APIのバリデーションを実装する
まだ実際の運用には繋げられてはおらず、現在開発への取り入れを検討している段階です。
これらの活用の狙いは、よりOASに依存したスキーマ駆動の開発です。
バックエンド・フロントエンド双方がOASに依存することで、コードの品質を担保し、バグの発生を防ぎます。
少しでもご参考になれば、幸いです。
参考
OpenAPI定義からTypeScript型を生成し、フロントエンド・バックエンド間でスキーマ駆動開発
【OpenAPI】APIスキーマから勝手に型がつくaxiosを作って幸せになる【openapi-typescript】
OpenAPI + Express.js + TypeScriptでAPI開発するTips
ExpressでREST APIを実装する時にreq/resのvalidationを楽に実装できるライブラリを使ってみた
Discussion