YAMLで病むる人への処方箋【TypeSpec】
この記事は TypeSpec Advent Calendar 2024 18日目の記事として公開しています。
はじめに
皆さんはTypeSpecを使って、OpenAPIのSpecファイルを管理していますか?
私の常駐先では、API開発はOpenAPIのSpecファイル(yaml)を作成し、これに基づきOrvalで生成された型情報を用いてフロントエンドとバックエンドを同時に開発しています。
今回OpenAPIのSpecファイルを見直すにあたり、最近ちょくちょく話題になっているTypeSpecを検討[1]してみたのでまとめておきます。
見直すキッカケ
Specファイルである1つのyamlファイルに全ての記述があり日々肥大化が進んでいるというものがありました。そして肥大化が進む上で、以下のような問題が起きやすい状況にありました。
- ファイルサイズの増加(約3400行)
- コンフリクトがしばしば。。
- そもそもあまり直感的ではない & 行数も多く目的の情報を見つけづらい
上記を解決するために以下の2つの観点から、yamlファイル・TypeSpecのどちらで解決・運用していくべきなのかを検討しました。
- スキーマの継承や結合を用いた、スキーマの重複の整理
- ファイルの分割
TypeSpecとは
- TypeScriptライクな書き心地で Open API Spec がかけるDSL(安心安全のマイクロソフト謹製)。
- プログラミング言語のような書き心地でコード分割・再利用ができる。
playgroundも公式で提供されておりますので、ブラウザ上からも試すことができます。
早速比較してみる
上記で記載した通り、以下2つの観点で比較してみます。
- スキーマの継承や結合を用いた、スキーマの重複の整理
- ファイルの分割
スキーマの継承や結合を用いた、スキーマの重複の整理
Spec Type | やること | pros/cons |
---|---|---|
yaml |
allOf と $ref を用いる |
✅ Pros: ・特になし(難しくも簡単でもない) ❌ Cons: ・複雑なスキーマになってくると、可読性はあまり良くない。 |
⭐️TypeSpec |
extends またはジェネリクスを用いる |
✅ Pros: ・直感的で分かりやすい ❌ Cons: ・特に無し |
こちらはTypeSpecの方が優勢かと思いました。
例として、あるスキーマを元に新しいスキーマを定義するケースを見てみます。
openapi.yaml
components:
schemas:
Animal:
type: object
required:
- name
- weight
- height
properties:
name:
type: string
weight:
type: integer
height:
type: integer
Cat:
type: object
required:
- category
properties:
category:
allOf:
- $ref: '#/components/schemas/CatTypeEnum'
example: ミックス
description: 猫の種類
allOf:
- $ref: '#/components/schemas/Animal'
CatTypeEnum:
type: string
enum:
- ミックス
- スコティッシュフォールド
- マンチカン
上記と同様の表現をTypeSpecで行うとなると以下のようになります。
enum CatTypeEnum {
MIX: "ミックス",
SCOTTISH_FOLD: "スコティッシュフォールド",
MUNCHIKIN: "マンチカン",
}
model Animal {
name: string;
weight: integer;
height: integer;
}
model Cat extends Animal {
@doc("猫の種類")
@example(CatTypeEnum.MIX)
category: CatTypeEnum;
}
TypeScriptなどのプログラミング言語を普段から触れているエンジニアからすると、TypeSpecで書かれたコードの方が断然可読性が高いと感じました。
(例はかなりシンプルなものなので、これぐらいならyamlでも全然読めそうな気はしますが、実際はもっと複雑になるのでパッと見ではなかなか理解しづらいです、、、yamlツライ)
ファイルの分割
Spec Type | やること | pros/cons |
---|---|---|
yaml |
$ref を用いる |
✅ Pros: ・拡張機能を入れればコードジャンプできる ❌ Cons: ・ $ref が使えないObjectがある |
⭐️TypeSpec | Imports機能を使う | ✅ Pros: ・拡張機能を入れればコードジャンプできる + 自動保管が出る。 ・プログラミング言語的な方法で馴染みがある(main.tsnまとめてimportを書くみたいなこともできる) ❌ Cons: ・特になし |
TypeSpecでは imports機能 を使って実現することができます。
TypeSpecファイルをimportする方法としては、./
か ../
で始まる 相対パス または 絶対パスに対応しており、ディレクトリを呼び出す事も可能です。
$ref
を用いたファイル分割と比較した際に、利用可能な場所の制限がないこと、かつプログラミング言語チックに実現できることから個人的にはTypeSpecの方が好みだな〜と感じました。
routes/main.tsp
import "./user.tsp";
import "./post.tsp";
import "@typespec/http";
import "@typespec/openapi";
import "./routes"; // equivalent to `import "./routes/main.tsp";
import "./models"; // equivalent to `import "./models/main.tsp";
using TypeSpec.Http;
using TypeSpec.OpenAPI;
@service({
title: "Sample API",
description: "サンプルAPIの説明文です。",
})
namespace RootService;
比較結果、TypeSpecの勝ち...なのか...?
比較結果、どちらの観点においてもTypeSpecが優勢だと感じましたが、検証を進める上で良い点もあれば、微妙な点もそれぞれありました。
使ってみてよかった部分
- 静的型付けが行われるのは有難いと思った。(コンパイルで怒ってくれる)
- VSCodeの拡張機能を入れれば、自動保管が効くし、フォーマッター・リンターがデフォルトで存在する(ありがたい)。シンプルに開発体験が良い。
-
@visibility
を用いたCRUD別の表示・非表示の切り替え、@typespec/versioning
を用いてバージョニングに合わせて表示・非表示の切り替えをできるのは良いと思った。
個人的には @visibility
を使うことでCRUD別のプロパティの切り替えを簡単に実現できるのは良いなと感じたので、こちらを取り上げてみます。
@visibility
を使うことでCRUD別のプロパティの切り替えを簡単に実現できる
以下のようなケースを考えてみます。
型 | 名前 | create | update | response | 備考 |
---|---|---|---|---|---|
integer | id | × | × | 〇 | ユーザーID |
string | name | 〇 | 〇 | 〇 | ユーザー名 |
string | mail_address | 〇 | 〇 | 〇 | メールアドレス |
string | password | 〇 | 〇 | × | パスワード |
boolean | sns_relation_flag | 〇 | × | × | SNS連携フラグ |
id プロパティは自動採番するので、読み取り専用にしたい。password プロパティ は create リクエスト のみ含まれ、update時または レスポンスには含めたくないようなケースです。
yamlファイルであれば、Create時またはUpdate時のみレスポンスに含めたいフィールドがあり、レスポンスボディには含めないという表現を実現するには、writeOnly属性では対応できないため、そのプロパティのモデルを定義して、allOfで結合して全体で1つのモデルを定義するような方法が考えられるかと思います。
これが @visibility
を使うことでシンプルかつ可読性が高い状態で実現することができます。
model User {
@visibility(Lifecycle.Read) id: string;
name: string;
mail_address: string;
@visibility(Lifecycle.Create, Lifecycle.Update) password: string;
@visibility(Lifecycle.Create)
@removeVisibility(Lifecycle.Read)
sns_relation_flag:boolean;
}
@route("/users")
interface Users {
@post create(@path id: string, @body user: User): User;
@get get(@path id: string): User;
}
上記のtspファイルで生成されるyamlファイル
openapi: 3.0.0
info:
title: Widget Service
version: 0.0.0
tags: []
paths:
/users/{id}:
post:
operationId: Users_create
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: The request has succeeded.
content:
application/json:
schema:
$ref: '#/components/schemas/User'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserCreate'
get:
operationId: Users_get
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: The request has succeeded.
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required:
- id
- name
- mail_address
properties:
id:
type: string
readOnly: true
name:
type: string
mail_address:
type: string
UserCreate:
type: object
required:
- name
- mail_address
- password
- sns_relation_flag
properties:
name:
type: string
mail_address:
type: string
password:
type: string
sns_relation_flag:
type: boolean
使ってみて微妙な部分
基本的には全体を通してボジティブな印象なのですが、実際の運用に持っていけるかどうかという観点で検証した際に、一部使いづらさを感じた部分もありました。
- description周りが若干面倒
- Namespaceを元にスキーマが定義される
description周りが若干面倒
OpenAPI ドキュメントにおけるinfo
、servers
、paths
、components
オブジェクトなど対して、簡単な概要や振る舞いの詳細をdescription
として設定しているケースは多いと思います。
基本的には、TypeSpec組み込みのデコレーターである@doc
(公式)を使えば解消するのですが、エンドポイント毎に各種エラーレスポンスのdescriptionの記述を書く場合に少し面倒だなと感じました。
以下のように設定することで、各種エラーレスポンスのdescriptionを固定することはできますが、全てのエンドポイントに対して固定のものになってしまいます。
実装例
@route("/users")
interface Users {
@returnsDoc("Returns UserList.")
@post create(@path id: string, @body user: User): User | SharedErrors.ErrorResponse| SharedErrors.NotFoundResponse | SharedErrors.UnauthorizedResponse | SharedErrors.InternalServerErrorResponse;
}
namespace SharedErrors;
@error
@doc("The server could not understand the request")
model ErrorResponse {
...BadRequestResponse;
...Body<ValidationError>;
}
@error
@doc("The server cannot find the requested resource.")
model NotFoundResponse {
...NotFoundResponse;
...Body<NotFoundError>;
}
@error
@doc("The client must authenticate itself to get the requested response")
model UnauthorizedResponse {
...UnauthorizedResponse;
...Body<UnauthorizedError>;
}
@error
@doc("InternalServerError")
model InternalServerErrorResponse {
...Response<500>;
...Body<InternalServerError>;
}
また、エンドポイントに対して@returnsDoc
と@errorDoc
を設定することが出来るんですが、複数のエラーレスポンスに対して全て同じdescriptionを設定することになってしまいます。
実装例
@route("/users")
interface Users {
@returnsDoc("Returns doc")
@errorsDoc("error doc")
@post create(@path id: string, @body user: User): User | UserErrorResponse| UserNotFoundResponse | UserUnauthorizedResponse | UserInternalServerErrorResponse;
}
@error
model UserErrorResponse {
...BadRequestResponse;
...Body<ValidationError>;
}
@error
model UserNotFoundResponse {
...NotFoundResponse;
...Body<NotFoundError>;
}
@error
model UserUnauthorizedResponse {
...UnauthorizedResponse;
...Body<UnauthorizedError>;
}
@error
model UserInternalServerErrorResponse {
...Response<500>;
...Body<InternalServerError>;
}
上記のtspファイルを元に生成されるyamlファイル
paths:
/users/{id}:
post:
operationId: Users_create
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Returns doc
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: error doc
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
'401':
description: error doc
content:
application/json:
schema:
$ref: '#/components/schemas/UnauthorizedError'
'404':
description: error doc
content:
application/json:
schema:
$ref: '#/components/schemas/NotFoundError'
'500':
description: error doc
content:
application/json:
schema:
$ref: '#/components/schemas/InternalServerError'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserCreate'
そのため、エンドポイント毎に共通のエラーレスポンスのmodelを再度定義することで「エンドポイント毎に各種エラーレスポンスのdescriptionを設定する」部分を実現していますが若干面倒なのでデコレーターから設定できると有難いな〜と思ってます。
実装例
@route("/users")
interface Users {
@returnsDoc("Returns doc")
@post create(@path id: string, @body user: User): User | UsecaseBadRequestResponse| UsecaseInternalServerErrorResponse;
@get get(@path id: string): User;
}
// BaseErrorResponseを再定義して@docでdescriptionを設定してあげる
@doc("リクエストパラメータの不備による、XXXXのデータ取得失敗")
model UsecaseBadRequestResponse is SharedErrors.BaseErrorResponse;
@doc("サーバーエラーによる、XXXXのデータ取得失敗")
model UsecaseInternalServerErrorResponse is SharedErrors.BaseInternalServerErrorResponse;
import "@typespec/http";
using TypeSpec.Http;
namespace SharedErrors;
@error
model BaseErrorResponse {
...BadRequestResponse;
...Body<ValidationError>;
}
@error
model BaseInternalServerErrorResponse {
...Response<500>;
...Body<InternalServerErrorResponse>;
}
@error
model ValidationError {
code: "VALIDATION_ERROR";
message: string;
details: string[];
}
@error
model InternalServerError {
code: "INTERNAL_SERVER_ERROR";
message: string;
}
Namespaceを元にスキーマが定義される
最上位となるルートのNamespaceにそのままModelなどを定義すると、衝突しないように変数名を一意になるように考慮する必要があります。そのため、サービス毎に Namespace を定義する方が運用としては好ましいです。
以下では、UserService
という名前空間を定義してその中でmodelとエンドポイントについて記述をしていますが、yamlファイルに出力すると、User
というモデルのスキーマ名は UserService.User
であることがわかります。
namespace RootService;
namespace UserService {
model User {
@visibility(Lifecycle.Read) id: string;
name: string;
mail_address: string;
@visibility(Lifecycle.Create, Lifecycle.Update) password: string;
@visibility(Lifecycle.Create)
sns_relation_flag: boolean;
}
@route("/users")
interface Users {
@returnsDoc("Returns doc")
@post
create(@path id: string, @body user: User): User;
@get get(@path id: string): User;
}
}
出力されたyamlファイル(componentsのみ)
components:
schemas:
UserService.User:
type: object
required:
- id
- name
- mail_address
properties:
id:
type: string
readOnly: true
name:
type: string
mail_address:
type: string
UserService.UserCreate:
type: object
required:
- name
- mail_address
- password
- sns_relation_flag
properties:
name:
type: string
mail_address:
type: string
password:
type: string
sns_relation_flag:
type: boolean
上記のように、サービス毎に Namespace を定義する運用だと、TypeSpecから生成される Specファイル のスキーマの名前は{{NameSpace}}.{{modelName}}
となります。
既にOpenAPIの定義ファイルを導入しているプロダクトの運用にもよりますが、自分たちのようにOpenAPIの定義ファイルから型情報を生成してフロントエンド・バックエンドで利用しているケースの場合、修正範囲が大きくなると感じました。
Modelに関しては、最上位となるルートのNamespaceに定義して、衝突しないように変数名を一意になるようにすれば、既存のスキーマへの影響は抑えることは出来そうですが、Modelの数が増えた際の管理が煩雑になりそうなので避けたい所です。
まとめ
何はともかく、直感的で分かりやすいというのが個人的に一番嬉しい部分だと思いました。
NameSpaceを適切に分割する前提でのスキーマ名を許容できるのであれば、個人的には運用に耐えられるのではないかと感じました。
逆にいうと、NameSpaceを適切に分割することで、規則性のあるスキーマ名にすることができるため、Orvalによる型定義の質を担保することに繋がるかなと思います。そのため、上記のような修正を許容できる、または新規のプロダクトなどにおいては採用する余地はありそうです。
この記事がTypeSpecを検討してみたい、もしくはしている方の参考に少しでもなれば、幸いです。
参考資料
-
この記事がADR的な立ち位置なので、TypeSpecを導入した場合は追記するかもしれません(2024/12/16現在) ↩︎
-
https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md ↩︎
Discussion