CLOVER🍀

That was when it all began.

APIを蚘述するための蚀語、TypeSpecを䜿っおOpenAPIドキュメントを生成しおみる

これは、なにをしたくお曞いたもの

APIを蚘述するための蚀語ずしお、TypeSpecずいうものがあるようなので1床どんなものか把握詊しおおきたいなずいうこずで。

TypeSpec

TypeSpecのWebサむトはこちらです。

typespec.io

GitHubリポゞトリヌ。

GitHub - microsoft/typespec

ドキュメントはこちら。

Installation | TypeSpec

なのですが、抂芁を掎むにはMicrosoft Learnにあるドキュメントをたず読んだ方がいいかもしれたせん。

TypeSpec の概要 - TypeSpec とは - TypeSpec | Microsoft Learn

TypeSpecは、Microsoftが開発しおいるAPIを蚘述するための蚀語です。.tspずいう拡匵子のファむルを䜜成するようです。

よくTypeSpecずOpenAPIドキュメント生成の組み合わせで名前を芋かけるのですが、それはあくたで生成できるものの䞀皮の
ようですね。

TypeSpecでAPI仕様を蚘述しお、TypeSpecコンパむラヌが゚ミッタヌず呌ばれるものを䜿甚し、結果を生成したす。
珟時点で゚ミッタヌには以䞋があるようです。

なのでTypeSpecでAPI仕様を曞き、゚ミッタヌを䜿っお別の仕様やコヌド生成を行えるずいった代物のようです。
そしお特にOpenAPIドキュメントが生成できるずころに泚目されやすいのかなず。

蚀語仕様ずしおはこちら。

Language Basics / Overview

ナヌスケヌスずしおは、OpenAPIドキュメントの生成、バリデヌションの定矩、
シンタックスハむラむトやコヌド補完などのツヌルのサポヌトが挙げられおいたす。

ちなみに、OpenAPIドキュメントからTypeSpecぞ倉換するこずもできるようです。

OpenAPI3 to TypeSpec | TypeSpec

TypeSpecを䜿い始めるには、ひずたずNode.jsがあれば倧䞈倫そうです。

Installation | TypeSpec

実隓的扱いでスタンドアロンバむナリヌもあるようですが、珟時点だずNode.jsで扱った方がよさそうですね。

環境

今回の環境はこちら。

$ node --version
v22.15.0


$ npm --version
10.9.2

TypeSpecを䜿ったプロゞェクトの䜜成

では、TypeSpecを䜿ったプロゞェクトの䜜成を行っおみたす。

$ npx --package @typespec/compiler@latest tsp init

今回はTypeSpec 1.0.0-rc.1を䜿うこずになりたす。

Need to install the following packages:
@typespec/compiler@1.0.0-rc.1
Ok to proceed? (y) y

通垞は以䞋のコマンドを䜿うようですが、グロヌバルにむンストヌルしたくなかったのでこうしたした 。

$ npm install -g @typespec/compiler
$ tsp init

ここからは、察話圢匏でどのようなプロゞェクトを䜜成するかを聞かれたす。

プロゞェクトテンプレヌト。

? Select a project template: (Use arrow keys)
❯ Generic REST API
  TypeSpec library
  TypeSpec emitter

プロゞェクト名。コマンドを実行しおいる芪ディレクトリヌの名前が、デフォルトで蚭定されるようになりたす。

? Enter a project name: (project-name)

぀たり、新芏プロゞェクトを新芏ディレクトリヌから䜜成するコマンドではありたせん。

゚ミッタヌ。今回はOpenAPIを遞択。

? What emitters do you want to use?: (Press space to select, a to toggle all, i to invert selection and enter to proceed.)
❯ ◉ OpenAPI 3.1 document       [@typespec/openapi3]
  ◯ C# client                  [@typespec/http-client-csharp]
  ◯ Java client                [@typespec/http-client-java]
  ◯ JavaScript client          [@typespec/http-client-js]
  ◯ Python client              [@typespec/http-client-python]
  ◯ C# server stubs            [@typespec/http-server-csharp]

䟝存ラむブラリヌもむンストヌルされ、これだけファむルが生成されたした。

$ ll
合蚈 84
drwxrwxr-x  3 xxxxx xxxxx  4096  4月 27 15:20 ./
drwxrwxr-x 44 xxxxx xxxxx  4096  4月 27 15:16 ../
-rw-rw-r--  1 xxxxx xxxxx   102  4月 27 15:19 .gitignore
-rw-rw-r--  1 xxxxx xxxxx   872  4月 27 15:19 main.tsp
drwxrwxr-x 92 xxxxx xxxxx  4096  4月 27 15:20 node_modules/
-rw-rw-r--  1 xxxxx xxxxx 54752  4月 27 15:20 package-lock.json
-rw-rw-r--  1 xxxxx xxxxx   666  4月 27 15:19 package.json
-rw-rw-r--  1 xxxxx xxxxx   146  4月 27 15:19 tspconfig.yaml

