Skip to content

Commit 2aa1af5

Browse files
committed
[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>
1 parent 40c0444 commit 2aa1af5

File tree

2 files changed

+201
-6
lines changed

2 files changed

+201
-6
lines changed

src/main/java/org/eclipse/yasson/internal/ComponentMatcher.java

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import jakarta.json.bind.JsonbConfig;
2626
import jakarta.json.bind.adapter.JsonbAdapter;
27+
import jakarta.json.bind.annotation.JsonbTypeSerializer;
2728
import jakarta.json.bind.serializer.JsonbDeserializer;
2829
import jakarta.json.bind.serializer.JsonbSerializer;
2930

@@ -142,9 +143,28 @@ public Optional<SerializerBinding<?>> getSerializerBinding(Type propertyRuntimeT
142143
ComponentBoundCustomization customization) {
143144

144145
if (customization == null || customization.getSerializerBinding() == null) {
145-
return searchComponentBinding(propertyRuntimeType, ComponentBindings::getSerializer);
146+
return searchComponentBinding(propertyRuntimeType, ComponentBindings::getSerializer, this::getAnnotationBasedSerializer);
146147
}
147-
return Optional.of(customization.getSerializerBinding());
148+
final SerializerBinding<?> binding = customization.getSerializerBinding();
149+
150+
// If the binding type exactly matches the runtime type, use it (optimization)
151+
if (binding.getBindingType().equals(propertyRuntimeType)) {
152+
return Optional.of(binding);
153+
}
154+
155+
// Special handling for Object type: search for more specific serializers based on runtime type
156+
// This allows annotation-based or config-based serializers on concrete types to be found
157+
// when the property is declared as Object but has a specific runtime type
158+
if (Object.class.equals(binding.getBindingType())) {
159+
final Optional<SerializerBinding<?>> moreSpecific = searchComponentBinding(propertyRuntimeType,
160+
ComponentBindings::getSerializer, this::getAnnotationBasedSerializer);
161+
if (moreSpecific.isPresent()) {
162+
return moreSpecific;
163+
}
164+
}
165+
166+
// Use the customization binding (user explicitly configured it for this property)
167+
return Optional.of(binding);
148168
}
149169

150170
/**
@@ -175,7 +195,14 @@ public Optional<AdapterBinding> getSerializeAdapterBinding(Type propertyRuntimeT
175195
if (customization == null || customization.getSerializeAdapterBinding() == null) {
176196
return searchComponentBinding(propertyRuntimeType, ComponentBindings::getAdapterInfo);
177197
}
178-
return Optional.of(customization.getSerializeAdapterBinding());
198+
// Check if the customization's adapter binding matches the runtime type
199+
AdapterBinding binding = customization.getSerializeAdapterBinding();
200+
if (matches(propertyRuntimeType, binding.getBindingType())) {
201+
return Optional.of(binding);
202+
}
203+
// The annotation-based adapter doesn't match the runtime type,
204+
// fall through to search for a better match based on runtime type
205+
return searchComponentBinding(propertyRuntimeType, ComponentBindings::getAdapterInfo);
179206
}
180207

181208
/**
@@ -194,7 +221,19 @@ public Optional<AdapterBinding> getDeserializeAdapterBinding(Type propertyRuntim
194221
return Optional.of(customization.getDeserializeAdapterBinding());
195222
}
196223

197-
private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(Type runtimeType, Function<ComponentBindings, T> supplier) {
224+
/**
225+
* Search for a component binding for the given runtime type.
226+
*
227+
* @param runtimeType The runtime type to find a component for
228+
* @param supplier Function to extract the desired component from ComponentBindings
229+
* @param annotationDiscovery Optional function for runtime annotation discovery (null if not applicable)
230+
* @param <T> The type of component binding to search for
231+
* @return Optional containing the component binding if found
232+
*/
233+
private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
234+
Type runtimeType,
235+
Function<ComponentBindings, T> supplier,
236+
Function<Class<?>, Optional<T>> annotationDiscovery) {
198237
// First check if there is an exact match
199238
ComponentBindings binding = userComponents.get(runtimeType);
200239
if (binding != null) {
@@ -206,6 +245,15 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
206245

207246
Optional<Class<?>> runtimeClass = ReflectionUtils.getOptionalRawType(runtimeType);
208247
if (runtimeClass.isPresent()) {
248+
// Check for annotation-based component on the runtime type itself
249+
// Currently only used for @JsonbTypeSerializer during serialization
250+
if (annotationDiscovery != null) {
251+
Optional<T> annotationBased = annotationDiscovery.apply(runtimeClass.get());
252+
if (annotationBased.isPresent()) {
253+
return annotationBased;
254+
}
255+
}
256+
209257
// Check if any interfaces have a match
210258
for (Class<?> ifc : runtimeClass.get().getInterfaces()) {
211259
ComponentBindings ifcBinding = userComponents.get(ifc);
@@ -220,7 +268,7 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
220268
// check if the superclass has a match
221269
Class<?> superClass = runtimeClass.get().getSuperclass();
222270
if (superClass != null && superClass != Object.class) {
223-
Optional<T> superBinding = searchComponentBinding(superClass, supplier);
271+
Optional<T> superBinding = searchComponentBinding(superClass, supplier, annotationDiscovery);
224272
if (superBinding.isPresent()) {
225273
return superBinding;
226274
}
@@ -229,7 +277,64 @@ private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
229277

230278
return Optional.empty();
231279
}
232-
280+
281+
// Convenience overload for components without annotation discovery (deserializers, adapters)
282+
private <T extends AbstractComponentBinding> Optional<T> searchComponentBinding(
283+
final Type runtimeType,
284+
final Function<ComponentBindings, T> supplier) {
285+
return searchComponentBinding(runtimeType, supplier, null);
286+
}
287+
288+
/**
289+
* Discovers and caches a serializer defined by @JsonbTypeSerializer annotation on a runtime type.
290+
*
291+
* <p>This method performs <strong>runtime</strong> annotation discovery during serialization,
292+
* which is distinct from the build-time annotation introspection performed by AnnotationIntrospector.
293+
* It is invoked when serializing a property where the runtime type is more specific than the
294+
* declared type (e.g., a property declared as {@code Object} containing an instance of a class
295+
* annotated with @JsonbTypeSerializer).</p>
296+
*
297+
* <p>Note: Only @JsonbTypeSerializer is checked, not @JsonbTypeAdapter or @JsonbTypeDeserializer,
298+
* because:</p>
299+
* <ul>
300+
* <li>Serializers are unidirectional (serialization only), so runtime discovery is complete</li>
301+
* <li>Deserializers don't apply - we lack runtime type information during deserialization</li>
302+
* <li>Adapters are bidirectional - discovering them only at runtime during serialization
303+
* would be incomplete since they couldn't be discovered during deserialization</li>
304+
* </ul>
305+
*
306+
* @param clazz The runtime class to check for @JsonbTypeSerializer annotation
307+
* @return SerializerBinding if annotation is present and successfully introspected, empty otherwise
308+
*/
309+
private Optional<SerializerBinding<?>> getAnnotationBasedSerializer(final Class<?> clazz) {
310+
// Check if the class has a @JsonbTypeSerializer annotation
311+
final JsonbTypeSerializer annotation = clazz.getAnnotation(JsonbTypeSerializer.class);
312+
if (annotation == null) {
313+
return Optional.empty();
314+
}
315+
316+
// Thread-safe get-or-create using compute
317+
final SerializerBinding<?> binding = userComponents.compute(clazz, (type, bindings) -> {
318+
// If already cached, return as-is
319+
if (bindings != null && bindings.getSerializer() != null) {
320+
return bindings;
321+
}
322+
323+
// Create new serializer binding
324+
final Class<? extends JsonbSerializer> serializerClass = annotation.value();
325+
final JsonbSerializer<?> serializer = jsonbContext.getComponentInstanceCreator().getOrCreateComponent(serializerClass);
326+
final SerializerBinding<?> newBinding = new SerializerBinding<>(clazz, serializer);
327+
328+
// Create or update ComponentBindings
329+
if (bindings == null) {
330+
return new ComponentBindings(clazz, newBinding, null, null);
331+
}
332+
return new ComponentBindings(clazz, newBinding, bindings.getDeserializer(), bindings.getAdapterInfo());
333+
}).getSerializer();
334+
335+
return Optional.ofNullable(binding);
336+
}
337+
233338
private <T> Optional<T> getMatchingBinding(Type runtimeType, ComponentBindings binding, Function<ComponentBindings, T> supplier) {
234339
final T component = supplier.apply(binding);
235340
if (component != null && matches(runtimeType, binding.getBindingType())) {

src/test/java/org/eclipse/yasson/serializers/SerializersTest.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import jakarta.json.bind.JsonbBuilder;
3636
import jakarta.json.bind.JsonbConfig;
3737
import jakarta.json.bind.JsonbException;
38+
import jakarta.json.bind.annotation.JsonbTypeSerializer;
3839
import jakarta.json.bind.config.PropertyOrderStrategy;
3940
import jakarta.json.bind.serializer.DeserializationContext;
4041
import jakarta.json.bind.serializer.JsonbDeserializer;
@@ -78,6 +79,7 @@
7879
import static org.eclipse.yasson.Jsonbs.defaultJsonb;
7980
import static org.eclipse.yasson.Jsonbs.nullableJsonb;
8081
import static org.junit.jupiter.api.Assertions.assertEquals;
82+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
8183
import static org.junit.jupiter.api.Assertions.assertNull;
8284
import static org.junit.jupiter.api.Assertions.assertTrue;
8385
import static org.junit.jupiter.api.Assertions.fail;
@@ -812,4 +814,92 @@ public void testCustomSerializersInContainer(){
812814

813815
}
814816

817+
/**
818+
* Test that annotation-based serializers work when property is declared as Object
819+
* but the runtime type has @JsonbTypeSerializer annotation.
820+
* This is a regression test for issue #689.
821+
*/
822+
@Test
823+
public void testAnnotationBasedSerializerWithObjectTypedProperty() throws Exception {
824+
try (Jsonb jsonb = JsonbBuilder.create()) {
825+
826+
final ObjectPropertyContainer container = new ObjectPropertyContainer();
827+
final AnnotatedWithSerializerType objectInstance = new AnnotatedWithSerializerType();
828+
objectInstance.value = "test";
829+
container.annotatedAsObject = objectInstance;
830+
container.annotatedConcrete = new AnnotatedWithSerializerType();
831+
container.annotatedConcrete.value = "test2";
832+
833+
final String result = jsonb.toJson(container);
834+
835+
// Both properties should use the annotation-based serializer
836+
final String expected = "{\"annotatedAsObject\":{\"valueField\":\"replaced value\"},\"annotatedConcrete\":{\"valueField\":\"replaced value\"}}";
837+
assertEquals(expected, result);
838+
839+
// Deserialization: annotatedConcrete uses annotation-based deserializer
840+
// annotatedAsObject is declared as Object so JSON-B creates a HashMap (expected behavior)
841+
final ObjectPropertyContainer deserialized = jsonb.fromJson(expected, ObjectPropertyContainer.class);
842+
// In the JSON, the type looks like an object and therefore is a map
843+
assertInstanceOf(Map.class, deserialized.annotatedAsObject, "Object property deserializes to Map");
844+
final Map<?, ?> map = (Map<?, ?>) deserialized.annotatedAsObject;
845+
assertTrue(map.containsKey("valueField"));
846+
assertEquals("replaced value", map.get("valueField"));
847+
assertEquals("replaced value", deserialized.annotatedConcrete.value);
848+
}
849+
}
850+
851+
/**
852+
* Test that field-level and method-level @JsonbTypeSerializer annotations work on Object-typed properties.
853+
* This tests existing AnnotationIntrospector code (not runtime discovery).
854+
*/
855+
@Test
856+
public void testFieldAndMethodLevelSerializerOnObjectType() throws Exception {
857+
try (Jsonb jsonb = JsonbBuilder.create()) {
858+
final ObjectWithAnnotatedFields container = new ObjectWithAnnotatedFields();
859+
container.fieldAnnotated = "test field";
860+
container.setMethodAnnotated("test method");
861+
862+
final String result = jsonb.toJson(container);
863+
864+
// Both should use their respective serializers
865+
final String expected = "{\"fieldAnnotated\":\"FIELD:test field\",\"methodAnnotated\":\"METHOD:test method\"}";
866+
assertEquals(expected, result);
867+
}
868+
}
869+
870+
public static class ObjectWithAnnotatedFields {
871+
@JsonbTypeSerializer(ObjectFieldSerializer.class)
872+
public Object fieldAnnotated;
873+
874+
private Object methodAnnotated;
875+
876+
@JsonbTypeSerializer(ObjectMethodSerializer.class)
877+
public Object getMethodAnnotated() {
878+
return methodAnnotated;
879+
}
880+
881+
public void setMethodAnnotated(Object methodAnnotated) {
882+
this.methodAnnotated = methodAnnotated;
883+
}
884+
}
885+
886+
public static class ObjectFieldSerializer implements JsonbSerializer<Object> {
887+
@Override
888+
public void serialize(Object obj, JsonGenerator generator, SerializationContext ctx) {
889+
generator.write("FIELD:" + obj.toString());
890+
}
891+
}
892+
893+
public static class ObjectMethodSerializer implements JsonbSerializer<Object> {
894+
@Override
895+
public void serialize(Object obj, JsonGenerator generator, SerializationContext ctx) {
896+
generator.write("METHOD:" + obj.toString());
897+
}
898+
}
899+
900+
public static class ObjectPropertyContainer {
901+
public Object annotatedAsObject; // Declared as Object - this was the bug scenario
902+
public AnnotatedWithSerializerType annotatedConcrete; // Declared concretely - should always work
903+
}
904+
815905
}

0 commit comments

Comments
 (0)