💊

YAMLで病むる人への処方箋【TypeSpec】

2024/12/18に公開

この記事は TypeSpec Advent Calendar 2024 18日目の記事として公開しています。

https://qiita.com/advent-calendar/2024/typespec

はじめに

皆さんは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も公式で提供されておりますので、ブラウザ上からも試すことができます。

https://typespec.io/

早速比較してみる

上記で記載した通り、以下2つの観点で比較してみます。

  • スキーマの継承や結合を用いた、スキーマの重複の整理
  • ファイルの分割

スキーマの継承や結合を用いた、スキーマの重複の整理

Spec Type やること pros/cons
yaml allOf$ref を用いる ✅ Pros:
・特になし(難しくも簡単でもない)
❌ Cons:
・複雑なスキーマになってくると、可読性はあまり良くない。
⭐️TypeSpec extendsまたはジェネリクスを用いる ✅ Pros:
・直感的で分かりやすい
❌ Cons:
・特に無し

こちらはTypeSpecの方が優勢かと思いました。

例として、あるスキーマを元に新しいスキーマを定義するケースを見てみます。

openapi.yaml
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で行うとなると以下のようになります。

models/cat.tsp
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
routes/main.tsp
import "./user.tsp";
import "./post.tsp";
main.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 を使うことでシンプルかつ可読性が高い状態で実現することができます。

main.tsp
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.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 ドキュメントにおけるinfoserverspathscomponentsオブジェクトなど対して、簡単な概要や振る舞いの詳細をdescriptionとして設定しているケースは多いと思います。

基本的には、TypeSpec組み込みのデコレーターである@doc(公式)を使えば解消するのですが、エンドポイント毎に各種エラーレスポンスのdescriptionの記述を書く場合に少し面倒だなと感じました。

以下のように設定することで、各種エラーレスポンスのdescriptionを固定することはできますが、全てのエンドポイントに対して固定のものになってしまいます。

実装例
main.tsp
@route("/users")
interface Users {
  @returnsDoc("Returns UserList.")
  @post create(@path id: string, @body user: User): User | SharedErrors.ErrorResponse| SharedErrors.NotFoundResponse | SharedErrors.UnauthorizedResponse | SharedErrors.InternalServerErrorResponse;
}
error.tsp
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を設定することになってしまいます。

実装例
main.tsp
@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を設定する」部分を実現していますが若干面倒なのでデコレーターから設定できると有難いな〜と思ってます。

実装例
main.tsp
@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;
error.tsp
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であることがわかります。

main.tsp
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のみ)
openapi.yaml
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を検討してみたい、もしくはしている方の参考に少しでもなれば、幸いです。

参考資料

https://typespec.io/

脚注
  1. この記事がADR的な立ち位置なので、TypeSpecを導入した場合は追記するかもしれません(2024/12/16現在) ↩︎

  2. https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md ↩︎

Discussion