[TypeScript]OpenAPIv3の定義ファイルから、入力補完をバッチリ効かせて開発する

7 min read読了の目安(約6500字

TypeScriptの入力補完

TypeScriptはVSCodeのような開発環境を使うと、様々な物が入力補完で候補を絞り込めます。この機能があるかないかで開発効率に段違いな差が出てきます。そうなってくるとあらゆる物を補完の対象にしたくなるのは人情でしょう。今回はOpenAPIv3の定義ファイルを元に、RestAPIへのアクセスを補完しまくります。

OpenAPI(Swagger)の利点

OpenAPIでRestAPIの定義を作っておくと、以下のような利点を得ることが出来ます。

  • ドキュメントをチームで共有
  • モックサーバの立ち上げ
  • 各言語の型情報を生成

今回はTypeScriptを使用しますが、共通仕様のフォーマットなので言語を選びません。

TypeScriptでの活用方法

OpenAPIの定義情報からTypeScriptの型を生成するパッケージはいくつか用意されており、それらを活用することでRestAPI入出力のための型情報を手動で書くという状況をある程度回避できます。

今回はopenapi-typescriptというパッケージを利用して型情報を生成します。

今回使用している主なパッケージ

  • openapi-typescript
    OpenAPIの定義情報をTypeScriptで利用出来る形に変換するパッケージ
  • request-restapi
    openapi-typescriptで出力したTypeScriptの型情報を利用して、RestAPIにアクセスするパッケージ(自前で作成しました)

型情報の取得の仕方

サンプル用にGitHubのOpenAPI定義情報をTypeScriptの定義に変換します

npx openapi-typescript https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/ghes-3.0/ghes-3.0.yaml > github.d.ts

このコマンドでgithub.d.tsにTypeScript用の型情報が生成されます。このファイル、定義情報だけで1.2MBもあります。サンプルではこれを利用してTypeScriptでの入力補完を検証します。

プログラム

GitHubのRestAPIにアクセスするためのサンプルです。
https://github.com/SoraKumo001/request-restapi-test

認証用のTokenが必要になるので、事前にhttps://github.com/settings/tokensでトークンを作成し、.envファイルにTOKEN=GITHUBのアクセストークンを入れておく必要があります。作成するトークンには一切権限は付けなくてかまいません。

import { Rest } from "request-restapi";
import { paths } from "./github";
import env from "dotenv";

env.config();
const token = process.env.TOKEN;
const rest = new Rest<paths>({ baseUrl: "https://api.github.com", token });

//Display user name
(async () => {
  const resultUser = await rest.request({
    path: "/user",
    method: "get",
  });
  // codeを識別した時点でbodyが確定する
  if (resultUser.code === 200) {
    const { body } = resultUser;
    console.log(`UserName: ${body.name}`);
  } else {
    console.error(resultUser);
  }
  console.log("---------");

  // View repository list
  for (let page = 1; page < 100; page++) {
    const result = await rest.request({
      path: "/user/repos",
      method: "get",
      query: { page },
    });
    if (result.code === 200) {
      const { body } = result;
      if (body.length === 0) break;
      body.forEach((value) => {
        console.log(value.name);
      });
    } else {
      console.error(result);
      break;
    }
  }
  console.log("---------");

  // View specific repository information
  const resultRepo = await rest.request({
    path: "/repos/{owner}/{repo}",
    params: { owner: "SoraKumo001", repo: "request-restapi" },
    method: "get",
  });
  if (resultRepo.code === 200) {
    const { body, headers } = resultRepo;
    console.log(body);
    console.log(headers);
  } else {
    console.error(resultRepo);
  }
})();

全部補完される

プログラムだけ見ると、RestAPIにアクセスする機能を提供しているだけに見えます。一般的なパッケージと違うのは、request-restapiによって型情報がガッチガチに固められていることです。

  • パスの入力補完

  • パス決定後はメソッドの候補が絞られる

  • パスパラメータの情報も表示される

  • bodyに設定するパラメータも全部出る

  • 戻り値も当然型情報が提供される

  • codeを判別するとデータの種類が絞られる

今回はGitHubのAPI定義を使用していますが、OpenAPIv3仕様の定義ファイルがあれば、その他のAPIでも同じように利用出来ます。

この型定義を行うための地獄の景色

request-restapiは以下のように作られています。
プログラムのほとんどは型を算定するためのコードで占められています。

import fetch from "node-fetch";

interface Props {
  baseUrl: string;
  authKey?: string;
  token?: string;
}

export class Rest<T> {
  private readonly baseUrl: string;
  private readonly authKey: string;
  private readonly token?: string;
  constructor({ baseUrl, token, authKey = "Bearer" }: Props) {
    this.baseUrl = baseUrl;
    this.authKey = authKey;
    this.token = token;
  }
  public request<
    P extends T,
    PATH extends keyof P,
    METHOD extends keyof P[PATH],
    RET extends P[PATH][METHOD] extends { responses: infer res }
      ? {
          [P in keyof res]: {
            code: P;
            headers: Headers;
            body: res[P] extends { schema: infer R }
              ? R
              : res[P] extends { content: { "application/json": infer R2 } }
              ? R2
              : Blob;
          };
        } extends {
          [P in string]: infer R;
        }
        ? R
        : never
      : never
  >({
    method,
    path,
    params,
    headers,
    query,
    body,
    token,
  }: {
    method: METHOD;
    path: PATH;
    params?: P[PATH][METHOD] extends { parameters: { path: infer R } }
      ? R extends { [M in keyof R]: R[M] }
        ? R
        : never
      : P[PATH] extends { parameters: { path: infer R } }
      ? R extends { [M in keyof R]: R[M] }
        ? R
        : never
      : never;
    headers?: P[PATH][METHOD] extends { parameters: { header: infer R } }
      ? R extends { [_ in string]: unknown }
        ? R
        : never
      : never;
    query?: P[PATH][METHOD] extends { parameters: { query: infer R } }
      ? R extends { [_ in string]: unknown }
        ? R
        : never
      : never;
    body?: P[PATH][METHOD] extends {
      requestBody: { content: { [key: string]: infer R } };
    }
      ? R
      : never;
    token?: string;
  }): Promise<RET> {
    const regularParam = params
      ? Object.entries(params).reduce(
          (p, [key, value]) =>
            p.replace(new RegExp(`\\{${key}\\}`), String(value)),
          path as string
        )
      : path;
    const queryParam = query
      ? Object.entries(query)
          .reduce((a, [key, value]) => `${a}${key}=${value}&`, "?")
          .trimEnd()
      : "";
    return fetch(this.baseUrl + regularParam + queryParam, {
      method: (method as string).toUpperCase(),
      headers: {
        "Content-Type": "application/json",
        ...(typeof headers === "object" ? headers : {}),
        ...(token || this.token
          ? { Authorization: `${this.authKey} ${token || this.token}` }
          : {}),
      },
      body: body && JSON.stringify(body),
    }).then(
      async (res) =>
        ({
          code: res.status,
          headers: res.headers,
          body: await res.json().catch(async () => await res.blob()),
        } as RET)
    );
  }
}

なんかもうTypeScriptって頑張れば何でもありなんじゃないかと

TypeScriptはそんな言語です