diff --git a/docs/api.rst b/docs/api.rst index 089d721..7d5f840 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -53,5 +53,8 @@ Exceptions .. autoclass:: flask_discord.HttpException :members: +.. autoclass:: flask_discord.RateLimited + :members: + .. autoclass:: flask_discord.Unauthorized :members: diff --git a/docs/conf.py b/docs/conf.py index c6011f6..7383427 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,13 @@ extensions = [ 'pallets_sphinx_themes', ] +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'flask': ('https://flask.palletsprojects.com/en/1.1.x/', None), + 'cachetools': ('https://cachetools.readthedocs.io/en/stable/', None), + 'requests_oauthlib': ('https://requests-oauthlib.readthedocs.io/en/latest/', None) +} + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index d037e27..b16e025 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ Discord's OAuth2 API easier. - Clean object-oriented design. - Covers most of the scopes provided by the API. - Supports various discord models and objects. +- An internal smart caching layer to increase the performance. Contents diff --git a/flask_discord/__init__.py b/flask_discord/__init__.py index b6164b5..a02857e 100644 --- a/flask_discord/__init__.py +++ b/flask_discord/__init__.py @@ -9,8 +9,9 @@ __all__ = [ "requires_authorization", "HttpException", + "RateLimited", "Unauthorized", ] -__version__ = "0.1.11" +__version__ = "0.1.50" diff --git a/flask_discord/_http.py b/flask_discord/_http.py index 848ef5a..891bb39 100644 --- a/flask_discord/_http.py +++ b/flask_discord/_http.py @@ -1,3 +1,7 @@ +import cachetools +import requests +import typing +import json import os import abc @@ -5,6 +9,7 @@ from . import configs from . import exceptions from flask import session +from collections.abc import Mapping from requests_oauthlib import OAuth2Session @@ -21,22 +26,46 @@ class DiscordOAuth2HttpClient(abc.ABC): The client secret of discord application provided. redirect_uri : str The default URL to use to redirect user to after authorization. + users_cache : cachetools.LFUCache + Any dict like mapping to internally cache the authorized users. Preferably an instance of + cachetools.LFUCache or cachetools.TTLCache. If not specified, default cachetools.LFUCache is used. + Uses the default max limit for cache if ``DISCORD_USERS_CACHE_MAX_LIMIT`` isn't specified in app config. """ SESSION_KEYS = [ + "DISCORD_USER_ID", "DISCORD_OAUTH2_STATE", "DISCORD_OAUTH2_TOKEN", ] - def __init__(self, app): + def __init__(self, app, users_cache=None): self.client_id = app.config["DISCORD_CLIENT_ID"] self.client_secret = app.config["DISCORD_CLIENT_SECRET"] self.redirect_uri = app.config["DISCORD_REDIRECT_URI"] + self.users_cache = cachetools.LFUCache( + app.config.get("DISCORD_USERS_CACHE_MAX_LIMIT", configs.DISCORD_USERS_CACHE_DEFAULT_MAX_LIMIT) + ) if users_cache is None else users_cache + if not issubclass(self.users_cache.__class__, Mapping): + raise ValueError("Instance users_cache must be a mapping like object.") if "http://" in self.redirect_uri: os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true" app.discord = self + @property + def user_id(self) -> typing.Union[int, None]: + """A property which returns Discord user ID if it exists in flask :py:attr:`flask.session` object. + + Returns + ------- + int + The Discord user ID of current user. + None + If the user ID doesn't exists in flask :py:attr:`flask.session`. + + """ + return session.get("DISCORD_USER_ID") + @staticmethod def _token_updater(token): session["DISCORD_OAUTH2_TOKEN"] = token @@ -63,7 +92,7 @@ class DiscordOAuth2HttpClient(abc.ABC): return OAuth2Session( client_id=self.client_id, token=token or session.get("DISCORD_OAUTH2_TOKEN"), - state=state, + state=state or session.get("DISCORD_OAUTH2_STATE"), scope=scope, redirect_uri=self.redirect_uri, auto_refresh_kwargs={ @@ -73,7 +102,7 @@ class DiscordOAuth2HttpClient(abc.ABC): auto_refresh_url=configs.DISCORD_TOKEN_URL, token_updater=self._token_updater) - def request(self, route: str, method="GET", data=None, **kwargs) -> dict: + def request(self, route: str, method="GET", data=None, oauth=True, **kwargs) -> typing.Union[dict, str]: """Sends HTTP request to provided route or discord endpoint. Note @@ -88,21 +117,33 @@ class DiscordOAuth2HttpClient(abc.ABC): Specify the HTTP method to use to perform this request. data : dict, optional The optional payload the include with the request. + oauth : bool + A boolean determining if this should be Discord OAuth2 session request or any standard request. Returns ------- - dict - Dictionary containing received from sent HTTP GET request. + dict, str + Dictionary containing received from sent HTTP GET request if content-type is ``application/json`` + otherwise returns raw text content of the response. Raises ------ flask_discord.Unauthorized Raises :py:class:`flask_discord.Unauthorized` if current user is not authorized. + flask_discord.RateLimited + Raises an instance of :py:class:`flask_discord.RateLimited` if application is being rate limited by Discord. """ - response = self._make_session().request(method, configs.DISCORD_API_BASE_URL + route, data, **kwargs) + route = configs.DISCORD_API_BASE_URL + route + response = self._make_session( + ).request(method, route, data, **kwargs) if oauth else requests.request(method, route, data=data, **kwargs) if response.status_code == 401: raise exceptions.Unauthorized + if response.status_code == 429: + raise exceptions.RateLimited(response) - return response.json() + try: + return response.json() + except json.JSONDecodeError: + return response.text diff --git a/flask_discord/client.py b/flask_discord/client.py index 83241d7..ec25d02 100644 --- a/flask_discord/client.py +++ b/flask_discord/client.py @@ -58,9 +58,13 @@ class DiscordOAuth2Session(_http.DiscordOAuth2HttpClient): def revoke(self): """This method clears current discord token, state and all session data from flask `session `_. Which means user will have - to go through discord authorization token grant flow again. + to go through discord authorization token grant flow again. Also tries to remove the user from internal + cache if they exist. """ + + self.users_cache.pop(self.user_id, None) + for session_key in self.SESSION_KEYS: try: session.pop(session_key) @@ -72,18 +76,21 @@ class DiscordOAuth2Session(_http.DiscordOAuth2HttpClient): """A boolean indicating whether current session has authorization token or not.""" return self._make_session().authorized - def fetch_user(self) -> models.User: - """This method requests for data of current user from discord and returns user object. + @staticmethod + def fetch_user() -> models.User: + """This method returns user object from the internal cache if it exists otherwise makes an API call to do so. Returns ------- flask_discord.models.User """ - return models.User(self.request("/users/@me")) + return models.User.get_from_cache() or models.User.fetch_from_api() - def fetch_connections(self) -> list: - """Requests and returns connections of current user from discord. + @staticmethod + def fetch_connections() -> list: + """This method returns list of user connection objects from internal cache if it exists otherwise + makes an API call to do so. Returns ------- @@ -91,11 +98,19 @@ class DiscordOAuth2Session(_http.DiscordOAuth2HttpClient): List of :py:class:`flask_discord.models.UserConnection` objects. """ - connections_payload = self.request("/users/@me/connections") - return [models.UserConnection(payload) for payload in connections_payload] + user = models.User.get_from_cache() + try: + if user.connections is not None: + return user.connections + except AttributeError: + pass - def fetch_guilds(self) -> list: - """Requests and returns guilds of current user from discord. + return models.UserConnection.fetch_from_api() + + @staticmethod + def fetch_guilds() -> list: + """This method returns list of guild objects from internal cache if it exists otherwise makes an API + call to do so. Returns ------- @@ -103,5 +118,11 @@ class DiscordOAuth2Session(_http.DiscordOAuth2HttpClient): List of :py:class:`flask_discord.models.Guild` objects. """ - guilds_payload = self.request("/users/@me/guilds") - return [models.Guild(payload) for payload in guilds_payload] + user = models.User.get_from_cache() + try: + if user.guilds is not None: + return user.guilds + except AttributeError: + pass + + return models.Guild.fetch_from_api() diff --git a/flask_discord/configs.py b/flask_discord/configs.py index 63736cf..ab3a4aa 100644 --- a/flask_discord/configs.py +++ b/flask_discord/configs.py @@ -19,3 +19,5 @@ DISCORD_IMAGE_FORMAT = "png" DISCORD_ANIMATED_IMAGE_FORMAT = "gif" DISCORD_USER_AVATAR_BASE_URL = DISCORD_IMAGE_BASE_URL + "avatars/{user_id}/{avatar_hash}.{format}" DISCORD_GUILD_ICON_BASE_URL = DISCORD_IMAGE_BASE_URL + "icons/{guild_id}/{icon_hash}.png" + +DISCORD_USERS_CACHE_DEFAULT_MAX_LIMIT = 100 diff --git a/flask_discord/exceptions.py b/flask_discord/exceptions.py index ef47d38..58407b6 100644 --- a/flask_discord/exceptions.py +++ b/flask_discord/exceptions.py @@ -1,6 +1,44 @@ +import json + + class HttpException(Exception): """Base Exception class representing a HTTP exception.""" +class RateLimited(HttpException): + """A HTTP Exception raised when the application is being rate limited. + It provides the ``response`` attribute which can be used to get more details of the actual response from + the Discord API with few more shorthands to ``response.json()``. + + Attributes + ---------- + response : requests.Response + The actual response object received from Discord API. + json : dict + The actual JSON data received. Shorthand to ``response.json()``. + message : str + A message saying you are being rate limited. + retry_after : int + The number of milliseconds to wait before submitting another request. + is_global : bool + A value indicating if you are being globally rate limited or not + + """ + + def __init__(self, response): + self.response = response + try: + self.json = self.response.json() + except json.JSONDecodeError: + self.json = dict() + self.message = self.response.text + else: + self.message = self.json["message"] + self.is_global = self.json["global"] + self.retry_after = self.json["retry_after"] + finally: + super().__init__(self.message) + + class Unauthorized(HttpException): """A HTTP Exception raised when user is not authorized.""" diff --git a/flask_discord/models/__init__.py b/flask_discord/models/__init__.py index 6cc439f..c3839d3 100644 --- a/flask_discord/models/__init__.py +++ b/flask_discord/models/__init__.py @@ -1,6 +1,7 @@ -from .guild import Guild +from .connections import UserConnection +from .integration import Integration from .user import User, Bot -from .connections import UserConnection, Integration +from .guild import Guild __all__ = [ @@ -8,5 +9,4 @@ __all__ = [ "User", "Bot", "UserConnection", - "Integration", ] diff --git a/flask_discord/models/base.py b/flask_discord/models/base.py index c40689e..aaa43e6 100644 --- a/flask_discord/models/base.py +++ b/flask_discord/models/base.py @@ -1,7 +1,50 @@ -from abc import ABC +from flask import current_app +from abc import ABCMeta, abstractmethod -class DiscordModelsBase(ABC): +class DiscordModelsMeta(ABCMeta): + + ROUTE = str() + + def __init__(cls, name, *args, **kwargs): + if not cls.ROUTE and name != "DiscordModelsBase": + raise NotImplementedError(f"ROUTE must be specified in a Discord model: {name}.") + super().__init__(name, *args, **kwargs) + + +class DiscordModelsBase(metaclass=DiscordModelsMeta): + + MANY = False + + @abstractmethod + def __init__(self, payload): + self._payload = payload + + @staticmethod + def _request(*args, **kwargs): + """A shorthand to :py:func:flask_discord.request`. It uses Flask current_app local proxy to get the + Flask-Discord client. + + """ + return current_app.discord.request(*args, **kwargs) + + @classmethod + def fetch_from_api(cls): + """A class method which returns an instance or list of instances of this model by implicitly making an + API call to Discord. + + Returns + ------- + cls + An instance of this model itself. + [cls, ...] + List of instances of this model when many of these models exist. + + """ + payload = cls._request(cls.ROUTE) + if cls.MANY: + return [cls(_) for _ in payload] + return cls(payload) def to_json(self): """A utility method which returns raw payload object as it was received from discord. diff --git a/flask_discord/models/connections.py b/flask_discord/models/connections.py index f6b4b40..9182ce1 100644 --- a/flask_discord/models/connections.py +++ b/flask_discord/models/connections.py @@ -1,55 +1,10 @@ +from flask import current_app + from .base import DiscordModelsBase - -from .user import User +from .integration import Integration -class Integration(DiscordModelsBase): - """"Class representing discord server integrations. - - Attributes - ---------- - id : int - Integration ID. - name : str - Name of integration. - type : str - Integration type (twitch, youtube, etc). - enabled : bool - A boolean representing if this integration is enabled. - syncing : bool - A boolean representing if this integration is syncing. - role_id : int - ID that this integration uses for subscribers. - expire_behaviour : int - An integer representing the behaviour of expiring subscribers. - expire_grace_period : int - An integer representing the grace period before expiring subscribers. - user : User - Object representing user of this integration. - account : dict - A dictionary representing raw - `account `_ object. - synced_at : ISO8601 timestamp - Representing when this integration was last synced. - - """ - - def __init__(self, payload): - self._payload = payload - self.id = int(self._payload.get("id", 0)) - self.name = self._payload.get("name") - self.type = self._payload.get("type") - self.enabled = self._payload.get("enabled") - self.syncing = self._payload.get("syncing") - self.role_id = int(self._payload.get("role_id", 0)) - self.expire_behaviour = self._payload.get("expire_behaviour") - self.expire_grace_period = self._payload.get("expire_grace_period") - self.user = User(self._payload.get("user", dict())) - self.account = self._payload.get("account") - self.synced_at = self._payload.get("synced_at") - - -class UserConnection(object): +class UserConnection(DiscordModelsBase): """Class representing connections in discord account of the user. Attributes @@ -78,8 +33,11 @@ class UserConnection(object): """ + MANY = True + ROUTE = "/users/@me/connections" + def __init__(self, payload): - self._payload = payload + super().__init__(payload) self.id = self._payload["id"] self.name = self._payload.get("name") self.type = self._payload.get("type") @@ -97,3 +55,32 @@ class UserConnection(object): def is_visible(self): """A property returning bool if this integration is visible to everyone.""" return bool(self.visibility) + + @classmethod + def fetch_from_api(cls, cache=True): + """A class method which returns an instance or list of instances of this model by implicitly making an + API call to Discord. If an instance of :py:class:`flask_discord.User` exists in the users internal cache + who are attached to these connections then, the cached property :py:attr:`flask_discord.User.connections` + is updated. + + Parameters + ---------- + cache : bool + Determines if the :py:attr:`flask_discord.User.guilds` cache should be updated with the new guilds. + + Returns + ------- + list[flask_discord.UserConnection, ...] + List of instances of :py:class:`flask_discord.UserConnection` to which this user belongs. + + """ + connections = super().fetch_from_api() + + if cache: + user = current_app.discord.users_cache.get(current_app.discord.user_id) + try: + user.connections = connections + except AttributeError: + pass + + return connections diff --git a/flask_discord/models/guild.py b/flask_discord/models/guild.py index f8dc6f3..6069baf 100644 --- a/flask_discord/models/guild.py +++ b/flask_discord/models/guild.py @@ -1,5 +1,5 @@ from .base import DiscordModelsBase - +from flask import current_app from .. import configs @@ -21,8 +21,11 @@ class Guild(DiscordModelsBase): """ + MANY = True + ROUTE = "/users/@me/guilds" + def __init__(self, payload): - self._payload = payload + super().__init__(payload) self.id = int(self._payload["id"]) self.name = self._payload["name"] self.icon_hash = self._payload.get("icon") @@ -38,3 +41,31 @@ class Guild(DiscordModelsBase): if not self.icon_hash: return return configs.DISCORD_GUILD_ICON_BASE_URL.format(guild_id=self.id, icon_hash=self.icon_hash) + + @classmethod + def fetch_from_api(cls, cache=True): + """A class method which returns an instance or list of instances of this model by implicitly making an + API call to Discord. If an instance of :py:class:`flask_discord.User` exists in the users internal cache + who belongs to these guilds then, the cached property :py:attr:`flask_discord.User.guilds` is updated. + + Parameters + ---------- + cache : bool + Determines if the :py:attr:`flask_discord.User.guilds` cache should be updated with the new guilds. + + Returns + ------- + list[flask_discord.Guild, ...] + List of instances of :py:class:`flask_discord.Guild` to which this user belongs. + + """ + guilds = super().fetch_from_api() + + if cache: + user = current_app.discord.users_cache.get(current_app.discord.user_id) + try: + user.guilds = {guild.id: guild for guild in guilds} + except AttributeError: + pass + + return guilds diff --git a/flask_discord/models/integration.py b/flask_discord/models/integration.py new file mode 100644 index 0000000..f44958a --- /dev/null +++ b/flask_discord/models/integration.py @@ -0,0 +1,42 @@ +class Integration(object): + """"Class representing discord server integrations. + + Attributes + ---------- + id : int + Integration ID. + name : str + Name of integration. + type : str + Integration type (twitch, youtube, etc). + enabled : bool + A boolean representing if this integration is enabled. + syncing : bool + A boolean representing if this integration is syncing. + role_id : int + ID that this integration uses for subscribers. + expire_behaviour : int + An integer representing the behaviour of expiring subscribers. + expire_grace_period : int + An integer representing the grace period before expiring subscribers. + account : dict + A dictionary representing raw + `account `_ object. + synced_at : ISO8601 timestamp + Representing when this integration was last synced. + + """ + + def __init__(self, payload): + self._payload = payload + self.id = int(self._payload.get("id", 0)) + self.name = self._payload.get("name") + self.type = self._payload.get("type") + self.enabled = self._payload.get("enabled") + self.syncing = self._payload.get("syncing") + self.role_id = int(self._payload.get("role_id", 0)) + self.expire_behaviour = self._payload.get("expire_behaviour") + self.expire_grace_period = self._payload.get("expire_grace_period") + # self.user = User(self._payload.get("user", dict())) + self.account = self._payload.get("account") + self.synced_at = self._payload.get("synced_at") diff --git a/flask_discord/models/user.py b/flask_discord/models/user.py index 3f60f97..0d67362 100644 --- a/flask_discord/models/user.py +++ b/flask_discord/models/user.py @@ -1,9 +1,9 @@ -from flask import current_app, session - -import requests +from .. import configs +from .guild import Guild from .base import DiscordModelsBase -from .. import configs, exceptions +from flask import current_app, session +from .connections import UserConnection class User(DiscordModelsBase): @@ -35,11 +35,15 @@ class User(DiscordModelsBase): premium_type : int An integer representing the `type of nitro subscription `_. + connections : list + A list of :py:class:`flask_discord.UserConnection` instances. These are cached and this list might be empty. """ + ROUTE = "/users/@me" + def __init__(self, payload): - self._payload = payload + super().__init__(payload) self.id = int(self._payload["id"]) self.username = self._payload["username"] self.discriminator = self._payload["discriminator"] @@ -52,6 +56,25 @@ class User(DiscordModelsBase): self.flags = self._payload.get("flags") self.premium_type = self._payload.get("premium_type") + # Few properties which are intended to be cached. + self._guilds = None # Mapping of guild ID to flask_discord.models.Guild(...). + self.connections = None # List of flask_discord.models.UserConnection(...). + + @property + def guilds(self): + """A cached mapping of user's guild ID to :py:class:`flask_discord.Guild`. The guilds are cached when the first + API call for guilds is requested so it might be an empty dict. + + """ + try: + return list(self._guilds.values()) + except AttributeError: + pass + + @guilds.setter + def guilds(self, value): + self._guilds = value + def __str__(self): return f"{self.name}#{self.discriminator}" @@ -73,6 +96,51 @@ class User(DiscordModelsBase): """A boolean representing if avatar of user is animated. Meaning user has GIF avatar.""" return self.avatar_hash.startswith("a_") + @classmethod + def fetch_from_api(cls, guilds=False, connections=False): + """A class method which returns an instance of this model by implicitly making an + API call to Discord. The user returned from API will always be cached and update in internal cache. + + Parameters + ---------- + guilds : bool + A boolean indicating if user's guilds should be cached or not. Defaults to ``False``. If chose to not + cache, user's guilds can always be obtained from :py:func:`flask_discord.Guilds.fetch_from_api()`. + connections : bool + A boolean indicating if user's connections should be cached or not. Defaults to ``False``. If chose to not + cache, user's connections can always be obtained from :py:func:`flask_discord.Connections.fetch_from_api()`. + + Returns + ------- + cls + An instance of this model itself. + [cls, ...] + List of instances of this model when many of these models exist.""" + self = super().fetch_from_api() + current_app.discord.users_cache.update({self.id: self}) + session["DISCORD_USER_ID"] = self.id + + if guilds: + self.fetch_guilds() + if connections: + self.fetch_connections() + + return self + + @classmethod + def get_from_cache(cls): + """A class method which returns an instance of this model if it exists in internal cache. + + Returns + ------- + flask_discord.User + An user instance if it exists in internal cache. + None + If the current doesn't exists in internal cache. + + """ + return current_app.discord.users_cache.get(current_app.discord.user_id) + def add_to_guild(self, guild_id) -> dict: """Method to add user to the guild, provided OAuth2 session has already been created with ``guilds.join`` scope. @@ -92,19 +160,39 @@ class User(DiscordModelsBase): Raises :py:class:`flask_discord.Unauthorized` if current user is not authorized. """ - route = configs.DISCORD_API_BASE_URL + f"/guilds/{guild_id}/members/{self.id}" data = {"access_token": session["DISCORD_OAUTH2_TOKEN"]["access_token"]} headers = {"Authorization": f"Bot {current_app.config['DISCORD_BOT_TOKEN']}"} - response = requests.put(route, json=data, headers=headers) + return self._request( + f"/guilds/{guild_id}/members/{self.id}", method="PUT", oauth=False, json=data, headers=headers + ) or dict() - if response.status_code == 401: - raise exceptions.Unauthorized + def fetch_guilds(self) -> list: + """A method which makes an API call to Discord to get user's guilds. It prepares the internal guilds cache + and returns list of all guilds the user is member of. - if response.status_code == 204: - return dict() + Returns + ------- + list + List of :py:class:`flask_discord.Guilds` instances. - return response.json() + """ + self._guilds = {guild.id: guild for guild in Guild.fetch_from_api(cache=False)} + return self.guilds + + def fetch_connections(self) -> list: + """A method which makes an API call to Discord to get user's connections. It prepares the internal connection + cache and returns list of all connection instances. + + Returns + ------- + list + A list of :py:class:`flask_discord.UserConnection` instances. + + """ + self.connections = UserConnection.fetch_from_api(cache=False) + return self.connections class Bot(User): """Class representing the client user itself.""" + # TODO: What is this? diff --git a/requirements.txt b/requirements.txt index 224cd10..08d7a6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask +cachetools requests_oauthlib \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 1edeba1..9987640 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,15 +16,30 @@ app.config["DISCORD_REDIRECT_URI"] = "http://127.0.0.1:5000/callback" discord = DiscordOAuth2Session(app) +HYPERLINK = '{}' + + @app.route("/") def index(): + if not discord.authorized: + return HYPERLINK.format(url_for(".login"), "Login") + return f""" + {HYPERLINK.format(url_for(".me"), "@ME")}
+ {HYPERLINK.format(url_for(".logout"), "Logout")}
+ {HYPERLINK.format(url_for(".user_guilds"), "My Servers")}
+ {HYPERLINK.format(url_for(".add_to_guild", guild_id=475549041741135881), "Add me to 475549041741135881.")} + """ + + +@app.route("/login/") +def login(): return discord.create_session() @app.route("/callback/") def callback(): discord.callback() - return redirect(url_for(".me")) + return redirect(url_for(".index")) @app.route("/me/")