フューチャー技術ブログ

OpenAPIにおけるundefinedとnullの設計

はじめに

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:
schema:
type: object
properties:
id:
type: string
required:
- id

正しいケース

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:
schema:
type: object
properties:
id:
type: string
nullable: true
required:
- id

OpenAPI3.1の場合
https://spec.openapis.org/oas/v3.1.0#data-types
nullに関する注記は削除され、JSON Schemaの仕様と同じく`null``を型として明確にサポートするようになりました。

application/json:
schema:
type: object
properties:
id:
type:
- string
- 'null'
required:
- id

バージョンによって表現の差異はありますが、意味するところは同じです。

正しいケース

idに具体的な文字列が指定されている

{ "id": "00001" }

idに空文字が指定されている

{ "id": "" }

idnullが指定されている

{ "id": null }

正しくないケース

idのキーが存在しない

{}

これは先述したrequiredの条件を満たしていないためNGとなります。

undefined vs null

ここまで見てきたようにundefinednullは似て非なるものです。
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 {}(空オブジェクト)

そもそもintegernumberのような数値型やブーリアン型は空値を表現する方法がないので、この方法は採用できません。
数値について、例えば業務上自然数しか入りえない項目(例. 年齢)に対して-1のような値を利用して空値を表現するような場合を見かけますが、これは設計上望ましくないと考えます。

さらに言うと、空値の表現が可能な文字列やオブジェクト含め、後述するバリデーションの観点から、null型以外の型で空値を表現するのは基本的に望ましくないと考えています。

筆者の意見

結論から言うと原則null値は利用せずキー自体を含めないundefinedが良いと思っています。
特にOpenAPIの仕様に基づいてAPI仕様をドキュメントする場合において、null値を許容するように記述するのは煩雑です。

ただしここで「原則」と言ったのはAPIのユースケースに応じて明確にnull値を表現したいケースは存在すると考えています。

ユースケースの観点

先程のユーザリソースを例にCRUDを考えてみましょう。

取得(GET)

scoreが存在する場合は、score値が返却されます。

$ curl -X GET /users/00001
{ "id": "00001", "name": "Bob", score: 70 }

scoreが存在しない場合は、レスポンスにscoreプロパティは含めません。

$ curl -X GET /users/00001
{ "id": "00001", "name": "Bob" }
新規作成(POST)

作成時にscore値が存在する場合は、リクエストにscore値を含めます。

$curl -X POST /users --data '{ "name": "Bob", "score": 70 }'
{ "id": "00001", "name": "Bob", score: 70 }

作成時にscore値が存在しない場合は、リクエストにscoreプロパティは含めません。もちろんこの時のレスポンスとして作成したリソースを返却する場合、レスポンスの中にもscoreプロパティは含まれません。

$curl -X POST /users --data '{ "name": "Bob" }'
{ "id": "00001", "name": "Bob" }
全件更新(PUT)

新規作成(POST)の場合と同様です。

差分更新(PATCH)

差分更新として指定された一部の項目のみを更新したいというケースは多からず存在するでしょう。この場合はnull値を明示的に指定する必要があると考えています。

というのもリクエストのプロパティからscore自体を除外してしまうと、更新対象外となってしまい意図した挙動となりません。

$curl -X PATCH /users/00001 --data '{ }'
{ "id": "00001", "name": "Bob", score: 70 } // score is not cleared.

このような場合、明確にnull値を指定してアップデートをする必要があります。

$curl -X PATCH /users/00001 --data '{ "score": null }'
{ "id": "00001", "name": "Bob" } // score is cleared.

バリデーションの観点

リクエスト/レスポンス(特にリクエスト)はサーバ側でバリデーションを実施することが基本です。
先程例としてあげたユーザオブジェクトがscoreの代わりにオプショナルな属性としてemailを持つケースをもとに考えてみましょう。
バリデーションを行うため、emailRFC 5321の仕様に則ったフォーマットを保持することを前提として考えます。

項目 データ型 必須 フォーマット
ID 文字列
Name 文字列
Email 文字列 RFC 5321形式
undefinedで表現する場合

OpenAPI定義は次のようになります。

application/json:
schema:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
required:
- id
- name

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:
schema:
type: object
properties:
id:
type: string
name:
type: string
email:
type:
- string
- 'null'
format: email
required:
- id
- name

こちらもundefinedで表現した場合と同様にバリデーションについては特筆すべき事項はありません。

emailに適切なフォーマットの値が指定される場合

{ "id": "00001", "name": "Bob", "email": "bob@example.com" } // Valid

emailnull値が指定される場合

{ "id": "00001", "name": "Bob", "email": null } // Valid

emailに不適切なフォーマットの値が指定される場合

{ "id": "00001", "name": "Bob", "email": "invalid" } // Invalid
null型以外で空値を表現する場合

最後にemailの空値をnull型ではなく空文字で表現する場合を考えてみましょう。
OpenAPI定義は次のようになります。

application/json:
schema:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
required:
- id
- name

このときJSONにて空のemailを表現するためには空文字を利用することになりますが、下記のJSONはJSON Schema ValidationでNGと判断されます。

emailに空文字が指定される場合

{ "id": "00001", "name": "Bob", "email": "" } // Invalid

これは空文字がメールアドレスのフォーマットとして許容されないからです。
null型の代わりに空文字を採用する場合、OpenAPIの定義上format: emailを除いてあげないと、空値を表現することができません。これは本末転倒と言わざるを得ないでしょう。

その他注意事項

空値の表現にnull型を用いる場合で、enum(列挙型)を利用している場合は、型だけでなくenumの要素としてもnullを含めてあげないとエラーとなります。

application/json:
schema:
type: object
properties:
id:
type: string
name:
type: string
color:
type:
- string
- 'null'
enum:
- 'red'
- 'yellow'
- 'green'
- null # これがないとnull指定時にエラーとなる
required:
- id
- name

プログラムの観点

最後に実装するプログラム視点での注意点を補足しておこうと思います。

クライアントサイド

フロントエンドがWEBの場合は、JavaScriptやTypeScriptを用いてクライアント側の実装をすることが多いと思います。
JavaScriptは言語としてundefined型とnull型を持つので上記のいずれのパターンにも、特に問題なく対応できると思っています。(今のところ筆者は課題感を持っていません。)

サーバサイド

サーバサイドについては、上述した差分更新のユースケースなどでundefinedの場合とnull値の場合を識別したい場合に少し工夫が必要になる場合があるかもしれません。

ほとんどのプログラムにおいてはJSONを対応するオブジェクトにデシリアライズすることになると思いますが、デシリアライズした後に上記の識別をしなければならないケースが該当します。

例えばGolangをで先程のユーザオブジェクトを素直に表現すると次のようになります。

type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email *string `json:"email"`
}

この場合、emailundefinedの場合とnull値の場合を判別することができません。

func main() {
// The email is undefined.
user1 := &User{}
json.Unmarshal([]byte(`{ "id": "00001", "name": "Bob" }`), user1)

// The email is null.
user2 := &User{}
json.Unmarshal([]byte(`{ "id": "00001", "name": "Bob", "email": null }`), user2)

// Both email values are nil
fmt.Println(user1.Email == user2.Email)
}

undefinedの場合とnull値を判別したい場合は別途構造体を用意する形となります。

// NullString represents a string value that may be null.
type NullString struct {
Value *string
Set bool
}


func (v NullString) MarshalJSON() ([]byte, error) {
if v.Set {
return json.Marshal(v.Value)
}
return json.Marshal(nil)
}

func (v *NullString) UnmarshalJSON(data []byte) error {
v.Set = true
// Return if the data is null
if string(data) == "null" {
return nil
}
if err := json.Unmarshal(data, &v.Value); err != nil {
return err
}
return nil
}

func (v NullString) String() string {
if !v.Set {
return "<nil>"
}
if v.Value == nil {
return "null"
}
return *v.Value
}

これを用いてユーザオブジェクトを再定義すると下記のようになります。

type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email NullString `json:"email"`
}

undefinedの場合はSetプロパティがfalsenull値が指定された場合ははSetプロパティがtrueとなります。

func main() {
// The email is undefined.
user1 := &User{}
json.Unmarshal([]byte(`{ "id": "00001", "name": "Bob" }`), user1)

// The email is null.
user2 := &User{}
json.Unmarshal([]byte(`{ "id": "00001", "name": "Bob", "email": null }`), user2)

fmt.Println(user1.Email.Set) // false
fmt.Println(user2.Email.Set) // true
}

おわりに

いかがでしたでしょうか。

この辺りの設計は一概に正解があるというものではないので、ぜひご意見ある方はコメントいただけますと幸いです。

いずれにしても設計・開発を推進していく上では、設計者・開発者でこのあたりの方針について認識を合わせ、システム全体として統一感のとれた作りにしておくことが大切だと思っています。