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 cd1ba89768ef CAMEL-26083: camel-support - 
RestConsumerContextPathMatcher keeps a wildcard path while another consumer 
uses it (#28591)
cd1ba89768ef is described below

commit cd1ba89768ef672cf1505cc03c82be1c934c8355
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 28 21:34:37 2026 +0530

    CAMEL-26083: camel-support - RestConsumerContextPathMatcher keeps a 
wildcard path while another consumer uses it (#28591)
    
    RestConsumerContextPathMatcher no longer drops a wildcard path while 
another consumer still uses it, and keeps the compiled pattern of a path 
already registered. camel-undertow registers and unregisters the rest path only 
when the consumer was added or removed, so a route that never started cannot 
remove the path of a running one.
    
    Co-Authored-By: Claude <[email protected]>
---
 .../org/apache/camel/http/common/CamelServlet.java |   7 +-
 ...pTwoRoutesSameWildcardPathStopOneRouteTest.java |  85 ++++++++++++
 .../ServletSharedHttpRegistryWildcardPathTest.java | 153 +++++++++++++++++++++
 .../undertow/handlers/RestRootHandler.java         |  15 +-
 ...ertowWildcardPathRemoveNotStartedRouteTest.java |  57 ++++++++
 .../support/RestConsumerContextPathMatcher.java    |  33 +++--
 .../RestConsumerContextPathMatcherTest.java        |  26 ++++
 7 files changed, 359 insertions(+), 17 deletions(-)

diff --git 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
index 8f329bcec9d4..3e2ec76e106e 100644
--- 
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
+++ 
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/CamelServlet.java
@@ -508,8 +508,11 @@ public class CamelServlet extends HttpServlet implements 
HttpRegistryProvider {
     @Override
     public void disconnect(HttpConsumer consumer) {
         log.debug("Disconnecting consumer: {}", consumer);
-        consumers.remove(consumer.getEndpoint().getEndpointUri());
-        RestConsumerContextPathMatcher.unRegister(consumer.getPath());
+        // only a consumer this servlet connected has registered its path: a 
registry shared by servlets of other
+        // names disconnects a consumer from every servlet, and a connect 
refused as a duplicate did not register
+        if (consumers.remove(consumer.getEndpoint().getEndpointUri(), 
consumer)) {
+            RestConsumerContextPathMatcher.unRegister(consumer.getPath());
+        }
     }
 
     @Override
diff --git 
a/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpTwoRoutesSameWildcardPathStopOneRouteTest.java
 
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpTwoRoutesSameWildcardPathStopOneRouteTest.java
new file mode 100644
index 000000000000..e3a4bd00fc5a
--- /dev/null
+++ 
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpTwoRoutesSameWildcardPathStopOneRouteTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.netty.http;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Two consumers with the same wildcard path (one per HTTP method, or one per 
port): stopping one of them must not stop
+ * the wildcard matching of the other, which is still running.
+ */
+class NettyHttpTwoRoutesSameWildcardPathStopOneRouteTest extends 
BaseNettyTestSupport {
+
+    @RegisterExtension
+    AvailablePortFinder.Port port2 = AvailablePortFinder.find();
+
+    @Test
+    void otherMethodOnSameWildcardPathStillServed() throws Exception {
+        assertEquals("post", post(getPort(), "/files/a"));
+        assertEquals("get", get(getPort(), "/files/a"));
+
+        context.getRouteController().stopRoute("get");
+
+        assertEquals("post", post(getPort(), "/files/a"));
+    }
+
+    @Test
+    void sameWildcardPathOnOtherPortStillServed() throws Exception {
+        assertEquals("other-port", post(port2.getPort(), "/docs/b"));
+
+        context.getRouteController().stopRoute("docs");
+
+        assertEquals("other-port", post(port2.getPort(), "/docs/b"));
+    }
+
+    private String post(int p, String path) {
+        return template.requestBodyAndHeader("netty-http:http://localhost:"; + 
p + path + "?throwExceptionOnFailure=false",
+                "x", Exchange.HTTP_METHOD, "POST", String.class);
+    }
+
+    private String get(int p, String path) {
+        return template.requestBodyAndHeader("netty-http:http://localhost:"; + 
p + path + "?throwExceptionOnFailure=false",
+                null, Exchange.HTTP_METHOD, "GET", String.class);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("netty-http:http://0.0.0.0:{{port}}/files/*?httpMethodRestrict=GET";).routeId("get")
+                        .transform().constant("get");
+                
from("netty-http:http://0.0.0.0:{{port}}/files/*?httpMethodRestrict=POST";).routeId("post")
+                        .transform().constant("post");
+
+                
from("netty-http:http://0.0.0.0:{{port}}/docs/*";).routeId("docs")
+                        .transform().constant("first-port");
+                from("netty-http:http://0.0.0.0:"; + port2.getPort() + 
"/docs/*").routeId("docs2")
+                        .transform().constant("other-port");
+                // a catch-all on the second port, started last: it must only 
get what no other consumer matches
+                from("netty-http:http://0.0.0.0:"; + port2.getPort() + 
"/*").routeId("catchAll")
+                        .transform().constant("catch-all");
+            }
+        };
+    }
+}
diff --git 
a/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletSharedHttpRegistryWildcardPathTest.java
 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletSharedHttpRegistryWildcardPathTest.java
new file mode 100644
index 000000000000..a780815e5b5a
--- /dev/null
+++ 
b/components/camel-servlet/src/test/java/org/apache/camel/component/servlet/ServletSharedHttpRegistryWildcardPathTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.servlet;
+
+import java.util.List;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.http.common.DefaultHttpRegistry;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.RestConsumerContextPathMatcher;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.util.ObjectHelper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A servlet component with an httpRegistry shared by two servlets of 
different names: each consumer is connected to
+ * (and registers its path with) the servlet of its name only, but 
disconnecting it goes to both servlets. The wildcard
+ * path of a consumer must stay registered while another consumer of the same 
path is still started.
+ */
+class ServletSharedHttpRegistryWildcardPathTest extends CamelTestSupport {
+
+    // the matcher keeps the paths in static state: use paths no other test 
registers
+    private static final String FILES = "/sharedregistry/files/*";
+    private static final String DUPLICATE = "/sharedregistry/duplicate/*";
+
+    private final DefaultHttpRegistry registry = new DefaultHttpRegistry();
+    private final CamelHttpTransportServlet servletA = new 
CamelHttpTransportServlet();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+
+        servletA.setServletName("a");
+        registry.register(servletA);
+        CamelHttpTransportServlet servletB = new CamelHttpTransportServlet();
+        servletB.setServletName("b");
+        registry.register(servletB);
+        context.getComponent("servlet", 
ServletComponent.class).setHttpRegistry(registry);
+
+        return context;
+    }
+
+    @Test
+    void stoppingOneConsumerKeepsTheWildcardPathOfTheOther() throws Exception {
+        assertNotNull(match(FILES, "/sharedregistry/files/a"));
+
+        context.getRouteController().stopRoute("get");
+
+        assertNotNull(match(FILES, "/sharedregistry/files/a"),
+                "the wildcard path of the POST consumer, still started, must 
match");
+
+        context.getRouteController().stopRoute("post");
+
+        assertNull(match(FILES, "/sharedregistry/files/a"));
+    }
+
+    @Test
+    void aConsumerThatFailsAsDuplicateDoesNotRemoveTheOther() throws Exception 
{
+        String uri = "servlet:" + DUPLICATE + "?servletName=a";
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(uri).routeId("first").setBody(constant("first"));
+            }
+        });
+        assertNotNull(match(DUPLICATE, "/sharedregistry/duplicate/a"));
+
+        // the same endpoint in another CamelContext with the same registry: 
the servlet refuses it as a duplicate, and
+        // the consumer that failed to connect is disconnected again
+        try (CamelContext other = new DefaultCamelContext()) {
+            other.getComponent("servlet", 
ServletComponent.class).setHttpRegistry(registry);
+            other.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    from(uri).routeId("second").setBody(constant("second"));
+                }
+            });
+            Exception e = assertThrows(Exception.class, other::start);
+            IllegalStateException cause = 
ObjectHelper.getException(IllegalStateException.class, e);
+            assertNotNull(cause, "cause: " + e);
+            assertTrue(cause.getMessage().startsWith("Duplicate request path 
for "), "cause: " + e);
+        }
+
+        assertNotNull(match(DUPLICATE, "/sharedregistry/duplicate/a"),
+                "the wildcard path of the first consumer, still started, must 
match");
+        assertEquals(1, servletA.getConsumers().values().stream()
+                .filter(c -> 
c.getEndpoint().getEndpointUri().contains("duplicate")).count());
+
+        context.getRouteController().stopRoute("first");
+        assertNull(match(DUPLICATE, "/sharedregistry/duplicate/a"));
+    }
+
+    private static RestConsumerContextPathMatcher.ConsumerPath<String> 
match(String consumerPath, String requestPath) {
+        return RestConsumerContextPathMatcher.matchBestPath("POST", 
requestPath,
+                List.of(new WildcardPath(consumerPath)));
+    }
+
+    private record WildcardPath(String path) implements 
RestConsumerContextPathMatcher.ConsumerPath<String> {
+
+        @Override
+        public String getConsumerPath() {
+            return path;
+        }
+
+        @Override
+        public String getRestrictMethod() {
+            return null;
+        }
+
+        @Override
+        public String getConsumer() {
+            return path;
+        }
+
+        @Override
+        public boolean isMatchOnUriPrefix() {
+            return false;
+        }
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("servlet:" + FILES + 
"?servletName=a&httpMethodRestrict=GET").routeId("get").setBody(constant("get"));
+                from("servlet:" + FILES + 
"?servletName=a&httpMethodRestrict=POST").routeId("post")
+                        .setBody(constant("post"));
+            }
+        };
+    }
+}
diff --git 
a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/RestRootHandler.java
 
