Repository: amaziahub/mimicker
Pull request #20: #4: support for query params
Author: login: austinmilt
Labels: name: enhancement
description: New feature or request
Created: 2025-05-03T22:55:42Z | Merged: 2025-05-14T10:10:22Z

This PR adds support for query params in endpoint templates. It is meant to be mostly backward-compatible while allowing users to match on and extract query parameters at runtime.

## Main Changes
- updated the regex that is the primary means of matching requests and extracting named parameters. In particular, the regex now explicitly makes room for ignoring query parameters or explicitly making them part of the endpoint mock endpoint check (`regex.py`, `error.py`, `test_regex.py`). This updated regex replaces the matching in `stub_group.py` and `route.py`.
- distinct from matching and explicitly named parameters, the code now also supports making URL query params available in custom response functions using `kwargs['query']`. It follows the pattern of allowing multivalues in query params, meaning every found key appears with a list of 1+ values (`handler.py`, `test_mimicker_get.py`)
- added examples to `README.md`

## Related Issue
Closes #4 (Support for query parameters) 

Linked issue #4: feature: support query param

Diff:
diff --git a/README.md b/README.md
index a4ca685..2ea9ca1 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,65 @@ mimicker(8080).routes(
 # {"message": "Hello, world!"}
 ```
 
+#### Using Query Parameters
+
+##### Explicitly defined query parameters
+
+Mimicker can handle query parameters dynamically. Here's how you can mock an endpoint with a variable in the query:
+
+```python
+from mimicker.mimicker import mimicker, get
+
+mimicker(8080).routes(
+    get("/hello?name={name}")
+    .body({"message": "Hello, {name}!"})
+    .status(200)
+)
+
+# When the client sends a request to /hello?name=world, the response will be:
+# {"message": "Hello, world!"}
+```
+
+##### Implicit query parameters
+
+When query parameters are not explicitly part of the path template, Mimicker will match
+all otherwise matching requests against those parameters as if using a wildcard. For instance
+
+```python
+from mimicker.mimicker import mimicker, get
+
+mimicker(8080).routes(
+    get("/hello")
+    .body({"message": "Hello, world!"})
+    .status(200)
+)
+
+# When the client sends a request to /hello?name=bob, the request will match and the response will be:
+# {"message": "Hello, world!"}
+```
+
+These implicitly matched query parameters are available through dynamic responses
+using the `"query_params"` key in `kwargs` in `response_func` (see below), e.g.
+
+```python
+from mimicker.mimicker import mimicker, get
+
+def custom_response(**kwargs):
+    query_params: Dict[str, List[str]] = kwargs['query_params']
+    return 200, {"message": f"Hello {query_params['name'][0]}!"}
+
+mimicker(8080).routes(
+    get("/hello")
+    .response_func(custom_response)
+)
+
+# When the client sends a request to /hello?name=world, the request will match and the response will be:
+# {"message": "Hello, world!"}
+```
+
+Note that because query parameters can be repeated with multiple values, they will
+always appear in a list of values.
+
 #### Using Headers
 
 You can also mock responses with custom headers:
diff --git a/mimicker/exceptions.py b/mimicker/exceptions.py
new file mode 100644
index 0000000..6e7adac
--- /dev/null
+++ b/mimicker/exceptions.py
@@ -0,0 +1,5 @@
+class TemplateError(Exception):
+    """
+    Exception raised when a path template is invalid.
+    """
+    pass
diff --git a/mimicker/handler.py b/mimicker/handler.py
index 6a6429f..04b9ac9 100644
--- a/mimicker/handler.py
+++ b/mimicker/handler.py
@@ -2,6 +2,7 @@
 import json
 from time import sleep
 from typing import Any, Tuple, Optional, Dict, List
+from urllib.parse import parse_qs, urlparse
 
 from logger import get_logger
 from mimicker.stub_group import Stub, StubGroup
@@ -43,7 +44,7 @@ def _handle_request(self, method: str):
         )
 
         if matched_stub:
-            self._send_response(matched_stub, path_params, request_body, request_headers)
+            self._send_response(matched_stub, path_params, parse_qs(urlparse(self.path).query), request_body, request_headers)
         else:
             self.logger.warning("No match for %s %s. Returning 404.", method, self.path)
             self._send_404_response(method)
@@ -59,12 +60,12 @@ def log_incoming_request(self, method, request_body, request_headers):
             f"\nBody:\n{body_str}" if body_str else ""
         )
 
-    def _send_response(self, matched_stub: Stub, path_params: Dict[str, str], request_body: Any, request_headers: Dict[str, str]):
+    def _send_response(self, matched_stub: Stub, path_params: Dict[str, str], query_params: Dict[str, List[str]], request_body: Any, request_headers: Dict[str, str]):
         status_code, delay, response, response_func, headers = matched_stub
         if delay > 0:
             sleep(delay)
         if response_func:
-            status_code, response = response_func(payload=request_body, headers=request_headers, params=path_params)
+            status_code, response = response_func(payload=request_body, headers=request_headers, params=path_params, query_params=query_params)
 
         self.send_response(status_code)
         self._set_headers(headers)
diff --git a/mimicker/regex.py b/mimicker/regex.py
new file mode 100644
index 0000000..d695739
--- /dev/null
+++ b/mimicker/regex.py
@@ -0,0 +1,132 @@
+import re
+from re import Pattern
+
+from mimicker.exceptions import TemplateError
+
+def parse_endpoint_pattern(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern into a regular expression for URL matching.
+    
+    This function creates a regex pattern that can match URLs with both static and
+    parameterized path segments, as well as query parameters. Path parameters are
+    specified using curly braces (e.g., '/{user_id}/profile'). Query parameters
+    can be either static or parameterized.
+    
+    Examples:
+        "/users/{id}"                  # Matches: "/users/123"
+        "/api/{version}/items"         # Matches: "/api/v1/items"
+        "/search?q={term}"             # Matches: "/search?q=python"
+        "/filter?min={min}&max={max}"  # Matches: "/filter?min=10&max=100"
+    
+    Args:
+        template: A URL template with optional path and query parameters.
+                  Path parameters are specified as {name}.
+                  Query parameters can be static or parameterized as key={name}.
+    
+    Returns:
+        A compiled regex Pattern that matches URLs according to the template.
+    
+    Raises:
+        TemplateError: If parameter names are repeated.
+        TemplateError: If query parameters do not match the expected URL format.
+    """
+    try:
+
+        if '?' in template:
+            return _parse_with_query(template)
+        
+        else:
+            return _parse_without_query(template)
+        
+    except re.error as e:
+        raise TemplateError(f"invalid template: {template!r}") from e
+
+
+def _parse_without_query(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern without explicit query parameters.
+    
+    Creates a regex pattern that matches the path exactly and optionally
+    matches any query string (without parsing its contents).
+    
+    Args:
+        template: URL template without a '?' character
+        
+    Returns:
+        Compiled regex Pattern for matching URLs
+    """
+    path_regex = _build_path_regex(template)
+    # Allow any query string (or none) after the path
+    full = rf'^{path_regex}(?:\?.*)?$'
+    return re.compile(full)
+
+
+def _parse_with_query(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern with explicit query parameters.
+    
+    Creates a regex pattern that matches both the path and specific query parameters.
+    Query parameters can be static values or parameterized with {name} syntax.
+    
+    Args:
+        template: URL template containing a '?' followed by query parameters
+        
+    Returns:
+        Compiled regex Pattern for matching URLs with specific query parameters
+        
+    Raises:
+        TemplateError: If a query parameter doesn't contain an equals sign
+    """
+    path_t, query_t = template.split('?', 1)
+    path_regex = _build_path_regex(path_t)
+    
+    qpats = []
+    for qp in query_t.split('&'):
+        if '=' not in qp:
+            raise TemplateError(f"invalid query segment (no '=' found): {qp!r}")
+        
+        key, val_t = qp.split('=', 1)
+
+        # Handle parameterized query values like key={param}
+        if val_t.startswith('{') and val_t.endswith('}'):
+            name = val_t[1:-1]
+            qpats.append(fr'{re.escape(key)}=(?P<{name}>[^&]+)')  # Named capture group
+
+        else:
+            # Handle static query values which are not parameterized
+            qpats.append(fr'{re.escape(key)}={re.escape(val_t)}')
+
+    # Construct final regex that matches both path and query parts
+    full = rf'^{path_regex}\?{"&".join(qpats)}$'
+    return re.compile(full)
+
+
+
+def _build_path_regex(path_t: str) -> str:
+    """
+    Build a regex pattern for matching the path portion of a URL.
+    
+    Converts a path template with parameterized segments into a regex string.
+    Each path segment is either matched literally or converted to a named capture
+    group if it's a parameter.
+    
+    Args:
+        path_t: Path template, possibly containing parameters like {name}
+        
+    Returns:
+        A regex string for matching the path (not compiled)
+    """
+    parts = path_t.strip('/').split('/')
+    regex_parts = []
+    for part in parts:
+        # Check if the part is a parameter (e.g., {id})
+        m = re.fullmatch(r'\{(\w+)}', part)
+        if m:
+            name = m.group(1)  # Extract parameter name from inside braces
+            regex_parts.append(f'(?P<{name}>[^/?]+)')  # Create named capture group to capture parameter names
+        else:
+            # Escape special regex characters in literal path segments
+            regex_parts.append(re.escape(part))
+    
+    # Preserve leading slash and handle empty path case
+    return '/' + '/'.join(regex_parts) if parts and parts[0] else '/'
\ No newline at end of file
diff --git a/mimicker/route.py b/mimicker/route.py
index 95c153a..819acc2 100644
--- a/mimicker/route.py
+++ b/mimicker/route.py
@@ -1,6 +1,8 @@
 import re
 from typing import Dict, Tuple, Any, Callable, Optional, Pattern, Union, List
 
+from mimicker.regex import parse_endpoint_pattern
+
 
 class Route:
     """
@@ -22,11 +24,7 @@ def __init__(self, method: str, path: str):
         self._status = 200
         self._headers: List[Tuple[str, str]] = []
         self._response_func: Optional[Callable[..., Tuple[int, Any]]] = None
-
-        escaped_path = re.escape(path)
-        parameterized_path = re.sub(r'\\{(\w+)\\}',
-                                    r'(?P<\1>[^/]+)', escaped_path)
-        self._compiled_path: Pattern = re.compile(f"^{parameterized_path}$")
+        self._compiled_path: Pattern = parse_endpoint_pattern(path)
 
     def delay(self, delay: float):
         """
diff --git a/mimicker/stub_group.py b/mimicker/stub_group.py
index f59b235..687fc01 100644
--- a/mimicker/stub_group.py
+++ b/mimicker/stub_group.py
@@ -1,6 +1,6 @@
-import re
 from typing import Pattern, Dict, Tuple, Any, Optional, Callable, Union, List, NamedTuple
 
+from mimicker.regex import parse_endpoint_pattern
 
 class Stub(NamedTuple):
     status_code: int
@@ -25,8 +25,7 @@ def add(self, method: str, pattern: Union[str, Pattern],
             self.stubs[method] = {}
 
         if isinstance(pattern, str):
-            substituted_pattern = re.sub(r'\{(\w+)\}', r'(?P<\1>[^/]+)', pattern)
-            pattern = re.compile(f"^{substituted_pattern}$")
+            pattern = parse_endpoint_pattern(pattern)
 
         self.stubs[method][pattern] = Stub(status_code, delay, response, response_func, headers)
 
diff --git a/tests/test_mimicker_get.py b/tests/test_mimicker_get.py
index faed2f1..0950558 100644
--- a/tests/test_mimicker_get.py
+++ b/tests/test_mimicker_get.py
@@ -68,6 +68,28 @@ def test_get_path_param(mimicker_server):
     assert_that(resp.json(), equal_to({"message": "Hello, world!"}))
 
 
+def test_get_when_implicit_query_params_then_variables_are_available_in_query(mimicker_server):
+    def answer(**kwargs):
+        return 200, {"message": f"Hello, {kwargs['query_params']['greet'][0]}!"}
+
+    mimicker_server.routes(
+        get("/hello").
+        response_func(answer)
+    )
+    resp = Client().get('/hello?greet=world')
+    assert_that(resp.json(), equal_to({"message": "Hello, world!"}))
+
+
+def test_get_when_explicit_query_param_then_variables_are_available_in_path(mimicker_server):
+    mimicker_server.routes(
+        get("/hello2?greeting={greet}").
+        body({"message": "Hello, {greet}!"}).
+        status(200)
+    )
+    resp = Client().get('/hello2?greeting=world')
+    assert_that(resp.json(), equal_to({"message": "Hello, world!"}))
+
+
 def test_get_empty_response(mimicker_server):
     mimicker_server.routes(
         get("/empty").
diff --git a/tests/test_regex.py b/tests/test_regex.py
new file mode 100644
index 0000000..61ae17a
--- /dev/null
+++ b/tests/test_regex.py
@@ -0,0 +1,125 @@
+from re import Pattern
+from hamcrest import assert_that, equal_to, none, not_none, raises
+from mimicker.exceptions import TemplateError
+from mimicker.regex import parse_endpoint_pattern
+
+def test_parse_endpoint_pattern_static_path_matches_static_path():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker")
+    match = pattern.match("/hello/mimicker")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({}))
+
+
+def test_parse_endpoint_pattern_static_path_and_mismatched_path_does_not_match():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker")
+    match = pattern.match("/hello/mimicker/mismatch")
+    assert_that(match, none())
+
+
+def test_parse_endpoint_pattern_with_explicit_query_param_matches_query_param():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker?greeting={name}")
+    match = pattern.match("/hello/mimicker?greeting=world")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"name": "world"}))
+
+
+def test_parse_endpoint_pattern_with_explicit_query_param_and_mismatched_query_does_not_match():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker?mismatched={name}")
+    match = pattern.match("/hello/mimicker?greeting=world")
+    assert_that(match, none())
+
+
+def test_parse_endpoint_pattern_without_explicit_query_param_matches_all_query_params():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker")
+    match = pattern.match("/hello/mimicker?greeting=world&age=20&city=NewYork")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({}))
+
+
+def test_parse_endpoint_pattern_path_param_matches_path_param():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/mimicker")
+    match = pattern.match("/hello/mimicker")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"greeting": "hello"}))
+
+
+def test_parse_endpoint_pattern_with_multiple_path_params_matches():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/{name}")
+    match = pattern.match("/hello/mimicker")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"greeting": "hello", "name": "mimicker"}))
+
+
+def test_parse_endpoint_pattern_with_terminal_path_param_doesnt_include_query_params_in_value():
+    pattern: Pattern = parse_endpoint_pattern("/hello/{name}")
+    match = pattern.match("/hello/mimicker?ignored=param")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"name": "mimicker"}))
+
+
+def test_parse_endpoint_pattern_path_param_and_path_mismatched_path_does_not_match():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/mimicker")
+    match = pattern.match("/hello/mimicker/mismatch")
+    assert_that(match, none())
+
+
+def test_parse_endpoint_pattern_path_param_and_implicit_query_param_matches_path_param_and_implicit_query_param():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/mimicker")
+    match = pattern.match("/hello/mimicker?greeting=world")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"greeting": "hello"}))
+
+
+def test_parse_endpoint_pattern_path_param_and_query_param_matches_path_param_and_query_param():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/mimicker?age={age}")
+    match = pattern.match("/hello/mimicker?age=20")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"greeting": "hello", "age": "20"}))
+
+
+def test_parse_endpoint_pattern_path_param_and_query_param_and_path_mismatched_path_does_not_match():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting}/mimicker?age={age}")
+    match = pattern.match("/hello/mimicker/mismatch?greeting=world&age=20&city=NewYork")
+    assert_that(match, none())
+    
+
+def test_parse_endpoint_pattern_with_multiple_path_params_and_query_params_matches():
+    pattern: Pattern = parse_endpoint_pattern("/some/path/{greeting}/ignored/path/{name}/ignored/2?age={age}&city={city}&ignored=param")
+    match = pattern.match("/some/path/hello/ignored/path/mimicker/ignored/2?age=20&city=NewYork&ignored=param")
+    assert_that(match, not_none())
+    assert_that(match.groupdict(), equal_to({"greeting": "hello", "name": "mimicker", "age": "20", "city": "NewYork"}))
+
+
+def test_parse_endpoint_pattern_with_repeated_query_param_names_throws_error():
+    assert_that(
+        lambda: parse_endpoint_pattern("/hello/mimicker?age={age}&city={city}&age={age}"), 
+        raises(TemplateError)
+    )
+
+
+def test_parse_endpoint_pattern_with_repeated_query_params_distinctly_named_matches():
+    pattern: Pattern = parse_endpoint_pattern("/hello/mimicker?greeting={name1}&greeting={name2}")
+    match = pattern.match("/hello/mimicker?greeting=world1&greeting=world2")
+    assert_that(match, not_none())  
+    assert_that(match.groupdict(), equal_to({"name1": "world1", "name2": "world2"}))
+
+
+def test_parse_endpoint_pattern_with_repeated_param_names_throws_error():
+    assert_that(
+        lambda: parse_endpoint_pattern("/{greeting}/mimicker?greeting={greeting}"), 
+        raises(TemplateError)
+    )
+
+
+def test_parse_endpoint_pattern_with_repeated_params_distinctly_named_matches():
+    pattern: Pattern = parse_endpoint_pattern("/{greeting1}/mimicker?greeting={greeting2}")
+    match = pattern.match("/hello/mimicker?greeting=world1")
+    assert_that(match, not_none())  
+    assert_that(match.groupdict(), equal_to({"greeting1": "hello", "greeting2": "world1"}))
+
+
+def test_parse_endpoint_pattern_with_invalid_query_param_throws_error():
+    assert_that(
+        lambda: parse_endpoint_pattern("/hello/mimicker?age={age1}&city={city}&age{age2}"), 
+        raises(TemplateError)
+    )
diff --git a/tests/test_stub_group.py b/tests/test_stub_group.py
index e597270..328c9cd 100644
--- a/tests/test_stub_group.py
+++ b/tests/test_stub_group.py
@@ -1,4 +1,4 @@
-from hamcrest import assert_that, none, is_
+from hamcrest import assert_that, none, is_, not_none
 
 from mimicker.stub_group import StubGroup
 
@@ -33,7 +33,7 @@ def test_none_on_partial_match():
 def test_match_w_path_param():
     stub_group = StubGroup()
     stub_group.add("GET",
-                   r"^/hello/(?P<name>\w+)$",
+                   "/hello/{name}",
                    200,
                    {"message": "Hello, {name}!"})
 
@@ -43,6 +43,31 @@ def test_match_w_path_param():
     assert_that(path_param, is_({"name": "mimicker"}))
 
 
+def test_match_w_explicit_query_param_matches_query_param():
+    stub_group = StubGroup()
+    stub_group.add("GET",
+                   "/hello/mimicker?greeting={name}",
+                   200,
+                   {"message": "Hello, {name}!"})
+
+    matched, path_param = stub_group.match("GET", "/hello/mimicker?greeting=world")
+    
+    assert_that(matched, is_((200, 0., {"message": "Hello, {name}!"}, None, None)))
+    assert_that(path_param, is_({"name": "world"}))
+
+
+def test_match_without_explicit_query_param_matches_all_query_params():
+    stub_group = StubGroup()
+    stub_group.add("GET",
+                   "/hello/mimicker",
+                   200,
+                   {"message": "Hello, world!"})
+
+    matched, _ = stub_group.match("GET", "/hello/mimicker?greeting=world&age=20&city=NewYork")
+    
+    assert_that(matched, not_none())
+
+
 def test_match_w_delay():
     stub_group = StubGroup()
     stub_group.add("GET", "/hi", 200, {"message": "hello"}, delay=3.)
@@ -58,7 +83,7 @@ def test_match_stub_with_response_func():
     def dynamic_response():
         return 202, {"status": "dynamic"}
 
-    stub_group.add("GET", r"^/dynamic$",
+    stub_group.add("GET", "/dynamic",
                    200, {}, response_func=dynamic_response)
     matched, _ = stub_group.match("GET", "/dynamic")
     assert_that(matched, is_((200, 0., {}, dynamic_response, None)))


Reviews:
user: login: amaziahub
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-04T12:22:18Z
user: login: amaziahub
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-12T11:12:25Z
user: login: amaziahub
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-12T11:23:38Z
user: login: amaziahub
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-12T11:34:04Z
user: login: amaziahub
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-14T10:10:09Z

Review comments:
diff_hunk: @@ -29,21 +30,26 @@ def do_PATCH(self):
     def _handle_request(self, method: str):
         request_headers = {key.lower(): value for key, value in self.headers.items()}
         request_body = self._get_request_body()
+
+        parsed_url = urlparse(self.path)
+        path_only = parsed_url.path
path: mimicker/handler.py
user: login: amaziahub
body: `path_only` only used in line 39. lets inline it there please.
created_at: 2025-05-04T12:14:58Z
author_association: OWNER
diff_hunk: @@ -29,21 +30,26 @@ def do_PATCH(self):
     def _handle_request(self, method: str):
         request_headers = {key.lower(): value for key, value in self.headers.items()}
         request_body = self._get_request_body()
+
+        parsed_url = urlparse(self.path)
+        path_only = parsed_url.path
+        query_params = parse_qs(parsed_url.query)
path: mimicker/handler.py
user: login: amaziahub
body: `query_params` only used in line 43. lets inline it there please.
created_at: 2025-05-04T12:15:52Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,5 @@
+class TemplateError(Exception):
path: mimicker/error.py
user: login: amaziahub
body: lets change the name of the file from `error.py` -> `exceptions.py`
created_at: 2025-05-12T11:12:25Z
author_association: OWNER
diff_hunk: @@ -59,12 +61,12 @@ def log_incoming_request(self, method, request_body, request_headers):
             f"\nBody:\n{body_str}" if body_str else ""
         )
 
-    def _send_response(self, matched_stub: Stub, path_params: Dict[str, str], request_body: Any, request_headers: Dict[str, str]):
+    def _send_response(self, matched_stub: Stub, path_params: Dict[str, str], query_params: Dict[str, List[str]], request_body: Any, request_headers: Dict[str, str]):
         status_code, delay, response, response_func, headers = matched_stub
         if delay > 0:
             sleep(delay)
         if response_func:
-            status_code, response = response_func(payload=request_body, headers=request_headers, params=path_params)
+            status_code, response = response_func(payload=request_body, headers=request_headers, params=path_params, query=query_params)
path: mimicker/handler.py
user: login: amaziahub
body: variable should be called `query_params` and not `query` for readability
created_at: 2025-05-12T11:23:38Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,132 @@
+import re
+from re import Pattern
+
+from mimicker.error import TemplateError
+
+def parse_endpoint_pattern(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern into a regular expression for URL matching.
+    
+    This function creates a regex pattern that can match URLs with both static and
+    parameterized path segments, as well as query parameters. Path parameters are
+    specified using curly braces (e.g., '/{user_id}/profile'). Query parameters
+    can be either static or parameterized.
+    
+    Examples:
+        "/users/{id}"                  # Matches: "/users/123"
+        "/api/{version}/items"         # Matches: "/api/v1/items"
+        "/search?q={term}"             # Matches: "/search?q=python"
+        "/filter?min={min}&max={max}"  # Matches: "/filter?min=10&max=100"
+    
+    Args:
+        template: A URL template with optional path and query parameters.
+                  Path parameters are specified as {name}.
+                  Query parameters can be static or parameterized as key={name}.
+    
+    Returns:
+        A compiled regex Pattern that matches URLs according to the template.
+    
+    Raises:
+        TemplateError: If parameter names are repeated.
+        TemplateError: If query parameters do not match the expected URL format.
+    """
+    try:
+
+        if '?' in template:
+            return _parse_with_query(template)
+        
+        else:
+            return _parse_without_query(template)
+        
+    except re.error as e:
+        raise TemplateError(f"invalid template: {template!r}") from e
+
+
+def _parse_without_query(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern without explicit query parameters.
+    
+    Creates a regex pattern that matches the path exactly and optionally
+    matches any query string (without parsing its contents).
+    
+    Args:
+        template: URL template without a '?' character
+        
+    Returns:
+        Compiled regex Pattern for matching URLs
+    """
+    path_regex = _build_path_regex(template)
+    # Allow any query string (or none) after the path
+    full = rf'^{path_regex}(?:\?.*)?$'
+    return re.compile(full)
+
+
+def _parse_with_query(template: str) -> Pattern:
+    """
+    Parse an endpoint pattern with explicit query parameters.
+    
+    Creates a regex pattern that matches both the path and specific query parameters.
+    Query parameters can be static values or parameterized with {name} syntax.
+    
+    Args:
+        template: URL template containing a '?' followed by query parameters
+        
+    Returns:
+        Compiled regex Pattern for matching URLs with specific query parameters
+        
+    Raises:
+        TemplateError: If a query parameter doesn't contain an equals sign
+    """
+    path_t, query_t = template.split('?', 1)
+    path_regex = _build_path_regex(path_t)
+    
+    qpats = []
+    for qp in query_t.split('&'):
+        if '=' not in qp:
+            raise TemplateError(f"invalid query segment (no '=' found): {qp!r}")
+        
+        key, val_t = qp.split('=', 1)
+
+        # Handle parameterized query values like key={param}
+        if val_t.startswith('{') and val_t.endswith('}'):
+            name = val_t[1:-1]
+            qpats.append(fr'{re.escape(key)}=(?P<{name}>[^&]+)')  # Named capture group
+
+        else:
+            # Handle static query values which are not parameterized
+            qpats.append(fr'{re.escape(key)}={re.escape(val_t)}')
+
+    # Construct final regex that matches both path and query parts
+    full = rf'^{path_regex}\?{"&".join(qpats)}$'
+    return re.compile(full)
+
+
+
+def _build_path_regex(path_t: str) -> str:
+    """
+    Build a regex pattern for matching the path portion of a URL.
+    
+    Converts a path template with parameterized segments into a regex string.
+    Each path segment is either matched literally or converted to a named capture
+    group if it's a parameter.
+    
+    Args:
+        path_t: Path template, possibly containing parameters like {name}
+        
+    Returns:
+        A regex string for matching the path (not compiled)
+    """
+    parts = path_t.strip('/').split('/')
+    regex_parts = []
+    for part in parts:
+        # Check if the part is a parameter (e.g., {id})
+        m = re.fullmatch(r'\{(\w+)\}', part)
path: mimicker/regex.py
user: login: amaziahub
body: im pretty sure there's a redundant char here

from this: 
`m = re.fullmatch(r'\{(\w+)\}', part)`

to this:
`m = re.fullmatch(r'\{(\w+)}', part)`

created_at: 2025-05-12T11:34:04Z
author_association: OWNER

PR comments:
user: login: amaziahub
created_at: 2025-05-04T12:22:09Z
body: Hi @austinmilt, 

Thank you for you contribution! ⭐ 
I've left a couple of comments on the PR its self.
Before accepting the PR, Please update the documentation (`readme` file) with the new feature. 
Also add tests to the matchers ->  which is `stub_group`.

Also, Im missing some error handling here for cases like:
query parameters are missing or malformed. we should have a specific mimicker exception for this. 

Amazia
author_association: OWNER
user: login: austinmilt
created_at: 2025-05-04T20:12:06Z
body: > Hi @austinmilt,
> 
> Thank you for you contribution! ⭐ I've left a couple of comments on the PR its self. Before accepting the PR, Please update the documentation (`readme` file) with the new feature. Also add tests to the matchers -> which is `stub_group`.
> 
> Also, Im missing some error handling here for cases like: query parameters are missing or malformed. we should have a specific mimicker exception for this.
> 
> Amazia

Thanks for the review @amaziahub . In addressing your comments I found some tests failed unexpectedly, which led me down a rabbithole of improvements to the code. I have made quite a few more changes which are now described in the PR description, and added tests to address your comments and more. Thanks
author_association: CONTRIBUTOR
user: login: amaziahub
created_at: 2025-05-12T11:37:45Z
body: Hi @austinmilt 
Great PR and changes! 
Added some comments here, please go over it. and once it done please push and ill release it!

Would love you to hit the ⭐ for the repo!
author_association: OWNER
user: login: austinmilt
created_at: 2025-05-12T23:24:02Z
body: > Hi @austinmilt Great PR and changes! Added some comments here, please go over it. and once it done please push and ill release it!
> 
> Would love you to hit the ⭐ for the repo!

@amaziahub Thanks for the review! Changes made
author_association: CONTRIBUTOR