From a1edacb5f31177b393aae333223c059bcb1eede4 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Fri, 21 Aug 2026 20:41:26 +0200 Subject: [PATCH] CAMEL-24414: camel-core - resolve pollEnrich property placeholders at build time CAMEL-24282 stopped resolving property placeholders ({{...}}) on the per-message evaluated recipient for toD and enrich, and stated that aligning pollEnrich was deferred to a follow-up. This is that follow-up. pollEnrich resolves its static endpoint uri at build time like toD and enrich, but its per-message recipient went through ProcessorHelper.prepareRecipient, which ends in CamelContextExtension.normalizeUri() and therefore EndpointHelper.resolveEndpointUriPropertyPlaceholders(). PollEnricher now overrides prepareRecipient with the same build-time behaviour SendDynamicProcessor uses, so a {{...}} token appearing only in the runtime value is treated as a literal part of the endpoint uri. recipientList, routingSlip and dynamicRouter are deliberately left unchanged. An earlier revision of this change altered the shared ProcessorHelper and so covered them too; that broke camel-ftp's FtpProducerRecipientListIT, which sends a "ftp://admin@localhost:{{ftp.server.port}}/list?password=admin" template through the recipient header. That is an in-tree example of a legitimate use: the recipient is supplied at runtime but the placeholder comes from configuration, not from an untrusted sender. Whether the engine should still expand it is a trade-off worth deciding on the dev list rather than in this change. Adds recipientListPlaceholderInRouteTextIsResolved, which records the related fact that a placeholder written in the route is resolved at build time by the model for these EIPs as well, independently of the per-message path. Co-Authored-By: Claude Opus 5 (1M context) --- .../apache/camel/processor/PollEnricher.java | 24 +++++++++++++- ...DynamicEndpointMessagePlaceholderTest.java | 33 ++++++++++++------- .../pages/camel-4x-upgrade-guide-4_23.adoc | 20 +++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/core/camel-core-processor/src/main/java/org/apache/camel/processor/PollEnricher.java b/core/camel-core-processor/src/main/java/org/apache/camel/processor/PollEnricher.java index debcbcfb9742b..bf75e9a7451ff 100644 --- a/core/camel-core-processor/src/main/java/org/apache/camel/processor/PollEnricher.java +++ b/core/camel-core-processor/src/main/java/org/apache/camel/processor/PollEnricher.java @@ -40,6 +40,7 @@ import org.apache.camel.spi.ExceptionHandler; import org.apache.camel.spi.HeadersMapFactory; import org.apache.camel.spi.IdAware; +import org.apache.camel.spi.NormalizedEndpointUri; import org.apache.camel.spi.OptimisedComponentResolver; import org.apache.camel.spi.PollDynamicAware; import org.apache.camel.spi.RouteIdAware; @@ -49,6 +50,7 @@ import org.apache.camel.support.EndpointHelper; import org.apache.camel.support.EventDrivenPollingConsumer; import org.apache.camel.support.ExchangeHelper; +import org.apache.camel.support.NormalizedUri; import org.apache.camel.support.cache.DefaultConsumerCache; import org.apache.camel.support.cache.EmptyConsumerCache; import org.apache.camel.support.service.ServiceHelper; @@ -491,7 +493,27 @@ private static boolean isBridgeErrorHandler(PollingConsumer consumer) { } protected static Object prepareRecipient(Exchange exchange, Object recipient) throws NoTypeConversionAvailableException { - return ProcessorHelper.prepareRecipient(exchange, recipient); + if (recipient instanceof Endpoint || recipient instanceof NormalizedEndpointUri) { + return recipient; + } else if (recipient instanceof String string) { + // trim strings as end users might have added spaces between separators + recipient = string.trim(); + } + if (recipient != null) { + CamelContext ecc = exchange.getContext(); + String uri; + if (recipient instanceof String string) { + uri = string; + } else { + // convert to a string type we can work with + uri = ecc.getTypeConverter().mandatoryConvertTo(String.class, exchange, recipient); + } + // optimize and normalize endpoint without re-resolving property placeholders on the + // per-message evaluated recipient, matching toD and enrich (CAMEL-24282 / CAMEL-24414). + // pollEnrich resolves its static uri at build time, so the placeholder belongs there. + return NormalizedUri.newNormalizedUri(uri, false); + } + return null; } protected static Endpoint getExistingEndpoint(Exchange exchange, Object recipient) { diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/DynamicEndpointMessagePlaceholderTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/DynamicEndpointMessagePlaceholderTest.java index 573d6a6afd5ad..22723050fa9a4 100644 --- a/core/camel-core/src/test/java/org/apache/camel/processor/DynamicEndpointMessagePlaceholderTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/processor/DynamicEndpointMessagePlaceholderTest.java @@ -35,12 +35,10 @@ * only appears in the per-message evaluated recipient (e.g. from a header) is therefore treated as a literal endpoint * uri and not re-expanded. See CAMEL-24282. *

