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"
- + "¶meter.name=string"
- + "¶meter.name.description=Who to greet"
- + "¶meter.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.
*/