Skip to content

Commit eaea57a

Browse files
Fix urljoin bug with base URL path prefixes (#2994)
* Fix urljoin bug with base URL path prefixes Replace urljoin with string concatenation in URL pattern construction to preserve path prefixes in base URLs. This fixes the issue where MockVWS(base_vws_url="http://localhost/prefix") would incorrectly register handlers at http://localhost/targets instead of http://localhost/prefix/targets. Add tests for path prefix handling in both requests_mock and respx implementations. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * Fix bytes branch never reached in respx callback Normalize httpx header keys to title case in _to_request_data so that validators can find headers like Authorization and Content-Type, which httpx stores as lowercase. This enables properly-authenticated requests through the respx mock. Add test_vumark_bytes_response to exercise the bytes response path in the respx callback, which is only reachable via the vumark endpoint with valid authentication. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pylint spelling: vumark -> VuMark in docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 606c81d commit eaea57a

File tree

4 files changed

+131
-12
lines changed

4 files changed

+131
-12
lines changed

src/mock_vws/_requests_mock_server/decorators.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Callable, Mapping
66
from contextlib import ContextDecorator
77
from typing import Any, Literal, Self
8-
from urllib.parse import urljoin, urlparse
8+
from urllib.parse import urlparse
99

1010
import requests
1111
from beartype import BeartypeConf, beartype
@@ -222,10 +222,7 @@ def __enter__(self) -> Self:
222222
(self._mock_vwq_api, self._base_vwq_url),
223223
):
224224
for route in api.routes:
225-
url_pattern = urljoin(
226-
base=base_url,
227-
url=f"{route.path_pattern}$",
228-
)
225+
url_pattern = base_url.rstrip("/") + route.path_pattern + "$"
229226
compiled_url_pattern = re.compile(pattern=url_pattern)
230227

231228
for http_method in route.http_methods:

src/mock_vws/_respx_mock_server/decorators.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Callable, Mapping
66
from contextlib import ContextDecorator
77
from typing import Literal, Self
8-
from urllib.parse import urljoin, urlparse
8+
from urllib.parse import urlparse
99

1010
import httpx
1111
import respx
@@ -48,7 +48,7 @@ def _to_request_data(request: httpx.Request) -> RequestData:
4848
return RequestData(
4949
method=request.method,
5050
path=request.url.raw_path.decode(encoding="ascii"),
51-
headers=request.headers,
51+
headers={k.title(): v for k, v in request.headers.items()},
5252
body=request.content,
5353
)
5454

@@ -238,10 +238,7 @@ def __enter__(self) -> Self:
238238
(self._mock_vwq_api, self._base_vwq_url),
239239
):
240240
for route in api.routes:
241-
url_pattern = urljoin(
242-
base=base_url,
243-
url=f"{route.path_pattern}$",
244-
)
241+
url_pattern = base_url.rstrip("/") + route.path_pattern + "$"
245242
compiled_url_pattern = re.compile(pattern=url_pattern)
246243

247244
for http_method in route.http_methods:

tests/mock_vws/test_requests_mock_usage.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,50 @@ def test_custom_base_vwq_url() -> None:
347347
timeout=30,
348348
)
349349

350+
@staticmethod
351+
def test_custom_base_vws_url_with_path_prefix() -> None:
352+
"""A custom base VWS URL with a path prefix intercepts at the
353+
prefix.
354+
"""
355+
with MockVWS(
356+
base_vws_url="https://vuforia.vws.example.com/prefix",
357+
real_http=False,
358+
):
359+
with pytest.raises(
360+
expected_exception=requests.exceptions.ConnectionError
361+
):
362+
requests.get(
363+
url="https://vuforia.vws.example.com/summary",
364+
timeout=30,
365+
)
366+
367+
requests.get(
368+
url="https://vuforia.vws.example.com/prefix/summary",
369+
timeout=30,
370+
)
371+
372+
@staticmethod
373+
def test_custom_base_vwq_url_with_path_prefix() -> None:
374+
"""A custom base VWQ URL with a path prefix intercepts at the
375+
prefix.
376+
"""
377+
with MockVWS(
378+
base_vwq_url="https://vuforia.vwq.example.com/prefix",
379+
real_http=False,
380+
):
381+
with pytest.raises(
382+
expected_exception=requests.exceptions.ConnectionError
383+
):
384+
requests.post(
385+
url="https://vuforia.vwq.example.com/v1/query",
386+
timeout=30,
387+
)
388+
389+
requests.post(
390+
url="https://vuforia.vwq.example.com/prefix/v1/query",
391+
timeout=30,
392+
)
393+
350394
@staticmethod
351395
def test_no_scheme() -> None:
352396
"""An error if raised if a URL is given with no scheme."""

