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へのある程度の理解がありつつ、その上での学習やメンテナンスのコストが発生する気がしていて便利な反面、導入はちょっと悩ましいなというのが今回の感想です。