OpenAPI Spec を出力できる DSL、TypeSpec の実践例

この記事はドワンゴ Advent Calendar 2024の 1 日目の記事です。

はじめに

こんにちは。ZEN IDの開発をしている、エンジニアのユーンです。

株式会社ドワンゴは先日2024年11月16日に開催された日本最大級のTypeScriptをテーマとした技術カンファレンス TSKaigi Kansai 2024 にプラチナスポンサーとして協賛いたしました。

ドワンゴのスポンサーブース
ドワンゴのスポンサーブース

私は個人でセッション「型付き API リクエストを実現するいくつかの手法とその選択」を発表し、OpenAPI を中心とした手法を一例として紹介しました。

speakerdeck.com

このセッションで OpenAPI を記述する手段として紹介した TypeSpec について、 ZEN ID での実践例を交えて深く取り上げます。

OpenAPI の手書きに疲れた方、また TypeSpec を使い始めたはいいもののどうスケールすれば良いか困っている方にお読みいただけると嬉しいです。

なお、弊社ホリちゃんによるスポンサートークについては別途当ブログでフォローアップ記事が掲載されますので、ご期待ください!

kansai.tskaigi.org

TypeSpec とは

TypeSpec は、Microsoft が中心となって開発している TypeScript や C# に似た文法を持つ OSS の DSL です。

typespec.io

上記では OpenAPI を出力とするケースを紹介していますが、TypeSpec 自体は OpenAPI 以外にも validation 用の JSON Schema や Protobuf などの出力も可能です。

本記事では OpenAPI に焦点を当てて紹介します。

なお、ここでは TypeSpec v0.62.0 の情報に基づいて記載しています。

参考に、本記事で取り上げるソースコードを含むリポジトリを用意しています。

github.com

TypeSpec で書く OpenAPI の概観

TypeSpec で OpenAPI を書く際の基本的な構文は以下のようになります。

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

using TypeSpec.Http;
using TypeSpec.OpenAPI;

model PetTag {
  id: uint64;
  name: string;
}

model Pet {
  id: string;
  name: string;
  tags: PetTag[];
}

@service({
  title: "PetService API",
})
namespace PetService {
  @route("/pets/{id}")
  interface Pets {
    @operationId("get-pet")
    @summary("Get a pet by ID")
    @get
    get(@path id: string): {
      @statusCode
      statusCode: 200;

      @body pet: Pet;
    };
  }
}

TypeSpec では主に modelnamespaceinterface の概念を使って OpenAPI を記述します。

model はそのままモデルの型定義をするのに使用し、 interface はメソッド(decorator を付与することでエンドポイント等として定義する)を持つことができる構造体となります。

namespace はその名の通り名前空間を定義するために使用します。interface の中に interface を持つことはできないため、包含関係を示すのに使用します。

API 全体の namespace に @service decorator でサービスを定義することで、 OpenAPI のエンドポイントとして認識されます。

同一の namespace に生やした interface に @route decorator でパスを定義し、get メソッドに @get decorator でメソッドを定義することで、該当パスへの GET リクエストのエンドポイントとして定義します。

@statusCode@body decorator によりレスポンスが定義されます。

その他、 @operationId@summary などの decorator で OpenAPI のメタ情報を定義できます。

上記に対して OpenAPI3 の出力は以下の通りとなります。

openapi: 3.0.0
info:
  title: PetService API
  version: 0.0.0
tags: []
paths:
  /pets/{id}:
    get:
      operationId: get-pet
      summary: Get a pet by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
components:
  schemas:
    Pet:
      type: object
      required:
        - id
        - name
        - tags
      properties:
        id:
          type: string
        name:
          type: string
        tags:
          type: array
          items:
            $ref: '#/components/schemas/PetTag'
    PetTag:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: integer
          format: uint64
        name:
          type: string

他にも @versioned@server など、より詳細に記述するための decorator が存在します。

他にも、REST として厳密にリソースとして定義するために Model に定義できる decorator も数多く存在します。

ファイルの分割

さて、上記のようなノリで OpenAPI を書いていくと、あっという間にファイルが肥大化してしまいますが、もちろん TypeSpec はファイルの分割が可能です。それも綺麗に。

例えば、以下のようなディレクトリ構造を目指します。

spec/
├── main.tsp
├── models/
│   ├── pet.tsp
│   ├── pet-tag.tsp
│   └── main.tsp
└── routes/
    ├── pets.tsp
    └── main.tsp

models/pet.tspmodels/pet-tag.tsp にそれぞれ Model の定義を記述し、 models/main.tsp でそれらを import します。

models/main.tsp

import "./pet.tsp";
import "./pet-tag.tsp";

同様に、 routes/pet.tsp に API エンドポイントの定義をしていきますが、この定義が PetService namespace 配下に属するように記述します。

routes/pet.tsp

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

using TypeSpec.Http;
using TypeSpec.OpenAPI;

