Skip to content

Commit

Permalink
Add the start of OAuth functionality..
Browse files Browse the repository at this point in the history
  • Loading branch information
EvieePy committed Feb 9, 2024
1 parent 85a30f8 commit 43e9055
Show file tree
Hide file tree
Showing 10 changed files with 283 additions and 15 deletions.
2 changes: 1 addition & 1 deletion twitchio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
__copyright__ = "Copyright 2017-Present (c) TwitchIO, PythonistaGuild"
__version__ = "3.0.0a"


from . import authentication as authentication
from .exceptions import *
from .http import HTTPClient as HTTPClient
26 changes: 26 additions & 0 deletions twitchio/authentication/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
MIT License
Copyright (c) 2017 - Present PythonistaGuild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

from .oauth import OAuth as OAuth
from .payloads import *
68 changes: 68 additions & 0 deletions twitchio/authentication/oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
MIT License
Copyright (c) 2017 - Present PythonistaGuild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from __future__ import annotations

from typing import TYPE_CHECKING

from ..http import HTTPClient
from .payloads import *


if TYPE_CHECKING:
from ..types_.responses import RefreshTokenResponse, ValidateTokenResponse


class OAuth(HTTPClient):
def __init__(self, *, client_id: str, client_secret: str) -> None:
super().__init__()

self.client_id = client_id
self.client_secret = client_secret

async def validate_token(self, token: str, /) -> ValidateTokenPayload:
token = token.removeprefix("Bearer ").removeprefix("OAuth ")

headers: dict[str, str] = {"Authorization": f"OAuth {token}"}
data: ValidateTokenResponse = await self.request_json("GET", "/oauth2/validate", use_id=True, headers=headers)

return ValidateTokenPayload(data)

async def refresh_token(self, refresh_token: str, /) -> RefreshTokenPayload:
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}

params: dict[str, str] = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
}

data: RefreshTokenResponse = await self.request_json(
"POST", "/oauth2/token", use_id=True, headers=headers, params=params
)

return RefreshTokenPayload(data)

async def revoke_token(self, token: str, /) -> ...:
raise NotImplementedError
79 changes: 79 additions & 0 deletions twitchio/authentication/payloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
MIT License
Copyright (c) 2017 - Present PythonistaGuild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from __future__ import annotations

from collections.abc import Iterator, Mapping
from typing import TYPE_CHECKING, Any


if TYPE_CHECKING:
from ..types_.responses import *


__all__ = (
"RefreshTokenPayload",
"ValidateTokenPayload",
)


class BasePayload(Mapping[str, Any]):
__slots__ = ("raw_data",)

def __init__(self, raw: OAuthResponses, /) -> None:
self.raw_data = raw

def __getitem__(self, key: str) -> Any:
return self.raw_data[key] # type: ignore

def __iter__(self) -> Iterator[str]:
return iter(self.raw_data)

def __len__(self) -> int:
return len(self.raw_data)


class RefreshTokenPayload(BasePayload):
__slots__ = ("access_token", "refresh_token", "expires_in", "scope", "token_type")

def __init__(self, raw: RefreshTokenResponse, /) -> None:
super().__init__(raw)

self.access_token: str = raw["access_token"]
self.refresh_token: str = raw["refresh_token"]
self.expires_in: int = raw["expires_in"]
self.scope: str | list[str] = raw["scope"]
self.token_type: str = raw["token_type"]


class ValidateTokenPayload(BasePayload):
__slots__ = ("client_id", "login", "scopes", "user_id", "expires_in")

def __init__(self, raw: ValidateTokenResponse, /) -> None:
super().__init__(raw)

self.client_id: str = raw["client_id"]
self.login: str = raw["login"]
self.scopes: list[str] = raw["scopes"]
self.user_id: str = raw["user_id"]
self.expires_in: int = raw["expires_in"]
23 changes: 23 additions & 0 deletions twitchio/authentication/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
MIT License
Copyright (c) 2017 - Present PythonistaGuild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
5 changes: 4 additions & 1 deletion twitchio/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,17 @@

class TwitchioException(Exception):
"""Base exception for TwitchIO."""

# TODO: Document this class.


class TwitchioHTTPException(TwitchioException):
"""Exception raised when an HTTP request fails."""

# TODO: Document this class.
# TODO: Add extra attributes to this class. E.g. response, status, etc.

def __init__(self, msg: str = "", /, *, route: Route | None = None) -> None:
def __init__(self, msg: str = "", /, *, route: Route | None = None, status: int) -> None:
self.route = route
self.status = status
super().__init__(msg)
40 changes: 30 additions & 10 deletions twitchio/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
if TYPE_CHECKING:
from typing_extensions import Unpack

