Skip to content

CAMEL-24501: Report starter configuration options that cannot be bound - #1935

Open
Croway wants to merge 3 commits into
apache:mainfrom
Croway:CAMEL-24501-generator-fail-closed-binding
Open

CAMEL-24501: Report starter configuration options that cannot be bound#1935
Croway wants to merge 3 commits into
apache:mainfrom
Croway:CAMEL-24501-generator-fail-closed-binding

Conversation

@Croway

@Croway Croway commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes CAMEL-24501.

SpringBootAutoConfigurationMojo generates two pieces of binding code into every starter that discard
configuration without reporting it, so an option that never took effect is indistinguishable from one that
did. Both are addressed here; the third item on the ticket is deferred and explained below.

1. Generated converters no longer turn an unresolvable value into null

An option of a complex (object) type is configured with a reference to a bean:

camel.component.http.ssl-context-parameters = #bean:mySslContextParameters

The generated convert() body returned null for any value that did not start with #, and for a value
naming a bean that does not exist, so a typo in the bean id produced a component with the option unset. It
also returned null for a target type its switch did not list, which happens when the bound type is a
subtype of a registered pair.

The body now delegates to a new org.apache.camel.spring.boot.util.BeanReferenceHelper, which

  • resolves #bean:myBean, #myBean, a plain myBean, #autowired and #type:com.foo.MyType;
  • throws IllegalArgumentException naming the value, the target type and the configuration prefix when the
    value cannot be resolved to a bean of that type.

The per-type switch is gone, so the generated converters shrink to a single delegating line and the whole
"not in the switch" branch disappears.

These converters are registered with @ConfigurationPropertiesBinding, so they take part in every
@ConfigurationProperties binding in the application. BeanReferenceHelper therefore only applies the strict
behaviour when the class being bound is Camel's own — under org.apache.camel, or annotated with
@ConfigurationProperties for a camel. prefix. Any other class keeps the behaviour it had before, so putting a
starter on the classpath cannot make an unrelated application property fail to bind. See the review follow-up
below for why this is decided in convert() rather than in ConditionalGenericConverter.matches.

2. Generated customizers no longer drop an option that cannot be set

createComponentBody / createDataFormatBody / createLanguageBody emitted
CamelPropertiesHelper.copyProperties, which binds with failIfNotSet=false, while the same mojo emits
failIfNotSet=true for the camel.rest.* path. They now call a new
CamelPropertiesHelper.copyConfigurationProperties, which

  • removes enabled and customizer before binding — they belong to the auto-configuration layer, not to the
    Camel target, and were being offered to it on every startup;
  • fails with IllegalArgumentException when an option the application configured itself cannot be set;
  • logs at DEBUG when an option that only carries its catalog default cannot be set.

Whether an option was configured by the application is decided with Spring's
ConfigurationPropertySources, so relaxed binding and environment variables are handled.

Why not blanket failIfNotSet=true

That was the first implementation, and it does not survive contact with the existing generated code. The
configuration classes materialise every catalog default as a field initializer (item 2 on the ticket), so the
customizer copies defaults for options that were never bindable. The clearest case is camel-core-starter:

Cannot configure option [trim] with value [true] as the bean class
[org.apache.camel.language.simple.SimpleLanguage] has no suitable setter method

trim, pretty, trimResult and nested are options of the expression model, not of SimpleLanguage, and
they carry catalog defaults, so failIfNotSet=true would abort startup for every application that has
camel-core-starter on the classpath. Failing only on options the application actually set keeps the fix
narrow and still closes the reported hole.

Behaviour change and how to opt back

An application that configured an option which never took effect now fails at startup, with a message naming
the option. The remedy is to correct or remove it. To keep starting while doing so:

camel.springboot.lenient-configuration-binding = true

Such an option is then logged at WARN with its name, instead of being dropped silently as before. The
legacy CamelPropertiesHelper.copyProperties / setCamelProperties(..., failIfNotSet=false) path, which is
public API and no longer used by generated code, also logs at WARN now instead of staying silent.

An upgrade-guide entry for docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc in
apache/camel has been written and will be raised separately.

Deferred

Item 2 of the ticket — catalog defaults emitted as literal field initializers, so the customizer copies a
default over a value set programmatically on a user-supplied component bean — is not fixed here. Dropping
the initializers is not a local change:

  • the generated getters return the defaults today, and those getters are effectively public API;
  • spring-configuration-metadata.json derives defaultValue from the field initializers, so IDE completion
    and the generated .adoc documentation pages (which the readme mojo builds from that metadata) would both
    lose every default;
  • the alternative — having the customizer skip a value equal to the catalog default — makes it impossible to
    set an option back to its default explicitly.

