📝

TypeSpec で OpenAPI の `oneOf` を書く (ちょっと Rust)

に公開

こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。

前置き

最近新しいプロダクトを作っていて、その API 定義を作るのに TypeSpec を利用しています。私は正直そこまで OpenAPI と違いはないなと思っていますが、チームではウケが良いです。ファイルを分割する仕組みがちょっと楽かなくらい。

TypeSpec の記事は Zenn にもそれなりにあるなあという感じですね。

https://zenn.dev/topics/typespec

Fairy Devices では、基本的に Rust で Web のバックエンドサーバーを書くので、TypeSpec が TypeScript を前提としているから混乱した部分とか書けると嬉しいのかなと思っています。具体的には、Rust の enum を OpenAPI の oneOf で設計することが多いので、この記事ではそのあたりを TypeSpec でどうやるかの解説をします。

先に結論としては「Named unions を使おう」です。この記事では TypeSpec のバージョンは 1.2.1 を使っています。

OpenAPI の oneOf

OpenAPI には oneOf というキーワードがあります。

https://swagger.io/docs/specification/v3_0/data-models/oneof-anyof-allof-not/

これは、とある型 A が「型 B と型 C のどちらか」であるような場合に利用できます。例えば以下のように書くと、型 Account は「型 EmailAccount と型 PhoneAccount のどちらか」であると定義できます。

openapi-example.yaml
components:
  schema:
    Account:
      oneOf:
        - $ref: '#/components/schemas/EmailAccount'
        - $ref: '#/components/schemas/PhoneAccount'

    EmailAccount:
      type: object
      properties:
        kind:
          type: string
          enum: [email]
        emailAddress:
          type: string
      required:
        - kind
        - emailAddress

    PhoneAccount:
      type: object
      properties:
        kind:
          type: string
          enum: [phone]
        phoneNumber:
          type: string
      required:
        - phoneNumber
        - kind

Rust で oneOf を受け取る

Rust の serde で考えると、上記は以下のような Internally tagged で書かれた enum でシリアライズ、デシリアライズできます。(参考: Enum representations · Serde。) むしろ Internally tagged で受け取るために上記のような API 定義をしたという話ではありますが。

#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
enum Account {
    Email(EmailAccount),
    Phone(PhoneAccount),
}

#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct EmailAccount {
    email_address: String,
}

#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PhoneAccount {
    phone_number: String,
}

Rust Playground に載せたので、良ければ動作確認してみてください。

https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=955be8f8f43cb2245926d410a254b9f1

TypeSpec で oneOf [1]

TypeSpec のドキュメントを読むと、以下の enum、union で oneOf で表現される型が定義できそうです。

enum でのエラー

どちらでも OpenAPI には反映されますが、enum はなぜか例が書けませんでした。

main.tsp
@example("email") // <-- だめ。
enum AccountKind {
  email: "email",
  phone: "phone",
}

enum に例を書くと以下のようなエラーが出ます。(このあたりが、TypeScript が前提なのかなあと邪推してるあたりです。)

****.tsp:11:10 - error unassignable: Type '"email"' is not assignable to type 'AccountKind'
> 11 | @example("email")
     |          ^^^^^^^
エラー文のスクリーンキャプチャー

enum に  デコレーターを書いたときに出るエラー

union だと単なる値は大丈夫

union (Named unions) だと例を書いてもエラーが出ません。

main.tsp
@example("email")
@example("phone") // (余談) デコレーターは複数書けます。なぜか 1 個しか効かないけど。
union AccountKind { // <-- union にすると OK。
  email: "email",
  phone: "phone",
}
ビルド結果
components:
  schemas:
    AccountKind:
      type: string
      enum:
        - email
        - phone
      example: phone

(@example を複数書いても後勝ちします。@opExample の方は複数いけます。)

しかし、同じページで紹介されている Union expressions だとエラーになります。

main.tsp
@example("email") // <-- だめ。
alias AccountKind = "email" | "phone";

そもそも alias キーワードにはデコレーターが付けられないらしいエラーが出ます。

error format-failed: File '****.tsp' failed to format. Cannot decorate alias statement.
エラー文のスクリーンキャプチャー

alias に  デコレーターを書いたときに出るエラー

(ファイル全文が出てるので下にまだまだ続く……)

object (TypeSpec では model) で上記 union を使ってみる

最初の例の EmailAccount を定義するために、以下のように書いてみます。

main.tsp
@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
  kind: AccountKind.email;
  emailAddress: string;
}

すると、またエラーが出ます。(エラー文がちょっと面白い。)

****.tsp:25:13 - error unassignable: Type '"email"' is not assignable to type '"email"'
> 25 | @example(#{ kind: "email", emailAddress: "dummy address" })
     |             ^^^^

なぜかわかりませんが、union のヴァリアントを直接指定すると @example が失敗するようになります。以下のように、union そのものを渡してやると、エラーは出なくなります。

main.tsp
@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
  kind: AccountKind; // <-- `.email` を削った。(これは OK。)
  emailAddress: string;
}
ビルド結果
    EmailAccount:
      type: object
      required:
        - kind
        - emailAddress
      properties:
        kind:
          $ref: '#/components/schemas/AccountKind'
        emailAddress:
          type: string
      example:
        kind: email
        emailAddress: dummy address