䜜成されたファむルを芋おみたしょう。

package.json

{
  "name": "project-name",
  "version": "0.1.0",
  "type": "module",
  "peerDependencies": {
    "@typespec/compiler": "latest",
    "@typespec/http": "latest",
    "@typespec/rest": "latest",
    "@typespec/openapi": "latest",
    "@typespec/openapi3": "latest"
  },
  "devDependencies": {
    "@typespec/compiler": "latest",
    "@typespec/http": "latest",
    "@typespec/rest": "latest",
    "@typespec/openapi": "latest",
    "@typespec/openapi3": "latest"
  },
  "private": true,
  "packageManager": "npm@11.3.0+sha512.96eb611483f49c55f7fa74df61b588de9e213f80a256728e6798ddc67176c7b07e4a1cfc7de8922422cbce02543714367037536955221fa451b0c4fefaf20c66"
}

各パッケヌゞのlatest指定にちょっず驚きたしたが、むンストヌル方法がこうだったからずいうわけではなく、
npm install -g @typespec/compilerの埌にtsp initしおも同じ結果になりたした 。

TypeSpecの蚭定。

tspconfig.yaml

emit:
  - "@typespec/openapi3"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{output-dir}/schema"
    openapi-versions:
      - 3.1.0

生成されたAPI仕様。

main.tsp

import "@typespec/http";

