TypeSpecでOpenAPIのスキーマ定義をより楽に書けないか試みた
日々の開発でOpenAPIにはお世話になっているのですが、OpenAPIの記法もYAMLもなかなか慣れないのでちらほら見かけるTypeSpecでもう少し楽に書けないかを試してみました。
TypeSpecはMicrosoftが開発しているAPI定義言語です。
https://typespec.io/
環境の準備
ドキュメントには幾つかあったのですが、CLIが欲しいのでグローバルでインストールしました。
$ npm install -g @typespec/compiler
エディタが真っ白になるのでVSCodeのエクステンションも導入しました。
https://typespec.io/docs/introduction/editor/vscode/
どんな感じに書けるのか
modelを定義する
modelとして書いたものは、openapi.yamlでいうところのcomponentsとして生成されます。
// 出版物のベースモデル
model Publication {
id: string;
title: string;
publishedDate: string;
}
// 本モデル
model Book extends Publication {
author: string;
isbn: string;
}
// 雑誌モデル
model Magazine extends Publication {
edition: int32;
}
継承が使えるので、同じようなモデルを書く時も楽です。
継承している場合、コンパイルすると allOf
を利用した形になります。
components:
schemas:
Book:
type: object
required:
- author
- isbn
properties:
author:
type: string
isbn:
type: string
allOf:
- $ref: '#/components/schemas/Publication'
Magazine:
type: object
required:
- edition
properties:
edition:
type: integer
format: int32
allOf:
- $ref: '#/components/schemas/Publication'
Publication:
type: object
required:
- id
- title
- publishedDate
properties:
id:
type: string
title:
type: string
publishedDate:
type: string
routeを定義する
routeをつけたものは、pathsとして生成される。
// 本一覧取得用のルート
@route("/books")
op listBooks(): Book[];
// 雑誌一覧取得用のルート
@route("/magazines")
op listMagazines(): Magazine[];
ネストしたい場合は、interfaceで定義できました。
interfaceにつけたデコレーターのrouteを基点に、中で定義したメソッドのrouteの値をつなぐ形で生成できました。
@route("/publication")
@tag("publication")
interface Publication {
@useAuth({
type: AuthType.http,
scheme: "Bearer",
})
@summary("出版物取得API")
@operationId("getPublication")
@get
getPublication(): {
@statusCode statusCode: 200;
@body body: PublicationGetResponse;
} | {
@statusCode statusCode: 401;
@body error: UnauthorizedErrorResponse;
} | {
@statusCode statusCode: 404;
@body error: NotFoundErrorResponse;
};
@route("/magazine")
@summary("雑誌の取得API")
@operationId("getMagazine")
@get
op getMagazine(): {
@statusCode statusCode: 200;
@body body: MagazineGetReponse;
} | {
@statusCode statusCode: 401;
@body error: UnauthorizedErrorResponse;
} | {
@statusCode statusCode: 404;
@body error: NotFoundErrorResponse;
}
}
今回の場合、getMagazineは /publication/magazine のAPIとなります。
また、レスポンスの形式をステータスコード別に定義したいと思っていたのですが、上記の形で実現できました。
Handling Errors | TypeSpec
ファイル分割もできる
openapi.yamlを書いている時は長くなっていくファイルが見づらく、適宜分割してswagger-cliでバンドルするような書き方をしていたんですが、TypeSpecでは適宜分割してimportをしておくとコンパイル時に上手く結合してくれます。
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
import "./routes/user.tsp";
using TypeSpec.Http;
using TypeSpec.Rest;
@service({
title: "API",
})
namespace ApiService;
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi";
import "@typespec/openapi3";
using TypeSpec.Http;
using TypeSpec.OpenAPI;
using TypeSpec.Rest;
namespace ApiService.Route;
model UserGetResponse {
id: uint64;
name: string;
}
@route("/user")
@tag("User")
interface User {
@summary("ユーザー取得")
@operationId("getUser")
@get getUser(): UserGetResponse;
}
書いてみて感じること
覚えるまでは少し困ったんですが、YAMLを書くことになかなか慣れない自分がいるので、どこかTypeScriptに近い感覚で書けるのはとっつきやすいなと感じました。
コンパイルはできるものの、生成物の中でOpenAPIの仕様に満たない生成がされるような話も見かけるのでどうだろうなと思いながら使っているのが現状です。今のところまだ起きていません。
その話を書いていた人も言っていましたが、生成されたopenapi.yamlに対してRedoclyでLintをかけるというのはいいかもしれません。
https://redocly.com/
結局OpenAPIへのある程度の理解がありつつ、その上での学習やメンテナンスのコストが発生する気がしていて便利な反面、導入はちょっと悩ましいなというのが今回の感想です。