diff --git a/pom.xml b/pom.xml index 7babcc07a4..205d58cb5c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-redis - 4.1.0-SNAPSHOT + 4.1.0-GH-3306-SNAPSHOT Spring Data Redis Spring Data module for Redis diff --git a/src/main/java/org/springframework/data/redis/serializer/DefaultTypingPolicy.java b/src/main/java/org/springframework/data/redis/serializer/DefaultTypingPolicy.java new file mode 100644 index 0000000000..e747523de9 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/DefaultTypingPolicy.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.lang.reflect.Modifier; +import java.util.function.Predicate; +import org.springframework.util.ClassUtils; + +/** + * Policy that defines whether to include automatic type information for Jackson + * for each serialized type. + *

+ * Provides a {@link Builder builder} to create a composite policy consisting of + * outcomes to apply for individual types. + *

+ * An example that uses the default policy and adds a rule for a custom type: + *

+ * DefaultTypingPolicy.defaults()
+ *     .include((clazz) -> clazz == Person.class)
+ *     .build();
+ * 
+ *

+ * This is a {@link FunctionalInterface} whose functional method is + * {@link #outcomeForType(Class)}. + * + * @author Chris Bono + * @since 4.1 + */ +public interface DefaultTypingPolicy { + + /** + * The outcome to apply for a given type. + */ + enum Outcome { + + /** Include type hints for the given type */ + INCLUDE_TYPE_HINT, + + /** Do not include type hints for the given type */ + EXCLUDE_TYPE_HINT, + + /** No opinion for the given type - fallback to the default logic */ + NO_OPINION; + } + + /** + * Determine the outcome to take for a particular type. + * @param clazz the type to check. + * @return the outcome for the type. + */ + Outcome outcomeForType(Class clazz); + + /** + * Obtain a builder with no defaults configured. + * @return a builder with no defaults configured. + */ + static DefaultTypingPolicy.Builder empty() { + return new StdDefaultTypingPolicy.DefaultBuilder(); + } + + /** + * Obtain a builder with defaults configured. + * @return a builder with defaults configured. + */ + static DefaultTypingPolicy.Builder defaults() { + + DefaultTypingPolicy.Builder builder = new StdDefaultTypingPolicy.DefaultBuilder(); + builder.include((clazz) -> clazz == Object.class); + builder.exclude((clazz) -> Modifier.isFinal(clazz.getModifiers()) && clazz.getPackageName().startsWith("java")); + builder.exclude(ClassUtils::isPrimitiveOrWrapper); + builder.include(Class::isEnum); + builder.include(Class::isRecord); + + return builder; + } + + /** + * A mutable builder for creating a {@link DefaultTypingPolicy}. + */ + interface Builder { + + /** + * Adds a rule that will return an {@link Outcome#INCLUDE_TYPE_HINT} outcome + * for matching types. + * @param typeMatcher the predicate to match the types. + * @return this. + */ + Builder include(Predicate> typeMatcher); + + /** + * Adds a rule that will return an {@link Outcome#EXCLUDE_TYPE_HINT} outcome + * for matching types. + * @param typeMatcher the predicate to match the types. + * @return this. + */ + Builder exclude(Predicate> typeMatcher); + + /** + * Adds a rule that will return an {@link Outcome#NO_OPINION} outcome + * for matching types. + * @param typeMatcher the predicate to match the types. + * @return this. + */ + Builder fallback(Predicate> typeMatcher); + + /** + * Build the policy. + * @return the policy. + */ + DefaultTypingPolicy build(); + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java index 8865b6359e..05872a1dcd 100644 --- a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java @@ -24,10 +24,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.cache.support.NullValue; -import org.springframework.core.KotlinDetector; import org.springframework.data.util.Lazy; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -68,6 +66,7 @@ * @author Mao Shuai * @author John Blum * @author Anne Lee + * @author Chris Bono * @see Jackson2ObjectReader * @see Jackson2ObjectWriter * @see com.fasterxml.jackson.databind.ObjectMapper @@ -135,7 +134,7 @@ public GenericJackson2JsonRedisSerializer(@Nullable String typeHintPropertyName, registerNullValueSerializer(this.mapper, typeHintPropertyName); - this.mapper.setDefaultTyping(createDefaultTypeResolverBuilder(getObjectMapper(), typeHintPropertyName)); + this.mapper.setDefaultTyping(createDefaultTypeResolverBuilder(null, getObjectMapper(), typeHintPropertyName)); } /** @@ -216,10 +215,12 @@ private static Lazy getConfiguredTypeDeserializationPropertyName(ObjectM }); } - private static StdTypeResolverBuilder createDefaultTypeResolverBuilder(ObjectMapper objectMapper, + private static StdTypeResolverBuilder createDefaultTypeResolverBuilder(@Nullable DefaultTypingPolicy defaultTyping, + ObjectMapper objectMapper, @Nullable String typeHintPropertyName) { - StdTypeResolverBuilder typer = TypeResolverBuilder.forEverything(objectMapper).init(JsonTypeInfo.Id.CLASS, null) + StdTypeResolverBuilder typer = TypeResolverBuilder.forTyping(defaultTyping, objectMapper) + .init(JsonTypeInfo.Id.CLASS, null) .inclusion(As.PROPERTY); if (StringUtils.hasText(typeHintPropertyName)) { @@ -472,7 +473,9 @@ public static class GenericJackson2JsonRedisSerializerBuilder { private @Nullable ObjectMapper objectMapper; - private @Nullable Boolean defaultTyping; + private @Nullable Boolean defaultTypingEnabled; + + private @Nullable DefaultTypingPolicy defaultTyping; private boolean registerNullValueSerializer = true; @@ -490,6 +493,22 @@ private GenericJackson2JsonRedisSerializerBuilder() {} * @return this {@link GenericJackson2JsonRedisSerializer.GenericJackson2JsonRedisSerializerBuilder}. */ public GenericJackson2JsonRedisSerializerBuilder defaultTyping(boolean defaultTyping) { + this.defaultTypingEnabled = defaultTyping; + this.defaultTyping = null; + return this; + } + + /** + * Enable default typing by setting {@link DefaultTyping}. Enabling default typing will override + * {@link ObjectMapper#setDefaultTyping(com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder)} for a given + * {@link ObjectMapper}. Default typing is enabled by default if no {@link ObjectMapper} is provided. + * + * @param defaultTyping the predicate that matches whether the type should have type info hints added. + * @return this {@link GenericJackson2JsonRedisSerializer.GenericJackson2JsonRedisSerializerBuilder}. + * @since 4.0.4 + */ + public GenericJackson2JsonRedisSerializerBuilder defaultTyping(DefaultTypingPolicy defaultTyping) { + this.defaultTypingEnabled = true; this.defaultTyping = defaultTyping; return this; } @@ -599,9 +618,11 @@ public GenericJackson2JsonRedisSerializer build() { : new NullValueSerializer(this.typeHintPropertyName))); } - if ((!providedObjectMapper && (defaultTyping == null || defaultTyping)) - || (defaultTyping != null && defaultTyping)) { - objectMapper.setDefaultTyping(createDefaultTypeResolverBuilder(objectMapper, typeHintPropertyName)); + // enable default typing by default unless providing ObjectMapper or defaultTypingEnabled is explicitly set. + if ((!providedObjectMapper && (defaultTypingEnabled == null || defaultTypingEnabled)) + || (defaultTypingEnabled != null && defaultTypingEnabled)) { + objectMapper + .setDefaultTyping(createDefaultTypeResolverBuilder(defaultTyping, objectMapper, typeHintPropertyName)); } return new GenericJackson2JsonRedisSerializer(objectMapper, this.reader, this.writer, this.typeHintPropertyName); @@ -619,12 +640,17 @@ public GenericJackson2JsonRedisSerializer build() { */ private static class TypeResolverBuilder extends ObjectMapper.DefaultTypeResolverBuilder { - static TypeResolverBuilder forEverything(ObjectMapper mapper) { - return new TypeResolverBuilder(DefaultTyping.EVERYTHING, mapper.getPolymorphicTypeValidator()); + private final @Nullable DefaultTypingPolicy defaultTyping; + + static TypeResolverBuilder forTyping(@Nullable DefaultTypingPolicy defaultTyping, ObjectMapper mapper) { + return new TypeResolverBuilder( + defaultTyping, + mapper.getPolymorphicTypeValidator()); } - public TypeResolverBuilder(DefaultTyping typing, PolymorphicTypeValidator polymorphicTypeValidator) { - super(typing, polymorphicTypeValidator); + public TypeResolverBuilder(@Nullable DefaultTypingPolicy defaultTyping, PolymorphicTypeValidator polymorphicTypeValidator) { + super(DefaultTyping.EVERYTHING, polymorphicTypeValidator); + this.defaultTyping = defaultTyping; } @Override @@ -640,23 +666,17 @@ public ObjectMapper.DefaultTypeResolverBuilder withDefaultImpl(Class defaultI @Override public boolean useForType(JavaType javaType) { - if (javaType.isJavaLangObject()) { - return true; - } - - javaType = resolveArrayOrWrapper(javaType); - - if (javaType.isEnumType() || ClassUtils.isPrimitiveOrWrapper(javaType.getRawClass())) { - return false; - } + JavaType resolvedType = resolveArrayOrWrapper(javaType); + Class rawClass = resolvedType.getRawClass(); - if (javaType.isFinal() && !KotlinDetector.isKotlinType(javaType.getRawClass()) - && javaType.getRawClass().getPackageName().startsWith("java")) { - return false; - } + DefaultTypingPolicy typingPredicate = defaultTyping != null ? defaultTyping : + DefaultTypingPolicy.defaults().build(); - // [databind#88] Should not apply to JSON tree models: - return !TreeNode.class.isAssignableFrom(javaType.getRawClass()); + return switch (typingPredicate.outcomeForType(rawClass)) { + case INCLUDE_TYPE_HINT -> true; + case EXCLUDE_TYPE_HINT -> false; + case NO_OPINION -> !TreeNode.class.isAssignableFrom(rawClass); + }; } private JavaType resolveArrayOrWrapper(JavaType type) { diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java index e486a3f7a5..c8885e3421 100644 --- a/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java @@ -52,11 +52,9 @@ import org.jspecify.annotations.Nullable; import org.springframework.cache.support.NullValue; -import org.springframework.core.KotlinDetector; import org.springframework.data.util.Lazy; import org.springframework.lang.Contract; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -68,6 +66,7 @@ * {@link JacksonObjectWriter}. * * @author Christoph Strobl + * @author Chris Bono * @see JacksonObjectReader * @see JacksonObjectWriter * @see ObjectMapper @@ -266,7 +265,8 @@ public static class GenericJacksonJsonRedisSerializerBuilder builderFactory; private boolean cacheNullValueSupportEnabled = false; - private boolean defaultTyping = false; + private boolean defaultTypingEnabled; + private @Nullable DefaultTypingPolicy defaultTyping; private @Nullable String typePropertyName; private PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() .allowIfBaseType(Object.class).allowIfSubType((ctx, clazz) -> true).build(); @@ -323,7 +323,7 @@ public GenericJacksonJsonRedisSerializerBuilder enableSpringCacheNullValueSup @Contract("-> this") public GenericJacksonJsonRedisSerializerBuilder enableUnsafeDefaultTyping() { - this.defaultTyping = true; + this.defaultTypingEnabled = true; return this; } @@ -338,8 +338,29 @@ public GenericJacksonJsonRedisSerializerBuilder enableUnsafeDefaultTyping() { public GenericJacksonJsonRedisSerializerBuilder enableDefaultTyping(PolymorphicTypeValidator typeValidator) { typeValidator(typeValidator); + this.defaultTypingEnabled = true; + + return this; + } + + /** + * TODO: fix this javadoc + * Enables + * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * default typing} without any type validation constraints. + *

+ * WARNING: without restrictions of the {@link PolymorphicTypeValidator} deserialization is + * vulnerable to arbitrary code execution when reading from untrusted sources. + * + * @param defaultTyping the predicate that matches whether the type should have type info hints added. + * @return {@code this} builder. + */ + @Contract("_ -> this") + public GenericJacksonJsonRedisSerializerBuilder defaultTyping(DefaultTypingPolicy defaultTyping) { + + this.defaultTypingEnabled = true; + this.defaultTyping = defaultTyping; - this.defaultTyping = true; return this; } @@ -440,10 +461,10 @@ public GenericJacksonJsonRedisSerializer build() { })); } - if (defaultTyping) { + if (defaultTypingEnabled) { GenericJacksonJsonRedisSerializer.TypeResolverBuilder resolver = new GenericJacksonJsonRedisSerializer.TypeResolverBuilder( - typeValidator, DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY, JsonTypeInfo.Id.CLASS, typePropertyName); + typeValidator, defaultTyping, JsonTypeInfo.As.PROPERTY, JsonTypeInfo.Id.CLASS, typePropertyName); mapperBuilder.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).setDefaultTyping(resolver); @@ -596,17 +617,13 @@ public void setupModule(SetupContext context) { private static class TypeResolverBuilder extends DefaultTypeResolverBuilder { - public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, JsonTypeInfo.As includeAs) { - super(subtypeValidator, t, includeAs); - } - - public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, String propertyName) { - super(subtypeValidator, t, propertyName); - } + private final @Nullable DefaultTypingPolicy defaultTyping; - public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, JsonTypeInfo.As includeAs, + public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, @Nullable DefaultTypingPolicy defaultTyping, + JsonTypeInfo.As includeAs, JsonTypeInfo.Id idType, @Nullable String propertyName) { - super(subtypeValidator, t, includeAs, idType, propertyName); + super(subtypeValidator, DefaultTyping.NON_FINAL, includeAs, idType, propertyName); + this.defaultTyping = defaultTyping; } @Override @@ -622,23 +639,17 @@ public DefaultTypeResolverBuilder withDefaultImpl(Class defaultImpl) { @Override public boolean useForType(JavaType javaType) { - if (javaType.isJavaLangObject()) { - return true; - } - - javaType = resolveArrayOrWrapper(javaType); + JavaType resolvedType = resolveArrayOrWrapper(javaType); + Class rawClass = resolvedType.getRawClass(); - if (javaType.isEnumType() || ClassUtils.isPrimitiveOrWrapper(javaType.getRawClass())) { - return false; - } - - if (javaType.isFinal() && !KotlinDetector.isKotlinType(javaType.getRawClass()) - && javaType.getRawClass().getPackageName().startsWith("java")) { - return false; - } + DefaultTypingPolicy typingPolicy = defaultTyping != null ? defaultTyping : + DefaultTypingPolicy.defaults().build(); - // [databind#88] Should not apply to JSON tree models: - return !TreeNode.class.isAssignableFrom(javaType.getRawClass()); + return switch (typingPolicy.outcomeForType(rawClass)) { + case INCLUDE_TYPE_HINT -> true; + case EXCLUDE_TYPE_HINT -> false; + case NO_OPINION -> !TreeNode.class.isAssignableFrom(rawClass); + }; } private JavaType resolveArrayOrWrapper(JavaType type) { diff --git a/src/main/java/org/springframework/data/redis/serializer/StdDefaultTypingPolicy.java b/src/main/java/org/springframework/data/redis/serializer/StdDefaultTypingPolicy.java new file mode 100644 index 0000000000..651df72b5f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/StdDefaultTypingPolicy.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +import static org.springframework.data.redis.serializer.DefaultTypingPolicy.Outcome.INCLUDE_TYPE_HINT; +import static org.springframework.data.redis.serializer.DefaultTypingPolicy.Outcome.EXCLUDE_TYPE_HINT; +import static org.springframework.data.redis.serializer.DefaultTypingPolicy.Outcome.NO_OPINION; + +/** + * The default implementation of {@link DefaultTypingPolicy}, + * as created by the static factory methods. + * + * @see DefaultTypingPolicy#empty() + * @see DefaultTypingPolicy#defaults() + */ +final class StdDefaultTypingPolicy implements DefaultTypingPolicy { + + private final List typeSpecs = new ArrayList<>(); + + StdDefaultTypingPolicy(List typeSpecs) { + this.typeSpecs.addAll(typeSpecs); + } + + /** + * Determine the outcome to take for a particular type. + *

+ * Important: All rules are evaluated in the order they are + * added, with the last matching winning. If no outcome is matched + * for a particular type, the default {@link Outcome#NO_OPINION NO_OPINION} + * outcome is used. + * + * @param clazz the type to check. + * @return the outcome for the type. + */ + @Override + public Outcome outcomeForType(Class clazz) { + + List results = typeSpecs.stream() + .filter((typeSpec) -> typeSpec.typeMatcher().test(clazz)) + .map(JavaTypeSpec::outcome) + .toList(); + + return results.isEmpty() ? NO_OPINION : results.get(results.size() - 1); + } + + /** + * The default {@link DefaultTypingPolicy.Builder builder} implementation that + * builds an ordered list of rules to apply. + */ + static final class DefaultBuilder implements DefaultTypingPolicy.Builder { + + private final List typeSpecs = new ArrayList<>(); + + @Override + public DefaultTypingPolicy.Builder include(Predicate> typeMatcher) { + typeSpecs.add(new JavaTypeSpec(typeMatcher, INCLUDE_TYPE_HINT)); + return this; + } + + @Override + public DefaultTypingPolicy.Builder exclude(Predicate> typeMatcher) { + typeSpecs.add(new JavaTypeSpec(typeMatcher, EXCLUDE_TYPE_HINT)); + return this; + } + + @Override + public DefaultTypingPolicy.Builder fallback(Predicate> typeMatcher) { + typeSpecs.add(new JavaTypeSpec(typeMatcher, NO_OPINION)); + return this; + } + + @Override + public DefaultTypingPolicy build() { + return new StdDefaultTypingPolicy(typeSpecs); + } + + } + + record JavaTypeSpec(Predicate> typeMatcher, Outcome outcome) { + } +} diff --git a/src/test/java/org/springframework/data/redis/serializer/DefaultTypingPolicyUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/DefaultTypingPolicyUnitTests.java new file mode 100644 index 0000000000..82cdb5ef2b --- /dev/null +++ b/src/test/java/org/springframework/data/redis/serializer/DefaultTypingPolicyUnitTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.util.Arrays; +import java.util.Collection; +import org.assertj.core.util.introspection.ClassUtils; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.serializer.DefaultTypingPolicy.Outcome; + +import static org.assertj.core.api.Assertions.*; + +/** + * Unit tests for {@link DefaultTypingPolicy}. + * + * @author Chris Bono + */ +class DefaultTypingPolicyUnitTests { + + @Test + void emptyPolicyReturnsNoOpinionForAllTypes() { + + DefaultTypingPolicy policy = DefaultTypingPolicy.empty().build(); + + assertThat(policy.outcomeForType(String.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(Collection.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(ClassType.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(RecordType.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(EnumType.class)).isEqualTo(Outcome.NO_OPINION); + } + + @Test + void policyWithBasicRules() { + + DefaultTypingPolicy policy = DefaultTypingPolicy.empty() + .include(Class::isEnum) + .include(Class::isRecord) + .exclude(ClassUtils::isPrimitiveOrWrapper) + .exclude(ClassUtils::isInJavaLangPackage) + .build(); + + assertThat(policy.outcomeForType(ClassType.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(EnumType.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(RecordType.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(int.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(Integer.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(String.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + } + + @Test + void policyRulesCanBeOverridden() { + + DefaultTypingPolicy policy = DefaultTypingPolicy.empty() + .include(Class::isEnum) + .include(Class::isRecord) + .exclude(Class::isEnum) + .build(); + + assertThat(policy.outcomeForType(ClassType.class)).isEqualTo(Outcome.NO_OPINION); + assertThat(policy.outcomeForType(EnumType.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(RecordType.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + } + + @Test + void defaultPolicyRulesAsExpected() { + + DefaultTypingPolicy policy = DefaultTypingPolicy.defaults().build(); + + // java.lang.Object included + assertThat(policy.outcomeForType(Object.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + + // Final java.* type excluded + assertThat(policy.outcomeForType(Arrays.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + + // Non-final java.* type no opinion + assertThat(policy.outcomeForType(RuntimeException.class)).isEqualTo(Outcome.NO_OPINION); + + // Enums included + assertThat(policy.outcomeForType(EnumType.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + + // Records included + assertThat(policy.outcomeForType(RecordType.class)).isEqualTo(Outcome.INCLUDE_TYPE_HINT); + + // Primitive and wrappers excluded + assertThat(policy.outcomeForType(int.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + assertThat(policy.outcomeForType(Integer.class)).isEqualTo(Outcome.EXCLUDE_TYPE_HINT); + } + + class ClassType { + } + + record RecordType(String name) { + } + + enum EnumType { + ONE, TWO; + } +} diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java index c959637650..eccab08eb2 100644 --- a/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java @@ -63,7 +63,9 @@ * @author Christoph Strobl * @author Mark Paluch * @author John Blum + * @author Chris Bono */ +@SuppressWarnings("removal") class GenericJackson2JsonRedisSerializerUnitTests { private static final SimpleObject SIMPLE_OBJECT = new SimpleObject(1L); @@ -385,21 +387,66 @@ void deserializesUUIDFromBytes() { assertThat(deserializedUuid).isEqualTo(UUID.fromString("730145fe-324d-4fb1-b12f-60b89a045730")); } - @Test // GH-2396 - void serializesEnumIntoBytes() { + @Test // GH-3306 + void serializesEnumWithHintByDefault() { GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); - assertThat(serializer.serialize(EnumType.ONE)).isEqualTo(("\"ONE\"").getBytes(StandardCharsets.UTF_8)); + String expectedSerialized = "[\"%s\",\"ONE\"]"; + + assertThat(new String(serializer.serialize(EnumType.ONE))) + .isEqualTo(expectedSerialized.formatted(EnumType.class.getName())); + + assertThat(serializer.deserialize( + expectedSerialized.formatted(EnumType.class.getName()).getBytes(StandardCharsets.UTF_8), EnumType.class)) + .isEqualTo(EnumType.ONE); } - @Test // GH-2396 - void deserializesEnumFromBytes() { + @Test // GH-3306 + void serializesEnumWithoutHintWhenDefaultsOverridden() { + + DefaultTypingPolicy defaultTyping = DefaultTypingPolicy.defaults() + .exclude(Class::isEnum) + .build(); + + GenericJackson2JsonRedisSerializer serializer = GenericJackson2JsonRedisSerializer.builder() + .defaultTyping(defaultTyping).build(); + + assertThat(new String(serializer.serialize(EnumType.ONE))).isEqualTo(("\"ONE\"")); + + assertThat(serializer.deserialize("\"ONE\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) + .isEqualTo(EnumType.ONE); + } + + @Test // GH-3306 + void serializesRecordWithHintByDefault() { GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); - assertThat(serializer.deserialize("\"TWO\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) - .isEqualTo(EnumType.TWO); + String expectedSerialized = "{\"@class\":\"%s\",\"hello\":\"world\"}".formatted(Foo.class.getName()); + + assertThat(new String(serializer.serialize(new Foo("world")))).isEqualTo(expectedSerialized); + + assertThat(serializer.deserialize(expectedSerialized.getBytes(StandardCharsets.UTF_8), Foo.class)) + .isEqualTo(new Foo("world")); + } + + @Test // GH-3306 + void serializesRecordWithoutHintWhenDefaultsOverridden() { + + DefaultTypingPolicy defaultTyping = DefaultTypingPolicy.defaults() + .exclude(Class::isRecord) + .build(); + + GenericJackson2JsonRedisSerializer serializer = GenericJackson2JsonRedisSerializer.builder() + .defaultTyping(defaultTyping).build(); + + String expectedSerialized = "{\"hello\":\"world\"}"; + + assertThat(new String(serializer.serialize(new Foo("world")))).isEqualTo(expectedSerialized); + + assertThat(serializer.deserialize(expectedSerialized.getBytes(StandardCharsets.UTF_8), Foo.class)) + .isEqualTo(new Foo("world")); } @Test // GH-2396 @@ -416,7 +463,7 @@ void serializesJavaTimeIntoBytes() { } @Test // GH-2396 - void deserializesJavaTimeFrimBytes() { + void deserializesJavaTimeFromBytes() { GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); @@ -781,4 +828,7 @@ static class WithJsr310 { @JsonSerialize(using = LocalDateSerializer.class) @JsonDeserialize(using = LocalDateDeserializer.class) private LocalDate myDate; } + + record Foo(String hello) { + } } diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java index a3cec6c3d4..065a4ec3be 100644 --- a/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java @@ -60,6 +60,7 @@ * @author Christoph Strobl * @author Mark Paluch * @author John Blum + * @author Chris Bono */ class GenericJacksonJsonRedisSerializerUnitTests { @@ -342,21 +343,62 @@ void deserializesUUIDFromBytes() { assertThat(deserializedUuid).isEqualTo(UUID.fromString("730145fe-324d-4fb1-b12f-60b89a045730")); } - @Test // GH-2396 - void serializesEnumIntoBytes() { + @Test // GH-3306 + void serializesEnumWithHintByDefault() { - GenericJacksonJsonRedisSerializer serializer = this.serializer; + String expectedSerialized = "[\"%s\",\"ONE\"]"; + + assertThat(new String(serializer.serialize(EnumType.ONE))) + .isEqualTo(expectedSerialized.formatted(EnumType.class.getName())); - assertThat(serializer.serialize(EnumType.ONE)).isEqualTo(("\"ONE\"").getBytes(StandardCharsets.UTF_8)); + assertThat(serializer.deserialize( + expectedSerialized.formatted(EnumType.class.getName()).getBytes(StandardCharsets.UTF_8), EnumType.class)) + .isEqualTo(EnumType.ONE); } - @Test // GH-2396 - void deserializesEnumFromBytes() { + @Test // GH-3306 + void serializesEnumWithoutHintWhenDefaultsOverridden() { - GenericJacksonJsonRedisSerializer serializer = this.serializer; + DefaultTypingPolicy defaultTyping = DefaultTypingPolicy.defaults() + .exclude(Class::isEnum) + .build(); + + GenericJacksonJsonRedisSerializer serializer = GenericJacksonJsonRedisSerializer.builder() + .defaultTyping(defaultTyping).build(); + + assertThat(new String(serializer.serialize(EnumType.ONE))).isEqualTo(("\"ONE\"")); + + assertThat(serializer.deserialize("\"ONE\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) + .isEqualTo(EnumType.ONE); + } + + @Test // GH-3306 + void serializesRecordWithHintByDefault() { + + String expectedSerialized = "{\"@class\":\"%s\",\"hello\":\"world\"}".formatted(Foo.class.getName()); - assertThat(serializer.deserialize("\"TWO\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) - .isEqualTo(EnumType.TWO); + assertThat(new String(serializer.serialize(new Foo("world")))).isEqualTo(expectedSerialized); + + assertThat(serializer.deserialize(expectedSerialized.getBytes(StandardCharsets.UTF_8), Foo.class)) + .isEqualTo(new Foo("world")); + } + + @Test // GH-3306 + void serializesRecordWithoutHintWhenDefaultsOverridden() { + + DefaultTypingPolicy defaultTyping = DefaultTypingPolicy.defaults() + .exclude(Class::isRecord) + .build(); + + GenericJacksonJsonRedisSerializer serializer = GenericJacksonJsonRedisSerializer.builder() + .defaultTyping(defaultTyping).build(); + + String expectedSerialized = "{\"hello\":\"world\"}"; + + assertThat(new String(serializer.serialize(new Foo("world")))).isEqualTo(expectedSerialized); + + assertThat(serializer.deserialize(expectedSerialized.getBytes(StandardCharsets.UTF_8), Foo.class)) + .isEqualTo(new Foo("world")); } @Test // GH-2396 @@ -672,4 +714,9 @@ static class WithJsr310 { @JsonSerialize(using = LocalDateSerializer.class) @JsonDeserialize(using = LocalDateDeserializer.class) private LocalDate myDate; } + + record Foo(String hello) { + + } + }