MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

OpenAPIからTypeScriptの型定義を作る: APIファースト開発の効率化

システム開発部新規プロダクト開発ユニット(NDU)の大石です。
NDUはその名の通り、広告事業に関わらない様々な新規事業に関するサービスの開発・立ち上げを担当するユニットです。
新規事業の立ち上げではスピードが重視され、NDU では効率的な開発で迅速なローンチを目標の1つとして開発をしています。

今回はBtoB企業向けの商談獲得ツール「ショウグン」の開発で行ったAPIの型定義の効率化について紹介いたします。

ショウグン

OpenAPIとAPIファースト開発

NDUをはじめ、マイクロアドの様々なプロジェクトでOpenAPIを用いたAPIファーストな開発を取り入れており、ショウグンでも採用しています。

OpenAPI(旧 Swagger)は、REST APIの仕様を記述するための標準仕様です。
APIのエンドポイント、リクエスト・レスポンスの構造などをYAMLなどのフォーマットで定義でき、API設計の透明性と整合性を高めます。

APIファースト開発とは、APIの仕様をプロダクト開発の初期段階で設計し、その仕様を中心にフロントエンドやバックエンドの開発を進める手法です。

課題

OpenAPIで定義した内容と同じ内容でフロントエンドやバックエンドでそれぞれ型を定義しており、追加や修正のたびに2重3重のコストが発生していました。 また、場合によってはAPI定義と実装が合っていないといった、不具合の元となるような問題も発生していました。

特にNDUでは新規事業扱う特性上、0から開発するプロジェクトがほとんどで、初期に大量のAPI定義を書くことになります。 ここが効率化されるだけでより早いサービスローンチに繋げることができます。

ショウグンではこれらの課題への対策としてOpenAPIから型定義を生成し、フロントエンド・バックエンドで共有し、これらの手間を省くことで開発の効率化を行います。

OpenAPIから型を作る

ショウグンの構成

ショウグンはモノレポでフロントエンドからバックエンドまで、1つのリポジトリで管理しています。 API定義を以下のように共通パッケージ化し、それぞれのアプリケーション側でインポートして使えるようにしています。

ディレクトリ構成は以下のようになっていて、packagesにAPI定義などの共通パッケージを配置しています。 バックエンドとフロントエンドにはそれぞれNest.jsとNuxt3を使っています。

/(root)
  ├─ apps
  │     ├─ backend
  │     └─ frontend
  ├─ packages
  │     └─ api-schema
  └─ package.json

モノレポにはnpmのWorkspaces機能を使っていて、package.jsonは以下のようになっています。

{
  ...
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  ...
}

OpenAPIでAPI定義を作成する

それではAPI定義を作成していきます。

OpenAPIのバンドルにはRedocly CLIを使用しています。 API定義に関するディレクトリは以下のような構成になっています。

/(root)
└─ packages
  └─ api-schema
    ├─ src
    │ └─ index.yaml
    │
    ├─ output
    │ └─ openapi.yaml
    │
    ├─ types
    │ ├─ index.ts
    │ ├─ schema.ts
    │ └─ type-utils.ts
    │
    └─ package.json

簡略化のためsrc/index.yamlのみとなっていますが、実際は扱いやすいように複数のファイルに分かれています。

outputには複数ファイルに分かれているYAMLをバンドルしたものが出力され、types/schema.tsにYAMLから作成された型定義ファイルを出力します。 出力した型定義はそのままだと扱いにくいので、より扱いやすくするためにユーティリティ型をtypes/type-utils.tsとして用意し、これを通してアプリケーション側から利用しています。

packages/api-schema/package.jsonは以下のような内容になっています。

{
  "name": "@common/api-schema",
  "version": "0.1.0",
  "description": "API定義",
  "types": "types/index.ts",
  "scripts": {
    "preview-docs": "redocly preview-docs src/index.yaml",
    "bundle": "redocly bundle src/index.yaml -o output/openapi.yaml",
    "build-docs": "redocly build-docs src/index.yaml -o output/index.html",
    "lint": "redocly lint src/index.yaml",
    "generate:type": "openapi-typescript output/openapi.yaml --output types/schema.ts",
    "build": "npm run bundle && npm run generate:type && npm run build-docs"
  },
  "dependencies": {
    "type-fest": "*",
    "@redocly/cli": "*",
    "openapi-typescript": "*"
  }
}

API定義

サンプルとして以下のような定義を用意しました。 マスターから業種一覧を取得するためのAPIを想定しています。

openapi: 3.1.0

info:
  version: 0.1.0
  title: ショウグンAPI
  description: ショウグンのAPI

paths:
  /business_types:
    get:
      summary: 業種一覧
      description: 業種一覧
      operationId: findAllBusinessTypes
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                required:
                  - items
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required:
                        - businessTypeId
                        - businessTypeName
                      properties:
                        businessTypeId:
                          type: integer
                          examples:
                            - 1
                        businessTypeName:
                          type: string
                          examples:
                            - 通信サービス業

APIのレスポンスは以下のようなものになります。

{
  "items": [
    {
      "businessTypeId": 1,
      "businessTypeName": "通信サービス業"
    }
  ]
}

