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

Reply via email to