WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

openapi-typescript で型安全なAPIクライアントを作成する

こんにちは!コアシステム開発Gでテックリードをやっている古賀です。

以前に OpenAPI の紹介をしました。

blog.wh-plus.co.jp

今回はその続編として、OpenAPI スキーマ定義を活用し、TypeScript で型安全に API 通信を実現する方法を紹介します。
最終的な成果物を先に見たい方は、末尾の動画をご覧ください。

TypeScriptにおける型安全とは

TypeScriptにおける型安全とは、型の不一致をコンパイル時に検出し、ランタイムエラーの発生を未然に防ぐプログラミングスタイルです。

例えば、次のように文字列型の変数に数値を代入しようとすると、コンパイルエラーが発生します。

let name: string = "Taro";
name = 123;  // ❌ コンパイルエラー: number型はstring型に代入できない

このように型の不一致をプログラムの実行前に検出することで、意図しないデータが混入することを防ぎ、安全に記述することができます。

API通信では型安全性を担保しづらい

HTTP による API 通信を行うには、次のようにデータ送受信の取り決めを守る必要があります。

  • リクエストパラメータの型がサーバ側の期待通りであること
  • 必須パラメータが設定されていること
  • HTTPメソッドが期待通りであること
  • レスポンスの型がクライアント側の期待通りであること

これらのうち一つでも間違っていると、APIが正しく動作しなかったり、予期しないエラーが発生したりします。

上記内容を型安全に記述する方法の一つが『API 仕様を定義し、それを元にして型定義を生成・使用すること』です。

この記事では OpenAPI によるスキーマ定義を用意し、openapi-typescript で生成した型定義を使ってAPI 通信を実装していきます。

openapi-typescript とは

openapi-typescript は Node.js を使って OpenAPI 3.0 および 3.1 スキーマを TypeScript の型定義に変換してくれるものです。 OpenAPI 仕様を TypeScript 型定義に変換する類似ツールはいくつかありますが、次の点が特徴として挙げられます。

  • Node.js のみで実行可能で、Java など他の依存が不要。
  • npm trends においてトップである。
  • openapi-fetch という fetch クライアントを提供している。これを組み合わせることで型安全な fetch クライアントを作成することが可能。

openapi-typescript を使った型定義の生成

まずは openapi-typescript と typescript をインストールします。

npm i -D openapi-typescript typescript

tsconfig.json を公式ドキュメントに記載の設定にします。

{
  "compilerOptions": {
    "module": "ESNext", // or "NodeNext"
    "moduleResolution": "Bundler", // or "NodeNext",
    "noUncheckedIndexedAccess": true
  }
}

OpenAPI スキーマ定義を YAML ファイルで作成します。
POSTメソッドに /pet、GETメソッドに/pet/{petId}を用意しています。

openapi: 3.0.0
info:
  title: Sample Petstore
  description: This is a sample Pet Store Server based on the OpenAPI 3.0 specification.
  version: 1.0.0
tags:
  - name: pet
    description: Everything about your Pets
paths:
  /pet:
    post:
      tags:
        - addPet
      description: Add a new pet to the store.
      operationId: addPet
      requestBody:
        description: Create a new pet in the store
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
      responses:
        200:
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
              example:
                id: 1
                name: "American Shorthair"
                photoUrls: [ ]
                isAvailable: true

        400:
          $ref: "#/components/responses/BadRequest"
        500:
          $ref: "#/components/responses/InternalServerError"

  /pet/{petId}:
    get:
      tags:
        - getPet
      description: Returns a single pet.
      operationId: getPetById
      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64
      responses:
        200:
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
              example:
                id: 2
                name: "Sphynx"
                photoUrls: [ ]
                isAvailable: false
        400:
          $ref: "#/components/responses/BadRequest"
        500:
          $ref: "#/components/responses/InternalServerError"