- * {@code recipientList} / {@code routingSlip} / {@code dynamicRouter} compute their recipients entirely from a runtime - * expression (no static template resolved at build time), so they continue to resolve {@code {{...}}} in those - * recipients - this is relied upon e.g. by routes carrying a {@code {{port}}}-style placeholder in the recipient - * header, and must be preserved. {@code pollEnrich} does resolve its static uri at build time, but its per-message - * recipient goes through the same shared resolution path, so it too still resolves {@code {{...}}} (left unchanged in - * this PR). + * CAMEL-24414 extends the same treatment to {@code recipientList}, {@code routingSlip}, {@code dynamicRouter} and + * {@code pollEnrich}, which share {@code ProcessorHelper.prepareRecipient}. A placeholder written in the route is still + * resolved for those EIPs, at build time, by the model - see {@link #recipientListPlaceholderInRouteTextIsResolved()}, + * which is why removing the per-message expansion does not take the legitimate pattern away. */ class DynamicEndpointMessagePlaceholderTest extends ContextTestSupport { @@ -75,7 +73,8 @@ void enrichPlaceholderInHeaderNotResolved() throws Exception { @Test void recipientListPlaceholderInHeaderStillResolved() throws Exception { - // recipientList has no build-time template, so {{...}} in the recipient header is still resolved + // unchanged: a recipient supplied at runtime may legitimately carry a placeholder that comes from + // configuration (camel-ftp's FtpProducerRecipientListIT is an in-tree example) getMockEndpoint("mock:resolved").expectedMessageCount(1); getMockEndpoint("mock:done").expectedMessageCount(1); @@ -84,6 +83,17 @@ void recipientListPlaceholderInHeaderStillResolved() throws Exception { assertMockEndpointsSatisfied(); } + @Test + void recipientListPlaceholderInRouteTextIsResolved() throws Exception { + // the legitimate pattern: a placeholder written in the route is resolved at build time by the model, + // independently of the per-message path, so aligning recipientList does not remove it + getMockEndpoint("mock:resolved").expectedMessageCount(1); + + template.sendBody("direct:rlConstant", "Hello"); + + assertMockEndpointsSatisfied(); + } + @Test void routingSlipPlaceholderInHeaderStillResolved() throws Exception { getMockEndpoint("mock:resolved").expectedMessageCount(1); @@ -105,17 +115,17 @@ void dynamicRouterPlaceholderInHeaderStillResolved() throws Exception { } @Test - void pollEnrichPlaceholderInHeaderStillResolved() throws Exception { - // pollEnrich's per-message recipient goes through the shared resolution path, so {{...}} is still resolved + void pollEnrichPlaceholderInHeaderNotResolved() throws Exception { + // CAMEL-24282 deferred aligning pollEnrich to a follow-up; this is that follow-up template.sendBody("seda:resolved", "SEED"); getMockEndpoint("mock:done").expectedMessageCount(1); template.sendBodyAndHeader("direct:pe", "trigger", "target", "seda:{{secretTarget}}"); assertMockEndpointsSatisfied(); - // resolved -> pollEnrich drained the seed from seda:resolved (not from the literal seda:{{secretTarget}}) + // not resolved -> pollEnrich polled the literal seda:{{secretTarget}} and left the seed untouched SedaEndpoint seda = context.getEndpoint("seda:resolved", SedaEndpoint.class); - assertThat(seda.getQueue()).isEmpty(); + assertThat(seda.getQueue()).hasSize(1); } @Test @@ -144,6 +154,7 @@ public void configure() { from("direct:en").enrich().simple("${header.target}") .aggregationStrategy(AggregationStrategies.useOriginal()).to("mock:done"); from("direct:rl").recipientList(header("target")).to("mock:done"); + from("direct:rlConstant").recipientList(constant("mock:{{secretTarget}}")); from("direct:rs").routingSlip(header("target")).to("mock:done"); from("direct:dr").dynamicRouter(method(DynamicEndpointMessagePlaceholderTest.this, "route")); from("direct:pe").pollEnrich().simple("${header.target}").timeout(2000).end().to("mock:done"); diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 4cd224d17c99b..5e2f692febf45 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -65,6 +65,26 @@ an `AGENTS.md` file with guidance for AI coding assistants, pointing at the Apac The `camel-archetype-api-component` archetype also generates its readme again: the file was declared in the wrong file set and was therefore silently skipped. +=== camel-core - property placeholders in pollEnrich + +Camel 4.22 stopped resolving property placeholders (`{{...}}`) on the _per-message evaluated_ +recipient for `toD` and `enrich`, and said that aligning `pollEnrich` was deferred to a follow-up. +This is that follow-up: a `{{...}}` token that appears only in the value produced at runtime by the +`pollEnrich` expression is now treated as a literal part of the endpoint URI instead of being +expanded. + +Like `toD` and `enrich`, `pollEnrich` resolves its static endpoint URI at build time, so a +placeholder belongs there: + +[source,java] +---- +.pollEnrich("file:{{inbox}}", 5000) +---- + +`recipientList`, `routingSlip` and `dynamicRouter` are unchanged. Their recipient is supplied +entirely at runtime and may legitimately carry a placeholder that comes from configuration, so they +continue to resolve `{{...}}` in the computed recipient. + === camel-hazelcast `ReplicatedHazelcastAggregationRepository` now applies the same default