🦕

【スキーマ駆動開発】OpenAPIから型定義を生成し、APIバリデーションとしても活用する

2023/01/03に公開

はじめに

OpenAPI Specification(以下、OAS)は、一般的にAPI仕様のドキュメンテーションとして活用されるケースが多いと思います。
しかしOASには、ドキュメンテーション以外にも、様々な活用方法が存在しています。
本記事では、その活用の一部について述べようと思います。


Swagger.io

私が携わっている開発では、OASが開発初期からドキュメンテーション(一部バリデーション)として利用していました。

OASは、サードパーティ製のツールも豊富です。
今回新たなサードパーティ製のツールを導入して、OASの下記のような活用を図りました。

  1. OASからtypescript型定義を生成し、バックエンド・フロントエンドで共用する
  2. APIのバリデーションにOASを用いる

プロダクトの技術構成はおおまかに下記の通りです。
フロントエンド:React/Next.js
バックエンド:Typescript/Express.js

まずは、OASからのtypescript型定義の生成について述べようと思います。

OASからtypescript型定義を生成し、バックエンド・フロントエンドで共用する

解決したかった課題

開発の課題として、フロントエンドのAPIレスポンスの型定義が実態とそぐわず、バグの温床や開発効率を低下させるというものがありました。ドキュメンテーションとして存在するはずのOASと実際の型定義に不整合が発生していました。

そこでOASから生成した型をバックエンド・フロントエンドで共用することで、型の整合性の担保を狙いました。
型生成のライブラリとしては、openapi-typescriptを選択しています。

https://github.com/drwpow/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ファイルで管理しようとすると、あまりにファイルが肥大化してしまいます。

開発フローは下記の通りです。

  1. openapi/define/usersディレクトリを作成し、ymlファイル(OAS)を作成する。
  2. openapi/bin/generatedTypes.jsスクリプトを実行する。
  3. 2の実行により。openapi/src/docs/usersopenapi/src/generatedTypes/users/tsが作成される。
  4. 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配下が実際に使用されるファイル群です。
getSettingsResponseTypepatchSettingsRequestTypeをバックエンド・フロントエンドでimportして使用します。

APIのバリデーションにOASを用いる

OASを用いたAPIのバリデーションについて述べます。
express-openapi-validatorは、手軽かつ強力にバリデーションとしてOASを利用できます。
https://github.com/cdimascio/express-openapi-validator

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点の活用について述べました。

  1. openapi-typescriptを用いて、型を生成し、バックエンド・フロントエンドで共用する
  2. 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を楽に実装できるライブラリを使ってみた

GitHubで編集を提案

Discussion