TypeSpec で OpenAPI の `oneOf` を書く (ちょっと Rust)
こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。
前置き
最近新しいプロダクトを作っていて、その API 定義を作るのに TypeSpec を利用しています。私は正直そこまで OpenAPI と違いはないなと思っていますが、チームではウケが良いです。ファイルを分割する仕組みがちょっと楽かなくらい。
TypeSpec の記事は Zenn にもそれなりにあるなあという感じですね。
Fairy Devices では、基本的に Rust で Web のバックエンドサーバーを書くので、TypeSpec が TypeScript を前提としているから混乱した部分とか書けると嬉しいのかなと思っています。具体的には、Rust の enum を OpenAPI の oneOf で設計することが多いので、この記事ではそのあたりを TypeSpec でどうやるかの解説をします。
先に結論としては「Named unions を使おう」です。この記事では TypeSpec のバージョンは 1.2.1 を使っています。
OpenAPI の oneOf
OpenAPI には oneOf というキーワードがあります。
これは、とある型 A が「型 B と型 C のどちらか」であるような場合に利用できます。例えば以下のように書くと、型 Account は「型 EmailAccount と型 PhoneAccount のどちらか」であると定義できます。
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 に載せたので、良ければ動作確認してみてください。
TypeSpec で oneOf [1]
TypeSpec のドキュメントを読むと、以下の enum、union で oneOf で表現される型が定義できそうです。
enum でのエラー
どちらでも OpenAPI には反映されますが、enum はなぜか例が書けませんでした。
@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")
| ^^^^^^^
エラー文のスクリーンキャプチャー

union だと単なる値は大丈夫
union (Named unions) だと例を書いてもエラーが出ません。
@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 だとエラーになります。
@example("email") // <-- だめ。
alias AccountKind = "email" | "phone";
そもそも alias キーワードにはデコレーターが付けられないらしいエラーが出ます。
error format-failed: File '****.tsp' failed to format. Cannot decorate alias statement.
エラー文のスクリーンキャプチャー

(ファイル全文が出てるので下にまだまだ続く……)
object (TypeSpec では model) で上記 union を使ってみる
最初の例の EmailAccount を定義するために、以下のように書いてみます。
@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 そのものを渡してやると、エラーは出なくなります。
@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
しかし、EmailAccount の kind は "email" であると決まっているので、型で制限できないのは不便です。ここでやにわに "email" を直接指定してみます。
@example(#{ kind: "email", emailAddress: "dummy address" })
model EmailAccount {
kind: "email"; // <-- `"email"` を直接指定してみる。(これは OK。)
emailAddress: string;
}
これはうまく変換できます。なら話は簡単で、"email" などを定数化して使うように変更します。
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] デコレーターを使うと変更できます。
@OpenAPI.oneOf
union Account{
email: EmailAccount,
phone: PhoneAccount,
}
全体のソースとビルド結果
この記事で説明した全体のソースファイルは以下になります。
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 とか書いてなくても実は勝手に出力されていました。)
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, #{/* ... */}) みたいに) 別箇所でデコレーションできるから回避できたりと柔軟性が高いです。逆に定義する順番などについてのルールを、プロジェクトで秩序だって運用する必要がありそうだなと思いました。
`@@` でデコレーションしてる例
model EmailAccount {
emailAddress: string;
}
@@example(EmailAccount, #{ "emailAddress": "dummy address" })
-
シンタックスハイライトが効かない…… ↩︎
-
余談ですが
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