LoginSignup
0
0

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

Posted at

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

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

概要

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

セットアップ

コンパイラのダウンロード

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

pnpm install -g @typespec/compiler

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

tsp --version

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

新規プロジェクトの作成

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

tsp init

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

pnpm install

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

tsp compile .
生成されるyaml
tsp-output/@typespec/openapi3/openapi.yaml
openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths: {}
components: {}

API仕様を記述する

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

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

ライブラリを使用する

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

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

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

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

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

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

メタデータを追加する

次にメタデータを追加していきます。yaml内のtitleserverなどです。

main.tsp
/* 省略 */

using TypeSpec.Http;
using TypeSpec.Rest;

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

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

上記のversionは、VSCode上でDeprecatedの警告が出ていました。なんでも、@typespec/versioningというライブラリが用意されていて、これを用いることでより良くバージョン管理をすることができるみたいです。

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

モデルを追加する

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

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

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

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のようなデータ型も用意されています

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

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

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

生成される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: {}

APIの本体を追加する

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

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

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

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ファイルが生成されるようになりました。

生成される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: {}

もっとよく書く

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

変数の可視性を設定する

まずは変数の可視性(visibilityの設定です。

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

こうした時に、@visibilityのデコレーターで表示させるAPIの種類を設定することで、必要な関数でのみそのプロパティを表示するということが可能になります。詳しい設定はこちらのドキュメントの表にまとまっています。

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

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が追加されます。

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

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

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

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

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

課題点

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

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

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

使ってみた感想

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

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

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

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0