TypeSpecを使ったOpenAPIドキュメント生成

GMOメイクショップコアグループの原田です。フロントエンドとバックエンド間のREST APIやり取りに型定義が無いという課題があったため、OpenAPIドキュメントを作成、それを元にした型定義をフロントエンドで利用できるようにしました。今回は、OpenAPIドキュメントを書くために利用したTypeSpecをご紹介します。

課題

次世代EC管理画面ではSPAを採用しており、バックエンドとのやり取りが頻繁に発生します。やり取り方法にGraphQLを使うエンドポイントは graphql-codegenを用いた型定義が存在しますが、REST APIへのリクエストには型定義が無く、anyになっている状況がありました。しかし、型定義が無いとなると、正確に何をやり取りしているかがわからず、改修やリファクタに不安が生まれて時間がかかってしまいます。今回はこの問題の解消に取り組みました。

何がレスポンスに入っているかわからない現状

レスポンスにこのように正確な型情報を付与したい

解決策

同一のモノレポ内の別プロジェクトでOpenAPIを用いたAPIドキュメントが利用している点から、まずOpenAPIを使うのが今後のメンテナンス性向上のためにベストな選択だと判断しました。また、OpenAPI Generator TypeScript Axiosで型付きリクエストの自動生成 | Offers Tech Blogという記事の事例を読み、OpenAPIドキュメントとそれを元に生成した型定義を使う方法を採用することとしました。しかしながら、対応するエンドポイント数が多く、OpenAPIドキュメントを手書きするのは時間がかかりそうです。そこで、今回はAPIドキュメントを簡潔に書けるというTypeSpecの検証を行いました。

TypeSpecとは?

TypeSpecは、API ドキュメントを記述するための言語です。TypeScriptに似た構文を持ち、OpenAPI v3形式に簡単にコンパイルできるのが特徴です。TypeSpecでは、ジェネリクスやimportを利用できるため、API仕様の記述を簡潔に保ちながら、大規模なドキュメント生成が可能です。

github.com

TypeSpecの記法

TypeSpecの記法は、TypeScriptでのフロントエンド経験がある開発者にとっては、とてもわかりやすい構文になっています。 以下に構文の例を紹介します。

モデル定義

リクエストやレスポンス等の構造を定義するためにはmodelキーワードを使用します。モデルはOpenAPIの出力時にコンポーネントスキーマとして出力されます。

https://typespec.io/docs/language-basics/models

model RegisterCatRequest {
  @doc("お名前")
  @example("マロン")
  name: string;
  @doc("猫種")
  @example("スコティッシュフォールド")
  type: string;
  @doc("年齢")
  @example(10)
  age: numeric;
}

エイリアス

型の別名を定義するのには、エイリアス構文を利用します。 エイリアスはドキュメント作成時の文法エラーを防いだり、バリデーションを出力するために利用できます。

https://typespec.io/docs/language-basics/aliases

alias CatType = "ミックス" | "スコティッシュフォールド" | "マンチカン";

model RegisterCatRequest {
  ...

  @doc("猫種")
  @example("スコティッシュフォールド")
  type: CatType;
  ...
}

スカラー

型に対する制約(例: 数値の範囲)を持つカスタム型を定義する際にはスカラーを利用します。 スカラーはモデルと同様にOpenAPIの出力時にコンポーネントスキーマとして出力されます。

https://typespec.io/docs/language-basics/scalars

@doc("年齢")
@minValue(1)
@maxValue(40)
scalar CatAge extends numeric;

model RegisterCatRequest {
  ...

  @doc("年齢")
  age: CatAge;
}

定数定義

定数一覧を定義できるenumもあります。定数はリクエストのバリデーションとして出力されます。 便利ですが、enumへのコメントはOpenAPIのドキュメント仕様では表現できずビルド時に失われてしまうため注意が必要です。

https://typespec.io/docs/language-basics/enums

enum CatTypeEnum {
    @doc("この定数コメントは出力時に失われます")
    MIX: "ミックス",
    SCOTTISH_FOLD: "スコティッシュフォールド",
    MUNCHKIN: "マンチカン",
}

model RegisterCatRequest {
  ...
  @doc("猫種")
  @example(CatTypeEnum.SCOTTISH_FOLD)
  type: CatTypeEnum;
  ...
}

ルーティングデコレーター

APIエンドポイントを定義するためには interfaceを書き、その中に@route や @post等のデコレーターを使用して記述します。 OpenAPI特有のドキュメント情報も同様にデコレーターを使い記述することができます。

import "@typespec/openapi";
import "@typespec/http";

using TypeSpec.Http;
using TypeSpec.OpenAPI;

@service({
  title: "猫API",
})
@doc("PoC of TypeSpec document")
@info({
  version: "1.0.0",
})
@server("http://localhost:8080", "Dev")
namespace CatsAPI;

@tag("cats")
interface Cats {
    @route("/cats/new")
    @post
    @summary("猫情報をAPIに登録する")
    @doc("猫情報をデータベースに登録し、登録結果を返します")
    addCatInfo(@body body: CatsEndpoint.RegisterCatRequest): CatsEndpoint.RegisterCatResponse | CatsEndpoint.RegisterCatErrorResponse
}

ジェネリクス

ジェネリクスの機能は、ほとんどTypeScriptと同様の書き方で利用できます。 TypeSpecでの数値はnumber型ではなくnumeric型と記載する点に注意が必要です。

import "@typespec/http";

using TypeSpec.Http;

namespace SharedModels;

model SuccessResponse<T extends string> {
    @statusCode _: 200;
    message: T;
}

model ErrorResponse<T extends numeric, U extends string> {
    @statusCode _: T;
    message: U;
}

