This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 588e0028a651 CAMEL-25306: camel-rest-openapi, camel-rest-postman -
Decode the path parameters of contract-first operations (#27469)
588e0028a651 is described below
commit 588e0028a6516a7605b8a4805bc3d24cfcc1ab8e
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 7 15:56:27 2026 +0530
CAMEL-25306: camel-rest-openapi, camel-rest-postman - Decode the path
parameters of contract-first operations (#27469)
Contract-first rest-openapi operations mapped path placeholders from the
raw request path, so routes got percent-encoded values (A%221, X%20Y) while the
Rest DSL gave decoded ones. RestOpenApiProcessor now decodes each path
parameter as UTF-8 after splitting the path, so an encoded / stays inside its
parameter and + stays literal. Applies to camel-rest-postman too.
Closes #27469
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../org/apache/camel/http/base/HttpHelper.java | 24 ++++
.../org/apache/camel/http/base/HttpHelperTest.java | 10 ++
.../rest/openapi/RestOpenApiProcessor.java | 8 +-
.../RestOpenApiPathParameterDecodingTest.java | 131 +++++++++++++++++++++
.../src/test/resources/path-parameters.yaml | 51 ++++++++
.../rest/postman/RestPostmanProcessor.java | 7 +-
.../RestPostmanPathParameterDecodingTest.java | 84 +++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 7 ++
8 files changed, 318 insertions(+), 4 deletions(-)
diff --git
a/components/camel-http-base/src/main/java/org/apache/camel/http/base/HttpHelper.java
b/components/camel-http-base/src/main/java/org/apache/camel/http/base/HttpHelper.java
index da52065eb5f5..eb786893410a 100644
---
a/components/camel-http-base/src/main/java/org/apache/camel/http/base/HttpHelper.java
+++
b/components/camel-http-base/src/main/java/org/apache/camel/http/base/HttpHelper.java
@@ -17,6 +17,8 @@
package org.apache.camel.http.base;
import java.net.ProtocolException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -263,6 +265,28 @@ public final class HttpHelper {
}
}
+ /**
+ * Decodes the value of a path parameter as a path segment (RFC 3986):
percent-encoded octets are decoded as UTF-8,
+ * and a {@code +} is kept, as it is a space only in form-encoded query
strings. A value with a malformed escape is
+ * kept as it is.
+ * <p/>
+ * For consumers whose {@link Exchange#HTTP_PATH} is not decoded: decode
each value after the path has been split
+ * (see {@link #evalPlaceholders(BiConsumer, String, String)}), so an
encoded {@code /} stays in its parameter.
+ *
+ * @param value the value of the path parameter as it is in the request
path
+ * @return the decoded value
+ */
+ public static String decodePathParameter(String value) {
+ if (value.indexOf('%') == -1) {
+ return value;
+ }
+ try {
+ return URLDecoder.decode(value.replace("+", "%2B"),
StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return value;
+ }
+ }
+
/**
* Whether an uploaded file is accepted according to a {@code
fileNameExtWhitelist}.
* <p/>
diff --git
a/components/camel-http-base/src/test/java/org/apache/camel/http/base/HttpHelperTest.java
b/components/camel-http-base/src/test/java/org/apache/camel/http/base/HttpHelperTest.java
index 93fc7c14f5bf..25c36c6a25ec 100644
---
a/components/camel-http-base/src/test/java/org/apache/camel/http/base/HttpHelperTest.java
+++
b/components/camel-http-base/src/test/java/org/apache/camel/http/base/HttpHelperTest.java
@@ -61,4 +61,14 @@ class HttpHelperTest {
assertNotEquals(0, headers.size());
assertEquals("url", headers.get("key"));
}
+
+ @Test
+ void testDecodePathParameter() {
+ assertEquals("café", HttpHelper.decodePathParameter("caf%C3%A9"));
+ assertEquals("a/b", HttpHelper.decodePathParameter("a%2Fb"));
+ assertEquals("a+b", HttpHelper.decodePathParameter("a%2Bb"));
+ // a + is a literal in a path, and a malformed escape is kept
+ assertEquals("a+b c", HttpHelper.decodePathParameter("a+b%20c"));
+ assertEquals("100%", HttpHelper.decodePathParameter("100%"));
+ }
}
diff --git
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java
index 124112fb60a9..706b676d7b0b 100644
---
a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java
+++
b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java
@@ -19,6 +19,7 @@ package org.apache.camel.component.rest.openapi;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import io.swagger.v3.oas.models.OpenAPI;
@@ -139,8 +140,11 @@ public class RestOpenApiProcessor extends
AsyncProcessorSupport implements Camel
consumerPath = consumerPath.substring(1);
}
- // map path-parameters from operation to camel headers
- HttpHelper.evalPlaceholders(exchange.getMessage().getHeaders(),
path, consumerPath);
+ // map path-parameters from operation to camel headers (the path
is not decoded, so decode the value of
+ // each parameter after the path has been split, so an encoded /
stays in its parameter)
+ Map<String, Object> headers = exchange.getMessage().getHeaders();
+ HttpHelper.evalPlaceholders((k, v) -> headers.put(k,
HttpHelper.decodePathParameter(v.toString())), path,
+ consumerPath);
if (restRegistry != null) {
restRegistry.hit(verb, basePath, consumerPath);
diff --git
a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiPathParameterDecodingTest.java
b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiPathParameterDecodingTest.java
new file mode 100644
index 000000000000..b952b2438adc
--- /dev/null
+++
b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiPathParameterDecodingTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.rest.openapi;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.platform.http.PlatformHttpComponent;
+import org.apache.camel.component.platform.http.PlatformHttpEndpoint;
+import org.apache.camel.component.platform.http.spi.PlatformHttpConsumer;
+import org.apache.camel.component.platform.http.spi.PlatformHttpConsumerAware;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The value of a path parameter of a contract-first operation is decoded as a
path segment (RFC 3986), as the Rest DSL
+ * consumers do: percent-encoded octets as UTF-8, a {@code +} is kept, and an
encoded {@code /} stays inside its
+ * parameter.
+ */
+class RestOpenApiPathParameterDecodingTest extends ManagedCamelTestSupport {
+
+ private CamelContext camelContext;
+
+ @BeforeEach
+ public void createContext() throws Exception {
+ initializeContextForComponent("rest-openapi");
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:getItem").to("mock:getItem");
+ from("direct:getFile").to("mock:getFile");
+ }
+ };
+ }
+
+ @Override
+ protected CamelContext createCamelContext(String componentName) {
+ camelContext = new DefaultCamelContext();
+ camelContext.addComponent("platform-http",
mock(PlatformHttpComponent.class));
+ return camelContext;
+ }
+
+ private RestOpenApiProcessor createProcessor() throws Exception {
+ String specificationResource = "path-parameters.yaml";
+ OpenAPI openApi =
RestOpenApiEndpoint.loadSpecificationFrom(camelContext, specificationResource);
+ String basePath = RestOpenApiHelper.determineBasePath(camelContext,
null, null, openApi);
+
+ DefaultRestOpenapiProcessorStrategy strategy = new
DefaultRestOpenapiProcessorStrategy();
+ strategy.setCamelContext(camelContext);
+
+ RestOpenApiComponent component = new RestOpenApiComponent();
+ RestOpenApiEndpoint endpoint = new RestOpenApiEndpoint(
+ "rest-openapi:" + specificationResource,
specificationResource, component, null);
+
+ RestOpenApiProcessor processor = new RestOpenApiProcessor(endpoint,
openApi, basePath, null, strategy);
+ processor.setCamelContext(camelContext);
+ PlatformHttpConsumerAware consumerAware =
mock(PlatformHttpConsumerAware.class);
+ PlatformHttpConsumer consumer = mock(PlatformHttpConsumer.class);
+ PlatformHttpEndpoint platformHttpEndpoint =
mock(PlatformHttpEndpoint.class);
+ when(consumerAware.getPlatformHttpConsumer()).thenReturn(consumer);
+ when(consumer.getEndpoint()).thenReturn(platformHttpEndpoint);
+
when(platformHttpEndpoint.getServiceUrl()).thenReturn("http://localhost:8080");
+ processor.setPlatformHttpConsumer(consumerAware);
+ processor.afterPropertiesConfigured(camelContext);
+ return processor;
+ }
+
+ private Exchange get(String path) throws Exception {
+ Exchange exchange = new DefaultExchange(camelContext);
+ exchange.getMessage().setHeader(Exchange.HTTP_PATH, path);
+ exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+ createProcessor().process(exchange, done -> {
+ });
+ return exchange;
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "/items/A%221|A\"1",
+ "/items/X%20Y|X Y",
+ "/items/caf%C3%A9|café",
+ "/items/a+b|a+b",
+ "/items/a%2Bb|a+b",
+ "/items/100%25|100%",
+ "/items/%2541|%41",
+ "/items/100%|100%",
+ "/items/plain|plain" })
+ void testPathParameterIsDecoded(String path, String expected) throws
Exception {
+ Exchange exchange = get(path);
+
+ assertEquals(expected, exchange.getMessage().getHeader("id"));
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "/files/a%2Fb/c.txt|a/b|c.txt",
+ "/files/my%20dir/x%2By|my dir|x+y" })
+ void testEncodedSlashStaysInItsParameter(String path, String dir, String
name) throws Exception {
+ Exchange exchange = get(path);
+
+ assertEquals(dir, exchange.getMessage().getHeader("dir"));
+ assertEquals(name, exchange.getMessage().getHeader("name"));
+ }
+}
diff --git
a/components/camel-rest-openapi/src/test/resources/path-parameters.yaml
b/components/camel-rest-openapi/src/test/resources/path-parameters.yaml
new file mode 100644
index 000000000000..ed80d70a9020
--- /dev/null
+++ b/components/camel-rest-openapi/src/test/resources/path-parameters.yaml
@@ -0,0 +1,51 @@
+#
+# 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.
+#
+
+openapi: 3.0.0
+info:
+ title: Path parameter test API
+ version: 1.0.0
+paths:
+ /items/{id}:
+ get:
+ operationId: getItem
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The item
+ /files/{dir}/{name}:
+ get:
+ operationId: getFile
+ parameters:
+ - name: dir
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The file
diff --git
a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java
b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java
index 23aa3c49692f..7260aaad3411 100644
---
a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java
+++
b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java
@@ -99,8 +99,11 @@ public class RestPostmanProcessor extends
AsyncProcessorSupport implements Camel
consumerPath = consumerPath.substring(1);
}
- // turn the {name} markers of the matched template into message
headers
- HttpHelper.evalPlaceholders(exchange.getMessage().getHeaders(),
path, consumerPath);
+ // turn the {name} markers of the matched template into message
headers (the path is not decoded, so
+ // decode the value of each parameter after the path has been
split, so an encoded / stays in its parameter)
+ Map<String, Object> headers = exchange.getMessage().getHeaders();
+ HttpHelper.evalPlaceholders((k, v) -> headers.put(k,
HttpHelper.decodePathParameter(v.toString())), path,
+ consumerPath);
if (restRegistry != null) {
restRegistry.hit(verb, basePath, consumerPath);
diff --git
a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanPathParameterDecodingTest.java
b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanPathParameterDecodingTest.java
new file mode 100644
index 000000000000..88610d9e7c39
--- /dev/null
+++
b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanPathParameterDecodingTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.rest.postman;
+
+import java.util.List;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.rest.postman.support.PostmanRequestBinding;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The value of a path parameter of a collection request is decoded as a path
segment (RFC 3986), as the Rest DSL
+ * consumers do.
+ */
+class RestPostmanPathParameterDecodingTest {
+
+ private static final String COLLECTION =
"classpath:petstore-collection.json";
+
+ private DefaultCamelContext context;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ context = new DefaultCamelContext();
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:getPetById").to("mock:out");
+ }
+ });
+ context.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ context.stop();
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "/v3/pet/caf%C3%A9|café",
+ "/v3/pet/X%20Y|X Y",
+ "/v3/pet/a%2Fb|a/b" })
+ void testPathParameterIsDecoded(String path, String expected) throws
Exception {
+ List<PostmanRequestBinding> bindings
+ = context.getEndpoint("rest-postman:" + COLLECTION,
RestPostmanEndpoint.class).resolveBindings();
+ DefaultRestPostmanProcessorStrategy strategy = new
DefaultRestPostmanProcessorStrategy();
+ strategy.setCamelContext(context);
+ strategy.setMissingRequest("ignore");
+ RestPostmanProcessor processor
+ = new RestPostmanProcessor(bindings, null, COLLECTION, "/v3",
null, false, strategy);
+ processor.setCamelContext(context);
+ processor.afterPropertiesConfigured(context);
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getMessage().setHeader(Exchange.HTTP_PATH, path);
+ exchange.getMessage().setHeader(Exchange.HTTP_METHOD, "GET");
+ processor.process(exchange, done -> {
+ });
+
+
assertThat(exchange.getMessage().getHeader("petId")).isEqualTo(expected);
+ }
+}
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 0bd219a9d19a..1d7e57b09a62 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
@@ -1906,6 +1906,13 @@ through camel-http; set `componentName=vertx-http` to
keep the Vert.x client. An
is not affected. The Camel CLI (`camel run`) now adds camel-http, not
camel-vertx-http, for a rest-openapi producer
without `componentName`.
+=== camel-rest-openapi - path parameters are decoded
+
+The headers of the path parameters of a contract-first (`rest-openapi`
consumer) operation now hold the decoded
+value, as with the Rest DSL: `/items/caf%C3%A9` sets the `id` header to
`café`, not `caf%C3%A9`. Each path segment is
+decoded on its own (an encoded `/` stays in its parameter) and a `+` stays a
`+`. A value with a malformed escape is
+kept as it is. Routes that decoded these headers themselves must no longer do
so.
+
=== camel-jbang
The `--runtime` option of `camel run` has a new default value `jbang`, which
is the existing in-process