Jackson / ObjectMapper でEnumで取りえない値を変換したら勝手に値が選択される
はじめに
Enumは取りうる値に制約をかけたい場合や、定数定義などをカテゴライズして定義したい場合に使ったりすることがある。
SpringBoot
の REST-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