aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ToxicKidz <[email protected]>2022-02-27 18:59:36 -0500
committerGravatar GitHub <[email protected]>2022-02-27 18:59:36 -0500
commit114db8556dd236fd5ba58061357e7ae1e5e732b2 (patch)
treedd8f7da305c2031080b1f76045ead33d7b110937
parentMerge pull request #32 from python-discord/disnake-migration (diff)
parentfeat: 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.md3
-rw-r--r--botcore/__init__.py3
-rw-r--r--botcore/site_api.py153
-rw-r--r--docs/conf.py1
-rw-r--r--pyproject.toml2
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"