Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 111 additions & 6 deletions src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -142,9 +143,28 @@ public Optional<SerializerBinding<?>> getSerializerBinding(Type propertyRuntimeT
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);
}

/**
Expand Down Expand Up @@ -175,7 +195,14 @@ public Optional<AdapterBinding> getSerializeAdapterBinding(Type propertyRuntimeT
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);
}

/**
Expand All @@ -194,7 +221,19 @@ public Optional<AdapterBinding> getDeserializeAdapterBinding(Type propertyRuntim
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) {
Expand All @@ -206,6 +245,15 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(

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);
Expand All @@ -220,7 +268,7 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
// 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;
}
Expand All @@ -229,7 +277,64 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(

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())) {
Expand Down
90 changes: 90 additions & 0 deletions src/test/java/org/eclipse/yasson/serializers/SerializersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -812,4 +814,92 @@ public void testCustomSerializersInContainer(){

}

/**
* 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
}

}