namespace PetService;

@route("/pets/{id}")
interface Pets {
  @operationId("get-pet")
  @summary("Get a pet by ID")
  @get
  get(@path id: string): {
    @statusCode
    statusCode: 200;

    @body pet: Pet;
  };
}

{} で囲わない namespace 宣言をファイル冒頭(import の次)で行うと、ファイル全体が namespace に属することになります。

つまり、以下と同様になります。

namespace PetService {
  @route("/pets/{id}")
  interface Pets {
    // ...
  }
}

なお以下のように書くことはできません。

@route("/pets/{id}")
interface PetService.Pets {
  // ...
}

これは PetService は namespace であり、 interface の宣言に含むことはできないからです。

namespace であれば . で繋いで宣言することができます。

上記で import "../models"models ディレクトリの main.tsp を読み込むことを意味します。このファイルを import することで、このファイルが import しているファイル全てが import できることになります。

ただし、 import した途端に名前空間に展開されるため、衝突には十分注意してください。次のセクションでその対処法を紹介します。

最後に、エントリーポイントとなる main.tsp を記述します。

main.tsp

import "./routes";

@service({
  title: "PetService API",
})
namespace PetService;

namespace でモジュール境界を制御する

先のセクションでファイルを分割し import できましたが、これにより名前空間が汚染され、衝突する可能性があります。

その簡単な対処法は、 namespace を区切り、モジュール境界を明確にすることです。

例えば modelsModels namespace に属するようにし、 routesRoutes namespace に属するようにします。

また次セクションで取り上げるように、他サービスから参照されることも考慮し、 Models Routes もサービスごとの namespace に閉じるようにします。

例えば、以下のように namespace を定義します(一例です)。

models/pet.tsp

import "./pet-tag.tsp";

namespace PetService.Models;

model Pet {
  id: string;
  name: string;
  tags: PetTag[];
}

routes/pet.tsp

namespace PetService.Routes;

@route("/pets/{id}")
interface Pets {
  @operationId("get-pet")
  @summary("Get a pet by ID")
  @get
  get(@path id: string): {
    @statusCode
    statusCode: 200;

    @body pet: Models.Pet;
  };
}

pet.tsp では model を参照するのに、 Models.Pet としています。これは自身と同じ namespace である PetServiceModels を指しています。

上記では省略していた Version の定義も、サービスの namespace に閉じるのが良いので以下のように記述します。

main.tsp

import "@typespec/versioning";
import "./routes";

using TypeSpec.Versioning;

@service({
  title: "PetService API",
})
@versioned(Versions)
namespace PetService;
enum Versions {
  v1,
  v2,
}

別サービスの定義の利用

さて、TypeSpec では node modules 以下にある TypeSpec モジュールの定義を読み込むことで、パッケージを跨いだ定義の再利用が可能です。

上述のように conditonal exports field に typespec として tsp ファイルを指定するか、単に tspMain field に tsp ファイルを指定することで、 TypeSpec モジュールとして読み込めるようになります。

ここからは別の例として、 User 情報を扱う UserService のモデルを BFF で利用する例を紹介します。

まず、monorepo を前提とし、全体のディレクトリ構造は以下のようになります。

/
├── bff/
│   └── spec/
│       ├── main.tsp
│       ├── models/
│       │   ├── users.tsp
│       │   └── main.tsp
│       └── routes/
│           ├── user.tsp
│           └── main.tsp
└── user-service/
    └── spec/
        ├── main.tsp
        ├── models/
        │   ├── users.tsp
        │   └── main.tsp
        └── routes/
            ├── user.tsp
            └── main.tsp

UserService の User モデルは以下のように定義されています。

user-service/spec/models/user.tsp

namespace UserService.Models;

model User {
  id: string;
  name: string;
  email: string;
  ip_address: string;
  created_at: string;
  updated_at: string;
}

BFF から UserService の定義を参照する際、 Models のみを export できるよう、 user-service パッケージの package.json の exports field を以下のように記述します。

{
  "exports": {
    ".": {
      "typespec": "./spec/main.tsp"
    },
    "./models": {
      "typespec": "./spec/models/main.tsp"
    }
  }
}

また、 BFF 側には package.json の devDependencies に以下のように記述します。(pnpm での例です。他のパッケージマネージャの機能で monorepo を構築している場合は読み替えてください。)

{
  "devDependencies": {
    "user-service": "workspace:*"
  }
}

BFF で扱う User モデルでは、ip_address フィールドなど、持たないように定義したいプロパティがありますが、他は UserService のものを流用するため、 PickProperties を使用して以下のように定義します。

bff/spec/models/user.tsp

import "user-service/models";

namespace BFF.Models;

model User is PickProperties<UserService.Models.User, "id" | "name" | "email">;

先のセクションでの内容と同様に namespace を切っているため、 UserModels も衝突せず、それぞれ UserService.Models.UserBFF.Models.User として参照できます。

