Repository: encode/httpx

Issue:
URL.join behaviour is incorrect.
Our `URL.join` behavior currently isn't as expected. It should be in line with rfc3986#section-5.2, and Python's existing `urljoin`.

It ain't...

```python
>>> httpx.URL('http://www.example.com/some/directory/abc.html').join('../../foo.html')
URL('http://www.example.com/some/directory/foo.html')
```


Discussion:
Thoughts on using the built-in `urljoin` function (this is what urllib3 does)? Otherwise we could use [`rfc3986.URIReference.resolve_with()`](https://github.com/python-hyper/rfc3986/blob/master/src/rfc3986/_mixin.py#L231) as well.
Strongly prefer using `resolve_with()` which is what the intent already is: https://github.com/encode/httpx/blob/master/httpx/models.py#L201

Evidently not quite right tho.

Resolving patch:
diff --git a/httpx/models.py b/httpx/models.py
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -98,7 +98,8 @@ def __init__(
                 self.components = self.components.copy_with(authority=idna_authority)
 
         # Normalize scheme and domain name.
-        self.components = self.components.normalize()
+        if self.is_absolute_url:
+            self.components = self.components.normalize()
 
         # Add any query parameters.
         if params:
@@ -170,7 +171,7 @@ def is_absolute_url(self) -> bool:
         Return `True` for absolute URLs such as 'http://example.com/path',
         and `False` for relative URLs such as '/path'.
         """
-        # We don't use rfc3986's `is_absolute` because it treats
+        # We don't use `.is_absolute` from `rfc3986` because it treats
         # URLs with a fragment portion as not absolute.
         # What we actually care about is if the URL provides
         # a scheme and hostname to which connections should be made.


Test patch:
diff --git a/tests/models/test_url.py b/tests/models/test_url.py
--- a/tests/models/test_url.py
+++ b/tests/models/test_url.py
@@ -1,4 +1,6 @@
 from httpx import URL
+from httpx.exceptions import InvalidURL
+import pytest
 
 
 def test_idna_url():
@@ -31,7 +33,7 @@ def test_url_eq_str():
     assert str(url) == url
 
 
-def test_url__params():
+def test_url_params():
     url = URL("https://example.org:123/path/to/somewhere", params={"a": "123"})
     assert str(url) == "https://example.org:123/path/to/somewhere?a=123"
 
@@ -39,6 +41,75 @@ def test_url__params():
     assert str(url) == "https://example.org:123/path/to/somewhere?a=123"
 
 
+def test_url_join():
+    """
+    Some basic URL joining tests.
+    """
+    url = URL("https://example.org:123/path/to/somewhere")
+    assert url.join('/somewhere-else') == "https://example.org:123/somewhere-else"
+    assert url.join('somewhere-else') == "https://example.org:123/path/to/somewhere-else"
+    assert url.join('../somewhere-else') == "https://example.org:123/path/somewhere-else"
+    assert url.join('../../somewhere-else') == "https://example.org:123/somewhere-else"
+
+
+def test_url_join_rfc3986():
+    """
+    URL joining tests, as-per reference examples in RFC 3986.
+
+    https://tools.ietf.org/html/rfc3986#section-5.4
+    """
+
+    url = URL("http://example.com/b/c/d;p?q")
+
+    with pytest.raises(InvalidURL):
+        assert url.join("g:h") == "g:h"
+
+    assert url.join("g") == "http://example.com/b/c/g"
+    assert url.join("./g") == "http://example.com/b/c/g"
+    assert url.join("g/") == "http://example.com/b/c/g/"
+    assert url.join("/g") == "http://example.com/g"
+    assert url.join("//g") == "http://g"
+    assert url.join("?y") == "http://example.com/b/c/d;p?y"
+    assert url.join("g?y") == "http://example.com/b/c/g?y"
+    assert url.join("#s") == "http://example.com/b/c/d;p?q#s"
+    assert url.join("g#s") == "http://example.com/b/c/g#s"
+    assert url.join("g?y#s") == "http://example.com/b/c/g?y#s"
+    assert url.join(";x") == "http://example.com/b/c/;x"
+    assert url.join("g;x") == "http://example.com/b/c/g;x"
+    assert url.join("g;x?y#s") == "http://example.com/b/c/g;x?y#s"
+    assert url.join("") == "http://example.com/b/c/d;p?q"
+    assert url.join(".") == "http://example.com/b/c/"
+    assert url.join("./") == "http://example.com/b/c/"
+    assert url.join("..") == "http://example.com/b/"
+    assert url.join("../") == "http://example.com/b/"
+    assert url.join("../g") == "http://example.com/b/g"
+    assert url.join("../..") == "http://example.com/"
+    assert url.join("../../") == "http://example.com/"
+    assert url.join("../../g") == "http://example.com/g"
+
+    assert url.join("../../../g") == "http://example.com/g"
+    assert url.join("../../../../g") == "http://example.com/g"
+
+    assert url.join("/./g") == "http://example.com/g"
+    assert url.join("/../g") == "http://example.com/g"
+    assert url.join("g.") == "http://example.com/b/c/g."
+    assert url.join(".g") == "http://example.com/b/c/.g"
+    assert url.join("g..") == "http://example.com/b/c/g.."
+    assert url.join("..g") == "http://example.com/b/c/..g"
+
+    assert url.join("./../g") == "http://example.com/b/g"
+    assert url.join("./g/.") == "http://example.com/b/c/g/"
+    assert url.join("g/./h") == "http://example.com/b/c/g/h"
+    assert url.join("g/../h") == "http://example.com/b/c/h"
+    assert url.join("g;x=1/./y") == "http://example.com/b/c/g;x=1/y"
+    assert url.join("g;x=1/../y") == "http://example.com/b/c/y"
+
+    assert url.join("g?y/./x") == "http://example.com/b/c/g?y/./x"
+    assert url.join("g?y/../x") == "http://example.com/b/c/g?y/../x"
+    assert url.join("g#s/./x") == "http://example.com/b/c/g#s/./x"
+    assert url.join("g#s/../x") == "http://example.com/b/c/g#s/../x"
+
+
 def test_url_set():
     urls = (
         URL("http://example.org:123/path/to/somewhere"),