この記事はドワンゴ Advent Calendar 2024の 1 日目の記事です。
はじめに
こんにちは。ZEN IDの開発をしている、エンジニアのユーンです。
株式会社ドワンゴは先日2024年11月16日に開催された日本最大級のTypeScriptをテーマとした技術カンファレンス TSKaigi Kansai 2024 にプラチナスポンサーとして協賛いたしました。
私は個人でセッション「型付き API リクエストを実現するいくつかの手法とその選択」を発表し、OpenAPI を中心とした手法を一例として紹介しました。
このセッションで OpenAPI を記述する手段として紹介した TypeSpec について、 ZEN ID での実践例を交えて深く取り上げます。
OpenAPI の手書きに疲れた方、また TypeSpec を使い始めたはいいもののどうスケールすれば良いか困っている方にお読みいただけると嬉しいです。
なお、弊社ホリちゃんによるスポンサートークについては別途当ブログでフォローアップ記事が掲載されますので、ご期待ください!
TypeSpec とは
TypeSpec は、Microsoft が中心となって開発している TypeScript や C# に似た文法を持つ OSS の DSL です。
上記では OpenAPI を出力とするケースを紹介していますが、TypeSpec 自体は OpenAPI 以外にも validation 用の JSON Schema や Protobuf などの出力も可能です。
本記事では OpenAPI に焦点を当てて紹介します。
なお、ここでは TypeSpec v0.62.0 の情報に基づいて記載しています。
参考に、本記事で取り上げるソースコードを含むリポジトリを用意しています。
TypeSpec で書く OpenAPI の概観
TypeSpec で OpenAPI を書く際の基本的な構文は以下のようになります。
import "@typespec/http"; import "@typespec/openapi"; using TypeSpec.Http; using TypeSpec.OpenAPI; model PetTag { id: uint64; name: string; } model Pet { id: string; name: string; tags: PetTag[]; } @service({ title: "PetService API", }) namespace PetService { @route("/pets/{id}") interface Pets { @operationId("get-pet") @summary("Get a pet by ID") @get get(@path id: string): { @statusCode statusCode: 200; @body pet: Pet; }; } }
TypeSpec では主に model
、 namespace
、 interface
の概念を使って OpenAPI を記述します。
model
はそのままモデルの型定義をするのに使用し、 interface
はメソッド(decorator を付与することでエンドポイント等として定義する)を持つことができる構造体となります。
namespace
はその名の通り名前空間を定義するために使用します。interface
の中に interface
を持つことはできないため、包含関係を示すのに使用します。
API 全体の namespace に @service
decorator でサービスを定義することで、 OpenAPI のエンドポイントとして認識されます。
同一の namespace に生やした interface に @route
decorator でパスを定義し、get メソッドに @get
decorator でメソッドを定義することで、該当パスへの GET リクエストのエンドポイントとして定義します。
@statusCode
と @body
decorator によりレスポンスが定義されます。
その他、 @operationId
や @summary
などの decorator で OpenAPI のメタ情報を定義できます。
上記に対して OpenAPI3 の出力は以下の通りとなります。
openapi: 3.0.0 info: title: PetService API version: 0.0.0 tags: [] paths: /pets/{id}: get: operationId: get-pet summary: Get a pet by ID parameters: - name: id in: path required: true schema: type: string responses: '200': description: The request has succeeded. content: application/json: schema: $ref: '#/components/schemas/Pet' components: schemas: Pet: type: object required: - id - name - tags properties: id: type: string name: type: string tags: type: array items: $ref: '#/components/schemas/PetTag' PetTag: type: object required: - id - name properties: id: type: integer format: uint64 name: type: string
他にも @versioned
や @server
など、より詳細に記述するための decorator が存在します。
他にも、REST として厳密にリソースとして定義するために Model に定義できる decorator も数多く存在します。
ファイルの分割
さて、上記のようなノリで OpenAPI を書いていくと、あっという間にファイルが肥大化してしまいますが、もちろん TypeSpec はファイルの分割が可能です。それも綺麗に。
例えば、以下のようなディレクトリ構造を目指します。
spec/ ├── main.tsp ├── models/ │ ├── pet.tsp │ ├── pet-tag.tsp │ └── main.tsp └── routes/ ├── pets.tsp └── main.tsp
models/pet.tsp
、 models/pet-tag.tsp
にそれぞれ Model の定義を記述し、 models/main.tsp
でそれらを import します。
models/main.tsp
import "./pet.tsp"; import "./pet-tag.tsp";
同様に、 routes/pet.tsp
に API エンドポイントの定義をしていきますが、この定義が PetService
namespace 配下に属するように記述します。
routes/pet.tsp
import "@typespec/http"; import "@typespec/openapi"; import "../models"; using TypeSpec.Http; using TypeSpec.OpenAPI; namespace PetService; @route("/pets/{id}") interface Pets { @operationId("get-pet") @summary("Get a pet by ID") @get get(@path id: string): { @statusCode statusCode: 200; @body pet: Pet; }; }
{}
で囲わない namespace 宣言をファイル冒頭(import の次)で行うと、ファイル全体が namespace に属することになります。
つまり、以下と同様になります。
namespace PetService { @route("/pets/{id}") interface Pets { // ... } }
なお以下のように書くことはできません。
@route("/pets/{id}") interface PetService.Pets { // ... }
これは PetService
は namespace であり、 interface の宣言に含むことはできないからです。
namespace であれば .
で繋いで宣言することができます。
上記で import "../models"
は models
ディレクトリの main.tsp
を読み込むことを意味します。このファイルを import することで、このファイルが import しているファイル全てが import できることになります。
ただし、 import した途端に名前空間に展開されるため、衝突には十分注意してください。次のセクションでその対処法を紹介します。
最後に、エントリーポイントとなる main.tsp
を記述します。
main.tsp
import "./routes"; @service({ title: "PetService API", }) namespace PetService;
namespace でモジュール境界を制御する
先のセクションでファイルを分割し import できましたが、これにより名前空間が汚染され、衝突する可能性があります。
その簡単な対処法は、 namespace を区切り、モジュール境界を明確にすることです。
例えば models
は Models
namespace に属するようにし、 routes
は Routes
namespace に属するようにします。
また次セクションで取り上げるように、他サービスから参照されることも考慮し、 Models
Routes
もサービスごとの namespace に閉じるようにします。
例えば、以下のように namespace を定義します(一例です)。
models/pet.tsp
import "./pet-tag.tsp"; namespace PetService.Models; model Pet { id: string; name: string; tags: PetTag[]; }
routes/pet.tsp
namespace PetService.Routes; @route("/pets/{id}") interface Pets { @operationId("get-pet") @summary("Get a pet by ID") @get get(@path id: string): { @statusCode statusCode: 200; @body pet: Models.Pet; }; }
pet.tsp
では model を参照するのに、 Models.Pet
としています。これは自身と同じ namespace である PetService
の Models
を指しています。
上記では省略していた Version の定義も、サービスの namespace に閉じるのが良いので以下のように記述します。
main.tsp
import "@typespec/versioning"; import "./routes"; using TypeSpec.Versioning; @service({ title: "PetService API", }) @versioned(Versions) namespace PetService; enum Versions { v1, v2, }
別サービスの定義の利用
さて、TypeSpec では node modules 以下にある TypeSpec モジュールの定義を読み込むことで、パッケージを跨いだ定義の再利用が可能です。
上述のように conditonal exports field に typespec
として tsp ファイルを指定するか、単に tspMain
field に tsp ファイルを指定することで、 TypeSpec モジュールとして読み込めるようになります。
ここからは別の例として、 User 情報を扱う UserService のモデルを BFF で利用する例を紹介します。
まず、monorepo を前提とし、全体のディレクトリ構造は以下のようになります。
/ ├── bff/ │ └── spec/ │ ├── main.tsp │ ├── models/ │ │ ├── users.tsp │ │ └── main.tsp │ └── routes/ │ ├── user.tsp │ └── main.tsp └── user-service/ └── spec/ ├── main.tsp ├── models/ │ ├── users.tsp │ └── main.tsp └── routes/ ├── user.tsp └── main.tsp
UserService の User モデルは以下のように定義されています。
user-service/spec/models/user.tsp
namespace UserService.Models; model User { id: string; name: string; email: string; ip_address: string; created_at: string; updated_at: string; }
BFF から UserService の定義を参照する際、 Models
のみを export できるよう、 user-service
パッケージの package.json の exports field を以下のように記述します。
{ "exports": { ".": { "typespec": "./spec/main.tsp" }, "./models": { "typespec": "./spec/models/main.tsp" } } }
また、 BFF 側には package.json の devDependencies に以下のように記述します。(pnpm での例です。他のパッケージマネージャの機能で monorepo を構築している場合は読み替えてください。)
{ "devDependencies": { "user-service": "workspace:*" } }
BFF で扱う User モデルでは、ip_address
フィールドなど、持たないように定義したいプロパティがありますが、他は UserService のものを流用するため、 PickProperties
を使用して以下のように定義します。
bff/spec/models/user.tsp
import "user-service/models"; namespace BFF.Models; model User is PickProperties<UserService.Models.User, "id" | "name" | "email">;
先のセクションでの内容と同様に namespace を切っているため、 User
も Models
も衝突せず、それぞれ UserService.Models.User
と BFF.Models.User
として参照できます。
このように、他サービスの定義を再利用することで、各サービス間の定義を再利用しつつ、不要な情報は Omit するということができます。
なお余談ですが、 monorepo の各パッケージごとに単独で pnpm install をできビルドできるようにする際(shared-workspace-lockfile=false の場合)は、気をつけなければいけません。 各パッケージそれぞれに TypeSpec のモジュール一式をインストールした上で、互換性確保のためにバージョンを揃えてインストールする必要があります。
Real World での実践に向けて
最後に、上記以外に Real World で活用するにあたって、我々が実践していることを紹介します。
Routes namespace 分割を工夫し、見通しを良くする
Routes を見通しのよいように定義を分割する上で、以下のようにファイルを分割しやすいよう、 namespace を切っています。
spec/routes/users/main.tsp
namespace UserService.Routes; @route("/users") namespace Users { interface Root { @operationId("users-post") @post post( // ... ): { @statusCode statusCode: 201; }; } @route("/{id}") namespace Id { interface Root { @operationId("users-get") @get get(@path id: string): { @statusCode statusCode: 200; @body user: Models.User; }; } @route("/relations") namespace Relations { interface Root { @operationId("users-relations-post") @post post( // ... ): { @statusCode statusCode: 201; }; } @route("/{relationId}") namespace Id { interface Root { @operationId("users-relations-get") @get get(@path id: string, @path relationId: string): { @statusCode statusCode: 200; @body user: Models.Relation; }; } } } } }
REST に倣って Resource 単位になるようにパスを切り、対応して interface ではなく namespace を切っています。これは、 namespace をパスに対応させてネストさせるためです。
namespace はメソッドを持てないため、該当パスのルート(/
)に対応する interface として Root
を interface として定義し、 /
パスに対応するエンドポイントを定義しています。
パスパラメータの {id}
に対応する Id
namespace を定義し @route
decorator でパスを定義、Root
intarface と、その下のパスとして Relations
namespace を定義しています。
(Id.Root
以下にパスが増えない確信があるのであれば Id
を interface として定義しても良いかもしれませんが、ここでは一貫性のため Id
namespace と Root
interface を使っています。)
users/{id}
以下のパスが増えてきた場合は、ファイルを分割し、冒頭の namespace 定義を UserService.Routed.Users.Id
とすることで同様の振る舞いにできます。
認証についての記述をする
OpenAPI には認証に関する記述がありますが、これは TypeSpec でも記述できます。
Bearer Token を使った認証である、ということだけを表したい場合は、以下のように記述します。
@service({ title: "BFF API" }) @useAuth(BeararAuth) namespace BFF;
OAuth などの認可についても表現したい場合は、以下のようにヘルパーの型を定義して使うのがよさそうだと公式ドキュメントにも記載があります。
alias MyOAuth<S extends string[]> = OAuth2Auth< [ { type: OAuth2FlowType.clientCredentials; tokenUrl: "https://example.com/oauth2/token"; scope: S; } ], S >;
型パラメータの S extends string[]
を自ら定義した Scope 文字列の Union に置き換えることで、より厳密な記述が可能になります。
alias Scope = "read:users" | "write:users" alias MyOAuth<S extends Scope[]> = OAuth2Auth< [ { type: OAuth2FlowType.clientCredentials; tokenUrl: "https://example.com/oauth2/token"; scope: S; } ], S >;
API 定義側には以下のように書けます。
@route("/users") @useAuth(MyOAuth<["read:users"]>) namespace Users { // ... }
tspconfig.yaml の設定
公式の Getting Started に沿って生成した tspconfig は emitter として @typespec/openapi3
を指定しているのみですが、他にも様々な設定が可能です。
中でも emitter である @typespec/openapi3
の設定を記載し、出力先やファイル名の変更は、以下のように書けます。
tspconfig.yaml
emit: - "@typespec/openapi3" options: "@typespec/openapi3": emitter-output-dir: "{cwd}/openapi" output-file: "user-service-api.yaml"
上記の記述で、デフォルトの出力先である tsp-output/@typespec/openapi3/openapi.yaml
から変わって openapi/user-service-api.yaml
に OpenAPI の出力が保存されるようになります。
Redoc での表示
OpenAPI の yaml を綺麗な Web で表示するツールはいくつかありますが、中でも Redoc は手軽です。
redocly.yaml
extends: - recommended apis: user-service-api: root: ./openapi/user-service-api.yaml
おわりに
ドワンゴ教育事業では、継続的な開発の効率化を模索する仲間を募集しています。
カジュアル面談も行っています。 お気軽にご連絡ください!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。