Repository: helidon-io/helidon
Pull request #10032: Provides access to matching pattern in ServerRequest
Author: login: spericas
Labels: name: OCA Verified
description: All contributors have signed the Oracle Contributor Agreement.
Created: 2025-04-21T14:21:15Z | Merged: 2025-04-24T13:21:53Z

### Description

Provides access to matching pattern in ServerRequest. See issue #10022.

### Documentation

None



Diff:
diff --git a/http/http/src/main/java/io/helidon/http/PathMatcher.java b/http/http/src/main/java/io/helidon/http/PathMatcher.java
index fbfaf1ec6ef..bafdc0c8c6b 100644
--- a/http/http/src/main/java/io/helidon/http/PathMatcher.java
+++ b/http/http/src/main/java/io/helidon/http/PathMatcher.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
 
 package io.helidon.http;
 
+import java.util.Optional;
+
 import io.helidon.common.uri.UriPath;
 
 /**
@@ -40,4 +42,14 @@ public interface PathMatcher {
      * @return match result
      */
     PathMatchers.PrefixMatchResult prefixMatch(UriPath uriPath);
+
+    /**
+     * Returns the matching element for this matcher. This could be a prefix, pattern,
+     * etc. depending on the type of matcher.
+     *
+     * @return optional matching element
+     */
+    default Optional<String> matchingElement() {
+        return Optional.empty();
+    }
 }
diff --git a/http/http/src/main/java/io/helidon/http/PathMatchers.java b/http/http/src/main/java/io/helidon/http/PathMatchers.java
index 9d8664b8c5d..50e7cd046bd 100644
--- a/http/http/src/main/java/io/helidon/http/PathMatchers.java
+++ b/http/http/src/main/java/io/helidon/http/PathMatchers.java
@@ -19,6 +19,8 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -62,6 +64,7 @@ public static PathMatcher exact(String pathToMatch) {
      * @return prefix match path matcher
      */
     public static PathMatcher prefix(String pathToMatch) {
+        Objects.requireNonNull(pathToMatch);
         return new PrefixPathMatcher(fixPrefix(pathToMatch));
     }
 
@@ -134,7 +137,7 @@ public static PathMatcher pattern(String pattern) {
                                                     + ", index: " + (iter.index() - 1));
         }
 
