Repository: lundberg/respx
Pull request #34 (2019-11-29 16:43:12)

Add support for configuring pattern base url
```py
base_url = "https://foo.bar/"

async with respx.mock(base_url=base_url) as httpx_mock:
    httpx_mock.get("/", content="hello")
    async with httpx.Client(base_url=base_url) as client:
        response = await client.get("/")
        assert response.text == "hello"
```


Patch:
diff --git a/respx/api.py b/respx/api.py
index c6d700d..59496be 100644
--- a/respx/api.py
+++ b/respx/api.py
@@ -23,11 +23,13 @@ class HTTPXMock:
         self,
         assert_all_called: bool = True,
         assert_all_mocked: bool = True,
+        base_url: typing.Optional[str] = None,
         local: bool = True,
     ) -> None:
         self._is_local = local
         self._assert_all_called = assert_all_called
         self._assert_all_mocked = assert_all_mocked
+        self._base_url = base_url
         self._patchers: typing.List[asynctest.mock._patch] = []
         self._patterns: typing.List[RequestPattern] = []
         self.aliases: typing.Dict[str, RequestPattern] = {}
@@ -39,6 +41,7 @@ class HTTPXMock:
         func: typing.Optional[typing.Callable] = None,
         assert_all_called: typing.Optional[bool] = None,
         assert_all_mocked: typing.Optional[bool] = None,
