Repository: lundberg/respx

Issue:
URL pattern does not match or fails on URL-encoded special ascii symbols in URL
Hey!

I have faced a bug during an attempt to use respx with hypothesis to mock autogenerated URLs when encoded special symbols are present in the url path.

This code fails with the `AllMockedAssertionError`:
```python
import httpx
import respx

with respx.mock:
    url = 'http://test.com/%0a'
    respx.get(url=url).respond()
    httpx.get(url)
```

This code fails with `httpx.InvalidURL`:
```python
with respx.mock:
    url = 'http://test.com/%08'
    respx.get(url=url).respond()
    assert httpx.get(url).status_code == 200
```
Full traceback:
```
Traceback (most recent call last):
  File "...", line 8, in <module>
    respx.get(url=url).respond()
  File ".../python3.9/site-packages/respx/api.py", line 74, in get
    return mock.get(url, name=name, **lookups)
  File ".../python3.9/site-packages/respx/router.py", line 174, in get
    return self.request(method="GET", url=url, name=name, **lookups)
  File ".../python3.9/site-packages/respx/router.py", line 165, in request
    return self.route(method=method, url=url, name=name, **lookups)
  File ".../python3.9/site-packages/respx/router.py", line 132, in route
    route = Route(*patterns, **lookups)
  File ".../python3.9/site-packages/respx/models.py", line 119, in __init__
    self._pattern = M(*patterns, **lookups)
  File ".../python3.9/site-packages/respx/patterns.py", line 544, in M
    extras = parse_url_patterns(value)
  File ".../python3.9/site-packages/respx/patterns.py", line 648, in parse_url_patterns
    bases[Path.key] = Path(url.path, lookup=lookup)
  File ".../python3.9/site-packages/respx/patterns.py", line 92, in __init__
    self.value = self.clean(value)
  File ".../python3.9/site-packages/respx/patterns.py", line 417, in clean
    value = httpx.URL(path).path
  File ".../python3.9/site-packages/httpx/_urls.py", line 113, in __init__
    self._uri_reference = urlparse(url, **kwargs)
  File ".../python3.9/site-packages/httpx/_urlparse.py", line 160, in urlparse
    raise InvalidURL("Invalid non-printable ASCII character in URL")
httpx.InvalidURL: Invalid non-printable ASCII character in URL
```

I could reproduce this problem only with the `url` pattern, the following works fine:
```python
from urllib.parse import urlparse

import httpx
import respx

with respx.mock:
    url = 'http://test.com/%08'
    parsed = urlparse(url)
    respx.get(scheme=parsed.scheme, host=parsed.hostname, path=parsed.path, params=parsed.params).respond()
    assert httpx.get(url).status_code == 200
```

Versions:
* python 3.9.7
* respx==0.20.1
* httpx==0.24.0

Comments:

Patch:
diff --git a/respx/patterns.py b/respx/patterns.py
index ed4c51d..3bf8d9b 100644
--- a/respx/patterns.py
+++ b/respx/patterns.py
@@ -413,7 +413,16 @@ class Path(Pattern):
         self, value: Union[str, RegexPattern[str]]
     ) -> Union[str, RegexPattern[str]]:
         if self.lookup in (Lookup.EQUAL, Lookup.STARTS_WITH) and isinstance(value, str):
-            path = urljoin("/", value)  # Ensure leading slash
+            # Percent encode path, i.e. revert parsed path by httpx.URL.
+            # Borrowed from HTTPX's "private" quote and percent_encode utilities.
+            path = "".join(
+                char
+                if char
+                in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/"
+                else "".join(f"%{byte:02x}" for byte in char.encode("utf-8")).upper()
+                for char in value
+            )
+            path = urljoin("/", path)  # Ensure leading slash
             value = httpx.URL(path).path
         elif self.lookup is Lookup.REGEX and isinstance(value, str):
             value = re.compile(value)


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 151da8d..c126408 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -82,6 +82,7 @@ async def test_http_methods(client):
         ("https://foo.bar/baz/", re.compile(r"^https://foo.bar/\w+/$")),
         ("https://foo.bar/baz/", (b"https", b"foo.bar", None, b"/baz/")),
         ("https://foo.bar:443/baz/", (b"https", b"foo.bar", 443, b"/baz/")),
+        ("https://foo.bar/%08", "https://foo.bar/%08"),
     ],
 )
 async def test_url_match(client, url, pattern):