From b167f21e25a94ff24a05a4e9bb0580d63dd4f0b8 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Tue, 6 May 2025 18:37:07 -0400 Subject: [PATCH] ModelOptionsUtils: Add ACCEPT_EMPTY_STRING_AS_NULL_OBJECT and ObjectMapper overloads - Configure OBJECT_MAPPER to accept empty strings as null objects during deserialization. - Add overloaded jsonToMap(String, ObjectMapper) for custom ObjectMapper usage. - Add and update tests to verify correct handling of empty strings for both Map and POJO deserialization, including custom ObjectMapper scenarios. - Clarify documentation and ensure extensibility without global side effects. Fixes #2222 Signed-off-by: Mark Pollack --- .../ai/model/ModelOptionsUtils.java | 19 +++++-- .../ai/model/ModelOptionsUtilsTests.java | 54 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index 475227b96..0ace8cc58 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -69,7 +69,8 @@ public abstract class ModelOptionsUtils { .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .addModules(JacksonUtils.instantiateAvailableModules()) - .build(); + .build() + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); private static final List BEAN_MERGE_FIELD_EXCISIONS = List.of("class"); @@ -82,13 +83,25 @@ public abstract class ModelOptionsUtils { }; /** - * Converts the given JSON string to a Map of String and Object. + * Converts the given JSON string to a Map of String and Object using the default + * ObjectMapper. * @param json the JSON string to convert to a Map. * @return the converted Map. */ public static Map jsonToMap(String json) { + return jsonToMap(json, OBJECT_MAPPER); + } + + /** + * Converts the given JSON string to a Map of String and Object using a custom + * ObjectMapper. + * @param json the JSON string to convert to a Map. + * @param objectMapper the ObjectMapper to use for deserialization. + * @return the converted Map. + */ + public static Map jsonToMap(String json, ObjectMapper objectMapper) { try { - return OBJECT_MAPPER.readValue(json, MAP_TYPE_REF); + return objectMapper.readValue(json, MAP_TYPE_REF); } catch (Exception e) { throw new RuntimeException(e); diff --git a/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java b/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java index b2d8c2d51..5b22b118e 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java @@ -24,6 +24,11 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + /** * @author Christian Tzolov */ @@ -122,6 +127,55 @@ public class ModelOptionsUtilsTests { assertThat(target.getSpecificField()).isNull(); } + @Test + public void jsonToMap_emptyStringAsNullObject() { + String json = "{\"name\":\"\", \"age\":30}"; + // For Map: empty string remains "" + Map map = ModelOptionsUtils.jsonToMap(json); + assertThat(map.get("name")).isEqualTo(""); + assertThat(map.get("age")).isEqualTo(30); + + // Custom ObjectMapper: still "" for Map + ObjectMapper strictMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build() + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, false); + Map mapStrict = ModelOptionsUtils.jsonToMap(json, strictMapper); + assertThat(mapStrict.get("name")).isEqualTo(""); + } + + @Test + public void pojo_emptyStringAsNullObject() throws Exception { + String json = "{\"name\":\"\", \"age\":30}"; + + // POJO with default OBJECT_MAPPER (feature enabled) + Person person = ModelOptionsUtils.OBJECT_MAPPER.readValue(json, Person.class); + assertThat(person.name).isEqualTo(""); // String remains "" + assertThat(person.age).isEqualTo(30); // Integer is fine + + String jsonWithEmptyAge = "{\"name\":\"John\", \"age\":\"\"}"; + Person person2 = ModelOptionsUtils.OBJECT_MAPPER.readValue(jsonWithEmptyAge, Person.class); + assertThat(person2.name).isEqualTo("John"); + assertThat(person2.age).isNull(); // Integer: "" → null + + // POJO with feature disabled: should fail for Integer field + ObjectMapper strictMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build() + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, false); + assertThatThrownBy(() -> strictMapper.readValue(jsonWithEmptyAge, Person.class)).isInstanceOf(Exception.class); + } + + public static class Person { + + public String name; + + public Integer age; + + } + @Test public void getJsonPropertyValues() { record TestRecord(@JsonProperty("field1") String fieldA, @JsonProperty("field2") String fieldB) {