-        return new PatternPathMatcher(regexp.toString(), paramToGroupName);
+        return new PatternPathMatcher(regexp.toString(), paramToGroupName, pattern);
     }
 
     /**
@@ -327,6 +330,11 @@ public PrefixMatchResult prefixMatch(UriPath uriPath) {
             return PrefixMatchResult.notAccepted();
         }
 
+        @Override
+        public Optional<String> matchingElement() {
+            return Optional.of(path);
+        }
+
         @Override
         public String toString() {
             return "exact: " + path;
@@ -397,6 +405,11 @@ public PrefixMatchResult prefixMatch(UriPath uriPath) {
             return PrefixMatchResult.notAccepted();
         }
 
+        @Override
+        public Optional<String> matchingElement() {
+            return Optional.of(exactMatch + "/*");
+        }
+
         @Override
         public String toString() {
             return "prefix: " + prefix;
@@ -410,12 +423,14 @@ static class PatternPathMatcher implements PathMatcher {
         private final Pattern pattern;
         private final Pattern leftPattern;
         private final String patternString;
+        private final String sourcePattern;
 
-        PatternPathMatcher(String pattern, Map<String, String> paramToGroupName) {
+        PatternPathMatcher(String pattern, Map<String, String> paramToGroupName, String sourcePattern) {
             this.patternString = pattern;
             this.pattern = Pattern.compile(pattern);
             this.leftPattern = Pattern.compile(pattern + "(?<" + RIGHT_PART_PARAM_NAME + ">/.+)?");
             this.paramToGroupName = paramToGroupName;
+            this.sourcePattern = sourcePattern;
         }
 
         @Override
@@ -460,6 +475,11 @@ Pattern pattern() {
             return pattern;
         }
 
+        @Override
+        public Optional<String> matchingElement() {
+            return Optional.of(sourcePattern);
+        }
+
         private Parameters extractParams(Matcher matcher) {
             // Old school, but slightly faster than stream.
             Map<String, String> params = new HashMap<>(paramToGroupName.size());
diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Route.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Route.java
index 6e9a8db8004..60ab57d3b3f 100644
--- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Route.java
+++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Route.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
 
 package io.helidon.webserver.http2;
 
+import java.util.Optional;
 import java.util.function.Predicate;
 
 import io.helidon.http.HttpPrologue;
@@ -81,4 +82,9 @@ public void beforeStart() {
     public void afterStop() {
         handler.afterStop();
     }
+
+    @Override
+    public Optional<PathMatcher> pathMatcher() {
+        return Optional.of(pathMatcher);
+    }
 }
diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java
index 44ab7e45a98..3ea3c9e219a 100644
--- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java
+++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java
@@ -71,6 +71,7 @@ class Http2ServerRequest implements RoutingRequest {
     // preparation for continue support in HTTP/2
     private boolean continueSent;
     private UnaryOperator<InputStream> streamFilter = UnaryOperator.identity();
+    private String matchingPattern;
 
     Http2ServerRequest(ConnectionContext ctx,
                        HttpSecurity security,
@@ -189,6 +190,17 @@ public RoutingRequest prologue(HttpPrologue newPrologue) {
         return this;
     }
 
+    @Override
+    public RoutingRequest matchingPattern(String matchingPattern) {
+        this.matchingPattern = matchingPattern;
+        return this;
+    }
+
+    @Override
+    public Optional<String> matchingPattern() {
+        return Optional.of(matchingPattern);
+    }
+
     @Override
     public ListenerContext listenerContext() {
         return ctx.listenerContext();
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternBase.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternBase.java
new file mode 100644
index 00000000000..4eff00320b2
--- /dev/null
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternBase.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.http.Status;
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webclient.http1.Http1ClientResponse;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.HttpService;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+abstract class MatchingPatternBase {
+    static final Handler HANDLER = (req, res) -> res.send(req.matchingPattern().orElse(""));
+
+    private final Http1Client client;
+
+    MatchingPatternBase(Http1Client client) {
+        this.client = client;
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"/", "/foo", "/foo/bar", "/foo/bar/baz"})
+    void testBasic(String value) {
+        try (Http1ClientResponse response = client.get(value).request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is(value));
+        }
+    }
+
+    @Test
+    void testParams() {
+        try (Http1ClientResponse response = client.get("/greet/john").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet/{name}"));
+        }
+        try (Http1ClientResponse response = client.get("/greet/john/doe").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet/{name1}/{}"));
+        }
+        try (Http1ClientResponse response = client.get("/greet/john/and/doe").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet/{name1}/and/{name2}"));
+        }
+    }
+
+    @Test
+    void testWildcard() {
+        try (Http1ClientResponse response = client.get("/greet1/john").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet1/*"));
+        }
+        try (Http1ClientResponse response = client.get("/greet2/greet/john").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet2/greet/*"));
+        }
+        try (Http1ClientResponse response = client.get("/greet4/greet/john").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet4/*/*"));
+        }
+    }
+
+    @Test
+    void testOptional() {
+        try (Http1ClientResponse response = client.get("/greet3").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet3[/greet]"));
+        }
+        try (Http1ClientResponse response = client.get("/greet3/greet").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet3[/greet]"));
+        }
+    }
+
+    @Test
+    void testService() {
+        try (Http1ClientResponse response = client.get("/greet-service/john").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet-service/{name}"));
+        }
+        try (Http1ClientResponse response = client.get("/greet-service/john/doe").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet-service/{name1}/{name2}"));
+        }
+        try (Http1ClientResponse response = client.get("/greet-service/john/and/doe").request()) {
+            assertThat(response.status(), is(Status.OK_200));
+            String message = response.as(String.class);
+            assertThat(message, is("/greet-service/{name1}/and/{name2}"));
+        }
+    }
+
+    static class GreetService implements HttpService {
+
+        @Override
+        public void routing(HttpRules rules) {
+            rules.get("/", HANDLER)
+                    .get("/{name}", HANDLER)
+                    .get("/{name1}/{name2}", HANDLER)
+                    .get("/{name1}/and/{name2}", HANDLER);
+        }
+    }
+}
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternRoutingTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternRoutingTest.java
new file mode 100644
index 00000000000..7b88c6400de
--- /dev/null
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternRoutingTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.testing.junit5.RoutingTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+@RoutingTest
+class MatchingPatternRoutingTest extends MatchingPatternBase {
+
+    MatchingPatternRoutingTest(Http1Client client) {
+        super(client);
+    }
+
+    @SetUpRoute
+    static void routing(HttpRules rules) {
+        rules.get("/", HANDLER)
+                .get("/foo", HANDLER)
+                .get("/foo/bar", HANDLER)
+                .get("/foo/bar/baz", HANDLER)
+                .get("/greet/{name}", HANDLER)
+                .get("/greet/{name1}/{}", HANDLER)
+                .get("/greet/{name1}/and/{name2}", HANDLER)
+                .get("/greet1/*", HANDLER)
+                .get("/greet2/greet/*", HANDLER)
+                .get("/greet3[/greet]", HANDLER)
+                .get("/greet4/*/*", HANDLER)
+                .register("/greet-service", new GreetService());
+    }
+}
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternServerTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternServerTest.java
new file mode 100644
index 00000000000..9d15d1c4f7a
--- /dev/null
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternServerTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+@ServerTest
+class MatchingPatternServerTest extends MatchingPatternBase {
+
+    MatchingPatternServerTest(Http1Client client) {
+        super(client);
+    }
+
+    @SetUpRoute
+    static void routing(HttpRules rules) {
+        rules.get("/", HANDLER)
+                .get("/foo", HANDLER)
+                .get("/foo/bar", HANDLER)
+                .get("/foo/bar/baz", HANDLER)
+                .get("/greet/{name}", HANDLER)
+                .get("/greet/{name1}/{}", HANDLER)
+                .get("/greet/{name1}/and/{name2}", HANDLER)
+                .get("/greet1/*", HANDLER)
+                .get("/greet2/greet/*", HANDLER)
+                .get("/greet3[/greet]", HANDLER)
+                .get("/greet4/*/*", HANDLER)
+                .register("/greet-service", new GreetService());
+    }
+}
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java
index 70f24f2c69b..e04285c3519 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 package io.helidon.webserver.http;
 
 import java.util.Objects;