It needs its own change with the metadata and docs generation adjusted at the same time, and it is the
prerequisite for turning the strictness above into blanket failIfNotSet=true. The ticket is left open for it.

Tests

  • SpringBootAutoConfigurationMojoTest (new, 5 tests) — the generated converter and customizer bodies, and the
    reserved option name guard.
  • BeanReferenceHelperTest (new, 22 tests) — every supported syntax, the failure cases, and which bindings the
    helper considers Camel's own.
  • CamelPropertiesHelperTest (3 new tests) — enabled/customizer are not offered to the target, a
    configured option that cannot be set fails, a catalog default that cannot be set does not.
  • CamelPropertiesHelperLenientBindingTest (new) — the opt-out.
  • HttpComponentBeanReferenceBindingTest (new, 6 tests) — end-to-end through the Spring Boot binder on a real
    starter, including a third-party @ConfigurationProperties class that is not intercepted.

Run green: the generator plugin, core/camel-spring-boot (full suite), camel-core-starter,
camel-http-starter, camel-netty-http-starter and camel-jackson-starter.

Review follow-up

1. Generated converters are global (blocking). Spiked first, and the spike changed the shape of the fix.
targetType.getSource() does carry the bound class: it is a org.springframework.core.MethodParameter whose
getContainingClass()/getDeclaringClass() is the @ConfigurationProperties class (Spring Boot 4.1.1).
ConditionalGenericConverter.matches is nevertheless not usable for this: GenericConversionService caches the
converter it picked per source/target TypeDescriptor pair, and TypeDescriptor.equals ignores the source. In
the spike, with one camel.* class and one third-party class each having a HostnameVerifier field, matches
was called once and convert twice — so the decision taken for whichever class binds first is reused
for the other. Implementing matches that way would be order-dependent, and in the bad order it would give a
ConverterNotFoundException for a perfectly valid Camel property.

The decision is therefore taken in convert(), which the spike shows is invoked per binding with the correct
targetType. BeanReferenceHelper.isCamelConfigurationTarget(TypeDescriptor) resolves the bound class from
getSource() (MethodParameter or Field) and returns true when it is under org.apache.camel or carries
@ConfigurationProperties with a camel. prefix, and true when the source does not identify a class, so Camel's
own binding can never be weakened by a binder shape this does not recognise. For any other class the pre-4.23
behaviour is kept exactly: a value that is not a # reference converts to null. This is the substance of
option (a) with the precision the reviewer asked for — it keys on the class being bound, not on the target
type's package, so camel.component.http.x509-hostname-verifier = myVerifier still resolves even though
javax.net.ssl.HostnameVerifier is not a Camel type. The generated body stays one line, and all converters were
regenerated. Covered by BeanReferenceHelperTest (both rules, the unknown-source fallback, and that a
third-party binding is not intercepted) and end-to-end by HttpComponentBeanReferenceBindingTest.

2. #type:. After getBean(Class) the result is checked with type.isInstance(bean) and the same
IllegalArgumentException is thrown otherwise, naming the type actually found. Test:
testTypeOfAnUnrelatedBeanFailsClosed.

3. camel.springboot.lenient-configuration-binding. Now read from the Spring Environment through the
ApplicationContext the helper already receives, instead of Camel's PropertiesComponent. Declared in
core/camel-spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json next to
camel.vault.ignore-resolution-failures, and src/main/docs/spring-boot.json regenerated.

4. enabled / customizer. Confirmed by reading all 479 component, data format and language JSON files
under catalog/camel-catalog-provider-springboot: no option carries either name today. So that nothing can start
carrying one unnoticed, SpringBootAutoConfigurationMojo now fails the build when a catalog option is named
enabled or customizer, checked for components, data formats and languages.

5. Error message. It no longer just says to remove the option. It names the target class and says the option
may be listed in the starter documentation — which is generated from the catalog rather than from the target
class — in which case it has never taken effect.

6. Legacy copyProperties path. The WARN stays, and the upgrade note now says so explicitly: it is public
API used by hand-written customizers outside the generated starters, those keep ignoring an unbindable option,
but each one is now logged at WARN where nothing was logged before.

7. Language converter template. No starter generates a language converter today, so the fix to it (it
referenced an applicationContext field it did not declare) is covered only by the mojo string assertion in
SpringBootAutoConfigurationMojoTest, not by any running starter.

8. BeanReferenceHelperTest.testMessageMentionsTheTargetType uses assertTrue.

