Passed
Push — main ( 0c164a...90232b )
by
unknown
01:54 queued 10s
created

pincer.core.http   A

Complexity

Total Complexity 17

Size/Duplication

Total Lines 218
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 17
eloc 101
dl 0
loc 218
rs 10
c 0
b 0
f 0
1
# -*- coding: utf-8 -*-
2
# MIT License
3
#
4
# Copyright (c) 2021 Pincer
5
#
6
# Permission is hereby granted, free of charge, to any person obtaining
7
# a copy of this software and associated documentation files
8
# (the "Software"), to deal in the Software without restriction,
9
# including without limitation the rights to use, copy, modify, merge,
10
# publish, distribute, sublicense, and/or sell copies of the Software,
11
# and to permit persons to whom the Software is furnished to do so,
12
# subject to the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be
15
# included in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
25
import asyncio
26
import logging
27
from enum import Enum, auto
28
from json import dumps
29
from typing import Dict, Any, Optional, Protocol
30
31
from aiohttp import ClientSession
32
from aiohttp.client import _RequestContextManager
33
from aiohttp.typedefs import StrOrURL
34
35
from pincer import __package__
36
from pincer.exceptions import (
37
    NotFoundError, BadRequestError, NotModifiedError, UnauthorizedError,
38
    ForbiddenError, MethodNotAllowedError, RateLimitError, ServerError,
39
    HTTPError
40
)
41
42
log = logging.getLogger(__package__)
43
44
45
class RequestMethod(Enum):
46
    """HTTP Protocols supported by aiohttp"""
47
48
    DELETE = auto()
49
    GET = auto()
50
    HEAD = auto()
51
    OPTIONS = auto()
52
    PATCH = auto()
53
    POST = auto()
54
    PUT = auto()
55
56
57
class HttpCallable(Protocol):
58
    """aiohttp HTTP method"""
59
60
    def __call__(
61
        self, url: StrOrURL, *,
62
        allow_redirects: bool = True, json: Dict = None, **kwargs: Any
63
    ) -> _RequestContextManager:
64
        pass
65
66
67
class HTTPClient:
68
    """Interacts with Discord API through HTTP protocol"""
69
70
    def __init__(self, token: str, version: int = 9, ttl: int = 5):
71
        """
72
        Instantiate a new HttpApi object.
73
74
        :param token:
75
            Discord API token
76
77
        Keyword Arguments:
78
79
        :param version:
80
            The discord API version.
81
            See `developers/docs/reference#api-versioning`.
82
83
        :param ttl:
84
            Max amount of attempts after error code 5xx
85
        """
86
        self.header: Dict[str, str] = {
87
            "Authorization": f"Bot {token}"
88
        }
89
        self.endpoint: str = f"https://discord.com/api/v{version}"
90
        self.max_ttl: int = ttl
91
        self.__http_exceptions: Dict[int, HTTPError] = {
92
            304: NotModifiedError(),
93
            400: BadRequestError(),
94
            401: UnauthorizedError(),
95
            403: ForbiddenError(),
96
            404: NotFoundError(),
97
            405: MethodNotAllowedError(),
98
            429: RateLimitError()
99
        }
100
101
    async def __send(
102
        self, method: RequestMethod, endpoint: str, *,
103
        __ttl: int = None, data: Optional[Dict] = None
104
    ) -> Dict:
105
        """
106
        Send an api request to the Discord REST API.
107
108
        :param method: The method for the request. (eg GET or POST)
109
        :param endpoint: The endpoint to which the request will be sent.
110
        :param __ttl: Private param used for recursively setting the
111
        retry amount. (Eg set to 1 for 1 max retry)
112
        :param data: The data which will be added to the request.
113
        """
114
        __ttl = __ttl or self.max_ttl
115
116
        if __ttl == 0:
117
            logging.error(
118
                f"{method.value.name} {endpoint} has reached the "
119
                f"maximum retry count of  {self.max_ttl}."
120
            )