+import java.util.Optional;
 import java.util.function.Predicate;
 
 import io.helidon.http.HttpPrologue;
@@ -54,6 +55,15 @@ static HttpRouteImpl.Builder builder() {
      */
     Handler handler();
 
+    /**
+     * Path matcher for this HTTP route.
+     *
+     * @return optional path matcher
+     */
+    default Optional<PathMatcher> pathMatcher() {
+        return Optional.empty();
+    }
+
     /**
      * Fluent API builder for {@link HttpRoute}.
      */
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteImpl.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteImpl.java
index 66528e17743..625f89ac8a2 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteImpl.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteImpl.java
@@ -16,6 +16,7 @@
 
 package io.helidon.webserver.http;
 
+import java.util.Optional;
 import java.util.function.Predicate;
 
 import io.helidon.http.HttpPrologue;
@@ -49,6 +50,11 @@ public Handler handler() {
         return handler;
     }
 
+    @Override
+    public Optional<PathMatcher> pathMatcher() {
+        return Optional.of(pathMatcher);
+    }
+
     @Override
     public void beforeStart() {
         handler.beforeStart();
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteWrap.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteWrap.java
index 67516891538..f6f9c9edfbb 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteWrap.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRouteWrap.java
@@ -16,7 +16,10 @@
 
 package io.helidon.webserver.http;
 
+import java.util.Optional;
+
 import io.helidon.http.HttpPrologue;
+import io.helidon.http.PathMatcher;
 import io.helidon.http.PathMatchers;
 import io.helidon.webserver.WebServer;
 
@@ -61,4 +64,9 @@ public String toString() {
     boolean isList() {
         return false;
     }
+
+    @Override
+    public Optional<PathMatcher> pathMatcher() {
+        return route.pathMatcher();
+    }
 }
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoutingImpl.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoutingImpl.java
index 963a7091356..0c24fd4cc30 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoutingImpl.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoutingImpl.java
@@ -169,6 +169,7 @@ private RoutingResult doRoute(ConnectionContext ctx, RoutingRequest request, Rou
                 response.resetRouting();
                 RouteCrawler.CrawlerItem next = crawler.next();
                 request.path(next.path());
+                request.matchingPattern(next.matchingElement());
 
                 next.handler().handle(request, response);
                 if (response.shouldReroute()) {
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/RouteCrawler.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/RouteCrawler.java
index 0d6d04f6a8d..c9e825947f5 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/RouteCrawler.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/RouteCrawler.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
 import io.helidon.common.parameters.Parameters;
 import io.helidon.common.uri.UriPath;
 import io.helidon.http.HttpPrologue;
+import io.helidon.http.PathMatcher;
 import io.helidon.http.PathMatchers;
 import io.helidon.http.RoutedPath;
 import io.helidon.webserver.ConnectionContext;
@@ -111,7 +112,10 @@ boolean hasNext() {
             } else {
                 PathMatchers.MatchResult accepts = nextRoute.accepts(prologue);
                 if (accepts.accepted()) {
-                    next = new CrawlerItem(accepts.path(), nextRoute.handler());
+                    PathMatcher pathMatcher = nextRoute.pathMatcher().orElse(null);
+                    next = new CrawlerItem(accepts.path(),
+                                           pathMatcher != null ? pathMatcher.matchingElement().orElse("") : null,
+                                           nextRoute.handler());
 
                     if (parent != null) {
                         next = next.parent(parent);
@@ -131,7 +135,7 @@ CrawlerItem next() {
         return result;
     }
 
-    record CrawlerItem(RoutedPath path, Handler handler) {
+    record CrawlerItem(RoutedPath path, String matchingElement, Handler handler) {
         public CrawlerItem parent(RoutedPath parent) {
             Map<String, List<String>> newParams = new HashMap<>();
 
@@ -144,17 +148,20 @@ public CrawlerItem parent(RoutedPath parent) {
                 newParams.put(paramName, params.all(paramName));
             }
             newParams.replaceAll((name, values) -> List.copyOf(values));
-            RoutedPath result = new CrawlerRoutedPath(path, Parameters.create("http/path", newParams));
-            return new CrawlerItem(result, handler);
+            RoutedPath result = new CrawlerRoutedPath(path, matchingElement, Parameters.create("http/path", newParams));
+            return new CrawlerItem(result, parent.path() + matchingElement, handler);
         }
 
         private static final class CrawlerRoutedPath implements RoutedPath {
             private final UriPath path;
             private final Parameters templateParams;
+            private final String matchingElement;
 
             private CrawlerRoutedPath(UriPath path,
+                                      String matchingElement,
                                       Parameters templateParams) {
                 this.path = path;
+                this.matchingElement = matchingElement;
                 this.templateParams = templateParams;
             }
 
@@ -190,7 +197,7 @@ public Parameters pathParameters() {
 
             @Override
             public RoutedPath absolute() {
-                return new CrawlerRoutedPath(path.absolute(), templateParams);
+                return new CrawlerRoutedPath(path.absolute(), matchingElement, templateParams);
             }
         }
     }
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
index eb1d6e9da7b..b3393fe1647 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,7 +24,7 @@
  */
 public interface RoutingRequest extends ServerRequest {
     /**
-     * Update path of this request.
+     * Update path of this request. For internal use only to Helidon.
      *
      * @param routedPath routed path, that provides matched path parameters from path pattern
      * @return this instance
@@ -32,10 +32,21 @@ public interface RoutingRequest extends ServerRequest {
     RoutingRequest path(RoutedPath routedPath);
 
     /**
-     * Update prologue of this request.
+     * Update prologue of this request. For internal use only to Helidon.
      *
      * @param newPrologue new prologue to use (on rerouting)
      * @return this instance
      */
     RoutingRequest prologue(HttpPrologue newPrologue);
+
+    /**
+     * Update the pattern used to match this request. Such as "/foo/{bar}".
+     *  For internal use only to Helidon.
+     *
+     * @param matchingPattern the matching pattern
+     * @return this instance
+     */
+    default RoutingRequest matchingPattern(String matchingPattern) {
+        return this;
+    }
 }
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java
index deb358e15ca..7751694f73c 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2022, 2025 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -120,4 +120,13 @@ public interface ServerRequest extends HttpRequest {
      * @see io.helidon.webserver.ListenerConfig#enableProxyProtocol()
      */
     Optional<ProxyProtocolData> proxyProtocolData();
+
+    /**
+     * The pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @return matching pattern or {@code ""} if not available
+     */
+    default Optional<String> matchingPattern() {
+        return Optional.empty();
+    }
 }
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServiceRoute.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServiceRoute.java
index 2f5741397eb..3cfa6e29156 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServiceRoute.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServiceRoute.java
@@ -17,6 +17,7 @@
 package io.helidon.webserver.http;
 
 import java.util.List;
+import java.util.Optional;
 import java.util.function.Predicate;
 
 import io.helidon.http.HttpPrologue;
@@ -94,6 +95,11 @@ boolean isList() {
         return true;
     }
 
+    @Override
+    public Optional<PathMatcher> pathMatcher() {
+        return Optional.of(pathMatcher);
+    }
+
     RouteCrawler crawler(ConnectionContext ctx, RoutingRequest request) {
         return new RouteCrawler(ctx, request, routes);
     }
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Route.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Route.java
index af216c30c7d..22b7a41c2e6 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Route.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Route.java
@@ -16,6 +16,7 @@
 
 package io.helidon.webserver.http1;
 
+import java.util.Optional;
 import java.util.function.Predicate;
 
 import io.helidon.http.HttpPrologue;
@@ -67,6 +68,11 @@ public PathMatchers.MatchResult accepts(HttpPrologue prologue) {
         return pathMatcher.match(prologue.uriPath());
     }
 
+    @Override
+    public Optional<PathMatcher> pathMatcher() {
+        return Optional.of(pathMatcher);
+    }
+
     @Override
     public Handler handler() {
         return handler;
diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java
index 0337bb8f9ee..5f0da9b9cd5 100644
--- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java
+++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java
@@ -62,6 +62,7 @@ abstract class Http1ServerRequest implements RoutingRequest {
 
     private HttpPrologue prologue;
     private Context context;
+    private String matchingPattern;
 
     Http1ServerRequest(ConnectionContext ctx,
                        HttpSecurity security,
@@ -210,6 +211,17 @@ public Http1ServerRequest prologue(HttpPrologue newPrologue) {
         return this;
     }
 
+    @Override
+    public RoutingRequest matchingPattern(String matchingPattern) {
+        this.matchingPattern = matchingPattern;
+        return this;
+    }
+
+    @Override
+    public Optional<String> matchingPattern() {
+        return Optional.of(matchingPattern);
+    }
+
     @Override
     public UriInfo requestedUri() {
         return uriInfo.get();


Reviews:
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T10:06:15Z
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T10:08:31Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T14:24:24Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T14:24:34Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T14:25:27Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T14:26:54Z
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T15:59:56Z
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T16:01:08Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T16:39:50Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T16:40:01Z
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T17:38:43Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T17:47:40Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T17:49:50Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T18:28:20Z
user: login: tomas-langer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:52:06Z
user: login: spericas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:06:56Z
user: login: tomas-langer
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T13:19:27Z

Review comments:
diff_hunk: @@ -54,6 +54,15 @@ static HttpRouteImpl.Builder builder() {
      */
     Handler handler();
 
+    /**
+     * Path matcher for this HTTP route.
+     *
+     * @return path matcher or {@code null} if not applicable
+     */
+    default PathMatcher pathMatcher() {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java
user: login: tomas-langer
body: `null` can never be returned from Helidon APIs. 
It must either be optional, or create two method `boolean hasPathMatcher` and this method, where this method throws an exception if path matcher not available.
If this is not on critical path of HTTP routing, then `Optional` should be fine
created_at: 2025-04-22T09:56:26Z
author_association: MEMBER
diff_hunk: @@ -120,4 +120,13 @@ public interface ServerRequest extends HttpRequest {
      * @see io.helidon.webserver.ListenerConfig#enableProxyProtocol()
      */
     Optional<ProxyProtocolData> proxyProtocolData();
+
+    /**
+     * The pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @return matching pattern or {@code ""} if not available
+     */
+    default String matchingPattern() {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java
user: login: tomas-langer
body: I do not like the concept of returning empty string as a special case. It should either be `Optional<String>`, or same as above (guarding method, throwing method)
created_at: 2025-04-22T09:57:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.http.Status;
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webclient.http1.Http1ClientResponse;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.HttpService;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@ServerTest
path: webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternTest.java
user: login: tomas-langer
body: Please add the same as a `@RoutingTest` to make sure all of this works without actual port open.
created_at: 2025-04-22T10:01:13Z
author_association: MEMBER
diff_hunk: @@ -38,4 +38,14 @@ public interface RoutingRequest extends ServerRequest {
      * @return this instance
      */
     RoutingRequest prologue(HttpPrologue newPrologue);
+
+    /**
+     * Update the pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @param matchingPattern the matching patter
+     * @return this instance
+     */
+    default RoutingRequest matchingPattern(String matchingPattern) {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
user: login: tomas-langer
body: I do not find it great that this method is visible to users when using `ServerRequest`. This method should never ever be called by users, so it should be hidden from them.
created_at: 2025-04-22T10:08:31Z
author_association: MEMBER
diff_hunk: @@ -54,6 +54,15 @@ static HttpRouteImpl.Builder builder() {
      */
     Handler handler();
 
+    /**
+     * Path matcher for this HTTP route.
+     *
+     * @return path matcher or {@code null} if not applicable
+     */
+    default PathMatcher pathMatcher() {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/HttpRoute.java
user: login: spericas
body: Switching to `Optional`
created_at: 2025-04-22T14:24:23Z
author_association: MEMBER
diff_hunk: @@ -120,4 +120,13 @@ public interface ServerRequest extends HttpRequest {
      * @see io.helidon.webserver.ListenerConfig#enableProxyProtocol()
      */
     Optional<ProxyProtocolData> proxyProtocolData();
+
+    /**
+     * The pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @return matching pattern or {@code ""} if not available
+     */
+    default String matchingPattern() {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java
user: login: spericas
body: Switching to `Optional`
created_at: 2025-04-22T14:24:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.http.Status;
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webclient.http1.Http1ClientResponse;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.HttpService;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@ServerTest
path: webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternTest.java
user: login: spericas
body: Can you please elaborate more?
created_at: 2025-04-22T14:25:27Z
author_association: MEMBER
diff_hunk: @@ -38,4 +38,14 @@ public interface RoutingRequest extends ServerRequest {
      * @return this instance
      */
     RoutingRequest prologue(HttpPrologue newPrologue);
+
+    /**
+     * Update the pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @param matchingPattern the matching patter
+     * @return this instance
+     */
+    default RoutingRequest matchingPattern(String matchingPattern) {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
user: login: spericas
body: Isn't that the case about all the methods in `RoutingRequest`?
created_at: 2025-04-22T14:26:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.http.Status;
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webclient.http1.Http1ClientResponse;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.HttpService;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@ServerTest
path: webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternTest.java
user: login: tomas-langer
body: `@ServerTest` will start the webserver and client will connect over network. `@RoutingTest` will not start a server and client will connect in-memory. So just exactly the same class, just a different annotation (or create an abstract class with all the tests and annotations except for the class annotation, and then create two classes that extend it, one with `@ServerTest` and one with `@RoutingTest`
created_at: 2025-04-22T15:59:56Z
author_association: MEMBER
diff_hunk: @@ -38,4 +38,14 @@ public interface RoutingRequest extends ServerRequest {
      * @return this instance
      */
     RoutingRequest prologue(HttpPrologue newPrologue);
+
+    /**
+     * Update the pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @param matchingPattern the matching patter
+     * @return this instance
+     */
+    default RoutingRequest matchingPattern(String matchingPattern) {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
user: login: tomas-langer
body: Probably right, maybe we should document the methods a bit better, and note that this is intended for internal use.
created_at: 2025-04-22T16:01:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed 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 io.helidon.webserver.tests;
+
+import io.helidon.http.Status;
+import io.helidon.webclient.http1.Http1Client;
+import io.helidon.webclient.http1.Http1ClientResponse;
+import io.helidon.webserver.http.Handler;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.HttpService;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@ServerTest
path: webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/MatchingPatternTest.java
user: login: spericas
body: Done
created_at: 2025-04-22T16:39:50Z
author_association: MEMBER
diff_hunk: @@ -38,4 +38,14 @@ public interface RoutingRequest extends ServerRequest {
      * @return this instance
      */
     RoutingRequest prologue(HttpPrologue newPrologue);
+
+    /**
+     * Update the pattern used to match this request. Such as "/foo/{bar}".
+     *
+     * @param matchingPattern the matching patter
+     * @return this instance
+     */
+    default RoutingRequest matchingPattern(String matchingPattern) {
path: webserver/webserver/src/main/java/io/helidon/webserver/http/RoutingRequest.java
user: login: spericas
body: Added comment
created_at: 2025-04-22T16:40:01Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: tomas-langer
body: `null` is not allowed in Helidon as a parameter.
Also this method should not be removed, it still makes sense (as you can create a custom prefix path matcher using).
The default behavior can be to add `/*` to it for the `pathPattern`.
created_at: 2025-04-22T17:37:50Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
+    }
+
+    /**
+     * Prefix match path matcher that stores original path pattern.
+     *
+     * @param pathToMatch matching path such as "/foo"
+     * @param pathPattern complete path pattern such as "/foo/*"
+     * @return prefix match path matcher
+     */
+    public static PathMatcher prefix(String pathToMatch, String pathPattern) {
+        return new PrefixPathMatcher(fixPrefix(pathToMatch), pathPattern);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: tomas-langer
body: There should be a check for not-null for both parameters.
I am not sure I understand why the second parameter is added.
created_at: 2025-04-22T17:38:37Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: spericas
body: I wasn't sure adding "/*" was correct every time
created_at: 2025-04-22T17:47:40Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
+    }
+
+    /**
+     * Prefix match path matcher that stores original path pattern.
+     *
+     * @param pathToMatch matching path such as "/foo"
+     * @param pathPattern complete path pattern such as "/foo/*"
+     * @return prefix match path matcher
+     */
+    public static PathMatcher prefix(String pathToMatch, String pathPattern) {
+        return new PrefixPathMatcher(fixPrefix(pathToMatch), pathPattern);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: spericas
body: Related to the other question above, we don't want to return `"/foo/"` but `"/foo/*"`. If always adding `"/*"` is correct, then we can simplify all this indeed.
created_at: 2025-04-22T17:49:50Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
+    }
+
+    /**
+     * Prefix match path matcher that stores original path pattern.
+     *
+     * @param pathToMatch matching path such as "/foo"
+     * @param pathPattern complete path pattern such as "/foo/*"
+     * @return prefix match path matcher
+     */
+    public static PathMatcher prefix(String pathToMatch, String pathPattern) {
+        return new PrefixPathMatcher(fixPrefix(pathToMatch), pathPattern);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: spericas
body: Yes, this seems to work fine, and `/foo/*/*` is handled as a `PatternPathMatcher`. See updates and new test.
created_at: 2025-04-22T18:28:20Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
+    }
+
+    /**
+     * Prefix match path matcher that stores original path pattern.
+     *
+     * @param pathToMatch matching path such as "/foo"
+     * @param pathPattern complete path pattern such as "/foo/*"
+     * @return prefix match path matcher
+     */
+    public static PathMatcher prefix(String pathToMatch, String pathPattern) {
+        return new PrefixPathMatcher(fixPrefix(pathToMatch), pathPattern);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: tomas-langer
body: This method is `public`, so it can be used directly by a user.
created_at: 2025-04-23T12:52:06Z
author_association: MEMBER
diff_hunk: @@ -60,9 +61,22 @@ public static PathMatcher exact(String pathToMatch) {
      *
      * @param pathToMatch the path must be prefixed by the provided path
      * @return prefix match path matcher
+     * @deprecated use {@link #prefix(String, String)} instead to preserve original pattern
      */
+    @Deprecated(forRemoval = true, since = "4.3.0")
     public static PathMatcher prefix(String pathToMatch) {
-        return new PrefixPathMatcher(fixPrefix(pathToMatch));
+         return prefix(pathToMatch, null);
+    }
+
+    /**
+     * Prefix match path matcher that stores original path pattern.
+     *
+     * @param pathToMatch matching path such as "/foo"
+     * @param pathPattern complete path pattern such as "/foo/*"
+     * @return prefix match path matcher
+     */
+    public static PathMatcher prefix(String pathToMatch, String pathPattern) {
+        return new PrefixPathMatcher(fixPrefix(pathToMatch), pathPattern);
path: http/http/src/main/java/io/helidon/http/PathMatchers.java
user: login: spericas
body: Method `PathMatcher prefix(String, String)`? It has been removed with the latest commit
created_at: 2025-04-23T13:06:56Z
author_association: MEMBER

PR comments:
user: login: tjquinno
created_at: 2025-04-21T20:46:47Z
body: The approach here looks good to me.

One related but separate question is about filters. One use case--the one in the public slack channel that triggered this enhancement request--concerned adding a filter that would use the matched path. 

In that use case at least part of the filter's job would be to start an OpenTelemetry span according to the Otel semantic conventions, in which the name of the span uses low-cardinality route information--the matched path string  (`/greet/{name}`) not the actual path that is available via the request currently which can be high cardinality (`/greet/Joe` and `/greet/Julia` and many more).

To start the span, the code needs to know the name it wants to use for the span, and so for that reason it would be very useful to able to count on the matched path string being available via the request from filter code before invoking `filterChain#proceed`.

Are filters invoked before matching occurs or after? I didn't see this documented anywhere.

Santiago pointed out, as our doc does, that users can accomplish filtering with an `any` handler as well. If the matching path is not guaranteed to be available to filters then maybe the Javadoc for `Filter` or the doc section on filters should mention that?
author_association: MEMBER
user: login: romain-grecourt
created_at: 2025-04-21T21:14:42Z
body: > Are filters invoked before matching occurs or after? I didn't see this documented anywhere.

All filters are invoked before matching the routes, see https://github.com/helidon-io/helidon/blob/8da2cfa15998656b8589747487b8117356585339/webserver/webserver/src/main/java/io/helidon/webserver/http/Filters.java#L124

Both `path` and `matchingPattern` are updated while crawling, so the filters will get an empty string from `request. matchingPattern()`.

---

Maybe a callback to react when the matching occurs could be the solution.
E.g.

```java
routing.addFilter((chain, req, res) -> {
    req.whenMatched().thenRun(() -> {
        String pattern = req.matchingPattern();
        // will be invoked on every match
    });
    chain.proceed();
})
```

---

Another solution is to implement this use case using a non terminating handler:

```java
routing.any((req, res) -> {
    String pattern = req.matchingPattern();
    res.next();
})
```
author_association: CONTRIBUTOR
user: login: tjquinno
created_at: 2025-04-21T21:21:31Z
body: Yes, I said that Santiago and our doc both mention the non-terminating handler option.

Also my point was that our doc page and/or the Javadoc (perhaps for the new `matchingPattern` method itself?) should state when code can count on the value having been set.
author_association: MEMBER
user: login: tomas-langer
created_at: 2025-04-22T10:07:03Z
body: Filters are a bit problematic here - we are getting into the space of "pre-matching" and "post-matching" filters, which is not something WebServer is ready for right now.

author_association: MEMBER
user: login: tjquinno
created_at: 2025-04-23T11:09:01Z
body: Quick note. Although invocations of the new `matchingPattern` method on request objects will not always be on the critical path of request processing, once Helidon more fully supports OpenTelemetry in SE--including adding spans for incoming requests--that code _will_ invoke `matchingPattern` on each request to assign the correct name to the new span. 

Certainly the rest of the code path for creating such a span will be quite a bit longer than checking an `Optional`, so I don't think this argues against using an `Optional` (instead of a separate boolean method that indicates if the matching pattern is valid). I just wanted to be clear that in some use cases this code will be invoked for each request.
author_association: MEMBER
user: login: spericas
created_at: 2025-04-23T13:18:33Z
body: > Quick note. Although invocations of the new `matchingPattern` method on request objects will not always be on the critical path of request processing, once Helidon more fully supports OpenTelemetry in SE--including adding spans for incoming requests--that code _will_ invoke `matchingPattern` on each request to assign the correct name to the new span.
> 
> Certainly the rest of the code path for creating such a span will be quite a bit longer than checking an `Optional`, so I don't think this argues against using an `Optional` (instead of a separate boolean method that indicates if the matching pattern is valid). I just wanted to be clear that in some use cases this code will be invoked for each request.

So the span name will be derived from the matching pattern? That's odd
author_association: MEMBER
user: login: SentryMan
created_at: 2025-04-23T16:28:10Z
body: > Users can accomplish filtering with an `any` handler as well. 

Does this work when a filter cancels a request with an error?

I have some filters that perform actions that can fail like validation where we can return a 400/500 instead of proceeding to a handler. The OTEL spans should ideally work for these cases.
author_association: CONTRIBUTOR
user: login: tjquinno
created_at: 2025-04-23T19:08:40Z
body: > So the span name will be derived from the matching pattern? That's odd


Yes.

This link is also in a comment on the issue: https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-route

author_association: MEMBER