しかし、EmailAccountkind"email" であると決まっているので、型で制限できないのは不便です。ここでやにわに "email" を直接指定してみます。

main.tsp
@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
  kind: "email"; // <-- `"email"` を直接指定してみる。(これは OK。)
  emailAddress: string;
}

これはうまく変換できます。なら話は簡単で、"email" などを定数化して使うように変更します。

main.tsp
alias accountKindEmail = "email";
alias accountKindPhone = "phone";

union AccountKind {
  email: accountKindEmail,
  phone: accountKindPhone,
}

@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
  kind: accountKindEmail; // <-- `"email"` の alias を指定する。(これは OK。)
  emailAddress: string;
}

そうすると、無事エラーなく OpenAPI へ変換できるようになりました。ビルド結果は以下のようになります。kind は enum で、"email" のみになっていることがわかります。

components:
  schemas:
    AccountKind:
      type: string
      enum:
        - email
        - phone
    EmailAccount:
      type: object
      required:
        - kind
        - emailAddress
      properties:
        kind:
          type: string
          enum:
            - email
        emailAddress:
          type: string
      example:
        kind: email
        emailAddress: dummy address

EmailAccount の kind に直接 "email" を渡す段階まででいいんじゃないか?という疑問はあると思います。alias で定数として扱っているのは、パスパラメーターや別の object などから AccountKind を再利用することがあるからです。

@OpenAPI.oneOf デコレータ

最後に、union を OpenAPI として出力すると、実は oneOf ではなく allOf として出力されるのに対応する必要があります。以下の @OpenAPI.oneOf [2] デコレーターを使うと変更できます。

main.tsp
@OpenAPI.oneOf
union Account{
  email: EmailAccount,
  phone: PhoneAccount,
}
全体のソースとビルド結果

この記事で説明した全体のソースファイルは以下になります。

main.tsp
import "@typespec/http";
import "@typespec/openapi";
import "@typespec/openapi3";

alias accountKindEmail = "email";
alias accountKindPhone = "phone";

union AccountKind {
  email: accountKindEmail,
  phone: accountKindPhone,
}

@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
  kind: accountKindEmail;
  emailAddress: string;
}

model PhoneAccount {
  kind: accountKindPhone;
  phoneNumber: string;
}

@OpenAPI.oneOf
union Account{
  email: EmailAccount,
  phone: PhoneAccount,
}

上記を main.tsp として保存して、以下を実行します。

npm install --save-dev @typespec/{compiler,http,openapi,openapi3}
npm exec -- tsp compile --emit @typespec/openapi3 .

すると、tsp-output/@typespec/openapi3/openapi.yaml に以下の内容のファイルが作成されています。(title とか書いてなくても実は勝手に出力されていました。)

tsp-output/@typespec/openapi3/openapi.yaml
openapi: 3.0.0
info:
  title: (title)
  version: 0.0.0
tags: []
paths: {}
components:
  schemas:
    Account:
      oneOf:
        - $ref: '#/components/schemas/EmailAccount'
        - $ref: '#/components/schemas/PhoneAccount'
    AccountKind:
      type: string
      enum:
        - email
        - phone
    EmailAccount:
      type: object
      required:
        - kind
        - emailAddress
      properties:
        kind:
          type: string
          enum:
            - email
        emailAddress:
          type: string
      example:
        kind: email
        emailAddress: dummy address
    PhoneAccount:
      type: object
      required:
        - kind
        - phoneNumber
      properties:
        kind:
          type: string
          enum:
            - phone
        phoneNumber:
          type: string

感想

デコレーターがめっちゃ出てきて見づらくなりそうな部分もありつつ、@@ を使うと (@@example(Account, #{/* ... */}) みたいに) 別箇所でデコレーションできるから回避できたりと柔軟性が高いです。逆に定義する順番などについてのルールを、プロジェクトで秩序だって運用する必要がありそうだなと思いました。

`@@` でデコレーションしてる例
main.tsp
model EmailAccount {
  emailAddress: string;
}

@@example(EmailAccount, #{ "emailAddress": "dummy address" })
脚注
  1. シンタックスハイライトが効かない…… ↩︎

  2. 余談ですが using OpenAPI; すれば @OpenAPI.oneOf@oneOf と書けます。グローバルスコープとして TypeSpec ネームスペースがあって、using TypeSpec.OpenAPI; すると using したスコープに OpenAPI 以下を全部持ってこれる、TypeSpec 以下は最初から全部ある (using されてる) から using OpenAPI; もできる、みたいな感じみたいです。 https://typespec.io/docs/language-basics/namespaces/#using-namespaces ↩︎

フェアリーデバイセズ公式

Discussion