tests/mock_vws/test_respx_mock_usage.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""Tests for the usage of the mock for ``httpx`` via ``respx``."""
22

3+
import json
34
import socket
5+
import uuid
6+
from http import HTTPMethod, HTTPStatus
47

58
import httpx
69
import pytest
7-
from vws_auth_tools import rfc_1123_date
10+
from vws_auth_tools import authorization_header, rfc_1123_date
811

912
from mock_vws import MissingSchemeError, MockVWSForHttpx
1013
from mock_vws.database import CloudDatabase, VuMarkDatabase
14+
from mock_vws.target import VuMarkTarget
1115

1216

1317
def _request_unmocked_address() -> None:
@@ -208,6 +212,46 @@ def test_custom_base_vwq_url() -> None:
208212
timeout=30,
209213
)
210214

215+
@staticmethod
216+
def test_custom_base_vws_url_with_path_prefix() -> None:
217+
"""A custom base VWS URL with a path prefix intercepts at the
218+
prefix.
219+
"""
220+
with MockVWSForHttpx(
221+
base_vws_url="https://vuforia.vws.example.com/prefix",
222+
real_http=False,
223+
):
224+
with pytest.raises(expected_exception=httpx.ConnectError):
225+
httpx.get(
226+
url="https://vuforia.vws.example.com/summary",
227+
timeout=30,
228+
)
229+
230+
httpx.get(
231+
url="https://vuforia.vws.example.com/prefix/summary",
232+
timeout=30,
233+
)
234+
235+
@staticmethod
236+
def test_custom_base_vwq_url_with_path_prefix() -> None:
237+
"""A custom base VWQ URL with a path prefix intercepts at the
238+
prefix.
239+
"""
240+
with MockVWSForHttpx(
241+
base_vwq_url="https://vuforia.vwq.example.com/prefix",
242+
real_http=False,
243+
):
244+
with pytest.raises(expected_exception=httpx.ConnectError):
245+
httpx.post(
246+
url="https://vuforia.vwq.example.com/v1/query",
247+
timeout=30,
248+
)
249+
250+
httpx.post(
251+
url="https://vuforia.vwq.example.com/prefix/v1/query",
252+
timeout=30,
253+
)
254+
211255
@staticmethod
212256
def test_no_scheme() -> None:
213257
"""An error is raised if a URL is given with no scheme."""
@@ -348,3 +392,40 @@ def test_database_summary() -> None:
348392
)
349393
# We just verify we get a response (auth will fail but endpoint works)
350394
assert response.status_code is not None
395+
396+
@staticmethod
397+
def test_vumark_bytes_response() -> None:
398+
"""The VuMark endpoint returns bytes content via httpx."""
399+
vumark_target = VuMarkTarget(name="test-target")
400+
database = VuMarkDatabase(vumark_targets={vumark_target})
401+
target_id = vumark_target.target_id
402+
request_path = f"/targets/{target_id}/instances"
403+
content_type = "application/json"
404+
content = json.dumps(obj={"instance_id": uuid.uuid4().hex}).encode(
405+
encoding="utf-8"
406+
)
407+
date = rfc_1123_date()
408+
auth = authorization_header(
409+
access_key=database.server_access_key,
410+
secret_key=database.server_secret_key,
411+
method=HTTPMethod.POST,
412+
content=content,
413+
content_type=content_type,
414+
date=date,
415+
request_path=request_path,
416+
)
417+
with MockVWSForHttpx() as mock:
418+
mock.add_vumark_database(vumark_database=database)
419+
response = httpx.post(
420+
url="https://vws.vuforia.com" + request_path,
421+
headers={
422+
"Accept": "image/png",
423+
"Authorization": auth,
424+
"Content-Length": str(object=len(content)),
425+
"Content-Type": content_type,
426+
"Date": date,
427+
},
428+
content=content,
429+
timeout=30,
430+
)
431+
assert response.status_code == HTTPStatus.OK

0 commit comments

Comments
 (0)