+        base_url: typing.Optional[str] = None,
     ) -> typing.Union["HTTPXMock", typing.Callable]:
         """
         Decorator or Context Manager.
@@ -50,7 +53,7 @@ class HTTPXMock:
             # A. First stage of "local" decorator, WITH parentheses.
             # B. Only stage of "local" context manager, WITH parentheses,
             #    "global" context maanager hits __enter__ directly.
-            settings = {}
+            settings: typing.Dict[str, typing.Any] = {"base_url": base_url}
             if assert_all_called is not None:
                 settings["assert_all_called"] = assert_all_called
             if assert_all_mocked is not None:
@@ -161,7 +164,12 @@ class HTTPXMock:
 
         response = ResponseTemplate(status_code, headers, content)
         pattern = RequestPattern(
-            method, url, response, pass_through=pass_through, alias=alias
+            method,
+            url,
+            response,
+            pass_through=pass_through,
+            alias=alias,
+            base_url=self._base_url,
         )
 
         self.add(pattern, alias=alias)
diff --git a/respx/models.py b/respx/models.py
index 46e64ca..ff99856 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -3,12 +3,14 @@ import json as jsonlib
 import re
 import typing
 from functools import partial
+from urllib.parse import urljoin
 
 import asynctest
 from httpx.models import URL, Headers, HeaderTypes, Request
 
 Regex = type(re.compile(""))
 Kwargs = typing.Dict[str, typing.Any]
+URLPatternTypes = typing.Union[str, typing.Pattern[str]]
 ContentDataTypes = typing.Union[
     bytes, str, typing.List, typing.Dict, typing.Callable, Exception,
 ]
@@ -77,10 +79,11 @@ class RequestPattern:
     def __init__(
         self,
         method: typing.Union[str, typing.Callable],
-        url: typing.Optional[typing.Union[str, typing.Pattern]],
+        url: typing.Optional[URLPatternTypes],
         response: ResponseTemplate,
         pass_through: bool = False,
         alias: typing.Optional[str] = None,
+        base_url: typing.Optional[str] = None,
     ) -> None:
         self._match_func: typing.Optional[typing.Callable] = None
 
@@ -91,7 +94,7 @@ class RequestPattern:
             self._match_func = method
         else:
             self.method = method
-            self.url = url
+            self.set_url(url, base=base_url)
             self.pass_through = pass_through
 
         self.response = response
@@ -112,6 +115,29 @@ class RequestPattern:
             (request, response) for (request, response), _ in self.stats.call_args_list
         ]
 
+    def get_url(self) -> typing.Optional[URLPatternTypes]:
+        return self._url
+
+    def set_url(
+        self, url: typing.Optional[URLPatternTypes], base: typing.Optional[str] = None,
+    ) -> None:
+        url = url or None
+        if url is None:
+            url = url if base is None else base
+        elif isinstance(url, str):
+            url = url if base is None else urljoin(base, url)
+        elif isregex(url):
+            url = url if base is None else re.compile(urljoin(base, url.pattern))
+        else:
+            raise ValueError(
+                "Request url pattern must be str or compiled regex, got {}.".format(
+                    type(url).__name__
+                )
+            )
+        self._url = url
+
+    url = property(get_url, set_url)
+
     def match(
         self, request: Request
     ) -> typing.Optional[typing.Union[Request, ResponseTemplate]]:
@@ -132,24 +158,21 @@ class RequestPattern:
             response = self.response.clone(context={"request": request})
             return self._match_func(request, response)
 
-        # TODO: Split method and url test
-        if self.method == request.method and self.url:
-            if isinstance(self.url, str):
-                matches = self.url == str(request.url)
-            elif isregex(self.url):
-                match = self.url.match(str(request.url))
-                if match:
-                    matches = True
-                    url_params = match.groupdict()
-            else:
-                raise ValueError(
-                    "Request url pattern must be str or compiled regex, got {}.".format(
-                        type(self.url).__name__
-                    )
-                )
+        if self.method != request.method:
+            return None
+
+        if not self._url:
+            matches = True
+        elif isinstance(self._url, str):
+            matches = self._url == str(request.url)
+        else:
+            match = self._url.match(str(request.url))
+            if match:
+                matches = True
+                url_params = match.groupdict()
 
-            if matches:
-                return self.response.clone(context={"request": request, **url_params})
+        if matches:
+            return self.response.clone(context={"request": request, **url_params})
 
         return None
 


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index be574b2..7c69ab4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -41,7 +41,7 @@ async def test_http_methods():
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
-    "url", ["https://foo.bar/baz/", re.compile(r"^https://foo.bar/\w+/$")]
+    "url", [None, "", "https://foo.bar/baz/", re.compile(r"^https://foo.bar/\w+/$")]
 )
 async def test_url_match(url):
     async with respx.HTTPXMock(assert_all_mocked=False) as httpx_mock:
@@ -54,11 +54,9 @@ async def test_url_match(url):
 
 @pytest.mark.asyncio
 async def test_invalid_url_pattern():
-    async with respx.HTTPXMock(assert_all_called=False) as httpx_mock:
-        request = httpx_mock.get(["invalid"])
+    async with respx.HTTPXMock() as httpx_mock:
         with pytest.raises(ValueError):
-            await httpx.get("https://foo.bar/")
-        assert request.called is False
+            httpx_mock.get(["invalid"])
 
 
 @pytest.mark.asyncio
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 85ddec3..575d8c7 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -1,3 +1,4 @@
+import re
 from contextlib import ExitStack as does_not_raise
 
 import httpx
@@ -5,8 +6,6 @@ import pytest
 
 import respx
 
-# from .conftest import does_not_raise
-
 
 @pytest.mark.asyncio
 @respx.mock
@@ -160,6 +159,27 @@ async def test_configured_decorator():
     assert respx.stats.call_count == 0
 
 
+@pytest.mark.asyncio
+async def test_base_url():
+    async with respx.mock(base_url="https://foo.bar/") as httpx_mock:
+        request1 = httpx_mock.get("/baz/", content="baz")
+        request2 = httpx_mock.post(re.compile(r"(?P<slug>\w+)/?$"), content="slug")
+        request3 = httpx_mock.put(content="ok")
+
+        async with httpx.Client(base_url="https://foo.bar") as client:
+            response = await client.get("/baz/")
+            assert request1.called is True
+            assert response.text == "baz"
+
+            response = await client.post("/apa/")
+            assert request2.called is True
+            assert response.text == "slug"
+
+            response = await client.put("/")
+            assert request3.called is True
+            assert response.text == "ok"
+
+
 @pytest.mark.asyncio
 async def test_start_stop():
     url = "https://foo.bar/"
diff --git a/tests/test_stats.py b/tests/test_stats.py
index 66e2161..e64ff8c 100644
--- a/tests/test_stats.py
+++ b/tests/test_stats.py
@@ -23,6 +23,7 @@ async def test_alias():
 @pytest.mark.asyncio
 async def test_httpx_exception_handling():
     async with respx.HTTPXMock() as httpx_mock:
+        # TODO: Patch something else once HTTPX 0.8.1 is released
         with asynctest.mock.patch(
             "httpx.client.Client._dispatcher_for_request",
             side_effect=ValueError("mock"),
@@ -43,9 +44,9 @@ async def test_httpx_exception_handling():
 def test_stats(Backend):
     @respx.mock
     async def test(backend):
-        url = "https://foo/bar/1/"
-        respx.get(re.compile("http://some/url"))
-        respx.delete("http://some/url")
+        url = "https://foo.bar/1/"
+        respx.get(re.compile("https://some.thing"))
+        respx.delete("https://some.thing")
 
         foobar1 = respx.get(url, status_code=202, alias="get_foobar")
         foobar2 = respx.delete(url, status_code=200, alias="del_foobar")