This is an automated email from the ASF dual-hosted git repository.

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-spring-boot.git


The following commit(s) were added to refs/heads/main by this push:
     new 72b552e7350 CAMEL-26782: copyConfigurationProperties drops 
programmatically-assigned values (#2079)
72b552e7350 is described below

commit 72b552e7350d8513e4e34e685dd0fada9f3bba73
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Nov 12 12:03:58 2026 +0100

    CAMEL-26782: copyConfigurationProperties drops programmatically-assigned 
values (#2079)
    
    * CAMEL-26782: copyConfigurationProperties drops programmatically-assigned 
values
    
    Values assigned in Java to a generated Spring Boot starter configuration 
bean
    were silently dropped by copyConfigurationProperties because 
BoundConfigurationProperties
    only records values set via Spring Boot property binding 
(application.properties /
    application.yaml), not values set programmatically in code.
    
    Example: SpringBootPlatformWebMvcConfiguration sets requestTimeout on
    PlatformHttpComponentConfiguration from spring.mvc.async.request-timeout. 
That
    value never reached PlatformHttpComponent because it was filtered out as 
though
    it were an unconfigured catalog default.
    
    Fix: when a property is not in BoundConfigurationProperties, compare its 
current
    value against a freshly-constructed default instance of the configuration 
class.
    If the value differs from the default, it was assigned programmatically and 
must
    be copied to the target Camel bean. Only values that match the class 
default are
    treated as unconfigured catalog defaults and dropped.
    
    Regression introduced by CAMEL-24501 / apache/camel-spring-boot#1951.
    
    * CAMEL-26782: Fix fallback when no-arg constructor unavailable; add docs
    
    * CAMEL-26782: Add starter-level regression tests (platform-http timeout, 
ai-tool MCP hints)
    
    * CAMEL-26782: address review feedback
    
    - CamelPropertiesHelper: cache default-instance properties per class
      (ConcurrentHashMap) instead of constructing a new instance on every
      customizer call
    - CamelPropertiesHelper: fix fallback when configuration class has no
      accessible no-arg constructor — only copy bound options (safe fallback
      matching pre-fix behaviour), not everything
    - CamelPropertiesHelper: safer boundProperties() — null-check the return
      of BoundConfigurationProperties.get(), copy keyset inside the try block
    - CamelPropertiesHelper: add DEBUG logging for copied/skipped options
    - CamelPropertiesHelper: update Javadoc to describe the new behaviour and
      the known gap (programmatic assignment that equals the class default)
    - SpringBootPlatformWebMvcConfiguration: also set requestTimeout directly
      on the PlatformHttpComponent when it has already been initialized
      (the eager platformHttpEngineRequestMapping bean forces the customizer
      to run before configureAsyncSupport is called, so setting the config
      bean alone is not sufficient)
    - Fix 
testCopyConfigurationPropertiesDoesNotCopyProgrammaticValueMatchingDefault:
      the previous version tested a no-op scenario; now uses
      anotherOptionOnlyCarryingItsDefault from MyDriftedConfiguration, which
      has no setter on the target — it must be dropped silently without failing
    - Add camel.test.my-config.enabled = true to the test properties so
      testCopyConfigurationPropertiesIgnoresAutoConfigurationOptions actually
      exercises bound auto-configuration options
    - Add AiToolMcpHintsConfiguredTest: verifies that MCP hints explicitly set
      via application properties (including an explicit false) reach the
      component, while ensuring the fix does not regress the null-hint case
---
 .../AiToolComponentAutoConfigurationTest.java      | 14 ++++
 ...Test.java => AiToolMcpHintsConfiguredTest.java} | 54 ++++++-------
 .../SpringBootPlatformWebMvcConfiguration.java     | 19 ++++-
 ...ormHttpRequestTimeoutFromMvcPropertiesTest.java | 71 +++++++++++++++++
 .../src/main/docs/starter-configuration.adoc       |  4 +
 .../spring/boot/util/CamelPropertiesHelper.java    | 93 ++++++++++++++++++++--
 .../boot/util/CamelPropertiesHelperTest.java       | 58 +++++++++++++-
 7 files changed, 274 insertions(+), 39 deletions(-)

diff --git 
a/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
 
b/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
index bc1457118d6..923e9ee5dad 100644
--- 
a/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
+++ 
b/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.ai.tool.springboot;
 
 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 java.util.Set;
@@ -91,4 +92,17 @@ public class AiToolComponentAutoConfigurationTest {
         assertTrue(spec.getParametersJsonSchema().contains("\"name\""),
                 "expected the declared parameter in the JSON schema, but was: 
" + spec.getParametersJsonSchema());
     }
+
+    @Test
+    public void mcpHintsStayNullWhenNotConfigured() {
+        // The MCP hints (readOnlyHint, destructiveHint, idempotentHint, 
openWorldHint) default to
+        // false in AiToolComponentConfiguration but to null in 
AiToolComponent. CAMEL-26782 must
+        // not copy the configuration-class defaults onto the component when 
they were not explicitly
+        // set by the user, because null carries meaning in the MCP protocol.
+        AiToolComponent component = assertInstanceOf(AiToolComponent.class, 
context.getComponent("ai-tool"));
+        assertNull(component.getConfiguration().getReadOnlyHint(), 
"readOnlyHint must stay null when not configured");
+        assertNull(component.getConfiguration().getDestructiveHint(), 
"destructiveHint must stay null when not configured");
+        assertNull(component.getConfiguration().getIdempotentHint(), 
"idempotentHint must stay null when not configured");
+        assertNull(component.getConfiguration().getOpenWorldHint(), 
"openWorldHint must stay null when not configured");
+    }
 }