from .types_ import APIRequest, HTTPMethod
from .types_.requests import APIRequest, HTTPMethod


logger: logging.Logger = logging.getLogger(__name__)
Expand All @@ -61,7 +61,9 @@ class Route:
BASE: str = "https://api.twitch.tv/helix/"
ID_BASE: str = "https://id.twitch.tv/"

def __init__(self, method: HTTPMethod, endpoint: str, *, use_id: bool = False, **kwargs: Unpack[APIRequest]) -> None:
def __init__(
self, method: HTTPMethod, endpoint: str, *, use_id: bool = False, **kwargs: Unpack[APIRequest]
) -> None:
self.method = method

endpoint = endpoint.removeprefix("/")
Expand All @@ -82,7 +84,6 @@ def __str__(self) -> str:


class HTTPClient:

__slots__ = ("__session", "user_agent")

def __init__(self) -> None:
Expand All @@ -97,25 +98,29 @@ async def _init_session(self) -> None:
if self.__session and not self.__session.closed:
return

logger.debug("Initialising a new session on HTTPClient.")
logger.debug("Initialising a new session on %s.", self.__class__.__qualname__)
self.__session = aiohttp.ClientSession()

def clear(self) -> None:
if self.__session and self.__session.closed:
logger.debug("Clearing HTTPClient session. A new session will be created on the next request.")
logger.debug(
"Clearing %s session. A new session will be created on the next request.", self.__class__.__qualname__
)
self.__session = None

async def close(self) -> None:
if self.__session and not self.__session.closed:
try:
await self.__session.close()
except Exception as e:
logger.debug("Ignoring exception caught while closing HTTPClient session: %s.", e)
logger.debug("Ignoring exception caught while closing %s session: %s.", self.__class__.__qualname__, e)

self.clear()
logger.debug("HTTPClient session closed successfully.")
logger.debug("%s session closed successfully.", self.__class__.__qualname__)

async def request(self, method: HTTPMethod, endpoint: str, *, use_id: bool = False, **kwargs: Unpack[APIRequest]) -> dict[str, Any] | str:
async def request(
self, method: HTTPMethod, endpoint: str, *, use_id: bool = False, **kwargs: Unpack[APIRequest]
) -> Any:
await self._init_session()
assert self.__session is not None

Expand All @@ -124,12 +129,27 @@ async def request(self, method: HTTPMethod, endpoint: str, *, use_id: bool = Fal
kwargs["headers"] = headers_

route: Route = Route(method, endpoint, use_id=use_id, **kwargs)
logger.debug("Attempting a request to %s with %s.", route, self.__class__.__qualname__)

async with self.__session.request(method, route.url, **kwargs) as resp:
data: dict[str, Any] | str = await json_or_text(resp)

if resp.status >= 400:
logger.error('Request %s failed with status %s: %s', route, resp.status, data)
raise TwitchioHTTPException(f"Request {route} failed with status {resp.status}: {data}", route=route)
logger.error("Request %s failed with status %s: %s", route, resp.status, data)
raise TwitchioHTTPException(
f"Request {route} failed with status {resp.status}: {data}", route=route, status=resp.status
)

# TODO: This method is not complete. This is purely for testing purposes.
return data

async def request_json(
self, method: HTTPMethod, endpoint: str, *, use_id: bool = False, **kwargs: Unpack[APIRequest]
) -> Any:
data = await self.request(method, endpoint, use_id=use_id, **kwargs)

if isinstance(data, str):
# TODO: Add a TwitchioHTTPException here.
raise TypeError("Expected JSON data, but received text data.")

return data
2 changes: 0 additions & 2 deletions twitchio/types_/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,3 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

from .requests import APIRequest as APIRequest, HTTPMethod as HTTPMethod
50 changes: 50 additions & 0 deletions twitchio/types_/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
MIT License
Copyright (c) 2017 - Present PythonistaGuild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from typing import TypeAlias, TypedDict


__all__ = (
"RefreshTokenResponse",
"ValidateTokenResponse",
"OAuthResponses",
)


class RefreshTokenResponse(TypedDict):
access_token: str
refresh_token: str
expires_in: int
scope: str | list[str]
token_type: str


class ValidateTokenResponse(TypedDict):
client_id: str
login: str
scopes: list[str]
user_id: str
expires_in: int


OAuthResponses: TypeAlias = RefreshTokenResponse | ValidateTokenResponse
3 changes: 2 additions & 1 deletion twitchio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

try:
import orjson # type: ignore

_from_json = orjson.loads # type: ignore
except ImportError:
_from_json = json.loads


__all__ = ("_from_json", )
__all__ = ("_from_json",)

0 comments on commit 43e9055

Please sign in to comment.