🎉

TypeSpecを使ってOpenAPIを生成しよう

に公開

こんにちは。
リーナー開発チームのめろたんです。

最近は、年始から肘を脱臼と骨折をやりました。
みなさんは肘を大事にしてください。

今回は、TypeSpecを使ってAPIのスキーマを定義してる話をします!

TypeSpec is なに

TypeSpecは、Microsoftが開発している、APIのスキーマを定義するためのツール・記述フォーマットです。
https://typespec.io/

APIスキーマを定義するものだとOpenAPIが広く知られていますが、要は同じことができるものになります。
簡単にどういうものかを例を見ていきましょう。

import "@typespec/http";

using Http;

model Store {
  name: string;
  address: Address;
}

model Address {
  street: string;
  city: string;
}

@route("/stores")
interface Stores {
  list(@query filter: string): Store[];
  read(@path id: Store): Store;
}

このような感じで記述していきます。
Webフロントエンダーな皆様につきましては、TypeScriptのような記述形式で書きやすく読みやすいかなと思います。
ただこれだと、実際にスキーマとして使うことが現状だと難しいので、TypeSpecにはOpenAPIの形式に出力する機能があります。
実際にこれをOpenAPIに変換すると以下のようになります。

openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths:
  /stores:
    get:
      operationId: Stores_list
      parameters:
        - name: filter
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Store'
  /stores/{id}:
    get:
      operationId: Stores_read
      parameters:
        - name: id
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/Store'
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Store'
components:
  schemas:
    Address:
      type: object
      required:
        - street
        - city
      properties:
        street:
          type: string
        city:
          type: string
    Store:
      type: object
      required:
        - name
        - address
      properties:
        name:
          type: string
        address:
          $ref: '#/components/schemas/Address'

こんな感じで、OpenAPIを素で書くより圧倒的に書きやすく読みやすい形式で記述できるのがTypeSpecの利点になるかなと思います。

実際にどういう感じでつかっているか

大前提として、弊社はB2Bのサービスを作っているため、契約社様とその先の取引先様で、それぞれアクセスできるAPIのエンドポイントが違います。
そのために少し工夫している点があるので、それについて書いていきます!

ネームスペースを利用

契約社様とその先の取引先様で、それぞれアクセスできるAPIのエンドポイントが違うため、それぞれにネームスペースを作って、整理しています。

@route("/api")
namespace App {
  // 契約社様用のAPIのエンドポイント
  @route("/contract_companies")
  namespace ContractCompanyAPI

  // 取引先様用のAPIのエンドポイント
  @route("/partner/{name}")
  namespace PartnerAPI
}

このようにすることで、契約企業様用のAPIは/api/contract_companiesで、取引先様用のAPIは/api/partner/{name}とすることができます。
そしてそれぞれのネームスペース配下にAPIを定義していくことで、それぞれ専用にAPIを記述することができます。

ファイルを分割して、各APIを定義

TypeSpecではimportを使うことで、別ファイルに定義した内容を取り込むことができます。
そのため、各API毎にファイルを分割して、見通しが良くなるように作っています。

contract-company-api/index.tsp
import "./data_api.tsp"
import "./user_api.tsp"
contract-company-api/data_api.tsp
model DataListResponse {
  data: Data[];
}

model DataShowResponse {
  data: Data;
}

namespace App.ContractCompanyAPI {
  @routes("/data")
  interface DataAPI {
    @get list(): DataListResponse | Error;
    @get show(@path id: string): DataShowResponse | Error;
  }
}

このようにすることで、契約企業様用のAPIは/api/contract_companiesで、取引先様用のAPIは/api/partner/{name}とすることができます。
そしてそれぞれのネームスペース配下にAPIを定義していくことで、それぞれ専用にAPIを記述することができます。

モデルの共通化

先述した通り、契約社様とその先の取引先様で、それぞれアクセスできるAPIのエンドポイントが違うのですが、とはいえ扱うデータの型は一緒ということもあります。

その場合は、共通のエンティティとして別でmodelを定義するようにしています。

common/entity.tsp
model Data {
  title: string;
  description: string;
}
contract-company-api/data_api.tsp
import "../common/entity.tsp"

model DataListResponse {
  data: Data[];
}
// ....
partner-api/data_api.tsp
import "../common/entity.tsp"

model DataListResponse {
  data: Data[];
}
// ....

このようにすることで、共通のAPIであったり、内部的に同じデータを扱っている場合で、APIのレスポンスを変えたりした場合、一箇所を修正することで、簡単に対応することができます。

JSON Schemaの生成

TypeSpecでは、OpenAPI以外にJSON Schemaを生成することも可能です。
バックエンドで、より強固にバリデーションをかけたい等のシーンがある場合は、

困ったところ・難しかったところ

aliasの扱い

alias というのがありまして、

model ModelA {}
// ....
alias Models = ModelA | ModelB | ModelC

のようにして、文字通りエイリアスを作ることができます。
TypeScriptでいうところのtypeと似たような感じですね。

そしてこのaliasで定義してOpenAPIを生成すると、Modelsというcomponentsは生成されず、このModelsを使っている各所でこの内容が展開されます。
そのため、コードを書く際にModels型がないため、扱いがちょっとめんどくさくなってしまいました。

// Modelsを受け取って何かしら処理したいが、Modelsというcomponentsは無いため、OpenAPIからコード生成してもModelsという型ができず、自分たちで定義し直す必要がある。
type Models = ModelA | ModelB | ModelC;
funtion f(models: Models) {
}

こうすると、Modelsに新たにModelDを追加した際に、自分たちでまた型を修正する必要があり、バグの温床になるなという感じがありました。

そのため、aliasを使わず、unionを使うように変更しました。

model ModelA {}
// ....
union Models {
  ModelA;
  ModelB;
  ModelC;
}

このようにすることで、componentsModelsが生成されるようになり、コード生成する際も型が定義されるようになるため、自分たちでプラスでメンテするところを減らせました。

共通のパラメーターの定義

先述した、ネームスペースを利用して、その@routeにurlパラメーターをつけているところがあります。
そのネームスペース配下に定義した各APIに都度@pathでパラメーターをすべて付ける必要があり、それを都度書くのがめんどくさく、冗長に感じてちょっとやだなあという感じがあります。

@route("/partner/{name}"
namespace App.PartnerAPI {
  @route("/data/")
  interface DataAPI {
    @get list(@path name: string) DataListResponse | Error;
    @get show(@path name: string, @path uid: string) DataShowResponse | Error;
  }
}

このような場合、すべてのAPIに@path name: string を都度書く必要があり、めんどうだなとなっていました。

そのため、

model CommonParameters {
  @path name: string;
}
// ....
@get list(...CommonParameters) DataListResponse | Error;
@get show(...CommonParameters, @path uid: string) DataShowResponse | Error;

このように変更して、多少楽にすることができたのですが、それでもCommonParametersを都度各必要があり、ちょっとめんどくさいのは残ったままだなぁという感じになっています。

TypeSpecは自分たちでDecoratorを作ることができるため、これでうまくできるのではと考えていますが、まだ取り組めていません。
https://typespec.io/docs/extending-typespec/create-decorators/

まとめ

OpenAPIを素で書くより、圧倒的に書きやすく読みやすい形でつくれるのはとても良いなと思います!
またOpenAPIからコード生成するツール等がたくさんあるので、TypeSpecからOpenAPIを生成できるようにしているのは、現実的でとても扱いやすいなと感じています。

宣伝

リーナーではTypeSpecでAPIのスキーマを書いていきたいエンジニアを募集しています!

https://careers.leaner.co.jp/engineering

リーナーテックブログ

Discussion