diff options
author | 2022-02-27 18:59:36 -0500 | |
---|---|---|
committer | 2022-02-27 18:59:36 -0500 | |
commit | 114db8556dd236fd5ba58061357e7ae1e5e732b2 (patch) | |
tree | dd8f7da305c2031080b1f76045ead33d7b110937 | |
parent | Merge pull request #32 from python-discord/disnake-migration (diff) | |
parent | feat: Port the Site API wrapper from the bot repo (diff) |
Merge pull request #34 from python-discord/feat/site-api-wrapperv2.1.0
Port the Site API wrapper from the bot repo
-rw-r--r-- | CHANGELOG.md | 3 | ||||
-rw-r--r-- | botcore/__init__.py | 3 | ||||
-rw-r--r-- | botcore/site_api.py | 153 | ||||
-rw-r--r-- | docs/conf.py | 1 | ||||
-rw-r--r-- | pyproject.toml | 2 |
5 files changed, 160 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 918cf45c..67aca72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2.1.0 24th February 2022 + - Feature: Port the Site API wrapper from the bot repo. + ## 2.0.0 22nd February 2022 - Breaking: Moved regex to botcore.utils namespace - Feature: Migrate from discord.py 2.0a0 to disnake. diff --git a/botcore/__init__.py b/botcore/__init__.py index a5835306..7d3803f3 100644 --- a/botcore/__init__.py +++ b/botcore/__init__.py @@ -1,10 +1,11 @@ """Useful utilities and tools for Discord bot development.""" -from botcore import exts, utils +from botcore import exts, site_api, utils __all__ = [ exts, utils, + site_api, ] __all__ = list(map(lambda module: module.__name__, __all__)) diff --git a/botcore/site_api.py b/botcore/site_api.py new file mode 100644 index 00000000..ad72b104 --- /dev/null +++ b/botcore/site_api.py @@ -0,0 +1,153 @@ +"""An API wrapper around the Site API.""" + +import asyncio +from typing import Optional +from urllib.parse import quote as quote_url + +import aiohttp + +from botcore.utils.logging import get_logger + +log = get_logger(__name__) + + +class ResponseCodeError(ValueError): + """Raised in :meth:`APIClient.request` when a non-OK HTTP response is received.""" + + def __init__( + self, + response: aiohttp.ClientResponse, + response_json: Optional[dict] = None, + response_text: Optional[str] = None + ): + """ + Initialize a new :obj:`ResponseCodeError` instance. + + Args: + response (:obj:`aiohttp.ClientResponse`): The response object from the request. + response_json: The JSON response returned from the request, if any. + request_text: The text of the request, if any. + """ + self.status = response.status + self.response_json = response_json or {} + self.response_text = response_text + self.response = response + + def __str__(self): + """Return a string representation of the error.""" + response = self.response_json or self.response_text + return f"Status: {self.status} Response: {response}" + + +class APIClient: + """A wrapper for the Django Site API.""" + + session: Optional[aiohttp.ClientSession] = None + loop: asyncio.AbstractEventLoop = None + + def __init__(self, site_api_url: str, site_api_token: str, **session_kwargs): + """ + Initialize a new :obj:`APIClient` instance. + + Args: + site_api_url: The URL of the site API. + site_api_token: The token to use for authentication. + session_kwargs: Keyword arguments to pass to the :obj:`aiohttp.ClientSession` constructor. + """ + self.site_api_url = site_api_url + + auth_headers = { + 'Authorization': f"Token {site_api_token}" + } + + if 'headers' in session_kwargs: + session_kwargs['headers'].update(auth_headers) + else: + session_kwargs['headers'] = auth_headers + + # aiohttp will complain if APIClient gets instantiated outside a coroutine. Thankfully, we + # don't and shouldn't need to do that, so we can avoid scheduling a task to create it. + self.session = aiohttp.ClientSession(**session_kwargs) + + def _url_for(self, endpoint: str) -> str: + return f"{self.site_api_url}/{quote_url(endpoint)}" + + async def close(self) -> None: + """Close the aiohttp session.""" + await self.session.close() + + async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: + """ + Raise :exc:`ResponseCodeError` for non-OK response if an exception should be raised. + + Args: + response (:obj:`aiohttp.ClientResponse`): The response to check. + should_raise: Whether or not to raise an exception. + + Raises: + :exc:`ResponseCodeError`: + If the response is not OK and ``should_raise`` is True. + """ + if should_raise and response.status >= 400: + try: + response_json = await response.json() + raise ResponseCodeError(response=response, response_json=response_json) + except aiohttp.ContentTypeError: + response_text = await response.text() + raise ResponseCodeError(response=response, response_text=response_text) + + async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """ + Send an HTTP request to the site API and return the JSON response. + + Args: + method: The HTTP method to use. + endpoint: The endpoint to send the request to. + raise_for_status: Whether or not to raise an exception if the response is not OK. + **kwargs: Any extra keyword arguments to pass to :func:`aiohttp.request`. + + Returns: + The JSON response the API returns. + + Raises: + :exc:`ResponseCodeError`: + If the response is not OK and ``raise_for_status`` is True. + """ + async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: + await self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Equivalent to :meth:`APIClient.request` with GET passed as the method.""" + return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs) + + async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Equivalent to :meth:`APIClient.request` with PATCH passed as the method.""" + return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs) + + async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Equivalent to :meth:`APIClient.request` with POST passed as the method.""" + return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs) + + async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Equivalent to :meth:`APIClient.request` with PUT passed as the method.""" + return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs) + + async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: + """ + Send a DELETE request to the site API and return the JSON response. + + Args: + endpoint: The endpoint to send the request to. + raise_for_status: Whether or not to raise an exception if the response is not OK. + **kwargs: Any extra keyword arguments to pass to :func:`aiohttp.request`. + + Returns: + The JSON response the API returns, or None if the response is 204 No Content. + """ + async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: + if resp.status == 204: + return None + + await self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() diff --git a/docs/conf.py b/docs/conf.py index 52eaf60a..6ea701e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -131,6 +131,7 @@ extlinks = { intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "disnake": ("https://docs.disnake.dev/en/latest/", None), + "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), } diff --git a/pyproject.toml b/pyproject.toml index 34366568..c003cfc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bot-core" -version = "2.0.0" +version = "2.1.0" description = "Bot-Core provides the core functionality and utilities for the bots of the Python Discord community." authors = ["Python Discord <[email protected]>"] license = "MIT" |