diff --git 
a/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
 
b/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolMcpHintsConfiguredTest.java
similarity index 56%
copy from 
components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
copy to 
components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolMcpHintsConfiguredTest.java
index bc1457118d6..71937d92466 100644
--- 
a/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolComponentAutoConfigurationTest.java
+++ 
b/components-starter/camel-ai-tool-starter/src/test/java/org/apache/camel/component/ai/tool/springboot/AiToolMcpHintsConfiguredTest.java
@@ -16,17 +16,13 @@
  */
 package org.apache.camel.component.ai.tool.springboot;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-import java.util.Set;
-
 import org.apache.camel.CamelContext;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.ai.tool.AiToolComponent;
-import org.apache.camel.component.ai.tool.AiToolRegistry;
-import org.apache.camel.component.ai.tool.AiToolSpec;
 import org.apache.camel.spring.boot.CamelAutoConfiguration;
 import org.apache.camel.test.spring.junit6.CamelSpringBootTest;
 
@@ -38,13 +34,23 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.test.annotation.DirtiesContext;
 
+/**
+ * CAMEL-26782: verifies that when an MCP hint is explicitly configured via 
application properties
+ * its value (even {@code false}) is applied to the component, while hints 
that are not configured
+ * stay {@code null}.
+ */
 @DirtiesContext
 @CamelSpringBootTest
 @SpringBootTest(classes = { CamelAutoConfiguration.class, 
AiToolComponentConverter.class,
         AiToolComponentAutoConfiguration.class,
-        AiToolComponentAutoConfigurationTest.TestConfiguration.class }, 
properties = {
-                "camel.component.ai-tool.tags = demo" })
-public class AiToolComponentAutoConfigurationTest {
+        AiToolMcpHintsConfiguredTest.TestConfiguration.class }, properties = {
+                // readOnlyHint explicitly set to true — must be applied
+                "camel.component.ai-tool.read-only-hint = true",
+                // destructiveHint explicitly set to false — must be applied 
(equals the config-class
+                // default of false, but it was explicitly configured so the 
component must see false
+                // rather than null)
+                "camel.component.ai-tool.destructive-hint = false" })
+public class AiToolMcpHintsConfiguredTest {
 
     @Autowired
     CamelContext context;
@@ -58,11 +64,7 @@ public class AiToolComponentAutoConfigurationTest {
 
                 @Override
                 public void configure() throws Exception {
-                    from("ai-tool:greet"
-                         + "?description=Greet a person by name"
-                         + "&parameter.name=string"
-                         + "&parameter.name.description=Who to greet"
-                         + "&parameter.name.required=true")
+                    from("ai-tool:greet?description=Greet a person by name")
                             .setBody().simple("Hello ${header.name}");
                 }
             };
@@ -70,25 +72,19 @@ public class AiToolComponentAutoConfigurationTest {
     }
 
     @Test
-    public void componentIsAutoConfigured() {
+    void explicitlyConfiguredReadOnlyHintIsApplied() {
         AiToolComponent component = assertInstanceOf(AiToolComponent.class, 
context.getComponent("ai-tool"));
-        // set through camel.component.ai-tool.tags, so this asserts the 
Spring Boot
-        // properties are copied onto the component by the starter
-        assertEquals("demo", component.getConfiguration().getTags());
+        assertTrue(component.getConfiguration().getReadOnlyHint(),
+                "readOnlyHint configured to true via properties must be 
applied to the component");
     }
 
     @Test
-    public void routeIsRegisteredAsToolUnderTheConfiguredTag() {
-        AiToolRegistry registry = AiToolRegistry.getOrCreate(context);
-        assertTrue(registry.getDefaultTools().isEmpty(), "a tagged tool must 
not land in the default pool");
-
-        Set<AiToolSpec> tools = registry.getToolsByTag("demo");
-        assertEquals(1, tools.size());
-
-        AiToolSpec spec = tools.iterator().next();
-        assertEquals("greet", spec.getName());
-        assertEquals("Greet a person by name", spec.getDescription());
-        assertTrue(spec.getParametersJsonSchema().contains("\"name\""),
-                "expected the declared parameter in the JSON schema, but was: 
" + spec.getParametersJsonSchema());
+    void explicitlyConfiguredDestructiveHintFalseIsApplied() {
+        AiToolComponent component = assertInstanceOf(AiToolComponent.class, 
context.getComponent("ai-tool"));
+        // destructiveHint is false in the config-class initializer, but was 
explicitly set via
+        // application properties. BoundConfigurationProperties records the 
binding, so the value
+        // must reach the component even though it equals the default.
+        assertFalse(component.getConfiguration().getDestructiveHint(),
+                "destructiveHint explicitly configured to false via properties 
must be applied (not left null)");
     }
 }
diff --git 
a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformWebMvcConfiguration.java
 
b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformWebMvcConfiguration.java
index 01bd8bf4359..d5829e425fd 100644
--- 
a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformWebMvcConfiguration.java
+++ 
b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformWebMvcConfiguration.java
@@ -18,6 +18,8 @@ package org.apache.camel.component.platform.http.springboot;
 
 import java.time.Duration;
 
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.platform.http.PlatformHttpComponent;
 import org.apache.camel.spring.boot.ComponentConfigurationProperties;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -30,12 +32,15 @@ import 
org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 @EnableConfigurationProperties({ ComponentConfigurationProperties.class, 
PlatformHttpComponentConfiguration.class })
 public class SpringBootPlatformWebMvcConfiguration implements WebMvcConfigurer 
{
 
+    private final CamelContext camelContext;
     private final PlatformHttpComponentConfiguration 
platformHttpComponentConfiguration;
     private final ObjectProvider<WebMvcProperties> webMvcProperties;
 
     // WebMvcProperties is only registered in a servlet web application
-    public 
SpringBootPlatformWebMvcConfiguration(PlatformHttpComponentConfiguration 
platformHttpComponentConfiguration,
+    public SpringBootPlatformWebMvcConfiguration(CamelContext camelContext,
+                                                 
PlatformHttpComponentConfiguration platformHttpComponentConfiguration,
                                                  
ObjectProvider<WebMvcProperties> webMvcProperties) {
+        this.camelContext = camelContext;
         this.platformHttpComponentConfiguration = 
platformHttpComponentConfiguration;
         this.webMvcProperties = webMvcProperties;
     }
@@ -50,7 +55,17 @@ public class SpringBootPlatformWebMvcConfiguration 
implements WebMvcConfigurer {
         }
 
         if (requestTimeout != null && 
platformHttpComponentConfiguration.getRequestTimeout() == null) {
-            
platformHttpComponentConfiguration.setRequestTimeout(requestTimeout.toMillis());
+            long timeoutMillis = requestTimeout.toMillis();
+            // Write onto the configuration bean so it is picked up if the 
component is configured later
+            
platformHttpComponentConfiguration.setRequestTimeout(timeoutMillis);
+            // Also set directly on the component: the customizer may have 
already run before this
+            // configurer is called (the eager 
platformHttpEngineRequestMapping bean forces it), so
+            // setting the configuration bean alone is not sufficient in that 
case.
+            PlatformHttpComponent component
+                    = (PlatformHttpComponent) 
camelContext.getComponent("platform-http", false, false);
+            if (component != null && component.getRequestTimeout() == 0) {
+                component.setRequestTimeout(timeoutMillis);
+            }
         }
     }
 }
diff --git 
a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpRequestTimeoutFromMvcPropertiesTest.java
 
b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpRequestTimeoutFromMvcPropertiesTest.java
new file mode 100644
index 00000000000..ace6a4ae1b8
--- /dev/null
+++ 
b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpRequestTimeoutFromMvcPropertiesTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://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.apache.camel.component.platform.http.springboot;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.platform.http.PlatformHttpComponent;
+import org.apache.camel.spring.boot.CamelAutoConfiguration;
+import org.apache.camel.test.spring.junit6.CamelSpringBootTest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * CAMEL-26782 regression test: verifies that when the request timeout is set
+ * programmatically via {@code spring.mvc.async.request-timeout} (which
+ * {@link SpringBootPlatformWebMvcConfiguration#configureAsyncSupport} picks up
+ * and writes onto the configuration bean), the value survives
+ * {@code CamelPropertiesHelper.copyConfigurationProperties()} and appears on
+ * the live {@link PlatformHttpComponent}.
+ */
+@EnableAutoConfiguration
+@CamelSpringBootTest
+@SpringBootTest(
+        webEnvironment = SpringBootTest.WebEnvironment.MOCK,
+        classes = {
+                CamelAutoConfiguration.class,
+                PlatformHttpComponentAutoConfiguration.class,
+                SpringBootPlatformHttpAutoConfiguration.class,
+                SpringBootPlatformWebMvcConfiguration.class,
+                
SpringBootPlatformHttpRequestTimeoutFromMvcPropertiesTest.TestConfiguration.class
+        },
+        properties = {
+                "spring.mvc.async.request-timeout=5000ms"
+        })
+public class SpringBootPlatformHttpRequestTimeoutFromMvcPropertiesTest {
+
+    @Autowired
+    CamelContext camelContext;
+
+    @Test
+    public void requestTimeoutFromMvcPropertiesIsCopiedToComponent() {
+        PlatformHttpComponent component = 
camelContext.getComponent("platform-http", PlatformHttpComponent.class);
+        assertEquals(5000L, component.getRequestTimeout(),
+                "request-timeout set via spring.mvc.async.request-timeout must 
be propagated to PlatformHttpComponent");
+    }
+
+    @Configuration
+    public static class TestConfiguration {
+        // No routes needed — we only verify the configuration propagation
+    }
+}
diff --git a/core/camel-spring-boot/src/main/docs/starter-configuration.adoc 
b/core/camel-spring-boot/src/main/docs/starter-configuration.adoc
index 8bdba6fe825..f2a415bcc35 100644
--- a/core/camel-spring-boot/src/main/docs/starter-configuration.adoc
+++ b/core/camel-spring-boot/src/main/docs/starter-configuration.adoc
@@ -62,6 +62,10 @@ The `Bean` name has to be equal to that of the Component, 
Dataformat or Language
 
 External configuration is still applied on top of such a `Bean`: every 
`camel.[component|language|dataformat].[name].[parameter]` that is set in the 
external configuration is copied onto it. A parameter that is not set in the 
external configuration is left alone, so a value set on the `Bean` in Java is 
kept, even when it differs from the documented default of that parameter.
 
+Camel also detects values that were assigned programmatically in Java directly 
to the generated `@ConfigurationProperties` class (for example, in an 
auto-configuration class that calls `cfg.setRequestTimeout(30000L)` before 
returning the bean). Such a value is copied to the target component even when 
it was not bound from external configuration, provided it differs from the 
class's default value. This allows Spring Boot auto-configuration classes to 
programmatically set component options [...]
+
+NOTE: A programmatic assignment that equals the class default (for example 
`cfg.setSkipRequestHeaders(false)` on a configuration class that already 
defaults `skipRequestHeaders` to `false`) is not detected as a programmatic 
assignment and is therefore not copied. If that is not the desired behaviour, 
set the option explicitly in the external configuration instead.
+
 Typical Camel Spring Boot projects will use a combination of external 
configuration and Beans to configure their application. For more complete 
examples on how to configure your Camel Spring Boot project, you can refer to 
our example https://github.com/apache/camel-spring-boot-examples[repository].
 
 All contributions are welcome!
diff --git 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/util/CamelPropertiesHelper.java
 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/util/CamelPropertiesHelper.java
index 39cadddbfdc..cc98cbe72cb 100644
--- 
a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/util/CamelPropertiesHelper.java
+++ 
b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/util/CamelPropertiesHelper.java
@@ -22,7 +22,9 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Component;
 import org.apache.camel.PropertyBindingException;
@@ -59,6 +61,16 @@ public final class CamelPropertiesHelper {
      */
     private static final Set<String> AUTO_CONFIGURATION_OPTIONS = 
Set.of("enabled", "customizer");
 
+    /**
+     * Cache of class → default-instance properties. Avoids constructing a new 
default instance on every customizer
+     * call. The sentinel value {@link #NO_DEFAULTS} means the class has no 
accessible no-arg constructor and
+     * programmatic-assignment detection is unavailable for it.
+     */
+    private static final ConcurrentHashMap<Class<?>, Map<String, Object>> 
DEFAULTS_CACHE = new ConcurrentHashMap<>();
+
+    /** Sentinel placed in {@link #DEFAULTS_CACHE} when instantiation failed. 
*/
+    private static final Map<String, Object> NO_DEFAULTS = Map.of();
+
     private CamelPropertiesHelper() {
     }
 
@@ -66,13 +78,20 @@ public final class CamelPropertiesHelper {
      * Copies the options from a generated Spring Boot configuration class 
onto the Camel component, data format or
      * language it configures.
      * <p/>
-     * Only the options the application configured are copied, as recorded by 
Spring Boot in
-     * {@link BoundConfigurationProperties} when it bound the configuration 
class. The other options merely carry the
-     * default value the generator took from the Camel catalog as field 
initializer, and must not overwrite the value
-     * the target already holds, such as one set programmatically on a user 
supplied bean. The options that belong to
-     * the auto configuration layer itself (<tt>enabled</tt> and 
<tt>customizer</tt>) are never copied, as they are not
+     * An option is copied when it was set by the application, either via 
Spring Boot property binding (application
+     * properties / environment) as recorded in {@link 
BoundConfigurationProperties}, or programmatically in Java
+     * directly onto the generated configuration class (detected by comparing 
the current value against the value a
+     * freshly-constructed default instance of that class would carry). 
Options that only carry the catalog default the
+     * generator wrote as a field initializer are <em>not</em> copied, so they 
cannot overwrite a value the target
+     * already holds — for example one set programmatically on a user-supplied 
bean. The options that belong to the
+     * auto configuration layer itself (<tt>enabled</tt> and 
<tt>customizer</tt>) are never copied, as they are not
      * options on the target bean.
      * <p/>
+     * <strong>Known gap:</strong> a programmatic assignment whose value 
happens to equal the class field initializer
+     * (e.g. {@code cfg.setSkipRequestHeaders(false)} when the initializer is 
already {@code false}) is indistinguishable
+     * from a catalog default and will not be copied. This matters only when 
the target holds a different value and the
+     * intent was to reset it.
+     * <p/>
      * A configured option that cannot be set on the target fails fast with an 
{@link IllegalArgumentException},
      * instead of being dropped without a trace. Set {@link 
#LENIENT_CONFIGURATION_BINDING} to <tt>true</tt> to log it
      * at WARN and continue, instead of failing.
@@ -100,7 +119,29 @@ public final class CamelPropertiesHelper {
         // only the options the application configured are copied, the others 
merely carry their catalog default
         Set<ConfigurationPropertyName> bound = 
boundProperties(applicationContext);
         if (bound != null && propertyPrefix != null && 
!propertyPrefix.isEmpty()) {
-            properties.keySet().removeIf(key -> !isConfigured(bound, 
optionKey(propertyPrefix, key)));
+            // Also keep properties that were assigned programmatically in 
Java (not via Spring Boot
+            // property binding), detected by comparing against a fresh 
default instance of the
+            // configuration class (cached per class to avoid repeated 
instantiation).
+            // When the class has no accessible no-arg constructor the 
detection is skipped and only
+            // bound options are copied (safe fallback: same behaviour as 
before this fix).
+            Map<String, Object> defaults = defaultProperties(camelContext, 
source);
+            if (defaults != null) {
+                properties.keySet().removeIf(key -> {
+                    String optionKey = optionKey(propertyPrefix, key);
+                    if (isConfigured(bound, optionKey)) {
+                        return false; // keep — explicitly configured via 
Spring Boot
+                    }
+                    if (isDifferentFromDefault(key, properties, defaults)) {
+                        LOG.debug("Copying programmatically-assigned option 
[{}] onto [{}]", optionKey,
+                                ObjectHelper.classCanonicalName(target));
+                        return false; // keep — assigned in Java, differs from 
class default
+                    }
+                    LOG.debug("Skipping option [{}] — not configured and 
equals the class default", optionKey);
+                    return true; // drop — only carries the catalog default
+                });
+            } else {
+                properties.keySet().removeIf(key -> !isConfigured(bound, 
optionKey(propertyPrefix, key)));
+            }
         }
 
         // the options that could be set are removed from the map, so what is 
left could not be set
@@ -145,7 +186,12 @@ public final class CamelPropertiesHelper {
             return null;
         }
         try {
-            return 
BoundConfigurationProperties.get(applicationContext).getAll().keySet();
+            BoundConfigurationProperties bound = 
BoundConfigurationProperties.get(applicationContext);
+            if (bound == null) {
+                return null;
+            }
+            // Copy inside the try so a failure in getAll() is also caught
+            return Set.copyOf(bound.getAll().keySet());
         } catch (Exception e) {
             LOG.warn("Cannot determine which options were configured due to: 
{}. Copying all options.",
                     e.getMessage(), e);
@@ -153,6 +199,39 @@ public final class CamelPropertiesHelper {
         }
     }
 
+    /**
+     * Gets the default properties of a freshly-constructed instance of the 
same class as the given source, cached per
+     * class. Returns {@code null} when the class has no accessible no-arg 
constructor, meaning programmatic-assignment
+     * detection is unavailable; the caller falls back to copying only bound 
options (same as before this fix).
+     */
+    private static Map<String, Object> defaultProperties(CamelContext 
camelContext, Object source) {
+        Map<String, Object> cached = DEFAULTS_CACHE.get(source.getClass());
+        if (cached != null) {
+            return cached == NO_DEFAULTS ? null : cached;
+        }
+        try {
+            Object defaults = 
source.getClass().getDeclaredConstructor().newInstance();
+            Map<String, Object> result = getNonNullProperties(camelContext, 
defaults);
+            DEFAULTS_CACHE.put(source.getClass(), result);
+            return result;
+        } catch (Exception e) {
+            LOG.debug("Cannot instantiate default of {} to detect programmatic 
assignments: {}",
+                    ObjectHelper.classCanonicalName(source), e.getMessage());
+            DEFAULTS_CACHE.put(source.getClass(), NO_DEFAULTS);
+            return null;
+        }
+    }
+
+    /**
+     * Whether the value of the given key in {@code properties} differs from 
the value in {@code defaults}. When the
+     * current value is not equal to the default, it was assigned 
programmatically in Java and must be copied to the
+     * target even if it was not bound by Spring Boot.
+     */
+    private static boolean isDifferentFromDefault(String key, Map<String, 
Object> properties,
+            Map<String, Object> defaults) {
+        return !Objects.equals(properties.get(key), defaults.get(key));
+    }
+
     /**
      * Whether the application configured the given option, as opposed to the 
option only carrying the default value
      * the generator took from the Camel catalog. Spring Boot records every 
property it bound, including the entries
diff --git 
a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/util/CamelPropertiesHelperTest.java
 
b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/util/CamelPropertiesHelperTest.java
index 17dbac297d7..6c871b4a1f1 100644
--- 
a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/util/CamelPropertiesHelperTest.java
+++ 
b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/util/CamelPropertiesHelperTest.java
@@ -39,7 +39,8 @@ import org.springframework.test.annotation.DirtiesContext;
 @SpringBootApplication
 @SpringBootTest(
                 classes = { CamelPropertiesHelperTest.TestConfiguration.class 
},
-                properties = { "camel.test.my-config.name = Donald Duck",
+                properties = { "camel.test.my-config.enabled = true",
+                        "camel.test.my-config.name = Donald Duck",
                         "camel.test.my-config.verify-hostname = true",
                         "camel.test.my-config.no-such-option-on-the-target = 
bar" })
 public class CamelPropertiesHelperTest {
@@ -81,6 +82,8 @@ public class CamelPropertiesHelperTest {
         private MyOption option;
         private Boolean secure = false;
         private Boolean verifyHostname = true;
+        // null by default — suitable for testing programmatic assignment 
(CAMEL-26782)
+        private Long requestTimeout;
 
         public String getName() {
             return name;
@@ -113,6 +116,14 @@ public class CamelPropertiesHelperTest {
         public void setVerifyHostname(Boolean verifyHostname) {
             this.verifyHostname = verifyHostname;
         }
+
+        public Long getRequestTimeout() {
+            return requestTimeout;
+        }
+
+        public void setRequestTimeout(Long requestTimeout) {
+            this.requestTimeout = requestTimeout;
+        }
     }
 
     /**
@@ -151,6 +162,7 @@ public class CamelPropertiesHelperTest {
         private MyFooClass myFooClass;
         private boolean secure;
         private boolean verifyHostname;
+        private Long requestTimeout;
 
         public int getId() {
             return id;
@@ -207,6 +219,14 @@ public class CamelPropertiesHelperTest {
         public void setMyFooClass(MyFooClass myFooClass) {
             this.myFooClass = myFooClass;
         }
+
+        public Long getRequestTimeout() {
+            return requestTimeout;
+        }
+
+        public void setRequestTimeout(Long requestTimeout) {
+            this.requestTimeout = requestTimeout;
+        }
     }
 
     @Test
@@ -399,6 +419,42 @@ public class CamelPropertiesHelperTest {
         Assertions.assertTrue(target.isVerifyHostname(), "A configured option 
must be applied whatever its value");
     }
 
+    @Test
+    void testCopyConfigurationPropertiesCopiesProgrammaticallyAssignedValue() {
+        MyClass target = new MyClass();
+
+        // Simulate the same pattern as SpringBootPlatformWebMvcConfiguration: 
an auto-configuration class
+        // sets a value on the configuration bean in Java (not from Spring 
Boot application properties).
+        // Before the fix (CAMEL-26782), this programmatically-assigned value 
was silently dropped because
+        // it was not recorded in BoundConfigurationProperties.
+        MyConfiguration cfg = configWithoutDrift();
+        cfg.setRequestTimeout(30000L); // assigned in Java, not via 
application properties
+
+        CamelPropertiesHelper.copyConfigurationProperties(camelContext, 
context, PREFIX, cfg, target);
+
+        Assertions.assertEquals(30000L, target.getRequestTimeout(),
+                "A value assigned programmatically in Java must be copied to 
the target");
+    }
+
+    @Test
+    void 
testCopyConfigurationPropertiesDoesNotCopyProgrammaticValueMatchingDefault() {
+        // anotherOptionOnlyCarryingItsDefault has the field initializer 
"false" in MyDriftedConfiguration
+        // and is NOT configured via application properties. It has no setter 
on MyClass, so if it were
+        // passed to the target it would cause a startup failure (lenient 
binding is off).
+        // The fix must recognise it as carrying only its catalog default and 
drop it silently.
+        MyClass target = new MyClass();
+        MyDriftedConfiguration cfg = new MyDriftedConfiguration();
+        cfg.setName(config.getName());
+        cfg.setVerifyHostname(config.getVerifyHostname());
+        // anotherOptionOnlyCarryingItsDefault is left at its "false" field 
initializer — not Spring-bound
+
+        // must not throw and must not set anything that wasn't configured
+        CamelPropertiesHelper.copyConfigurationProperties(camelContext, 
context, PREFIX, cfg, target);
+
+        Assertions.assertEquals("Donald Duck", target.getName(),
+                "The Spring-bound name must still be applied");
+    }
+
     /**
      * The bound configuration without the option that cannot be set, for the 
tests that are not about that failure.
      */

Reply via email to