components:
  schemas:
    Pet:
      required:
        - name
        - photoUrls
      type: object
      properties:
        id:
          type: integer
          format: int64
          description: ペットのID
        name:
          description: ペットの名前
          type: string
        photoUrls:
          description: 写真のURLリスト
          type: array
          items:
            type: string
        isAvailable:
          description: 利用可能かどうか
          type: boolean
    ErrorResponse:
      required:
        - error
      type: object
      properties:
        error:
          type: string
          description: エラーメッセージ
      example:
        error: "This is a sample error message."

  responses:
    BadRequest:
      description: 400 Bad Request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    InternalServerError:
      description: Internal Server Error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

openapi-typescript のコマンドを package.json に定義します。

{
  "scripts": {
    "openapi": "openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts"
  }
}

npm run openapi を実行すると、型定義ファイル path/to/my/schema.d.ts が作成されます。 この型定義が API 仕様を表したものになっています。

型定義ファイルの中身

export interface paths {
    "/pet": {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        get?: never;
        put?: never;
        /** @description Add a new pet to the store. */
        post: operations["addPet"];
        delete?: never;
        options?: never;
        head?: never;
        patch?: never;
        trace?: never;
    };
    "/pet/{petId}": {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        /** @description Returns a single pet. */
        get: operations["getPetById"];
        put?: never;
        post?: never;
        delete?: never;
        options?: never;
        head?: never;
        patch?: never;
        trace?: never;
    };
}
export type webhooks = Record<string, never>;
export interface components {
    schemas: {
        Pet: {
            /**
             * Format: int64
             * @description ペットのID
             */
            id?: number;
            /** @description ペットの名前 */
            name: string;
            /** @description 写真のURLリスト */
            photoUrls: string[];
            /** @description 利用可能かどうか */
            isAvailable?: boolean;
        };
        /** @example {
         *       "error": "This is a sample error message."
         *     } */
        ErrorResponse: {
            /** @description エラーメッセージ */
            error: string;
        };
    };
    responses: {
        /** @description 400 Bad Request */
        BadRequest: {
            headers: {
                [name: string]: unknown;
            };
            content: {
                "application/json": components["schemas"]["ErrorResponse"];
            };
        };
        /** @description Internal Server Error */
        InternalServerError: {
            headers: {
                [name: string]: unknown;
            };
            content: {
                "application/json": components["schemas"]["ErrorResponse"];
            };
        };
    };
    parameters: never;
    requestBodies: never;
    headers: never;
    pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
    addPet: {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        /** @description Create a new pet in the store */
        requestBody: {
            content: {
                "application/json": components["schemas"]["Pet"];
            };
        };
        responses: {
            /** @description Successful operation */
            200: {
                headers: {
                    [name: string]: unknown;
                };
                content: {
                    /** @example {
                     *       "id": 1,
                     *       "name": "American Shorthair",
                     *       "photoUrls": [],
                     *       "isAvailable": true
                     *     } */
                    "application/json": components["schemas"]["Pet"];
                };
            };
            400: components["responses"]["BadRequest"];
            500: components["responses"]["InternalServerError"];
        };
    };
    getPetById: {
        parameters: {
            query?: never;
            header?: never;
            path: {
                /** @description ID of pet to return */
                petId: number;
            };
            cookie?: never;
        };
        requestBody?: never;
        responses: {
            /** @description Successful operation */
            200: {
                headers: {
                    [name: string]: unknown;
                };
                content: {
                    /** @example {
                     *       "id": 2,
                     *       "name": "Sphynx",
                     *       "photoUrls": [],
                     *       "isAvailable": false
                     *     } */
                    "application/json": components["schemas"]["Pet"];
                };
            };
            400: components["responses"]["BadRequest"];
            500: components["responses"]["InternalServerError"];
        };
    };
}

