技術と趣味となにか

ゆるくやる

TypeScript Compiler APIを使って型定義からOpenAPIのスキーマを生成する

TypeScript Compiler APIでは、TypeScriptコードを入力として木構造のデータ(AST)を生成し様々な形に変換をすることができます。 わかりやすいかつ一番有名なものはTypeScript Compiler APIの名前の通り、TypeScriptをJavaScriptコンパイルする変換です。

github.com

以前、適当なサーバーを立ててOpenAPIを生成したときにPythonのFastAPIを使ったのですが、TypeScriptで近いことはできないだろうかと考えていました。

今回はTypeScript Compiler APIを使って型定義からOpenAPIのスキーマを生成を試しました。

github.com

脱線 TypeScript Compiler APIを使うときの便利ツール

少しずれますがTypeScriptのASTを扱う際に便利なツールにTypeScript AST Viewerがあります。

ts-ast-viewer.com

左上の入力枠にTypeScriptコードを入力するといくつか情報が表示されます。

  • 中央 AST上でどういうデータ(シンタックス)として認識したか表示している
  • 右 ASTでノードが持つデータを表示している
  • 左下 入力したコードのASTをTypeScriptコードで生成するために必要な処理を表示している

TypeScriptコードから(TypeScriptコードも含む)別の何かに変換する際は、中央と右側の情報を見ながらやるとどういうデータを扱っているか分かりやすくていいです。
TypeScriptコードを生成する場合は左下を見ながらやるとノードの生成がとても楽そう。

TypeScript AST Viewer
TypeScript AST Viewer

プロジェクトの準備

npm init -y
npm install typescript
npm install -D @types/node

書く

まずコードを入力としてOpenAPIのスキーマを返すガワを書きます。

import * as typescript from 'typescript';

// スキーマ情報を保持するオブジェクト
const openapiComponents = {
  components: {
    schemes: [],
  },
};

export function type2openapiSchemes(src: string): string {
  // コードからASTを生成する
  const source = typescript.createSourceFile('', src, typescript.ScriptTarget.ES2020);

  // ASTを使って処理をする

  // スキーマのオブジェクトを文字列化して返す
  return JSON.stringify(openapiComponents);
}

source.statements でコードのトップレベルで定義されているノードの配列が得られるのでそこを起点として処理をしていきます。TypeScriptコード中から型定義だけの部分だけを処理するように、型定義をしているNodeで絞り込みます。

const f = (node: typescript.Node) => {
  if (typeof node !== 'object') return;
  if (node.kind === typescript.SyntaxKind.TypeAliasDeclaration) {
    const typeAliasDeclarationNode = <typescript.TypeAliasDeclaration>node;

    // Nodeの情報からスキーマオブジェクトにする
    const scheme = {};

    // Nodeの情報をJSONに詰めていく
    openapiComponents.components.schemes = {
      ...openapiComponents.components.schemes,
      [`${scheme.title}`]: scheme
    };
  }
}

export function type2openapiSchemes(src: string): string {
  ...

  // ASTを使って処理をする
  source.statements.forEach(f);

  ...
}

型定義をしているNodeの情報からスキーマオブジェクトを作っていく処理を作っていきます。OpenAPIのデータにする部分は型ごとに実装が必要ですがここは気合です。

const createSchemeFromTypeDeclaration = (typeName: string, members: typescript.NodeArray<typescript.TypeElement>) => {
  const properties = members.map((member) => {
    return {
      name: (<typescript.Identifier>member.name).escapedText,
      required: member.questionToken == null,
      type: (() => {
        // 型部分のNodeの種類毎にOpenAPIの仕様に合わせて変換する
        const type = (<typescript.PropertySignature>member).type;
        if (type.kind === typescript.SyntaxKind.StringKeyword) {
          return 'string';
        }
        if (type.kind === typescript.SyntaxKind.UnionType) {
          /* @ts-ignore next-line */
          const types = (type as typescript.UnionType).types;
          return types.map((type: typescript.LiteralType) => {
            const literal = (type as any).literal;
            if (literal.numericLiteralFlags != null && !Number.isNaN(literal.text)) {
              return Number(literal.text);
            }
            return literal.text;
          });
        }
        return null;
      })()
    };
  });

  return {
    title: typeName,
    required: properties.map((property) => property.required && property.name).filter((name) => name),
    type: 'object',
    properties: Object.assign({}, ...properties.map((property) => {
      return {
        [`${property.name}`]: {
          title: typeName,
          type: property.type,
        },
      };
    }))
  };
};

const createScheme = (node: typescript.TypeAliasDeclaration) => {
  return createSchemeFromTypeDeclaration(
    node.name.text,
    (<typescript.TypeLiteralNode>node.type).members
  );
}

うごかす

入力ファイル

export type PingResponse = {
  body: string;
  status: 'ok' | 'ng' | 1;
  nullableParameter?: string;
}

export type CommentResponse = {
  text: string;
}

出力をフォーマットしたもの

{
  "components": {
    "schemes": {
      "PingResponse": {
        "title": "PingResponse",
        "required": [
          "body",
          "status"
        ],
        "type": "object",
        "properties": {
          "body": {
            "title": "PingResponse",
            "type": "string"
          },
          "status": {
            "title": "PingResponse",
            "type": [
              "ok",
              "ng",
              1
            ]
          },
          "nullableParameter": {
            "title": "PingResponse",
            "type": "string"
          }
        }
      },
      "CommentResponse": {
        "title": "CommentResponse",
        "required": [
          "text"
        ],
        "type": "object",
        "properties": {
          "text": {
            "title": "CommentResponse",
            "type": "string"
          }
        }
      }
    }
  }
}

おわり

対応していない型がほとんどですし定義した型同士の参照はまだできませんが、コマンド化とGitHub packagesで公開をしてみました。

これで生成したものを使ってOpenAPIの定義全体を生成するWebフレームワークを作れたら面白いなと思いました 。

github.com

参考

Using the Compiler API · microsoft/TypeScript Wiki · GitHub
TypeScript Compiler API で型を自動生成する仕組みを TDD で実装する
TypeScriptのcompiler APIをいじる - asterisc