[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
+    }
+
 }