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")); + } +}