[689] Fix annotation-based serializers for Object-typed properties Annotation-based serializers (@JsonbTypeSerializer) were not applied to properties declared as Object type but containing instances with specific runtime types that have the annotation. Added runtime type discovery to find annotation-based serializers when serializing Object-typed properties, ensuring consistent behavior regardless of declared property type. Signed-off-by: James R. Perkins <jperkins@ibm.com>
diff --git a/src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java b/src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java index b4a2cf8..2763ea9 100644 --- a/src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java +++ b/src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java
@@ -24,6 +24,7 @@ import jakarta.json.bind.JsonbConfig; import jakarta.json.bind.adapter.JsonbAdapter; +import jakarta.json.bind.annotation.JsonbTypeSerializer; import jakarta.json.bind.serializer.JsonbDeserializer; import jakarta.json.bind.serializer.JsonbSerializer; @@ -142,9 +143,28 @@ ComponentBoundCustomization customization) { if (customization == null || customization.getSerializerBinding() == null) { - return searchComponentBinding(propertyRuntimeType, ComponentBindings::getSerializer); + return searchComponentBinding(propertyRuntimeType, ComponentBindings::getSerializer, this::getAnnotationBasedSerializer); } - return Optional.of(customization.getSerializerBinding()); + final SerializerBinding<?> binding = customization.getSerializerBinding(); + + // If the binding type exactly matches the runtime type, use it (optimization) + if (binding.getBindingType().equals(propertyRuntimeType)) { + return Optional.of(binding); + } + + // Special handling for Object type: search for more specific serializers based on runtime type + // This allows annotation-based or config-based serializers on concrete types to be found + // when the property is declared as Object but has a specific runtime type + if (Object.class.equals(binding.getBindingType())) { + final Optional<SerializerBinding<?>> moreSpecific = searchComponentBinding(propertyRuntimeType, + ComponentBindings::getSerializer, this::getAnnotationBasedSerializer); + if (moreSpecific.isPresent()) { + return moreSpecific; + } + } + + // Use the customization binding (user explicitly configured it for this property) + return Optional.of(binding); } /** @@ -175,7 +195,14 @@ if (customization == null || customization.getSerializeAdapterBinding() == null) { return searchComponentBinding(propertyRuntimeType, ComponentBindings::getAdapterInfo); } - return Optional.of(customization.getSerializeAdapterBinding()); + // Check if the customization's adapter binding matches the runtime type + AdapterBinding binding = customization.getSerializeAdapterBinding(); + if (matches(propertyRuntimeType, binding.getBindingType())) { + return Optional.of(binding); + } + // The annotation-based adapter doesn't match the runtime type, + // fall through to search for a better match based on runtime type + return searchComponentBinding(propertyRuntimeType, ComponentBindings::getAdapterInfo); } /** @@ -194,7 +221,19 @@ return Optional.of(customization.getDeserializeAdapterBinding()); } - private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(Type runtimeType, Function<ComponentBindings, T> supplier) { + /** + * Search for a component binding for the given runtime type. + * + * @param runtimeType The runtime type to find a component for + * @param supplier Function to extract the desired component from ComponentBindings + * @param annotationDiscovery Optional function for runtime annotation discovery (null if not applicable) + * @param <T> The type of component binding to search for + * @return Optional containing the component binding if found + */ + private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding( + Type runtimeType, + Function<ComponentBindings, T> supplier, + Function<Class<?>, Optional<T>> annotationDiscovery) { // First check if there is an exact match ComponentBindings binding = userComponents.get(runtimeType); if (binding != null) { @@ -206,6 +245,15 @@ Optional<Class<?>> runtimeClass = ReflectionUtils.getOptionalRawType(runtimeType); if (runtimeClass.isPresent()) { + // Check for annotation-based component on the runtime type itself + // Currently only used for @JsonbTypeSerializer during serialization + if (annotationDiscovery != null) { + Optional<T> annotationBased = annotationDiscovery.apply(runtimeClass.get()); + if (annotationBased.isPresent()) { + return annotationBased; + } + } + // Check if any interfaces have a match for (Class<?> ifc : runtimeClass.get().getInterfaces()) { ComponentBindings ifcBinding = userComponents.get(ifc); @@ -220,7 +268,7 @@ // check if the superclass has a match Class<?> superClass = runtimeClass.get().getSuperclass(); if (superClass != null && superClass != Object.class) { - Optional<T> superBinding = searchComponentBinding(superClass, supplier); + Optional<T> superBinding = searchComponentBinding(superClass, supplier, annotationDiscovery); if (superBinding.isPresent()) { return superBinding; } @@ -229,7 +277,64 @@ return Optional.empty(); } - + + // Convenience overload for components without annotation discovery (deserializers, adapters) + private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding( + final Type runtimeType, + final Function<ComponentBindings, T> supplier) { + return searchComponentBinding(runtimeType, supplier, null); + } + + /** + * Discovers and caches a serializer defined by @JsonbTypeSerializer annotation on a runtime type. + * + * <p>This method performs <strong>runtime</strong> annotation discovery during serialization, + * which is distinct from the build-time annotation introspection performed by AnnotationIntrospector. + * It is invoked when serializing a property where the runtime type is more specific than the + * declared type (e.g., a property declared as {@code Object} containing an instance of a class + * annotated with @JsonbTypeSerializer).</p> + * + * <p>Note: Only @JsonbTypeSerializer is checked, not @JsonbTypeAdapter or @JsonbTypeDeserializer, + * because:</p> + * <ul> + * <li>Serializers are unidirectional (serialization only), so runtime discovery is complete</li> + * <li>Deserializers don't apply - we lack runtime type information during deserialization</li> + * <li>Adapters are bidirectional - discovering them only at runtime during serialization + * would be incomplete since they couldn't be discovered during deserialization</li> + * </ul> + * + * @param clazz The runtime class to check for @JsonbTypeSerializer annotation + * @return SerializerBinding if annotation is present and successfully introspected, empty otherwise + */ + private Optional<SerializerBinding<?>> getAnnotationBasedSerializer(final Class<?> clazz) { + // Check if the class has a @JsonbTypeSerializer annotation + final JsonbTypeSerializer annotation = clazz.getAnnotation(JsonbTypeSerializer.class); + if (annotation == null) { + return Optional.empty(); + } + + // Thread-safe get-or-create using compute + final SerializerBinding<?> binding = userComponents.compute(clazz, (type, bindings) -> { + // If already cached, return as-is + if (bindings != null && bindings.getSerializer() != null) { + return bindings; + } + + // Create new serializer binding + final Class<? extends JsonbSerializer> serializerClass = annotation.value(); + final JsonbSerializer<?> serializer = jsonbContext.getComponentInstanceCreator().getOrCreateComponent(serializerClass); + final SerializerBinding<?> newBinding = new SerializerBinding<>(clazz, serializer); + + // Create or update ComponentBindings + if (bindings == null) { + return new ComponentBindings(clazz, newBinding, null, null); + } + return new ComponentBindings(clazz, newBinding, bindings.getDeserializer(), bindings.getAdapterInfo()); + }).getSerializer(); + + return Optional.ofNullable(binding); + } + private <T> Optional<T> getMatchingBinding(Type runtimeType, ComponentBindings binding, Function<ComponentBindings, T> supplier) { final T component = supplier.apply(binding); if (component != null && matches(runtimeType, binding.getBindingType())) {
diff --git a/src/test/java/org/eclipse/yasson/serializers/SerializersTest.java b/src/test/java/org/eclipse/yasson/serializers/SerializersTest.java index db0efdf..228ed1f 100644 --- a/src/test/java/org/eclipse/yasson/serializers/SerializersTest.java +++ b/src/test/java/org/eclipse/yasson/serializers/SerializersTest.java
@@ -35,6 +35,7 @@ import jakarta.json.bind.JsonbBuilder; import jakarta.json.bind.JsonbConfig; import jakarta.json.bind.JsonbException; +import jakarta.json.bind.annotation.JsonbTypeSerializer; import jakarta.json.bind.config.PropertyOrderStrategy; import jakarta.json.bind.serializer.DeserializationContext; import jakarta.json.bind.serializer.JsonbDeserializer; @@ -78,6 +79,7 @@ import static org.eclipse.yasson.Jsonbs.defaultJsonb; import static org.eclipse.yasson.Jsonbs.nullableJsonb; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -812,4 +814,92 @@ } + /** + * Test that annotation-based serializers work when property is declared as Object + * but the runtime type has @JsonbTypeSerializer annotation. + * This is a regression test for issue #689. + */ + @Test + public void testAnnotationBasedSerializerWithObjectTypedProperty() throws Exception { + try (Jsonb jsonb = JsonbBuilder.create()) { + + final ObjectPropertyContainer container = new ObjectPropertyContainer(); + final AnnotatedWithSerializerType objectInstance = new AnnotatedWithSerializerType(); + objectInstance.value = "test"; + container.annotatedAsObject = objectInstance; + container.annotatedConcrete = new AnnotatedWithSerializerType(); + container.annotatedConcrete.value = "test2"; + + final String result = jsonb.toJson(container); + + // Both properties should use the annotation-based serializer + final String expected = "{\"annotatedAsObject\":{\"valueField\":\"replaced value\"},\"annotatedConcrete\":{\"valueField\":\"replaced value\"}}"; + assertEquals(expected, result); + + // Deserialization: annotatedConcrete uses annotation-based deserializer + // annotatedAsObject is declared as Object so JSON-B creates a HashMap (expected behavior) + final ObjectPropertyContainer deserialized = jsonb.fromJson(expected, ObjectPropertyContainer.class); + // In the JSON, the type looks like an object and therefore is a map + assertInstanceOf(Map.class, deserialized.annotatedAsObject, "Object property deserializes to Map"); + final Map<?, ?> map = (Map<?, ?>) deserialized.annotatedAsObject; + assertTrue(map.containsKey("valueField")); + assertEquals("replaced value", map.get("valueField")); + assertEquals("replaced value", deserialized.annotatedConcrete.value); + } + } + + /** + * Test that field-level and method-level @JsonbTypeSerializer annotations work on Object-typed properties. + * This tests existing AnnotationIntrospector code (not runtime discovery). + */ + @Test + public void testFieldAndMethodLevelSerializerOnObjectType() throws Exception { + try (Jsonb jsonb = JsonbBuilder.create()) { + final ObjectWithAnnotatedFields container = new ObjectWithAnnotatedFields(); + container.fieldAnnotated = "test field"; + container.setMethodAnnotated("test method"); + + final String result = jsonb.toJson(container); + + // Both should use their respective serializers + final String expected = "{\"fieldAnnotated\":\"FIELD:test field\",\"methodAnnotated\":\"METHOD:test method\"}"; + assertEquals(expected, result); + } + } + + public static class ObjectWithAnnotatedFields { + @JsonbTypeSerializer(ObjectFieldSerializer.class) + public Object fieldAnnotated; + + private Object methodAnnotated; + + @JsonbTypeSerializer(ObjectMethodSerializer.class) + public Object getMethodAnnotated() { + return methodAnnotated; + } + + public void setMethodAnnotated(Object methodAnnotated) { + this.methodAnnotated = methodAnnotated; + } + } + + public static class ObjectFieldSerializer implements JsonbSerializer<Object> { + @Override + public void serialize(Object obj, JsonGenerator generator, SerializationContext ctx) { + generator.write("FIELD:" + obj.toString()); + } + } + + public static class ObjectMethodSerializer implements JsonbSerializer<Object> { + @Override + public void serialize(Object obj, JsonGenerator generator, SerializationContext ctx) { + generator.write("METHOD:" + obj.toString()); + } + } + + public static class ObjectPropertyContainer { + public Object annotatedAsObject; // Declared as Object - this was the bug scenario + public AnnotatedWithSerializerType annotatedConcrete; // Declared concretely - should always work + } + }