b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/RestRootHandler.java
index 78bb24146cdb..2ad6fb7d54c2 100644
--- 
a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/RestRootHandler.java
+++ 
b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/RestRootHandler.java
@@ -75,17 +75,20 @@ public class RestRootHandler implements HttpHandler {
      */
     public void addConsumer(UndertowConsumer consumer, HttpHandler handler) {
         handlers.put(consumer, handler);
-        consumers.add(consumer);
-        
RestConsumerContextPathMatcher.register(consumer.getEndpoint().getHttpURI().getPath());
+        if (consumers.add(consumer)) {
+            
RestConsumerContextPathMatcher.register(consumer.getEndpoint().getHttpURI().getPath());
+        }
     }
 
     /**
-     * Removes the given consumer
+     * Removes the given consumer. Only a consumer that was added unregisters 
its path: a consumer that failed to start,
+     * or whose route never started, is stopped without having been added.
      */
     public void removeConsumer(UndertowConsumer consumer) {
-        consumers.remove(consumer);
-        handlers.remove(consumer);
-        
RestConsumerContextPathMatcher.unRegister(consumer.getEndpoint().getHttpURI().getPath());
+        if (consumers.remove(consumer)) {
+            handlers.remove(consumer);
+            
RestConsumerContextPathMatcher.unRegister(consumer.getEndpoint().getHttpURI().getPath());
+        }
     }
 
     /**
diff --git 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/rest/RestUndertowWildcardPathRemoveNotStartedRouteTest.java
 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/rest/RestUndertowWildcardPathRemoveNotStartedRouteTest.java
new file mode 100644
index 000000000000..11fe0c1fec7b
--- /dev/null
+++ 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/rest/RestUndertowWildcardPathRemoveNotStartedRouteTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.undertow.rest;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.undertow.BaseUndertowTest;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A rest consumer that never started must not remove the wildcard path of a 
running consumer with the same path when
+ * its route is removed.
+ */
+class RestUndertowWildcardPathRemoveNotStartedRouteTest extends 
BaseUndertowTest {
+
+    @Test
+    void removingANeverStartedRouteKeepsTheWildcardPathOfTheRunningRoute() 
throws Exception {
+        assertEquals("get", get("/files/a"));
+
+        context.removeRoute("post");
+
+        assertEquals("get", get("/files/a"));
+    }
+
+    private String get(String path) {
+        return template.requestBody("undertow:http://localhost:"; + getPort() + 
path + "?throwExceptionOnFailure=false",
+                null, String.class);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
restConfiguration().component("undertow").host("localhost").port(getPort());
+
+                
from("rest:get:files/*").routeId("get").transform().constant("get");
+                
from("rest:post:files/*").routeId("post").autoStartup(false).transform().constant("post");
+            }
+        };
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/RestConsumerContextPathMatcher.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/RestConsumerContextPathMatcher.java
index 97bf69647358..6f9f19295d80 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/RestConsumerContextPathMatcher.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/RestConsumerContextPathMatcher.java
@@ -40,7 +40,12 @@ import java.util.stream.Collectors;
 public final class RestConsumerContextPathMatcher {
 
     private static final Pattern CONSUMER_PATH_PARAMETER_PATTERN = 
Pattern.compile("([^{]*)(\\{.*?\\})([^{]*)");
-    private static final Map<String, Pattern> PATH_PATTERN = new 
ConcurrentHashMap<>();
+    // shared by every consumer with the same path (other HTTP method, port, 
component or CamelContext), so each
+    // pattern counts the consumers that registered it
+    private static final Map<String, RegisteredPattern> PATH_PATTERN = new 
ConcurrentHashMap<>();
+
+    private record RegisteredPattern(Pattern pattern, int consumers) {
+    }
 
     private RestConsumerContextPathMatcher() {
     }
@@ -238,11 +243,20 @@ public final class RestConsumerContextPathMatcher {
     }
 
     /**
-     * Pre-compiled consumer path for wildcard match
+     * Pre-compiled consumer path for wildcard match. Each consumer that 
registers a path must unregister it once with
+     * {@link #unRegister(String)} when it is removed.
      *
      * @param consumerPath a consumer path
      */
     public static void register(String consumerPath) {
+        // a path that is already registered keeps its pattern
+        PATH_PATTERN.compute(consumerPath,
+                (path, registered) -> registered != null
+                        ? new RegisteredPattern(registered.pattern(), 
registered.consumers() + 1)
+                        : new RegisteredPattern(compileConsumerPath(path), 1));
+    }
+
+    private static Pattern compileConsumerPath(String consumerPath) {
         // Remove hyphens and underscores from parameter names
         // as these are not supported in regex named group names
         String regex = prepareConsumerPathRegex(consumerPath);
@@ -258,17 +272,18 @@ public final class RestConsumerContextPathMatcher {
         regex = regex.replace("*", ".*");
 
         // Match the provided path against the regex pattern
-        Pattern pattern = Pattern.compile(regex);
-        PATH_PATTERN.put(consumerPath, pattern);
+        return Pattern.compile(regex);
     }
 
     /**
-     * if the rest consumer is removed, we also remove pattern cache.
+     * if the rest consumer is removed, we also remove pattern cache, unless 
other consumers registered the same path.
      *
      * @param consumerPath a consumer path
      */
     public static void unRegister(String consumerPath) {
-        PATH_PATTERN.remove(consumerPath);
+        PATH_PATTERN.computeIfPresent(consumerPath,
+                (path, registered) -> registered.consumers() > 1
+                        ? new RegisteredPattern(registered.pattern(), 
registered.consumers() - 1) : null);
     }
 
     /**
@@ -390,12 +405,12 @@ public final class RestConsumerContextPathMatcher {
             requestPath = requestPath + "/";
         }
 
-        Pattern pattern = PATH_PATTERN.get(consumerPath);
-        if (pattern == null) {
+        RegisteredPattern registered = PATH_PATTERN.get(consumerPath);
+        if (registered == null) {
             return false;
         }
 
-        Matcher matcher = pattern.matcher(requestPath);
+        Matcher matcher = registered.pattern().matcher(requestPath);
 
         return matcher.matches();
     }
diff --git 
a/core/camel-support/src/test/java/org/apache/camel/support/RestConsumerContextPathMatcherTest.java
 
b/core/camel-support/src/test/java/org/apache/camel/support/RestConsumerContextPathMatcherTest.java
index f3af32767847..f23fc7796379 100644
--- 
a/core/camel-support/src/test/java/org/apache/camel/support/RestConsumerContextPathMatcherTest.java
+++ 
b/core/camel-support/src/test/java/org/apache/camel/support/RestConsumerContextPathMatcherTest.java
@@ -27,6 +27,7 @@ import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -135,6 +136,31 @@ public class RestConsumerContextPathMatcherTest {
         assertEquals("/camel/myapp/order/*", path3.getConsumerPath());
     }
 
+    @Test
+    void wildcardPathStaysRegisteredWhileAnotherConsumerUsesIt() {
+        // the paths are static state: use a path no other test registers
+        String wildcard = "/shared/files/*";
+        List<RestConsumerContextPathMatcher.ConsumerPath<MockConsumerPath>> 
consumerPaths = new ArrayList<>();
+        consumerPaths.add(new MockConsumerPath("POST", wildcard));
+
+        // two consumers with the same path, for example one per HTTP method 
or one per port
+        RestConsumerContextPathMatcher.register(wildcard);
+        RestConsumerContextPathMatcher.register(wildcard);
+        assertEquals(wildcard, 
RestConsumerContextPathMatcher.matchBestPath("POST", "/shared/files/a", 
consumerPaths)
+                .getConsumerPath());
+
+        // one of them is removed: the other one still matches
+        RestConsumerContextPathMatcher.unRegister(wildcard);
+        RestConsumerContextPathMatcher.ConsumerPath<?> path = 
RestConsumerContextPathMatcher.matchBestPath("POST",
+                "/shared/files/a", consumerPaths);
+        assertNotNull(path, "the wildcard path of the consumer that is still 
registered must match");
+        assertEquals(wildcard, path.getConsumerPath());
+
+        // the last one is removed as well
+        RestConsumerContextPathMatcher.unRegister(wildcard);
+        assertNull(RestConsumerContextPathMatcher.matchBestPath("POST", 
"/shared/files/a", consumerPaths));
+    }
+
     @Test
     public void testRestConsumerContextPathMatcherOrder() {
         List<RestConsumerContextPathMatcher.ConsumerPath<MockConsumerPath>> 
consumerPaths = new ArrayList<>();

Reply via email to