このように、他サービスの定義を再利用することで、各サービス間の定義を再利用しつつ、不要な情報は Omit するということができます。

UserService の Redoc
UserService の Redoc
BFF の Redoc
BFF の Redoc

なお余談ですが、 monorepo の各パッケージごとに単独で pnpm install をできビルドできるようにする際(shared-workspace-lockfile=false の場合)は、気をつけなければいけません。 各パッケージそれぞれに TypeSpec のモジュール一式をインストールした上で、互換性確保のためにバージョンを揃えてインストールする必要があります。

Real World での実践に向けて

最後に、上記以外に Real World で活用するにあたって、我々が実践していることを紹介します。

Routes namespace 分割を工夫し、見通しを良くする

Routes を見通しのよいように定義を分割する上で、以下のようにファイルを分割しやすいよう、 namespace を切っています。

spec/routes/users/main.tsp

namespace UserService.Routes;

@route("/users")
namespace Users {
  interface Root {
    @operationId("users-post")
    @post
    post(
      // ...
    ): {
      @statusCode
      statusCode: 201;
    };
  }

  @route("/{id}")
  namespace Id {
    interface Root {
      @operationId("users-get")
      @get
      get(@path id: string): {
        @statusCode
        statusCode: 200;

        @body user: Models.User;
      };
    }

    @route("/relations")
    namespace Relations {
      interface Root {
        @operationId("users-relations-post")
        @post
        post(
          // ...
        ): {
          @statusCode
          statusCode: 201;
        };
      }

      @route("/{relationId}")
      namespace Id {
        interface Root {
          @operationId("users-relations-get")
          @get
          get(@path id: string, @path relationId: string): {
            @statusCode
            statusCode: 200;

            @body user: Models.Relation;
          };
        }
      }
    }
  }
}

REST に倣って Resource 単位になるようにパスを切り、対応して interface ではなく namespace を切っています。これは、 namespace をパスに対応させてネストさせるためです。

namespace はメソッドを持てないため、該当パスのルート(/)に対応する interface として Root を interface として定義し、 / パスに対応するエンドポイントを定義しています。

パスパラメータの {id} に対応する Id namespace を定義し @route decorator でパスを定義、Root intarface と、その下のパスとして Relations namespace を定義しています。

(Id.Root 以下にパスが増えない確信があるのであれば Id を interface として定義しても良いかもしれませんが、ここでは一貫性のため Id namespace と Root interface を使っています。)

users/{id} 以下のパスが増えてきた場合は、ファイルを分割し、冒頭の namespace 定義を UserService.Routed.Users.Id とすることで同様の振る舞いにできます。

認証についての記述をする

OpenAPI には認証に関する記述がありますが、これは TypeSpec でも記述できます。

Bearer Token を使った認証である、ということだけを表したい場合は、以下のように記述します。

@service({
  title: "BFF API"
})
@useAuth(BeararAuth)
namespace BFF;

OAuth などの認可についても表現したい場合は、以下のようにヘルパーの型を定義して使うのがよさそうだと公式ドキュメントにも記載があります。

alias MyOAuth<S extends string[]> = OAuth2Auth<
  [
    {
      type: OAuth2FlowType.clientCredentials;
      tokenUrl: "https://example.com/oauth2/token";
      scope: S;
    }
  ],
  S
>;

型パラメータの S extends string[] を自ら定義した Scope 文字列の Union に置き換えることで、より厳密な記述が可能になります。

alias Scope = "read:users" | "write:users"

alias MyOAuth<S extends Scope[]> = OAuth2Auth<
  [
    {
      type: OAuth2FlowType.clientCredentials;
      tokenUrl: "https://example.com/oauth2/token";
      scope: S;
    }
  ],
  S
>;

API 定義側には以下のように書けます。

@route("/users")
@useAuth(MyOAuth<["read:users"]>)
namespace Users {
  // ...
}

tspconfig.yaml の設定

公式の Getting Started に沿って生成した tspconfig は emitter として @typespec/openapi3 を指定しているのみですが、他にも様々な設定が可能です。

中でも emitter である @typespec/openapi3 の設定を記載し、出力先やファイル名の変更は、以下のように書けます。

tspconfig.yaml

emit:
  - "@typespec/openapi3"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{cwd}/openapi"
    output-file: "user-service-api.yaml"

上記の記述で、デフォルトの出力先である tsp-output/@typespec/openapi3/openapi.yaml から変わって openapi/user-service-api.yaml に OpenAPI の出力が保存されるようになります。

Redoc での表示

OpenAPI の yaml を綺麗な Web で表示するツールはいくつかありますが、中でも Redoc は手軽です。

redocly.yaml

extends:
  - recommended

apis:
  user-service-api:
    root: ./openapi/user-service-api.yaml

おわりに

ドワンゴ教育事業では、継続的な開発の効率化を模索する仲間を募集しています。

カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com