9. isExplicitlyConfigured now logs at WARN with the exception when it cannot decide, since returning
false there downgrades a hard error to an ignored option.

All ~425 starters were regenerated again after these changes and the result is byte identical to the previous
CAMEL-24501: Regen commit, since the emitted converter and customizer bodies are unchanged one-liners and the
review only touched what they call and a build-time check. So there is no second regeneration commit. That run
also exercised the new reserved-name check against every catalog entry without tripping it.

Claude Code (Opus 5) on behalf of Federico Mariani

Croway and others added 2 commits September 2, 2026 15:08
The generated starter code discarded configuration in two places, in both
cases without a log line, so an option that never took effect looked exactly
like one that did.

The generated *ComponentConverter, *DataFormatConverter and *LanguageConverter
classes resolve the bean reference that an option of a complex (object) type is
configured with. They returned null for any value that did not start with #,
and for a value naming a bean that does not exist, so a typo in the bean id
produced a component with the option unset. The generated convert() body now
delegates to the new BeanReferenceHelper, which resolves #bean:id, #id, a plain
bean id, #autowired and #type:fqn, and throws IllegalArgumentException naming
the value, the target type and the configuration prefix when the value cannot
be resolved. This also drops the per-type switch, which returned null for a
target type it did not list.

The generated customizers copied the whole configuration onto the target with
CamelPropertiesHelper.copyProperties, which binds with failIfNotSet=false, so
an option with no matching setter on the target was dropped. The same mojo
emits failIfNotSet=true for camel.rest.*, so the two disagreed. The customizers
now call the new CamelPropertiesHelper.copyConfigurationProperties, which:

- removes the options owned by the auto configuration layer itself (enabled and
  customizer) before binding, as they are not options on the Camel target;
- fails with IllegalArgumentException when an option the application configured
  itself cannot be set;
- logs at DEBUG when an option that only carries its catalog default cannot be
  set, since the target keeps its own default and there is nothing the
  application can do about it.

Telling the two apart uses the Spring ConfigurationPropertySources, so a
catalog default that has never been bindable (camel.language.simple.trim, for
example, which is an option of the expression model rather than of
SimpleLanguage) does not turn into a startup failure for every application.
Blanket strict binding needs the catalog defaults to stop being materialised as
field initializers on the configuration classes first, which is left for a
follow-up.

camel.springboot.lenient-configuration-binding=true logs an explicitly
configured option at WARN and continues, instead of failing.

The language converter template also referenced an applicationContext field it
did not declare; no starter currently generates a language converter, so this
was latent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regenerated the starters with the updated generator plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Croway
Croway requested review from davsclaus and oscerd September 2, 2026 13:11
…ion binding

Review follow-up on the previous commit.

The generated converters are registered with @ConfigurationPropertiesBinding, so
they take part in every @ConfigurationProperties binding in the application, not
only in Camel's own. Failing closed unconditionally therefore turned an unrelated
application property of a type a starter registers for, such as
javax.net.ssl.HostnameVerifier, into a startup failure that talks about
camel.component.*.

BeanReferenceHelper now resolves the class being bound from
TypeDescriptor.getSource(), which Spring Boot's binder fills with the setter's
MethodParameter (or the Field for field access), and only applies the strict
behaviour when that class is Camel's own: under org.apache.camel, or annotated
with @ConfigurationProperties for a camel. prefix. Any other class keeps the
behaviour it had before, and an unrecognised source counts as Camel's own so that
Camel's own binding is never weakened.

This is decided in convert() rather than in ConditionalGenericConverter.matches:
GenericConversionService caches the converter it picked per source/target
TypeDescriptor pair and TypeDescriptor.equals ignores the source, so matches is
consulted once for the first class bound and the answer reused for every other
class with a field of the same type. A conditional converter would therefore be
order dependent, and in the bad order it would report a missing converter for a
valid Camel property.

Also from the review:

- #type: now checks that the bean found by type is assignable to the option type,
  instead of leaving a ClassCastException for the binder to hit later.
- camel.springboot.lenient-configuration-binding is read from the Spring
  Environment rather than from Camel's PropertiesComponent, and is declared in
  additional-spring-configuration-metadata.json so it shows up in IDE completion.
- The mojo fails the build if a catalog option is ever named enabled or
  customizer, since the customizers strip those before binding. No component,
  data format or language declares one today.
- The error message names the target class and points out that the option may be
  listed in the starter documentation, which is generated from the catalog rather
  than from that class, and so may never have taken effect.
- isExplicitlyConfigured logs at WARN when it cannot decide, as returning false
  there downgrades a hard error to an ignored option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants