はじめに
OpenAPI仕様に則ってREST APIの設計をする際に、値が存在しないという状態をどのように表現するかというお話です。
undefinedとは
まずはじめに、ここでundefinedと言っているのは、OpenAPIの仕様において、リクエスト/レスポンスのデータ型を定義するSchema Objectのプロパティの1つであるrequiredが指定されていない状態を指します。
OpenAPIにおけるrequiredの定義を確認してみましょう。
OpenAPIの仕様を参照すると、Schema ObjectはJSON Schemaの仕様に従うと記載されています。
The Schema Object allows the definition of input and output data types. These types can be objects, but also primitives and arrays. This object is a superset of the JSON Schema Specification Draft 2020-12.
For more information about the properties, see JSON Schema Core and JSON Schema Validation.
それではJSON Schemaの仕様を確認してみましょう。
An object instance is valid against this keyword if its property set contains all elements in this keyword’s array value.
少しわかりづらいですが、requiredとして指定されたプロパティはキーとしてインスタンスに含まれなければならないことを意味します。
具体的な例として下記のようなOpenAPI定義を考えてみましょう。
application/json: |
正しいケース
✅ idに具体的な文字列が指定されている
{ "id": "00001" } |
✅ idに空文字が指定されている
{ "id": "" } |
正しくないケース
❌ idのキーが存在しない
{} |
❌ idの値にnullが指定されている
{ "id": null } |
後述しますが、この場合requiredの条件は満たしますが、データ形が文字列ではないため、NGとなります。
このようにrequiredとはキー自体の必須・非必須を定義するプロパティであり、キーの具体的な値については関与していないということをまずは頭に入れておいてください。
nullとは
次にnullというのは、リクエスト/レスポンスにおけるプロパティの値としてのnullを指しています。
リクエスト/レスポンスのフォーマットとしてJSONが用いられることが多いと思いますが、JSONの仕様として null型というのは明確に定義されています。
OpenAPIにおいてこのnull型はどのように表現されるのでしょうか。
結論から言うとバージョンによって表現が異なります。
OpenAPI3.0の場合
https://spec.openapis.org/oas/v3.0.3#data-types
null is not supported as a type (see nullable for an alternative solution)
nullは型としてサポートされておらず、代わりにnullableを利用する仕様となっています。
application/json: |
OpenAPI3.1の場合
https://spec.openapis.org/oas/v3.1.0#data-typesnullに関する注記は削除され、JSON Schemaの仕様と同じく`null``を型として明確にサポートするようになりました。
application/json: |
バージョンによって表現の差異はありますが、意味するところは同じです。
正しいケース
✅ idに具体的な文字列が指定されている
{ "id": "00001" } |
✅ idに空文字が指定されている
{ "id": "" } |
✅ idにnullが指定されている
{ "id": null } |
正しくないケース
❌ idのキーが存在しない
{} |
これは先述したrequiredの条件を満たしていないためNGとなります。
undefined vs null
ここまで見てきたようにundefinedとnullは似て非なるものです。undefinedはキーの必須・非必須を定義しているのに対し、nullは値がnullかどうかを定義しています。
しかしながらREST APIを設計するにあたって空の項目をどちらで表現するかは意見が分かれているように思います。
例えば以下のようなユーザオブジェクトのJSON表現を考えてみましょう。
オプショナルなユーザ属性としてスコアを持ちますが、スコアが存在しない状態をどのように表現するのでしょうか。
| 項目 | データ型 | 必須 |
|---|---|---|
| ID | 文字列 | ○ |
| Name | 文字列 | ○ |
| Score | 数値 |
undefinedとして表現する場合
{ "id": "00001", "name": "Bob" } |
nullとして表現する場合
{ "id": "00001", "name": "Bob", "score": null } |
undefined派
- リクエスト/レスポンスのペイロードサイズを小さくするために
null値は利用しない方が良い。 - 必要なプロパティのみが含まれている方が視認性が良い。
なおGoogleのJSON Style Guideでは、明確にnull値が必要となる場合以外は、プロパティ自体含めない形が推奨(ここでいうundefined派)されています。
If a property is optional or has an empty or null value, consider dropping the property from the JSON, unless there’s a strong semantic reason for its existence.
null派
- データの構造の全量を把握できるため、
null値を含めた方が良い。
null以外の方法で空値を表現する派
null派の亜種となりますが、データ型に応じてはnull以外の方法で空値を表現できる場合があり、明示的にnull型を利用しない方法となります。
| データ型 | 空値の表現 |
|---|---|
| string | “”(空文字) |
| integer | 表現不可 |
| number | 表現不可 |
| boolean | 表現不可 |
| array | [](空配列) |
| object | {}(空オブジェクト) |
そもそもintegerやnumberのような数値型やブーリアン型は空値を表現する方法がないので、この方法は採用できません。
数値について、例えば業務上自然数しか入りえない項目(例. 年齢)に対して-1のような値を利用して空値を表現するような場合を見かけますが、これは設計上望ましくないと考えます。
さらに言うと、空値の表現が可能な文字列やオブジェクト含め、後述するバリデーションの観点から、null型以外の型で空値を表現するのは基本的に望ましくないと考えています。
筆者の意見
結論から言うと原則null値は利用せずキー自体を含めないundefinedが良いと思っています。
特にOpenAPIの仕様に基づいてAPI仕様をドキュメントする場合において、null値を許容するように記述するのは煩雑です。
ただしここで「原則」と言ったのはAPIのユースケースに応じて明確にnull値を表現したいケースは存在すると考えています。
ユースケースの観点
先程のユーザリソースを例にCRUDを考えてみましょう。
取得(GET)
scoreが存在する場合は、score値が返却されます。
$ curl -X GET /users/00001 |
scoreが存在しない場合は、レスポンスにscoreプロパティは含めません。
$ curl -X GET /users/00001 |
新規作成(POST)
作成時にscore値が存在する場合は、リクエストにscore値を含めます。
$curl -X POST /users --data '{ "name": "Bob", "score": 70 }' |
作成時にscore値が存在しない場合は、リクエストにscoreプロパティは含めません。もちろんこの時のレスポンスとして作成したリソースを返却する場合、レスポンスの中にもscoreプロパティは含まれません。
$curl -X POST /users --data '{ "name": "Bob" }' |
全件更新(PUT)
新規作成(POST)の場合と同様です。
差分更新(PATCH)
差分更新として指定された一部の項目のみを更新したいというケースは多からず存在するでしょう。この場合はnull値を明示的に指定する必要があると考えています。
というのもリクエストのプロパティからscore自体を除外してしまうと、更新対象外となってしまい意図した挙動となりません。
$curl -X PATCH /users/00001 --data '{ }' |
このような場合、明確にnull値を指定してアップデートをする必要があります。
$curl -X PATCH /users/00001 --data '{ "score": null }' |
バリデーションの観点
リクエスト/レスポンス(特にリクエスト)はサーバ側でバリデーションを実施することが基本です。
先程例としてあげたユーザオブジェクトがscoreの代わりにオプショナルな属性としてemailを持つケースをもとに考えてみましょう。
バリデーションを行うため、emailはRFC 5321の仕様に則ったフォーマットを保持することを前提として考えます。
| 項目 | データ型 | 必須 | フォーマット |
|---|---|---|---|
| ID | 文字列 | ○ | |
| Name | 文字列 | ○ | |
| 文字列 | RFC 5321形式 |
undefinedで表現する場合
OpenAPI定義は次のようになります。
application/json: |
JSON Schemaに基づいたValidation結果は次のようになり、特筆すべき事項はありません。
✅ emailに適切なフォーマットの値が指定される場合
{ "id": "00001", "name": "Bob", "email": "bob@example.com" } // Valid |
✅ emailが存在しない場合
{ "id": "00001", "name": "Bob" } // Valid |
❌ emailに不適切なフォーマットの値が指定される場合
{ "id": "00001", "name": "Bob", "email": "invalid" } // Invalid |
null型で表現する場合
OpenAPI定義は次のようになります。
application/json: |
こちらもundefinedで表現した場合と同様にバリデーションについては特筆すべき事項はありません。
✅ emailに適切なフォーマットの値が指定される場合
{ "id": "00001", "name": "Bob", "email": "bob@example.com" } // Valid |
✅ emailにnull値が指定される場合
{ "id": "00001", "name": "Bob", "email": null } // Valid |
❌ emailに不適切なフォーマットの値が指定される場合
{ "id": "00001", "name": "Bob", "email": "invalid" } // Invalid |
null型以外で空値を表現する場合
最後にemailの空値をnull型ではなく空文字で表現する場合を考えてみましょう。
OpenAPI定義は次のようになります。
application/json: |
このときJSONにて空のemailを表現するためには空文字を利用することになりますが、下記のJSONはJSON Schema ValidationでNGと判断されます。
❌ emailに空文字が指定される場合
{ "id": "00001", "name": "Bob", "email": "" } // Invalid |
これは空文字がメールアドレスのフォーマットとして許容されないからです。
null型の代わりに空文字を採用する場合、OpenAPIの定義上format: emailを除いてあげないと、空値を表現できません。これは本末転倒と言わざるを得ないでしょう。
その他注意事項
空値の表現にnull型を用いる場合で、enum(列挙型)を利用している場合は、型だけでなくenumの要素としてもnullを含めてあげないとエラーとなります。
application/json: |
プログラムの観点
最後に実装するプログラム視点での注意点を補足しておこうと思います。
クライアントサイド
フロントエンドがWebの場合は、JavaScriptやTypeScriptを用いてクライアント側の実装をすることが多いと思います。
JavaScriptは言語としてundefined型とnull型を持つので上記のいずれのパターンにも、特に問題なく対応できると思っています(今のところ筆者は課題感を持っていません)。
サーバサイド
サーバサイドについては、上述した差分更新のユースケースなどでundefinedの場合とnull値の場合を識別したい場合に少し工夫が必要になる場合があるかもしれません。
ほとんどのプログラムにおいてはJSONを対応するオブジェクトにデシリアライズすることになると思いますが、デシリアライズした後に上記の識別をしなければならないケースが該当します。
例えばGolangをで先程のユーザオブジェクトを素直に表現すると次のようになります。
type User struct { |
この場合、emailがundefinedの場合とnull値の場合を判別できません。
func main() { |
undefinedの場合とnull値を判別したい場合は別途構造体を用意する形となります。
// NullString represents a string value that may be null. |
これを用いてユーザオブジェクトを再定義すると下記のようになります。
type User struct { |
undefinedの場合はSetプロパティがfalse、null値が指定された場合ははSetプロパティがtrueとなります。
func main() { |
おわりに
いかがでしたでしょうか。
この辺りの設計は一概に正解があるというものではないので、ぜひご意見ある方はコメントいただけますと幸いです。
いずれにしても設計・開発を推進していく上では、設計者・開発者でこのあたりの方針について認識を合わせ、システム全体として統一感のとれた作りにしておくことが大切だと思っています。