皆さんは、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
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
のようなデコレーターが定義されています。
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
まずは、using
キーワードを使ってnamespace
を現在のスコープから利用できるようにしておきます。
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
+ using TypeSpec.Http;
+ using TypeSpec.Rest;
メタデータを追加する
次にメタデータを追加していきます。yaml内のtitle
やserver
などです。
/* 省略 */
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 プライベート)
- タスクの追加日
- タスクの達成日(未達成の場合は含まない)
これに従うと、モデルは以下のように記述できます。
/* 今までに書いてきたものを省略 */
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タスクを達成する
これらの定義はこんなように書くことができます。
/* 今までに書いてきたものを省略 */
@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が表示されるようになります。
/* 省略 */
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が追加されます。
/* 省略 */
// これは自動で`/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: これは既存のタスクの内容を更新するエンドポイントです。
## 省略 ##
それから、変数にも説明を追加してみます。
/* 省略 */
// これは自動で`/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の拡張で構文エラーを検知してくれたり、変更を検知して自動更新してくれたりのようなエコシステムも良いなーと思っています。
あとはもっと使われるようになってくれー!そんなことを思う限りです。
参考文献