Repository: pydantic/pydantic

Issue:
*Url.build functions doesn't percent-encode arguments
### Checks

* [x] I added a descriptive title to this issue
* [x] I have searched (google, github) for similar issues and couldn't find anything
* [x] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug

# Bug
Using (AnyUrl, HttpUrl, AnyHttpUrl) build functions results an unusable URL if characters like: `:`, `/`, `?`, `#`, `[`, ` `, `]`, `@` exists in the following arguments: `user`, `password`, `path`, `query`, `fragment`.

In other words these arguments should be [Percent-Encoded](https://tools.ietf.org/html/rfc3986#section-2.1) to work properly and not
produce a Url that have characters that conflicts with the [url-spec](https://www.w3.org/Addressing/URL/url-spec.txt).

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.8.2
            pydantic compiled: True
                 install path: /.../.venv/lib/python3.9/site-packages/pydantic
               python version: 3.9.6 (default, Jul  3 2021, 17:50:42)  [GCC 7.5.0]
                     platform: Linux-5.4.0-80-generic-x86_64-with-glibc2.27
     optional deps. installed: ['dotenv', 'email-validator', 'typing-extensions']
```

# Example

```py
>>> from pydantic import AnyUrl, HttpUrl, AnyHttpUrl
>>>
>>> AnyUrl.build(
...    scheme="ws",
...    user="@AwesomeUser",
...    password="Pa##w0rd?:/",
...    host="localhost",
...    port="5000"
...)
'ws://@AwesomeUser:Pa##w0rd?:/@localhost:5000'
>>> # ...
>>> # The same result with `HttpUrl`, `AnyHttpUrl`
```

The easiest way to percent-encode in Python is to use the `urllib.parse.quote` function


Discussion:
Should this also include the option for `quote_plus` in case it's needed for compatibility reasons?
Hi @kkirsche!

Sorry I'm not following, what are the compatibility reasons?

Not all APIs, in my experience at least, support percent-encoded spaces (`%20` instead of ` `). Some, but not all, only accept plus-variants (`+` instead of ` `) of the space encodings, requiring the use of `quote_plus` instead of `quote`.
Yeah sure I think this should be included.


Resolving patch:
diff --git a/docs/examples/types_url_building.py b/docs/examples/types_url_building.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/types_url_building.py
@@ -0,0 +1,10 @@
+from pydantic import AnyUrl, stricturl
+
+url = AnyUrl.build(scheme='https', host='example.com', query='query=my query')
+print(url)
+
+my_url_builder = stricturl(quote_plus=True)
+url = my_url_builder.build(
+    scheme='http', host='example.com', query='query=my query'
+)
+print(url)
diff --git a/pydantic/networks.py b/pydantic/networks.py
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -26,6 +26,7 @@
     cast,
     no_type_check,
 )
+from urllib.parse import quote, quote_plus
 
 from . import errors
 from .utils import Representation, update_not_none
@@ -177,6 +178,7 @@ class AnyUrl(str):
     user_required: bool = False
     host_required: bool = True
     hidden_parts: Set[str] = set()
+    quote_plus: bool = False
 
     __slots__ = ('scheme', 'user', 'password', 'host', 'tld', 'host_type', 'port', 'path', 'query', 'fragment')
 
@@ -239,18 +241,19 @@ def build(
 
         url = scheme + '://'
         if user:
-            url += user
+            url += cls.quote(user)
         if password:
-            url += ':' + password
+            url += ':' + cls.quote(password)
         if user or password:
             url += '@'
         url += host
         if port and ('port' not in cls.hidden_parts or cls.get_default_parts(parts).get('port') != port):
             url += ':' + port
         if path:
-            url += path
+            url += '/'.join(map(cls.quote, path.split('/')))
         if query:
-            url += '?' + query
+            queries = query.split('&')
+            url += '?' + '&'.join(map(lambda s: '='.join(map(cls.quote, s.split('='))), queries))
         if fragment:
             url += '#' + fragment
         return url
@@ -391,6 +394,10 @@ def apply_default_parts(cls, parts: 'Parts') -> 'Parts':
                 parts[key] = value  # type: ignore[literal-required]
         return parts
 
+    @classmethod
+    def quote(cls, string: str, safe: str = '') -> str:
+        return quote_plus(string, safe) if cls.quote_plus else quote(string, safe)
+
     def __repr__(self) -> str:
         extra = ', '.join(f'{n}={getattr(self, n)!r}' for n in self.__slots__ if getattr(self, n) is not None)
         return f'{self.__class__.__name__}({super().__repr__()}, {extra})'
@@ -558,6 +565,7 @@ def stricturl(
     tld_required: bool = True,
     host_required: bool = True,
     allowed_schemes: Optional[Collection[str]] = None,
+    quote_plus: bool = False,
 ) -> Type[AnyUrl]:
     # use kwargs then define conf in a dict to aid with IDE type hinting
     namespace = dict(
@@ -567,6 +575,7 @@ def stricturl(
         tld_required=tld_required,
         host_required=host_required,
         allowed_schemes=allowed_schemes,
+        quote_plus=quote_plus,
     )
     return type('UrlValue', (AnyUrl,), namespace)
 


Test patch:
diff --git a/tests/test_networks.py b/tests/test_networks.py
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -372,7 +372,7 @@ class Model(BaseModel):
 
 @pytest.mark.parametrize(
     'value',
-    ['file:///foo/bar', 'file://localhost/foo/bar' 'file:////localhost/foo/bar'],
+    ['file:///foo/bar', 'file://localhost/foo/bar', 'file:////localhost/foo/bar'],
 )
 def test_file_url_success(value):
     class Model(BaseModel):
@@ -679,12 +679,31 @@ class Model2(BaseModel):
         (dict(scheme='ws', user='foo', password='x', host='example.net'), 'ws://foo:x@example.net'),
         (dict(scheme='ws', host='example.net', query='a=b', fragment='c=d'), 'ws://example.net?a=b#c=d'),
         (dict(scheme='http', host='example.net', port='1234'), 'http://example.net:1234'),
+        (dict(scheme='http', user='foo@bar', host='example.net'), 'http://foo%40bar@example.net'),
+        (dict(scheme='http', user='foo', password='a b', host='example.net'), 'http://foo:a%20b@example.net'),
+        (dict(scheme='http', host='example.net', query='q=foo bar'), 'http://example.net?q=foo%20bar'),
+        (dict(scheme='http', host='example.net', path="/m&m's"), 'http://example.net/m%26m%27s'),
     ],
 )
 def test_build_url(kwargs, expected):
     assert AnyUrl(None, **kwargs) == expected
 
 
+@pytest.mark.parametrize(
+    'kwargs,expected',
+    [
+        (dict(scheme='https', host='example.com', query='query=my query'), 'https://example.com?query=my+query'),
+        (
+            dict(scheme='https', host='example.com', user='my name', password='a password'),
+            'https://my+name:a+password@example.com',
+        ),
+        (dict(scheme='https', host='example.com', path='/this is a path'), 'https://example.com/this+is+a+path'),
+    ],
+)
+def test_build_url_quote_plus(kwargs, expected):
+    assert stricturl(quote_plus=True).build(**kwargs) == expected
+
+
 @pytest.mark.parametrize(
     'kwargs,expected',
     [