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<>();