alias InternalErrorResponse = ErrorResponse<500, "内部エラーが発生しました">;

TypeSpecのコンパイル方法

TypeSpecで記述したドキュメントは、Node.jsのTypeSpecコンパイラを使ってコンパイルします。 例として、任意のプロジェクトフォルダで下記ファイルを作成してコンパイルすると、distフォルダ内にopenapi.yamlが生成されます。

{
  "name": "cat-api",
  "version": "1.0.0",
  "description": "cat api document (typespec)",
  "scripts": {
    "build": "tsp compile ./src --output-dir ./dist"
  },
  "private": true,
  "engineStrict": true,
  "packageManager": "npm@10.5.0",
  "engines": {
    "npm": "10.5.0",
    "yarn": "forbidden, use npm",
    "node": ">=20"
  },
  "dependencies": {
    "@typespec/compiler": "^0.60.1",
    "@typespec/http": "^0.60.0",
    "@typespec/openapi": "^0.60.0",
    "@typespec/openapi3": "^0.60.0",
    "@typespec/rest": "^0.60.0"
  }
}
emit:
  - "@typespec/openapi3"

# フォルダ作成やバージョン番号付与を行わず直接distフォルダに出力
options:
  "@typespec/openapi3":
    emitter-output-dir: "{output-dir}"
    output-file: "openapi.yaml"
    file-type: yaml
    omit-unreachable-types: true

output-dir: "{project-root}/dist"
import "@typespec/openapi";
import "@typespec/http";

using TypeSpec.Http;
using TypeSpec.OpenAPI;

@service({
    title: "猫API",
})
@doc("PoC of TypeSpec document")
@info({
    version: "1.0.0",
})
@server("http://localhost:8080", "Dev")
namespace CatsAPI;

model SuccessResponse<T extends string> {
    @statusCode _: 200;
    message: T;
}

model ErrorResponse<T extends numeric, U extends string> {
    @statusCode _: T;
    message: U;
}

alias InternalErrorResponse = ErrorResponse<500, "内部エラーが発生しました">;

@doc("年齢")
@minValue(1)
@maxValue(40)
scalar CatAge extends numeric;

// OpenAPIドキュメントにバリデーションとして出力される定数
enum CatTypeEnum {
    @doc("この定数コメントは出力時に失われます")
    MIX: "ミックス",
    SCOTTISH_FOLD: "スコティッシュフォールド",
    MUNCHKIN: "マンチカン",
}

model RegisterCatRequest {
    @doc("お名前")
    @example("マロン")
    name: string;
    @doc("猫種")
    @example(CatTypeEnum.SCOTTISH_FOLD)
    type: CatTypeEnum;
    @doc("年齢")
    @example(10)
    age: CatAge;
}
alias RegisterCatResponse = SuccessResponse<"登録成功">;
alias RegisterCatErrorResponse = ErrorResponse<400, "登録エラーが発生しました"> | InternalErrorResponse;

@tag("cats")
interface Cats {
    @route("/cats/new")
    @post
    @summary("猫情報をAPIに登録する")
    @doc("猫情報をデータベースに登録し、登録結果を返します")
    addCatInfo(@body body: RegisterCatRequest): RegisterCatResponse | RegisterCatErrorResponse
}
npm install
npm run build

TypeSpecのコンパイル結果

上記の例では、下記のようなOpenAPIスキーマが生成されます。

openapi: 3.0.0
info:
  title: 猫API
  version: 1.0.0
  description: PoC of TypeSpec document
tags:
  - name: cats
paths:
  /cats/new:
    post:
      operationId: Cats_addCatInfo
      summary: 猫情報をAPIに登録する
      description: 猫情報をデータベースに登録し、登録結果を返します
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    enum:
                      - 登録成功
        '400':
          description: The server could not understand the request due to invalid syntax.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    enum:
                      - 登録エラーが発生しました
        '500':
          description: Server error
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    enum:
                      - 内部エラーが発生しました
      tags:
        - cats
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CatsEndpoint.RegisterCatRequest'
components:
  schemas:
    CatsEndpoint.CatAge:
      type: number
      minimum: 1
      maximum: 40
      description: 年齢
    CatsEndpoint.RegisterCatRequest:
      type: object
      required:
        - name
        - type
        - age
      properties:
        name:
          type: string
          example: マロン
          description: お名前
        type:
          type: string
          enum:
            - ミックス
            - スコティッシュフォールド
            - マンチカン
          example: ミックス
          description: 猫種
        age:
          allOf:
            - $ref: '#/components/schemas/CatsEndpoint.CatAge'
          example: 10
          description: 年齢
servers:
  - url: http://localhost:8080
    description: Dev
    variables: {}

出力したものを、Swaggerや Redoclyを使い読み込み/書き出しするとAPIドキュメントを確認できます🎉

利用してみた感想

文法を覚えるのにやや慣れが必要ですが、JSONを直接書くよりは、確かに簡単に書くことができました。 リポジトリのREADMEで紹介されていたVSCode拡張機能も正しく動作し、書き心地がとてもよかったです。TypeScriptを使い慣れた方にはぜひおすすめしたい言語だと感じました。 一方で、TypeSpecではOpenAPIで表現できない情報まで記載できてしまい、OpenAPIへの出力時に何が保持され、何が失われるかわかりづらい点は少し懸念でした。

まとめ

この記事では TypeSpecを使いドキュメントを作成し、OpenAPIドキュメントをコンパイルする方法を紹介しました。 次回は、生成したOpenAPIドキュメントを活用して、フロントエンド側で型安全なAPI通信を実現した方法を紹介します。

参考資料