Repository: aio-libs/aiohttp

Issue:
Add a base_url option to ClientSession like httpx does
### Is your feature request related to a problem?

Yes, I would like to have the option to declare a `base_url` to a ClientSession, such that every request use the same `base_url` for that session.

Instead of writing:

```python
async with aiohttp.ClientSession() as session:
    async with session.get('http://python.org') as response:
        print(await response.text())
    async with session.get('http://python.org/foobar') as response:
        print(await response.text())
   async with session.get('http://python.org/foo/foo/bar') as response:
        print(await response.text())
```

Write:

```python
async with aiohttp.ClientSession(base_url='http://python.org') as session:
    async with session.get('/') as response:
        print(await response.text())
    async with session.get('/foobar') as response:
        print(await response.text())
   async with session.get('/foo/foo/bar') as response:
        print(await response.text())
```



### Describe the solution you'd like

Add an `base_url` parameter to ClientSession, like httpx does.

### Describe alternatives you've considered

An alternative is having a constant `BASE = 'http://python.org'` and then say:

```python
async with session.get(f'{BASE}/foobar') as response:
        print(await response.text())
```

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:

Resolving patch:
diff --git a/aiohttp/client.py b/aiohttp/client.py
--- a/aiohttp/client.py
+++ b/aiohttp/client.py
@@ -169,6 +169,7 @@ class ClientSession:
     """First-class interface for making HTTP requests."""
 
     __slots__ = (
+        "_base_url",
         "_source_traceback",
         "_connector",
         "_loop",
@@ -193,6 +194,7 @@ class ClientSession:
 
     def __init__(
         self,
+        base_url: Optional[StrOrURL] = None,
         *,
         connector: Optional[BaseConnector] = None,
         cookies: Optional[LooseCookies] = None,
@@ -216,6 +218,14 @@ def __init__(
         trace_configs: Optional[List[TraceConfig]] = None,
         read_bufsize: int = 2 ** 16,
     ) -> None:
+        if base_url is None or isinstance(base_url, URL):
+            self._base_url: Optional[URL] = base_url
+        else:
+            self._base_url = URL(base_url)
+            assert (
+                self._base_url.origin() == self._base_url
+            ), "Only absolute URLs without path part are supported"
+
         loop = asyncio.get_running_loop()
 
         if connector is None:
@@ -304,6 +314,14 @@ def request(
         """Perform HTTP request."""
         return _RequestContextManager(self._request(method, url, **kwargs))
 
+    def _build_url(self, str_or_url: StrOrURL) -> URL:
+        url = URL(str_or_url)
+        if self._base_url is None:
+            return url
+        else:
+            assert not url.is_absolute() and url.path.startswith("/")
+            return self._base_url.join(url)
+
     async def _request(
         self,
         method: str,
@@ -363,7 +381,7 @@ async def _request(
         proxy_headers = self._prepare_headers(proxy_headers)
 
         try:
-            url = URL(str_or_url)
+            url = self._build_url(str_or_url)
         except ValueError as e:
             raise InvalidURL(str_or_url) from e
 


Test patch:
diff --git a/tests/test_client_session.py b/tests/test_client_session.py
--- a/tests/test_client_session.py
+++ b/tests/test_client_session.py
@@ -706,3 +706,39 @@ async def test_requote_redirect_url_default_disable() -> None:
     session = ClientSession(requote_redirect_url=False)
     assert not session.requote_redirect_url
     await session.close()
+
+
+@pytest.mark.parametrize(
+    ("base_url", "url", "expected_url"),
+    [
+        pytest.param(
+            None,
+            "http://example.com/test",
+            URL("http://example.com/test"),
+            id="base_url=None url='http://example.com/test'",
+        ),
+        pytest.param(
+            None,
+            URL("http://example.com/test"),
+            URL("http://example.com/test"),
+            id="base_url=None url=URL('http://example.com/test')",
+        ),
+        pytest.param(
+            "http://example.com",
+            "/test",
+            URL("http://example.com/test"),
+            id="base_url='http://example.com' url='/test'",
+        ),
+        pytest.param(
+            URL("http://example.com"),
+            "/test",
+            URL("http://example.com/test"),
+            id="base_url=URL('http://example.com') url='/test'",
+        ),
+    ],
+)
+async def test_build_url_returns_expected_url(
+    create_session, base_url, url, expected_url
+) -> None:
+    session = await create_session(base_url)
+    assert session._build_url(url) == expected_url