OpenAPIのスキーマ定義を、プログラム的に書けるTypeSpecを使ってみた

皆さんは、WebアプリのAPI仕様を定義するときはどのような方法をとっているでしょうか。[Stoplight](https://stoplight.io/)のようなGUIツールを使うか、それともyamlをそのまま記述するか…。

私は今まで、後者のようにそのまま頑張ってyamlを書いていました。ただ、OpenAPIの文法を覚えなくちゃいけなくって大変だったり、冗長な作業になることもあったりして退屈だなぁーと思っていました。そんな中、この記事の主題である[TypeSpec](https://typespec.io/)というものを知って使ってみたので、その内容をこちらにまとめたいと思います。

## 概要
TypeSpecはMicrosoftが開発をしている、API仕様の定義などに特化した独自言語です。JavaScriptに似た使用感でAPI仕様を記述することができます。また、node.jsのエコシステムでコンパイルまで完結でき、セットアップの手順も少なく簡単でした。

https://typespec.io/

https://github.com/microsoft/typespec

## セットアップ
### コンパイラのダウンロード
まずはpnpmでコンパイラをダウンロードします。以後、node.jsのパッケージマネージャーはpnpmを、エディターはVSCodeを使用しますが、適宜読み替えて利用してください。

```shell
pnpm install -g @typespec/compiler
```

以下のコマンドでバージョンを確認して、`TypeSpec compiler v0.55.0`のように表示されればOKです。

```shell
tsp --version
```

それから、VSCode用の拡張機能もあるので、こちらもインストールしておきます。

https://typespec.io/docs/introduction/editor/vscode

### 新規プロジェクトの作成
新規プロジェクトの作成は以下のコマンドで行えます。その中で、プロジェクトのテンプレート、プロジェクト名、必要なライブラリを選択します。今回は、**`Generic REST API`のテンプレートを選択**し、**ライブラリはすべて選択**しておきます。

```shell
tsp init
```

そうするとディレクトリ内に各種ファイルが生成されるので、そのディレクトリで依存ライブラリをインストールします。

```shell
pnpm install
```

ここで一旦以下のコマンドを実行してコンパイルをすると、今の内容で空のAPI定義が生成されるはずです。

```shell
tsp compile .
```

<details>
<summary>生成されるyaml</summary>

```yaml:tsp-output/@typespec/openapi3/openapi.yaml
openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths: {}
components: {}
```

</details>

## API仕様を記述する
`tsp compile . --watch`コマンドを利用すると、ファイルの変更を検知してリアルタイムでコンパイルをしてくれて便利です！

この記事では、**一般的なToDoアプリのAPI**を考えてその仕様を記述してみます。

### ライブラリを使用する
まず自動生成された`main.tsp`を確認してみます。そこには以下のようなものが記述されています。これは、TypeScriptのように`import`キーワードでライブラリや別ファイルを読み込むんでいる記述です。

この`@typespec/http`では、今後主に使っていくことになる`@route`のようなデコレーターが定義されています。

```typescript:main.tsp
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
```

まずは、`using`キーワードを使って`namespace`を現在のスコープから利用できるようにしておきます。

```diff_typescript:main.tsp
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";

+ using TypeSpec.Http;
+ using TypeSpec.Rest;
```

### メタデータを追加する
次にメタデータを追加していきます。yaml内の`title`や`server`などです。

```diff_typescript:main.tsp
/* 省略 */

using TypeSpec.Http;
using TypeSpec.Rest;

+ @service({
+   title: "sample service",
+   version: "1.0"
+ })
+ @server("https://localhost", "テストサーバー")
+ namespace SampleService;
```

このように書くことで、生成されるyamlのtitleやserversを設定することができました。

:::note warn
上記の`version`は、VSCode上でDeprecatedの警告が出ていました。なんでも、`@typespec/versioning`という[ライブラリ](https://typespec.io/docs/libraries/versioning/reference)が用意されていて、これを用いることでより良くバージョン管理をすることができるみたいです。

ただ、使い方に少しクセがありそうだったので、こちらでは現状のまま、文字列直書きでバージョンを指定する実装で進めています。
:::

### モデルを追加する
今回はToDoアプリのAPIを作ってみます。ToDoタスクのモデルは以下のようなデータを含むものにします。

- タスクのID
- タスクの名前
- タスクの期限日時
- タスクの種類（仕事 or プライベート）
- タスクの追加日
- タスクの達成日（未達成の場合は含まない）

これに従うと、モデルは以下のように記述できます。

```typescript:main.tsp
/* 今までに書いてきたものを省略 */

model ToDoTask {
  task_id: string;
  task_name: string;
  limit_at: utcDateTime;
  kind: TaskKind;
  added_at: utcDateTime;
  finished_at?: utcDateTime;
}

union TaskKind {
  Work: "work",
  Private: "private",
}
```

TypeScriptと同じ感覚で書くことができます。プロパティの型は、stringやint32のようなものはもちろん、[utcDateTimeのようなデータ型も用意されています](https://typespec.io/docs/standard-library/built-in-data-types)。

タスクの種類(`kind`)は、仕事かプライベートかの2択ですので、これを[union型](https://typespec.io/docs/language-basics/unions)として`union TaskKind { ... }`で別途定義しています。このほかに、`kind: "work" | "private"`のようにも書くことができます。

タスクの達成日（`finished_at`）は未達成の場合含まれませんから、`?`をつけて必須ではなくしています。こうすると、生成されるyamlファイルで`required`ではなくなります。

ここまでで以下のようなyamlが生成されるようになりました。

<details>
<summary>生成されるyaml</summary>

```yaml
openapi: 3.0.0
info:
  title: sample service
  version: '1.0'
tags: []
paths: {}
components:
  schemas:
    TaskKind:
      type: string
      enum:
        - work
        - private
    ToDoTask:
      type: object
      required:
        - task_id
        - task_name
        - limit_at
        - kind
        - added_at
      properties:
        task_id:
          type: string
        task_name:
          type: string
        limit_at:
          type: string
          format: date-time
        kind:
          $ref: '#/components/schemas/TaskKind'
        added_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
servers:
  - url: https://localhost
    description: テストサーバー
    variables: {}
```
</details>

### APIの本体を追加する
タスクのモデルを定義できたので、APIの本体を追加していきます。今回は、以下のようなエンドポイントを作ることにしましょう。

- ToDoタスクの一覧を取得する
  - limitのクエリパラメータを設定して取得件数を制限する
- 特定のToDoタスクを取得する
- ToDoタスクを追加する
- ToDoタスクを削除する
- ToDoタスクの内容を変更する
- ToDoタスクを達成する

これらの定義はこんなように書くことができます。

```typescript:main.tsp
/* 今までに書いてきたものを省略 */

@route("/task")
@tag("Task")
namespace Task {
  interface Task {
    @get list(@query limit?: int32): ToDoTask[];
    @post create(new_task: ToDoTask): ToDoTask;

    // これは自動で`/task/{task_id}`のパスになる
    @put update(@path task_id: string, update_task: ToDoTask): ToDoTask;
  }

  @route("/{task_id}")
  interface EachTask {
    // これでも中身はすべて`/task/{task_id}`になる
    @get read(@path task_id: string): ToDoTask;
    @delete delete(@path task_id: string): {
      deleted_task_id: string;
    };

    // これのパスは`/task/{task_id}/complete`
    @route("/complete") @post complete(@path task_id: string): ToDoTask;
  }
}
```

順を追って説明します。

まず、**routeデコレーター**を使って`@route("/task")`のように**APIのパスを設定**します。このデコレーターがかかっている要素、ここでは`namespace Task`の中に書いたものは、このパスの下へ来ることになります。

次の行のtagデコレーター`@tag("Task")`では、OpenAPIの仕様の中のtagを設定しています。これもroute同様、Taskのnamespace全てにかかっています。

次の行の`namespace Task`では、namespaceを**pathのグループ**のように利用しています。これにより、毎回`/task/{task_id}`のようにURIのルートから書く必要がなくなります。このnamespaceは入れ子にすることができます。

具体的なAPIの定義は`interface`でグループにまとめています。このinterfaceは入れ子にはできません。


その後からはエンドポイントの定義です。`@get`や`@post`のようなデコレーターでHTTPメソッドを指定し、その後の関数定義のような部分`list(@query limit?: int32): ToDoTask[]`でリクエストとレスポンスを定義しています。

例えばこの例では、関数`list()`が1つのエンドポイントに該当し、リクエストパラメータ（＝関数の引数`@query limit?: int32`）と、レスポンスのボディ（＝戻り値`ToDoTask[]`）がそれぞれどんな型かを書いています。書き方自体はTypeScriptの関数の型定義に似ていますね。

リクエストパラメータの`@query`デコレーターは、パラメータが**クエリパラメータ**であることを表しています。後の例にも出てきますが、**クエリパラメータの場合は`@query`デコレーター**を、**パスパラメータの場合は`@path`デコレーター**を、**リクエストボディの場合は何も付けずに**引数を記入します。

例として、`@put update(@path task_id: string, update_task: ToDoTask): ToDoTask;`では、パスパラメータとしてtask_idを、リクエストボディとしてupdate_taskを受け取るようになっています。

また、このエンドポイントでは明示的に`/task/{task_id}`と言うパスを指定していませんが、内部的にパスパラメータを解釈してコンパイル後は自動的で`/task/{task_id}`として出力されます。

一方で、その後の`interface`に書いた`@route("/{task_id}")`のように、明示的にパスパラメータを設定することも可能です。この場合は、リクエストのパラメータに**パスパラメータを記載していないとエラー**になります。

ここまでの以下のようなyamlファイルが生成されるようになりました。

<details>
  <summary>生成されるyaml</summary>

```yaml
openapi: 3.0.0
info:
  title: sample service
  version: '1.0'
tags:
  - name: Task
paths:
  /task:
    get:
      tags:
        - Task
      operationId: Task_list
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            format: int32
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ToDoTask'
    post:
      tags:
        - Task
      operationId: Task_hoge
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ToDoTask'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                new_task:
                  $ref: '#/components/schemas/ToDoTask'
              required:
                - new_task
  /task/{task_id}:
    put:
      tags:
        - Task
      operationId: Task_update
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ToDoTask'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                update_task:
                  $ref: '#/components/schemas/ToDoTask'
              required:
                - update_task
    get:
      tags:
        - Task
      operationId: EachTask_read
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ToDoTask'
    delete:
      tags:
        - Task
      operationId: EachTask_delete
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted_task_id:
                    type: string
                required:
                  - deleted_task_id
  /task/{task_id}/complete:
    post:
      tags:
        - Task
      operationId: EachTask_complete
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ToDoTask'
components:
  schemas:
    TaskKind:
      type: string
      enum:
        - work
        - private
    ToDoTask:
      type: object
      required:
        - task_id
        - task_name
        - limit_at
        - kind
        - added_at
      properties:
        task_id:
          type: string
        task_name:
          type: string
        limit_at:
          type: string
          format: date-time
        kind:
          $ref: '#/components/schemas/TaskKind'
        added_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
servers:
  - url: https://localhost
    description: テストサーバー
    variables: {}
```

</details>

## もっとよく書く
ここからは、少し踏み入った便利な記述を試してみたいと思います。

### 変数の可視性を設定する
まずは[変数の可視性（`visibility`）](https://typespec.io/docs/standard-library/built-in-decorators#@visibility)の設定です。

例えば、上記のToDoアプリの例では`POST /task`で新しいタスクを追加します。ただ、タスクIDはサーバ側で採番するもので、そこは空にしてリクエストしたいということがあります。

こうした時に、@visibilityのデコレーターで表示させるAPIの種類を設定することで、必要な関数でのみそのプロパティを表示するということが可能になります。詳しい設定は[こちらのドキュメント](https://typespec.io/docs/libraries/http/operations#automatic-visibility)の表にまとまっています。

例えば以下のように記述すると、任意のレスポンスと`PATCH`および`PUT`のリクエストボディでのみこのIDが表示されるようになります。

```diff_typescript:main.tsp
/* 省略 */

model ToDoTask {
+  @visibility("read", "update")
  task_id: string;
+ 
  task_name: string;
  limit_at: utcDateTime;
  kind: TaskKind;
  added_at: utcDateTime;
  finished_at?: utcDateTime;
}

/* 省略 */
```

### descriptionなどを追加する
次に各エンドポイントの説明などを追加したいと思います。

例えば、以下のようにエンドポイントに追加すると、yamlの方ではエンドポイントのsummaryとdescriptionが追加されます。

```diff_typescript:main.tsp
/* 省略 */
    // これは自動で`/task/{task_id}`のパスになる
+    @doc("これは既存のタスクの内容を更新するエンドポイントです。")
+    @summary("タスク内容の更新")
    @put
    update(@path task_id: string, update_task: ToDoTask): ToDoTask;
/* 省略 */
```

```diff_yaml
## 省略 ##
  /task/{task_id}:
    put:
      tags:
        - Task
      operationId: Task_update
+     summary: タスク内容の更新
+     description: これは既存のタスクの内容を更新するエンドポイントです。
## 省略 ##
```

それから、変数にも説明を追加してみます。

```diff_typescript:main.tsp
/* 省略 */
    // これは自動で`/task/{task_id}`のパスになる
    @doc("これは既存のタスクの内容を更新するエンドポイントです。")
    @summary("タスク内容の更新")
    @put
    update(
-     @path task_id: string,
+     @doc("タスクのID") @path task_id: string,
      update_task: ToDoTask,
    ): ToDoTask;
/* 省略 */
```

```diff_yaml
      parameters:
        - name: task_id
          in: path
          required: true
+         description: タスクのID
          schema:
            type: string
```

より分かりやすくなりましたね。

最終的なコードは以下のGistに載せてあります。

https://gist.github.com/mochi-yu/ca82b9103b3255461fe42a1ba1695740

## 課題点
自分が使っていて感じた課題点のようなところ2点ほどを書いておきます。

1つはOpenAPIの仕様のうちまだ対応していないものがいくつかあるというところです。例えば、[タグのdescriptionが記載できなかったり](https://github.com/microsoft/typespec/issues/2220)、[レスポンス項目のexampleを設定できなかったり](https://github.com/microsoft/typespec/issues/2700)します。GitHub上でissueは立っているので、今後、より良くなっていくといいなーと思っています。

もう1つは日本語の文献が少ないなといったところです。英語でも文献が見つけやすいと言うことではなかったです。自分的にはとても使いやすかったので、もっと使われるようになれーという思いです。

## 使ってみた感想
初めにも書きましたが、今まで手書きでyamlを書いてAPIの仕様を定義していましたが、その文法を覚えるのが大変でいつも調べながら書いていたり、冗長な作業が多くてイけてないなーと思っていました。

その面で言うと、慣れ親しんだプログラミング言語的な文法だけで書くことができ、モデルを使い回したり、プロパティの可視性を設定できたり、痒い所に手が届く良いツールだといった感想です。それから、VSCodeの拡張で構文エラーを検知してくれたり、変更を検知して自動更新してくれたりのようなエコシステムも良いなーと思っています。

あとはもっと使われるようになってくれー！そんなことを思う限りです。

## 参考文献
https://zenn.dev/hidaka_rintaro/articles/321933bc72ff51

https://azukiazusa.dev/blog/typescript-like-syntax-for-openapi-schemas/