主な型定義は次のようになっています。

  • paths:エンドポイントごとにパラメータやHTTPメソッドが定義されており、存在するHTTPメソッドにはoperations型が当てられています。
  • operations:OpenAPI の operationIdをキーとして、エンドポイントのリクエストやレスポンスに関わる型が定義されています。
  • components:OpenAPI の Components Object に該当する型が定義されており、具体的には PetErrorResponseなどがあります。

openapi-fetch を使ったAPIクライアントの作成

openapi-fetch をインストールします。

npm i openapi-fetch

使い方は、まず openapi-fetch が提供する createClient() を呼び出してAPIクライアントを作成します。その際、先ほど作成された paths を渡すことで、OpenAPI スキーマに基づいた API クライアントが生成され、利用可能なエンドポイントやメソッドが型レベルで制約されます。

次に、APIクライアントの POST()GET()を呼び出すことでリクエストを送信します。

import createClient from "openapi-fetch";
import type {paths} from "./path/to/my/schema";

// APIクライアントの作成
const client = createClient<paths>({baseUrl: "http://127.0.0.1:4010"});

async function callAddPet() {
    // POSTリクエストの送信
    const {data, error} = await client.POST("/pet", {
        body: {
            name: "sample pet",
            photoUrls: [],
        },
    })
    if (error) {
        console.log('error', error.error);
        throw error;
    }
    if (data) {
        console.log(data.name)
        return
    }
    throw new Error("Unexpected API response: both data and error are undefined");
}

async function callGetPet() {
    // GETリクエストの送信
    const {data, error} = await client.GET("/pet/{petId}", {
        params: {
            path: {
                petId: 1
            }
        },
    })
    if (error) {
        console.log('error', error.error);
        throw error;
    }
    if (data) {
        console.log(data.name)
        return
    }
    throw new Error("Unexpected API response: both data and error are undefined");
}

ポイントは送受信に使用する次の値にはschema.d.tsで定義された型が当てられており、その型に反する値を記述するとコンパイルエラーになります。

URLパス

POST()GET() の第一引数には、OpenAPI スキーマで定義されているパスかつ、該当する HTTP メソッドが定義されているエンドポイントのみを指定できます。
今回の例では、POST メソッドが定義されているのは /pet のみなので、client.POST() に渡せるのは "/pet" のみです。
存在しない組み合わせである client.POST("/pet/123")client.POST("/pet/{petId}") を指定すると、型エラーとしてコンパイル時に検出されます。

body

リクエストボディ。components["schemas"]["Pet"]型。
POST("/pet") で新しいペットを登録する際に使用され、namephotoUrls は必須項目です。
型定義により、これらの必須フィールドが欠けているとコンパイルエラーになります。

data

200 成功レスポンス時に入るレスポンスボディ。components["schemas"]["Pet"]型。
登録されたペットの情報(id, name など)がこの data に格納されます。

error

400 or 500 エラー時に入るレスポンスボディ。components["schemas"]["ErrorResponse"]型。
error.error にサーバから返されたエラーメッセージ文字列が格納されます。

params

リクエストパラメータ。GET("/pet/{petId}") において、パスパラメータとして { petId: number } を指定します。
定義されたパラメータ以外を指定したり、型が異なる場合はコンパイルエラーになります。
クエリパラメータなども定義すれば型付けされ、同様に型安全に扱えます。

成果物

作成したコードに対して、vite-plugin-checker を使った型チェックをホットリロード中に実行しておくと、スキーマに反するコードを記述した際に即座にエラーを検出してくれます。これにより、型の不一致を早期に発見でき、型安全性を維持したまま快適に開発を進められます。

これでAPI通信を型安全に記述することができるようになりました!

さいごに

ホワイトプラスでは、ビジョンバリューに共感していただけるエンジニアを募集しています!
ネットクリーニングの「リネット」など、「生活領域×テクノロジー」で事業を展開しています。
弊社に興味がある方は、オウンドメディア「ホワプラSTYLE」をご覧ください。オンラインでのカジュアル面談も可能ですので、ぜひお気軽にお問い合わせください。