using Http;
@service(#{ title: "Widget Service" })
namespace DemoService;

model Widget {
  id: string;
  weight: int32;
  color: "red" | "blue";
}

model WidgetList {
  items: Widget[];
}

@error
model Error {
  code: int32;
  message: string;
}

model AnalyzeResult {
  id: string;
  analysis: string;
}

@route("/widgets")
@tag("Widgets")
interface Widgets {
  /** List widgets */
  @get list(): WidgetList | Error;
  /** Read widgets */
  @get read(@path id: string): Widget | Error;
  /** Create a widget */
  @post create(@body body: Widget): Widget | Error;
  /** Update a widget */
  @patch update(@path id: string, @body body: Widget): Widget | Error;
  /** Delete a widget */
  @delete delete(@path id: string): void | Error;

  /** Analyze a widget */
  @route("{id}/analyze") @post analyze(@path id: string): AnalyzeResult | Error;
}

コンパむルしおみたしょう。

$ npx tsp compile .

このサンプルでは、このようなOpenAPIドキュメントが生成されたした。

tsp-output/schema/openapi.yaml

openapi: 3.1.0
info:
  title: Widget Service
  version: 0.0.0
tags:
  - name: Widgets
paths:
  /widgets:
    get:
      operationId: Widgets_list
      description: List widgets
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetList'
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
    post:
      operationId: Widgets_create
      description: Create a widget
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Widget'
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Widget'
  /widgets/{id}:
    get:
      operationId: Widgets_read
      description: Read widgets
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Widget'
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
    patch:
      operationId: Widgets_update
      description: Update a widget
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Widget'
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WidgetUpdate'
    delete:
      operationId: Widgets_delete
      description: Delete a widget
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: 'There is no content to send for this request, but the headers may be useful. '
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
  /widgets/{id}/analyze:
    post:
      operationId: Widgets_analyze
      description: Analyze a widget
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyzeResult'
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
        - Widgets
components:
  schemas:
    AnalyzeResult:
      type: object
      required:
        - id
        - analysis
      properties:
        id:
          type: string
        analysis:
          type: string
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
        message:
          type: string
    Widget:
      type: object
      required:
        - id
        - weight
        - color
      properties:
        id:
          type: string
        weight:
          type: integer
          format: int32
        color:
          type: string
          enum:
            - red
            - blue
    WidgetList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Widget'
    WidgetUpdate:
      type: object
      properties:
        id:
          type: string
        weight:
          type: integer
          format: int32
        color:
          type: string
          enum:
            - red
            - blue

それでは、このmain.tspを倉曎しおAPI定矩を行っおいきたしょう。

゚ディタヌの蚭定

その前に、゚ディタヌの蚭定を行おうかず思ったのですが。

暙準ではVisual Stuido CodeずVisual Studio向けのExtensionがあるようです。

VS Code Extension | TypeSpec

Visual Studio Extension | TypeSpec

個人的にはEmacsが䜿いたいのですが、こちらのmodeはちょっずうたく組み蟌めたせんでした 。

GitHub - pradyuman/typespec-ts-mode: Emacs major mode for TypeSpec (using tree-sitter)

TypeSpec - LSP Mode - LSP support for Emacs

仕方がないのでtypescript-modeでやるこずにしたしたが、特に問題なかったです。

API仕様を蚘述しお、OpenAPIドキュメントを生成しおみる

それではAPI仕様を蚘述しお、OpenAPIドキュメントを生成しおみたしょう。

お題は、少し前にMicroProfile OpenAPI 4.0をテヌマにしたこちらの曞籍を扱うREST APIの再珟にしたいず思いたす。

WildFly 36 × SmallRye OpenAPI 4.0で出力するOpenAPIドキュメントのバージョンを3.1、3.0に切り替える - CLOVER🍀

䜜成したAPI仕様はこちら。

main.tsp

import "@typespec/http";
import "@typespec/openapi";

using Http;
using OpenAPI;

@service(#{ title: "My Sample REST API" })
@route("/api")
namespace SampleBookService;

model BookRequest {
  @doc("登録する曞籍のタむトル")
  @example("Javaの本")
  @maxLength(100)
  title: string;

  @doc("登録する曞籍の䟡栌")
  @example(1500)
  @minValue(1)
  price: int32;

  @doc("登録する曞籍の出版日")
  @example(plainDate.fromISO("2024-10-13"))
  publishDate?: plainDate; // optional
}

model BookResponse {
  isbn13: string;
  title: string;
  price: int32;
  publishDate?: plainDate;
}

@route("/books")
@tag("book")
namespace Books {
  @summary("登録された曞籍をすべお返华する")
  @doc("登録された曞籍を䟡栌の降順に゜ヌトしおすべお返华する")
  @operationId("findAllBooks")
  @get
  op list(): {
    @body body: BookResponse[];
  };

  @summary("指定されたISBNに察応する曞籍を取埗する")
  @doc("指定されたISBNに察応する曞籍を取埗する")
  @operationId("findBookByIsbn13")
  @get
  op read(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,
  ): BookResponse | NotFoundResponse;

  @summary("指定されたISBNに曞籍を登録する")
  @doc("指定されたISBNに察応する曞籍を登録する")
  @operationId("registerBook")
  op create(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,

    @doc("登録する曞籍デヌタ") @body body: BookRequest,
  ): {
    @statusCode _: "201";
    @body body: BookResponse;
  } | {
    @statusCode _: "400";
    @body body: BadRequestResponse;
  };

  @summary("指定されたISBNに察応する曞籍を削陀する")
  @doc("指定されたISBNに察応する曞籍を削陀する")
  @operationId("deleteBookByIsbn13")
  @delete
  op delete(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,
  ): NoContentResponse;
}

䞻に参照するドキュメントはこちらです。

TypeSpec for OpenAPI Developers | TypeSpec

ここでOpenAPI仕様での蚘述方法ず、TypeSpecでの蚘述方法を合わせおいきたす。

最初に生成されたTypeSpecのサンプルのOperationの定矩がこんな感じでしたが

interface Widgets {
  /** List widgets */
  @get list(): WidgetList | Error;
  /** Read widgets */
  @get read(@path id: string): Widget | Error;
  /** Create a widget */
  @post create(@body body: Widget): Widget | Error;
  /** Update a widget */
  @patch update(@path id: string, @body body: Widget): Widget | Error;
  /** Delete a widget */
  @delete delete(@path id: string): void | Error;

  /** Analyze a widget */
  @route("{id}/analyze") @post analyze(@path id: string): AnalyzeResult | Error;
}

ちゃんずsummaryやdescriptionを指定したり、operationIdも決めたりしようずするずちょっず長めになりたした。

@route("/books")
@tag("book")
namespace Books {
  @summary("登録された曞籍をすべお返华する")
  @doc("登録された曞籍を䟡栌の降順に゜ヌトしおすべお返华する")
  @operationId("findAllBooks")
  @get
  op list(): {
    @body body: BookResponse[];
  };

  @summary("指定されたISBNに察応する曞籍を取埗する")
  @doc("指定されたISBNに察応する曞籍を取埗する")
  @operationId("findBookByIsbn13")
  @get
  op read(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,
  ): BookResponse | NotFoundResponse;

  @summary("指定されたISBNに曞籍を登録する")
  @doc("指定されたISBNに察応する曞籍を登録する")
  @operationId("registerBook")
  op create(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,

    @doc("登録する曞籍デヌタ") @body body: BookRequest,
  ): {
    @statusCode _: "201";
    @body body: BookResponse;
  } | {
    @statusCode _: "400";
    @body body: BadRequestResponse;
  };

  @summary("指定されたISBNに察応する曞籍を削陀する")
  @doc("指定されたISBNに察応する曞籍を削陀する")
  @operationId("deleteBookByIsbn13")
  @delete
  op delete(
    @path
    @minLength(14)
    @maxLength(14)
    @doc("ISBN")
    isbn13: string,
  ): NoContentResponse;
}

@operationIdなどのOpenAPI甚のものは、OpenAPIのimportおよびuseを
しおおかないず䜿うこずができたせん。

import "@typespec/openapi";

...

using OpenAPI;

あず、レスポンスボディに察するdescriptionはどうしおもうたく付けられたせんでした 。ドキュメントを芋るず@docで
よさそうなのですが 。

慣れない堎合で、か぀既存のOpenAPIドキュメントがある堎合は1床こちらで倉換しおみるず参考になるでしょうね。

OpenAPI3 to TypeSpec | TypeSpec

では、蚘述したAPI仕様からOpenAPIドキュメントを生成しおみたす。

$ npx tsp compile .

結果はこちら

tsp-output/schema/openapi.yaml

openapi: 3.1.0
info:
  title: My Sample REST API
  version: 0.0.0
tags:
  - name: book
paths:
  /api/books:
    get:
      operationId: findAllBooks
      summary: 登録された曞籍をすべお返华する
      description: 登録された曞籍を䟡栌の降順に゜ヌトしおすべお返华する
      parameters: []
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookResponse'
      tags:
        - book
  /api/books/{isbn13}:
    get:
      operationId: findBookByIsbn13
      summary: 指定されたISBNに察応する曞籍を取埗する
      description: 指定されたISBNに察応する曞籍を取埗する
      parameters:
        - name: isbn13
          in: path
          required: true
          description: ISBN
          schema:
            type: string
            minLength: 14
            maxLength: 14
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
        '404':
          description: The server cannot find the requested resource.
      tags:
        - book
    post:
      operationId: registerBook
      summary: 指定されたISBNに曞籍を登録する
      description: 指定されたISBNに察応する曞籍を登録する
      parameters:
        - name: isbn13
          in: path
          required: true
          description: ISBN
          schema:
            type: string
            minLength: 14
            maxLength: 14
      responses:
        '201':
          description: The request has succeeded and a new resource has been created as a result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
        '400':
          description: The server could not understand the request due to invalid syntax.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TypeSpec.Http.BadRequestResponse'
      tags:
        - book
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookRequest'
        description: 登録する曞籍デヌタ
    delete:
      operationId: deleteBookByIsbn13
      summary: 指定されたISBNに察応する曞籍を削陀する
      description: 指定されたISBNに察応する曞籍を削陀する
      parameters:
        - name: isbn13
          in: path
          required: true
          description: ISBN
          schema:
            type: string
            minLength: 14
            maxLength: 14
      responses:
        '204':
          description: There is no content to send for this request, but the headers may be useful.
      tags:
        - book
components:
  schemas:
    BookRequest:
      type: object
      required:
        - title
        - price
      properties:
        title:
          type: string
          maxLength: 100
          description: 登録する曞籍のタむトル
          examples:
            - Javaの本
        price:
          type: integer
          format: int32
          minimum: 1
          description: 登録する曞籍の䟡栌
          examples:
            - 1500
        publishDate:
          type: string
          format: date
          description: 登録する曞籍の出版日
          examples:
            - '2024-10-13'
    BookResponse:
      type: object
      required:
        - isbn13
        - title
        - price
      properties:
        isbn13:
          type: string
        title:
          type: string
        price:
          type: integer
          format: int32
        publishDate:
          type: string
          format: date
    TypeSpec.Http.BadRequestResponse:
      type: object
      required:
        - statusCode
      properties:
        statusCode:
          type: number
          enum:
            - 400
          description: The status code.
      description: The server could not understand the request due to invalid syntax.

ひずたずよさそうです。

コヌドフォヌマットを行う堎合はこちら。

$ npx tsp format '**/*.tsp'

Formatter | TypeSpec

スタむルガむドも芋おおくずよいでしょう。

Style guide | TypeSpec

おわりに

APIを蚘述するための蚀語、TypeSpecを䜿っおOpenAPIドキュメントを生成しおみたした。

TypeSpecならではの曞き方を芚えないずいけないので、TypeSpecを曞いおOpenAPIドキュメントを生成しお、思ったずおりに
なっおいるかずいった確認を繰り返すこずになりたした。

蚘述方法が倉わったので、そういうものですよね。

個人的にはOpenAPIのみに絞っお芋るず、MicroProfile OpenAPIのようなコヌドからOpenAPIドキュメントを生成するのず
あたり倉わらない気がするのですが、そのようなフレヌムワヌク自身がサポヌトしおいない堎合や、他の゚ミッタヌを利甚できたり
するのでAPI仕様の管理をTypeSpecにたずめおいくず良さが出るのかなず思いたす。

ひずたずTypeSpecがどういうものかは抌さえられたのでよしずしたしょう。