Typescriptの型を出力する

型だけでなくクライアントまで生成してくれるライブラリなどもありますが、OpenAPIからTypescriptの型への変換はOpenAPI Typescriptを採用しました。
フルTypescriptのプロジェクトなので、Typescriptのみで完結し、異なるHTTPクライアントでも汎用的に使える型のみを生成できるところが採用理由です。

それでは型を生成します。

npm -w @common/api-schema run build

先程のYAMLから以下のような型が生成されました。(一部割愛)

export interface paths {
  "/business_types": {
    /**
     * 業種一覧
     * @description 業種一覧
     */
    get: operations["findAllBusinessTypes"];
  };
}
export interface operations {
  /**
   * 業種一覧
   * @description 業種一覧
   */
  findAllBusinessTypes: {
    responses: {
      /** @description OK */
      200: {
        content: {
          "application/json": {
            items: {
              businessTypeId: number;
              businessTypeName: string;
            }[];
          };
        };
      };
    };
  };
}

少々扱いにくいですが、以下のようにレスポンスの型を取り出せるようになりました。

paths["/business_types"]["get"]["responses"]["200"]["content"]["application/json"]

ユーティリティ型を作る

前述の通り、このままでは少々扱いにくいので以下のようなユーティリティ型を用意しました。(一部割愛)
他にもパスやクエリなどのパラメータ用のユーティリティ型や、エラー時のレスポンスを得るためのユーティリティ型も同様に定義しています。

import type { Get } from "type-fest";
import type { paths } from "./schema";

export type ApiPath = keyof paths;

// パスとメソッドからレスポンスの型を返す
export type ApiResponse<
  Path extends ApiPath,
  Method extends ExtractHttpMethod<Path>
> = Get<
  paths,
  [
    Path,
    Method,
    "responses",
    Extract<ExtractStatusCode<Path, Method>, SuccessCode>,
    "content",
    ExtractResponseType<Path, Method>
  ]
>;

これで簡潔に利用できるようになりました。

const businessTypes: ApiResponse<'/business_types', 'get'> = [
  {
    businessTypeId: 1
    businessTypeName: '通信サービス業'
  }
]

アプリケーション側から利用する

フロントエンドやバックエンドの各プロジェクトで、@common/api-schemaを依存関係として追加します。

npm -w frontend i -D @common/api-schema
npm -w backend i -D @common/api-schema

バックエンドでは以下のように返り値の型として使用することで、もし定義と実装にズレがあった場合はエラーとして検知されます。

import { ApiResponse } from "@common/api-schema";
import { Controller, Get } from "@nestjs/common";

@Controller("business_types")
export class BusinessTypesController {
  @Get()
  async findAllBusinessTypes(): Promise<ApiResponse<"/business_types", "get">> {
    return [
      {
        businessTypeId: 1,
        businessTypeName: "通信サービス業",
      },
    ];
  }
}

フロントエンドでは以下のように、composableとしてラップし、扱いやすくしています。

import type {
  ApiPath,
  ApiPathParams,
  ApiQueryParams,
  ApiRequestBody,
  ApiResponse,
  ExtractHttpMethod,
} from "@common/api-schema";
import type { AvailableRouterMethod } from "nitropack";
import type { MaybeRef } from "vue";

function buildPath(path: string, pathParams?: { [key: string]: any }): string {
  if (!pathParams) return path;

  return Object.entries(pathParams).reduce((acc, [key, value]) => {
    return acc.replace(`{${key}}`, String(value));
  }, path);
}

export const useApi = async <
  Path extends ApiPath,
  Method extends AvailableRouterMethod<Path> & ExtractHttpMethod<Path>
>(
  path: Path,
  method: Method,
  params?: {
    path?: MaybeRef<ApiPathParams<Path, Method>>;
    query?: MaybeRef<ApiQueryParams<Path, Method>>;
    body?: MaybeRef<ApiRequestBody<Path, Method>>;
  }
) => {
  const config = useRuntimeConfig();
  const baseURL = config.public.API_BASE_URL;
  const reqPath = buildPath(path, unref(params?.path));

  return $fetch<ApiResponse<typeof path, typeof method>>(reqPath, {
    method,
    query: unref(params?.query),
    body: unref(params?.body),
    baseURL,
  });
};

以下のように引数がサジェストされるようになり、未定義のエンドポイントを指定するとエラーとして検出されるようになりました。

また、返り値にも型推論で自動的に型がつき、誤ったプロパティの参照なども防げるようになっています。

まとめ

OpenAPIからTypeScriptの型定義を出力し、モノレポ環境で共通パッケージとして管理することで以下のようなメリットができ、開発体験の向上に繋がりました。

  • 型定義が一度で済むようになり開発コストが下がった
  • バックエンドでは定義と実装にズレがでなくなり、不具合が減った
  • フロントエンドでは引数のサジェストで開発効率があがり、誤ったプロパティへの参照を防ぐことで不具合減った

これにより、APIファースト開発がさらに効率化され、早期のローンチとより高品質なプロダクトの開発が可能となりました。