121
122
            raise ServerError(f"Maximum amount of retries for `{endpoint}`.")
123
124
        async with ClientSession() as session:
125
            methods: Dict[RequestMethod, HttpCallable] = {
126
                RequestMethod.DELETE: session.delete,
127
                RequestMethod.GET: session.get,
128
                RequestMethod.HEAD: session.head,
129
                RequestMethod.OPTIONS: session.options,
130
                RequestMethod.PATCH: session.patch,
131
                RequestMethod.POST: session.post,
132
                RequestMethod.PUT: session.put,
133
            }
134
135
            sender = methods.get(method)
136
137
            if not sender:
138
                log.debug(
139
                    "Could not find provided RequestMethod "
140
                    f"({method.value.name}) key in `methods` "
141
                    f"[http.py>__send]."
142
                )
143
144
                raise RuntimeError("Unsupported RequestMethod has been passed.")
145
146
<<<<<<< HEAD
0 ignored issues
show
introduced by
invalid syntax (<unknown>, line 146)
Loading history...
147
            log.debug(f"new {method.value.name} {endpoint} | {dumps(data)}")
148
=======
149
            log.debug(f"new {method.value} {route} | {dumps(data)}")
150
>>>>>>> 0c164abce2f21ca144d75c5d59350fd4795cac88
151
152
            async with sender(
153
                f"{self.endpoint}/{endpoint}",
154
                headers=self.header, json=data
155
            ) as res:
156
                if res.ok:
157
                    log.debug(
158
                        "Request has been sent successfully. "
159
                        "Returning json response."
160
                    )
161
162
                    return (
163
                        await res.json()
164
                        if res.content_type == "application/json"
165
                        else {}
166
                    )
167
168
                exception = self.__http_exceptions.get(res.status)
169
170
                if exception:
171
                    log.error(
172
                        f"An http exception occurred while trying to send "
173
                        f"a request to {endpoint}. ({res.status}, {res.reason})"
174
                    )
175
176
                    exception.__init__(res.reason)
177
                    raise exception
178
179
                # status code is guaranteed to be 5xx
180
                retry_in = 1 + (self.max_ttl - __ttl) * 2
181
                log.debug(
182
                    "Server side error occurred with status code "
183
                    f"{res.status}. Retrying in {retry_in}s."
184
                )
185
186
                await asyncio.sleep(retry_in)
187
                await self.__send(method, endpoint, __ttl=__ttl - 1, data=data)
188
189
    async def delete(self, route: str) -> Dict:
190
        """
191
        :return: JSON response from the discord API.
192
        """
193
        return await self.__send(RequestMethod.DELETE, route)
194
195
    async def get(self, route: str) -> Dict:
196
        """
197
        :return: JSON response from the discord API.
198
        """
199
        return await self.__send(RequestMethod.GET, route)
200
201
    async def head(self, route: str) -> Dict:
202
        """
203
        :return: JSON response from the discord API.
204
        """
205
        return await self.__send(RequestMethod.HEAD, route)
206
207
    async def options(self, route: str) -> Dict:
208
        """
209
        :return: JSON response from the discord API.
210
        """
211
        return await self.__send(RequestMethod.OPTIONS, route)
212
213
    async def patch(self, route: str, data: Dict) -> Dict:
214
        """
215
        :return: JSON response from the discord API.
216
        """
217
        return await self.__send(RequestMethod.PATCH, route, data=data)
218
219
    async def post(self, route: str, data: Dict) -> Dict:
220
        """
221
        :return: JSON response from the discord API.
222
        """
223
        return await self.__send(RequestMethod.POST, route, data=data)
224
225
    async def put(self, route: str, data: Dict) -> Dict:
226
        """
227
        :return: JSON response from the discord API.
228
        """
229
        return await self.__send(RequestMethod.PUT, route, data=data)
230