🏔️

Jackson / ObjectMapper でEnumで取りえない値を変換したら勝手に値が選択される

2023/07/03に公開

はじめに

Enumは取りうる値に制約をかけたい場合や、定数定義などをカテゴライズして定義したい場合に使ったりすることがある。
SpringBootREST-API などでもリクエストの値をEnumへバインドしたいこともしばしば。

この記事は、
JSONのリクエスト中の文字列プロパティをJavaのEnum型バインドしたい場合の話。
そんな時に陥った罠があったのでメモを残しておく。

何が起こるのか

にわかに信じ難い挙動だが、JacksonのObjectMapperで文字列プロパティ(string)をEnumに変換する場合、
変換先がない値を扱うと勝手に値を選択されてバインドされてしまう。
数値プロパティ(numeric)は変換先がない値を扱うとエラーとなる。こちらの挙動だと問題はない。
論より証拠なので、実際のコードと結果を示す。

実行結果は以下

{
  "animal": "DOG",
  "sports": 1
}

文字列プロパティ(string)をEnumへバインドすることは手段としてあり得ることであり、
理解せずにJackson / ObjectMapper を利用すると、アプリケーションが思わぬ動きをしてかなり危険な状態となる。

調べたところ、 @JsonCreator アノテーションを変換メソッドに付与することで解消できるという記事もあるが、
個別のEnumクラスへアノテーションを付け忘れた場合に、仮にRv・テストケースが抜けてしまった時の挙動が怖すぎるので、
セーフティネットを敷けないかと悩んだ。

なんでそうなるのか

文字列プロパティ(String)をEnumへ変換を行う場合の処理に癖がある。
クラスおよびメソッドは、 com.fasterxml.jackson.databind.deser.std.EnumDeserializer._deserializeAltString()
このメソッドに来るまでの流れとして、まずは deserialize を呼び出される。対象の値が文字列プロパティである場合は _fromString メソッドで処理が実行される。
ここで、値の変換先が見つからない場合、 _deserializeAltString メソッドの処理が実行される。

_deserializeAltString では値が存在する場合、文字列を1文字ずつ調べて数値である場合には、その数値をindex値として認識する。
Enumの列挙子を定義順に0始まりで並べた配列から、indexを用いてEnumを取り出し採用する。

Enumの値を数値文字列にしなければよいではないか。とも考えたが、
読み込むJSONに数値文字列が指定されている場合は同様の処理に落ちてしまうため、防御にはなりえない。

対策

ObjectMapperにて、 DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS を有効化することで、
文字列プロパティに値の変換先が存在しない場合の振る舞いとして全体設定での防御を行える。
なお、 @JsonCreator も忘れずに実装することが望ましい。

実行結果は以下

Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `JacksonDeserializeEnumInvalidValueSelectSample2$Animal` from String "00": not one of the values accepted for Enum class: [01, 02, 03]
 at [Source: (String)"{"animal": "00", "sports": 1}"; line: 1, column: 12] (through reference chain: JacksonDeserializeEnumInvalidValueSelectSample2$SampleBean["animal"])
	at com.fasterxml.jackson.databind.exc.InvalidFormatException.from(InvalidFormatException.java:67)
	at com.fasterxml.jackson.databind.DeserializationContext.weirdStringException(DeserializationContext.java:2002)
	at com.fasterxml.jackson.databind.DeserializationContext.handleWeirdStringValue(DeserializationContext.java:1230)
	at com.fasterxml.jackson.databind.deser.std.EnumDeserializer._deserializeAltString(EnumDeserializer.java:415)
	at com.fasterxml.jackson.databind.deser.std.EnumDeserializer._fromString(EnumDeserializer.java:279)
	at com.fasterxml.jackson.databind.deser.std.EnumDeserializer.deserialize(EnumDeserializer.java:248)
	at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:314)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:177)
	at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4825)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3772)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3740)
	at JacksonDeserializeEnumInvalidValueSelectSample2.main(JacksonDeserializeEnumInvalidValueSelectSample2.java:23)

Discussion