From cf1907f27b58fea2d580f8429c51b74a3ea6116b Mon Sep 17 00:00:00 2001 From: croway Date: Wed, 2 Sep 2026 15:01:37 +0200 Subject: [PATCH] CAMEL-24577: platform-http path variables follow the path Spring matched SpringBootPlatformHttpBinding evaluated the rest placeholders of the consumer path against getRawPath(request), which is the undecoded request URI with the context-path removed. Spring dispatched the request against the parsed RequestPath, whose segments are percent-decoded and stripped of matrix parameters, so the header could disagree with the path the request was matched on: /greeting/%61dmin set name to "%61dmin" while Spring matched "admin", and /greeting/name;v=1 set name to "name;v=1". The placeholders are now evaluated against the segments Spring matched, taken from ServletRequestPathUtils.parse(request), which also aligns the starter with the vertx engine, where the path params are decoded. The path is parsed rather than read back from the request attribute Spring caches, because the consumer services the request on its own executor and the dispatch may already have removed the attribute, which would make the value depend on timing. getRawPath() is unchanged, so Exchange.HTTP_PATH still reports the raw path and the context-path handling of CAMEL-22116 and CAMEL-23191 is preserved. Co-Authored-By: Claude Opus 5 --- .../src/main/docs/platform-http.adoc | 11 ++ .../SpringBootPlatformHttpBinding.java | 61 +++++-- ...otPlatformHttpBindingPathVariableTest.java | 64 ++++++++ ...pringBootPlatformHttpPathVariableTest.java | 152 ++++++++++++++++++ 4 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBindingPathVariableTest.java create mode 100644 components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpPathVariableTest.java diff --git a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc index 24898a54bba0..15053a6a48cb 100644 --- a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc +++ b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc @@ -6,6 +6,17 @@ The Platform HTTP starter provides Spring Boot auto-configuration for the Camel Platform HTTP component. +== Path variables + +A consumer path may declare placeholders, such as `platform-http:/greeting/{name}`, and the matched value is +set as a message header named after the placeholder. + +The values are taken from the path Spring Boot matched the request against, so they are percent-decoded and +carry no matrix parameters. A request to `/greeting/John%20Doe;v=1` sets the `name` header to `John Doe`. + +The `CamelHttpPath` header is not affected: it reports the raw request path, with the servlet context-path +removed. + == Undertow Access Log You can enable Undertow access log to be managed by whatever logging library you have in your camel application, you have to set the following parameters: diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java index ac434bcda008..6c432da6769d 100644 --- a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java @@ -43,8 +43,11 @@ import org.apache.camel.util.URISupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.multipart.support.StandardMultipartHttpServletRequest; +import org.springframework.web.util.ServletRequestPathUtils; import java.io.ByteArrayOutputStream; import java.io.File; @@ -73,20 +76,21 @@ public class SpringBootPlatformHttpBinding extends DefaultHttpBinding { protected void populateRequestParameters(HttpServletRequest request, Message message) { super.populateRequestParameters(request, message); - String path = getRawPath(request); + PlatformHttpEndpoint endpoint = (PlatformHttpEndpoint) message.getExchange().getFromEndpoint(); + String consumerPath = endpoint.getPath(); + if (consumerPath != null && consumerPath.startsWith("/")) { + consumerPath = consumerPath.substring(1); + } + if (consumerPath == null || !useRestMatching(consumerPath)) { + return; + } + String path = getMatchedPath(request); // skip leading slash if (path != null && path.startsWith("/")) { path = path.substring(1); } if (path != null) { - PlatformHttpEndpoint endpoint = (PlatformHttpEndpoint) message.getExchange().getFromEndpoint(); - String consumerPath = endpoint.getPath(); - if (consumerPath != null && consumerPath.startsWith("/")) { - consumerPath = consumerPath.substring(1); - } - if (useRestMatching(consumerPath)) { - HttpHelper.evalPlaceholders(message.getHeaders(), path, consumerPath); - } + HttpHelper.evalPlaceholders(message.getHeaders(), path, consumerPath); } } @@ -94,6 +98,45 @@ private boolean useRestMatching(String path) { return path.indexOf('{') > -1; } + /** + * The request path to evaluate the rest placeholders of the consumer path against. + *

+ * Spring matches the request against the {@link RequestPath} it parses from the request, whose segments are + * percent-decoded and stripped of matrix parameters. Evaluating the placeholders against that same path makes the + * headers agree with the path the request was actually matched on, and matches the decoded values the vertx engine + * provides. + *

+ * The path is parsed again instead of reading the one Spring cached in the request, because the request is serviced + * on another thread and the dispatch that cached it may already have removed it by then. + * + * @param request the current request + * @return the path the placeholders are evaluated against + */ + private String getMatchedPath(HttpServletRequest request) { + try { + // pathWithinApplication has the context-path (and any servlet path prefix) removed, which is the + // same part getRawPath skips + return toMatchedValue(ServletRequestPathUtils.parse(request).pathWithinApplication()); + } catch (Exception e) { + LOG.debug("Cannot parse request path of {}, using the raw path instead", request.getRequestURI(), e); + return getRawPath(request); + } + } + + private static String toMatchedValue(PathContainer path) { + StringBuilder sb = new StringBuilder(path.value().length()); + for (PathContainer.Element element : path.elements()) { + if (element instanceof PathContainer.PathSegment segment) { + // the decoded segment, without its matrix parameters + sb.append(segment.valueToMatch()); + } else { + // separator + sb.append(element.value()); + } + } + return sb.toString(); + } + @Override protected void populateAttachments(HttpServletRequest request, Message message) { // check if there is multipart files, if so will put it into DataHandler diff --git a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBindingPathVariableTest.java b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBindingPathVariableTest.java new file mode 100644 index 000000000000..569d543a5126 --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBindingPathVariableTest.java @@ -0,0 +1,64 @@ +/* + * 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 java.util.concurrent.TimeUnit; + +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.platform.http.PlatformHttpComponent; +import org.apache.camel.component.platform.http.PlatformHttpConstants; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The consumer services the request on its own executor, so the path variables must be resolved without relying on the + * request path the dispatch cached, which may already have been removed by then. + */ +public class SpringBootPlatformHttpBindingPathVariableTest { + + @Test + void pathVariablesAreResolvedWithoutACachedRequestPath() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.getRegistry().bind(PlatformHttpConstants.PLATFORM_HTTP_ENGINE_NAME, + new SpringBootPlatformHttpEngine(8080)); + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/greeting/{name}") + .transform().simple("${header.name}|${header.CamelHttpPath}"); + } + }); + context.start(); + + PlatformHttpComponent component = context.getComponent("platform-http", PlatformHttpComponent.class); + SpringBootPlatformHttpConsumer consumer + = (SpringBootPlatformHttpConsumer) component.getHttpEndpoints().iterator().next().getConsumer(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/greeting/%61dmin;v=1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + consumer.service(request, response).get(20, TimeUnit.SECONDS); + + assertEquals(200, response.getStatus()); + // the placeholder is decoded and carries no matrix parameter, CamelHttpPath stays raw + assertEquals("admin|/greeting/%61dmin;v=1", response.getContentAsString()); + } + } +} diff --git a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpPathVariableTest.java b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpPathVariableTest.java new file mode 100644 index 000000000000..d7abf3c5a2ac --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpPathVariableTest.java @@ -0,0 +1,152 @@ +/* + * 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 io.restassured.RestAssured; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +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.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.firewall.StrictHttpFirewall; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +/** + * Path variables must be taken from the path Spring matched the request against, so a percent encoded segment or a + * segment carrying matrix parameters is reported decoded and without its matrix parameters, as the vertx engine does. + */ +@EnableAutoConfiguration +@CamelSpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { CamelAutoConfiguration.class, + SpringBootPlatformHttpPathVariableTest.class, + SpringBootPlatformHttpPathVariableTest.TestConfiguration.class, + PlatformHttpComponentAutoConfiguration.class, + SpringBootPlatformHttpAutoConfiguration.class }) +public class SpringBootPlatformHttpPathVariableTest { + + @Autowired + private Environment env; + + @BeforeEach + void setUp() { + RestAssured.port = env.getRequiredProperty("local.server.port", Integer.class); + } + + @Configuration + public static class TestConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(csrf -> csrf.disable()); + return http.build(); + } + + @Bean + public WebSecurityCustomizer allowMatrixParametersCustomizer() { + // the strict firewall rejects matrix parameters by default + StrictHttpFirewall firewall = new StrictHttpFirewall(); + firewall.setAllowSemicolon(true); + return web -> web.httpFirewall(firewall); + } + + @Bean + public RouteBuilder pathVariableRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/greeting/{name}") + .transform().simple("${header.name}|${header.CamelHttpPath}"); + + rest("/rest") + .get("/{name}").to("direct:restName"); + + from("direct:restName") + .setBody().simple("${header.name}"); + } + }; + } + } + + @Test + public void testPlainPathVariable() { + given() + .when() + .get("/greeting/Camel") + .then() + .statusCode(200) + .body(equalTo("Camel|/greeting/Camel")); + } + + @Test + public void testPercentEncodedPathVariable() { + // Spring matched /greeting/admin, so the header must be admin, while CamelHttpPath stays the raw path + given() + .urlEncodingEnabled(false) + .when() + .get("/greeting/%61dmin") + .then() + .statusCode(200) + .body(equalTo("admin|/greeting/%61dmin")); + } + + @Test + public void testPercentEncodedSpaceInPathVariable() { + given() + .urlEncodingEnabled(false) + .when() + .get("/greeting/John%20Doe") + .then() + .statusCode(200) + .body(equalTo("John Doe|/greeting/John%20Doe")); + } + + @Test + public void testMatrixParameterInPathVariable() { + // Spring matched /greeting/name, the matrix parameter is not part of the segment it matched + given() + .urlEncodingEnabled(false) + .when() + .get("/greeting/name;v=1") + .then() + .statusCode(200) + .body(equalTo("name|/greeting/name;v=1")); + } + + @Test + public void testRestDslPercentEncodedPathVariable() { + given() + .urlEncodingEnabled(false) + .when() + .get("/rest/%61dmin") + .then() + .statusCode(200) + .body(equalTo("admin")); + } +}