Updated comments in cura/OAuth2

Used DOX_2_RST.py to convert doxygen style comments to sphinx style comments
This commit is contained in:
jelle Spijker 2020-05-11 13:16:29 +02:00 committed by Jelle Spijker
parent a503149452
commit b032101e55
No known key found for this signature in database
GPG key ID: 6662DC033BE6B99A
6 changed files with 135 additions and 71 deletions

View file

@ -16,23 +16,27 @@ from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settin
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
## Class containing several helpers to deal with the authorization flow.
class AuthorizationHelpers: class AuthorizationHelpers:
"""Class containing several helpers to deal with the authorization flow."""
def __init__(self, settings: "OAuth2Settings") -> None: def __init__(self, settings: "OAuth2Settings") -> None:
self._settings = settings self._settings = settings
self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL)
@property @property
## The OAuth2 settings object.
def settings(self) -> "OAuth2Settings": def settings(self) -> "OAuth2Settings":
"""The OAuth2 settings object."""
return self._settings return self._settings
## Request the access token from the authorization server.
# \param authorization_code: The authorization code from the 1st step.
# \param verification_code: The verification code needed for the PKCE
# extension.
# \return An AuthenticationResponse object.
def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server.
:param authorization_code: The authorization code from the 1st step.
:param verification_code: The verification code needed for the PKCE extension.
:return: An AuthenticationResponse object.
"""
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
@ -46,10 +50,13 @@ class AuthorizationHelpers:
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
## Request the access token from the authorization server using a refresh token.
# \param refresh_token:
# \return An AuthenticationResponse object.
def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse":
"""Request the access token from the authorization server using a refresh token.
:param refresh_token:
:return: An AuthenticationResponse object.
"""
Logger.log("d", "Refreshing the access token.") Logger.log("d", "Refreshing the access token.")
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
@ -64,10 +71,13 @@ class AuthorizationHelpers:
return AuthenticationResponse(success=False, err_message="Unable to connect to remote server") return AuthenticationResponse(success=False, err_message="Unable to connect to remote server")
@staticmethod @staticmethod
## Parse the token response from the authorization server into an AuthenticationResponse object.
# \param token_response: The JSON string data response from the authorization server.
# \return An AuthenticationResponse object.
def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse":
"""Parse the token response from the authorization server into an AuthenticationResponse object.
:param token_response: The JSON string data response from the authorization server.
:return: An AuthenticationResponse object.
"""
token_data = None token_data = None
try: try:
@ -89,10 +99,13 @@ class AuthorizationHelpers:
scope=token_data["scope"], scope=token_data["scope"],
received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT)) received_at=datetime.now().strftime(TOKEN_TIMESTAMP_FORMAT))
## Calls the authentication API endpoint to get the token data.
# \param access_token: The encoded JWT token.
# \return Dict containing some profile data.
def parseJWT(self, access_token: str) -> Optional["UserProfile"]: def parseJWT(self, access_token: str) -> Optional["UserProfile"]:
"""Calls the authentication API endpoint to get the token data.
:param access_token: The encoded JWT token.
:return: Dict containing some profile data.
"""
try: try:
token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = {
"Authorization": "Bearer {}".format(access_token) "Authorization": "Bearer {}".format(access_token)
@ -115,16 +128,22 @@ class AuthorizationHelpers:
) )
@staticmethod @staticmethod
## Generate a verification code of arbitrary length.
# \param code_length: How long should the code be? This should never be lower than 16, but it's probably better to
# leave it at 32
def generateVerificationCode(code_length: int = 32) -> str: def generateVerificationCode(code_length: int = 32) -> str:
"""Generate a verification code of arbitrary length.
:param code_length:: How long should the code be? This should never be lower than 16, but it's probably
better to leave it at 32
"""
return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) return "".join(random.choice("0123456789ABCDEF") for i in range(code_length))
@staticmethod @staticmethod
## Generates a base64 encoded sha512 encrypted version of a given string.
# \param verification_code:
# \return The encrypted code in base64 format.
def generateVerificationCodeChallenge(verification_code: str) -> str: def generateVerificationCodeChallenge(verification_code: str) -> str:
"""Generates a base64 encoded sha512 encrypted version of a given string.
:param verification_code:
:return: The encrypted code in base64 format.
"""
encoded = sha512(verification_code.encode()).digest() encoded = sha512(verification_code.encode()).digest()
return b64encode(encoded, altchars = b"_-").decode() return b64encode(encoded, altchars = b"_-").decode()

View file

@ -14,9 +14,12 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
## This handler handles all HTTP requests on the local web server.
# It also requests the access token for the 2nd stage of the OAuth flow.
class AuthorizationRequestHandler(BaseHTTPRequestHandler): class AuthorizationRequestHandler(BaseHTTPRequestHandler):
"""This handler handles all HTTP requests on the local web server.
It also requests the access token for the 2nd stage of the OAuth flow.
"""
def __init__(self, request, client_address, server) -> None: def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
@ -55,10 +58,13 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
# This will cause the server to shut down, so we do it at the very end of the request handling. # This will cause the server to shut down, so we do it at the very end of the request handling.
self.authorization_callback(token_response) self.authorization_callback(token_response)
## Handler for the callback URL redirect.
# \param query Dict containing the HTTP query parameters.
# \return HTTP ResponseData containing a success page to show to the user.
def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]:
"""Handler for the callback URL redirect.
:param query: Dict containing the HTTP query parameters.
:return: HTTP ResponseData containing a success page to show to the user.
"""
code = self._queryGet(query, "code") code = self._queryGet(query, "code")
state = self._queryGet(query, "state") state = self._queryGet(query, "state")
if state != self.state: if state != self.state:
@ -95,9 +101,10 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
self.authorization_helpers.settings.AUTH_FAILED_REDIRECT self.authorization_helpers.settings.AUTH_FAILED_REDIRECT
), token_response ), token_response
## Handle all other non-existing server calls.
@staticmethod @staticmethod
def _handleNotFound() -> ResponseData: def _handleNotFound() -> ResponseData:
"""Handle all other non-existing server calls."""
return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.") return ResponseData(status = HTTP_STATUS["NOT_FOUND"], content_type = "text/html", data_stream = b"Not found.")
def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None:
@ -110,7 +117,8 @@ class AuthorizationRequestHandler(BaseHTTPRequestHandler):
def _sendData(self, data: bytes) -> None: def _sendData(self, data: bytes) -> None:
self.wfile.write(data) self.wfile.write(data)
## Convenience helper for getting values from a pre-parsed query string
@staticmethod @staticmethod
def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]: def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str] = None) -> Optional[str]:
"""Convenience helper for getting values from a pre-parsed query string"""
return query_data.get(key, [default])[0] return query_data.get(key, [default])[0]

View file

@ -9,21 +9,26 @@ if TYPE_CHECKING:
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers
## The authorization request callback handler server.
# This subclass is needed to be able to pass some data to the request handler.
# This cannot be done on the request handler directly as the HTTPServer
# creates an instance of the handler after init.
class AuthorizationRequestServer(HTTPServer): class AuthorizationRequestServer(HTTPServer):
## Set the authorization helpers instance on the request handler. """The authorization request callback handler server.
This subclass is needed to be able to pass some data to the request handler. This cannot be done on the request
handler directly as the HTTPServer creates an instance of the handler after init.
"""
def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None:
"""Set the authorization helpers instance on the request handler."""
self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore
## Set the authorization callback on the request handler.
def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None:
"""Set the authorization callback on the request handler."""
self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore
## Set the verification code on the request handler.
def setVerificationCode(self, verification_code: str) -> None: def setVerificationCode(self, verification_code: str) -> None:
"""Set the verification code on the request handler."""
self.RequestHandlerClass.verification_code = verification_code # type: ignore self.RequestHandlerClass.verification_code = verification_code # type: ignore
def setState(self, state: str) -> None: def setState(self, state: str) -> None:

View file

@ -25,9 +25,11 @@ if TYPE_CHECKING:
from UM.Preferences import Preferences from UM.Preferences import Preferences
## The authorization service is responsible for handling the login flow,
# storing user credentials and providing account information.
class AuthorizationService: class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing
account information.
"""
# Emit signal when authentication is completed. # Emit signal when authentication is completed.
onAuthStateChanged = Signal() onAuthStateChanged = Signal()
@ -59,11 +61,16 @@ class AuthorizationService:
if self._preferences: if self._preferences:
self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}")
## Get the user profile as obtained from the JWT (JSON Web Token).
# If the JWT is not yet parsed, calling this will take care of that.
# \return UserProfile if a user is logged in, None otherwise.
# \sa _parseJWT
def getUserProfile(self) -> Optional["UserProfile"]: def getUserProfile(self) -> Optional["UserProfile"]:
"""Get the user profile as obtained from the JWT (JSON Web Token).
If the JWT is not yet parsed, calling this will take care of that.
:return: UserProfile if a user is logged in, None otherwise.
See also: :py:method:`cura.OAuth2.AuthorizationService.AuthorizationService._parseJWT`
"""
if not self._user_profile: if not self._user_profile:
# If no user profile was stored locally, we try to get it from JWT. # If no user profile was stored locally, we try to get it from JWT.
try: try:
@ -81,9 +88,12 @@ class AuthorizationService:
return self._user_profile return self._user_profile
## Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
# \return UserProfile if it was able to parse, None otherwise.
def _parseJWT(self) -> Optional["UserProfile"]: def _parseJWT(self) -> Optional["UserProfile"]:
"""Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there.
:return: UserProfile if it was able to parse, None otherwise.
"""
if not self._auth_data or self._auth_data.access_token is None: if not self._auth_data or self._auth_data.access_token is None:
# If no auth data exists, we should always log in again. # If no auth data exists, we should always log in again.
Logger.log("d", "There was no auth data or access token") Logger.log("d", "There was no auth data or access token")
@ -106,8 +116,9 @@ class AuthorizationService:
self._storeAuthData(self._auth_data) self._storeAuthData(self._auth_data)
return self._auth_helpers.parseJWT(self._auth_data.access_token) return self._auth_helpers.parseJWT(self._auth_data.access_token)
## Get the access token as provided by the repsonse data.
def getAccessToken(self) -> Optional[str]: def getAccessToken(self) -> Optional[str]:
"""Get the access token as provided by the repsonse data."""
if self._auth_data is None: if self._auth_data is None:
Logger.log("d", "No auth data to retrieve the access_token from") Logger.log("d", "No auth data to retrieve the access_token from")
return None return None
@ -122,8 +133,9 @@ class AuthorizationService:
return self._auth_data.access_token if self._auth_data else None return self._auth_data.access_token if self._auth_data else None
## Try to refresh the access token. This should be used when it has expired.
def refreshAccessToken(self) -> None: def refreshAccessToken(self) -> None:
"""Try to refresh the access token. This should be used when it has expired."""
if self._auth_data is None or self._auth_data.refresh_token is None: if self._auth_data is None or self._auth_data.refresh_token is None:
Logger.log("w", "Unable to refresh access token, since there is no refresh token.") Logger.log("w", "Unable to refresh access token, since there is no refresh token.")
return return
@ -135,14 +147,16 @@ class AuthorizationService:
Logger.log("w", "Failed to get a new access token from the server.") Logger.log("w", "Failed to get a new access token from the server.")
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
## Delete the authentication data that we have stored locally (eg; logout)
def deleteAuthData(self) -> None: def deleteAuthData(self) -> None:
"""Delete the authentication data that we have stored locally (eg; logout)"""
if self._auth_data is not None: if self._auth_data is not None:
self._storeAuthData() self._storeAuthData()
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
## Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.
def startAuthorizationFlow(self) -> None: def startAuthorizationFlow(self) -> None:
"""Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login."""
Logger.log("d", "Starting new OAuth2 flow...") Logger.log("d", "Starting new OAuth2 flow...")
# Create the tokens needed for the code challenge (PKCE) extension for OAuth2. # Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
@ -177,8 +191,9 @@ class AuthorizationService:
QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string))) QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string)))
## Callback method for the authentication flow.
def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:
"""Callback method for the authentication flow."""
if auth_response.success: if auth_response.success:
self._storeAuthData(auth_response) self._storeAuthData(auth_response)
self.onAuthStateChanged.emit(logged_in = True) self.onAuthStateChanged.emit(logged_in = True)
@ -186,8 +201,9 @@ class AuthorizationService:
self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message) self.onAuthenticationError.emit(logged_in = False, error_message = auth_response.err_message)
self._server.stop() # Stop the web server at all times. self._server.stop() # Stop the web server at all times.
## Load authentication data from preferences.
def loadAuthDataFromPreferences(self) -> None: def loadAuthDataFromPreferences(self) -> None:
"""Load authentication data from preferences."""
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to load authentication data, since no preference has been set!") Logger.log("e", "Unable to load authentication data, since no preference has been set!")
return return
@ -208,8 +224,9 @@ class AuthorizationService:
except ValueError: except ValueError:
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
## Store authentication data in preferences.
def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None:
"""Store authentication data in preferences."""
Logger.log("d", "Attempting to store the auth data") Logger.log("d", "Attempting to store the auth data")
if self._preferences is None: if self._preferences is None:
Logger.log("e", "Unable to save authentication data, since no preference has been set!") Logger.log("e", "Unable to save authentication data, since no preference has been set!")

View file

@ -20,18 +20,23 @@ if TYPE_CHECKING:
class LocalAuthorizationServer: class LocalAuthorizationServer:
## The local LocalAuthorizationServer takes care of the oauth2 callbacks.
# Once the flow is completed, this server should be closed down again by
# calling stop()
# \param auth_helpers An instance of the authorization helpers class.
# \param auth_state_changed_callback A callback function to be called when
# the authorization state changes.
# \param daemon Whether the server thread should be run in daemon mode.
# Note: Daemon threads are abruptly stopped at shutdown. Their resources
# (e.g. open files) may never be released.
def __init__(self, auth_helpers: "AuthorizationHelpers", def __init__(self, auth_helpers: "AuthorizationHelpers",
auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], auth_state_changed_callback: Callable[["AuthenticationResponse"], Any],
daemon: bool) -> None: daemon: bool) -> None:
"""The local LocalAuthorizationServer takes care of the oauth2 callbacks.
Once the flow is completed, this server should be closed down again by calling
:py:meth:`cura.OAuth2.LocalAuthorizationServer.LocalAuthorizationServer.stop()`
:param auth_helpers: An instance of the authorization helpers class.
:param auth_state_changed_callback: A callback function to be called when the authorization state changes.
:param daemon: Whether the server thread should be run in daemon mode.
.. note::
Daemon threads are abruptly stopped at shutdown. Their resources (e.g. open files) may never be released.
"""
self._web_server = None # type: Optional[AuthorizationRequestServer] self._web_server = None # type: Optional[AuthorizationRequestServer]
self._web_server_thread = None # type: Optional[threading.Thread] self._web_server_thread = None # type: Optional[threading.Thread]
self._web_server_port = auth_helpers.settings.CALLBACK_PORT self._web_server_port = auth_helpers.settings.CALLBACK_PORT
@ -39,10 +44,13 @@ class LocalAuthorizationServer:
self._auth_state_changed_callback = auth_state_changed_callback self._auth_state_changed_callback = auth_state_changed_callback
self._daemon = daemon self._daemon = daemon
## Starts the local web server to handle the authorization callback.
# \param verification_code The verification code part of the OAuth2 client identification.
# \param state The unique state code (to ensure that the request we get back is really from the server.
def start(self, verification_code: str, state: str) -> None: def start(self, verification_code: str, state: str) -> None:
"""Starts the local web server to handle the authorization callback.
:param verification_code: The verification code part of the OAuth2 client identification.
:param state: The unique state code (to ensure that the request we get back is really from the server.
"""
if self._web_server: if self._web_server:
# If the server is already running (because of a previously aborted auth flow), we don't have to start it. # If the server is already running (because of a previously aborted auth flow), we don't have to start it.
# We still inject the new verification code though. # We still inject the new verification code though.
@ -66,8 +74,9 @@ class LocalAuthorizationServer:
self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon)
self._web_server_thread.start() self._web_server_thread.start()
## Stops the web server if it was running. It also does some cleanup.
def stop(self) -> None: def stop(self) -> None:
"""Stops the web server if it was running. It also does some cleanup."""
Logger.log("d", "Stopping local oauth2 web server...") Logger.log("d", "Stopping local oauth2 web server...")
if self._web_server: if self._web_server:

View file

@ -8,8 +8,9 @@ class BaseModel:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
## OAuth OAuth2Settings data template.
class OAuth2Settings(BaseModel): class OAuth2Settings(BaseModel):
"""OAuth OAuth2Settings data template."""
CALLBACK_PORT = None # type: Optional[int] CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str] OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str] CLIENT_ID = None # type: Optional[str]
@ -20,16 +21,18 @@ class OAuth2Settings(BaseModel):
AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str
## User profile data template.
class UserProfile(BaseModel): class UserProfile(BaseModel):
"""User profile data template."""
user_id = None # type: Optional[str] user_id = None # type: Optional[str]
username = None # type: Optional[str] username = None # type: Optional[str]
profile_image_url = None # type: Optional[str] profile_image_url = None # type: Optional[str]
## Authentication data template.
class AuthenticationResponse(BaseModel): class AuthenticationResponse(BaseModel):
"""Data comes from the token response with success flag and error message added.""" """Authentication data template."""
# Data comes from the token response with success flag and error message added.
success = True # type: bool success = True # type: bool
token_type = None # type: Optional[str] token_type = None # type: Optional[str]
access_token = None # type: Optional[str] access_token = None # type: Optional[str]
@ -40,22 +43,25 @@ class AuthenticationResponse(BaseModel):
received_at = None # type: Optional[str] received_at = None # type: Optional[str]
## Response status template.
class ResponseStatus(BaseModel): class ResponseStatus(BaseModel):
"""Response status template."""
code = 200 # type: int code = 200 # type: int
message = "" # type: str message = "" # type: str
## Response data template.
class ResponseData(BaseModel): class ResponseData(BaseModel):
"""Response data template."""
status = None # type: ResponseStatus status = None # type: ResponseStatus
data_stream = None # type: Optional[bytes] data_stream = None # type: Optional[bytes]
redirect_uri = None # type: Optional[str] redirect_uri = None # type: Optional[str]
content_type = "text/html" # type: str content_type = "text/html" # type: str
## Possible HTTP responses.
HTTP_STATUS = { HTTP_STATUS = {
"""Possible HTTP responses."""
"OK": ResponseStatus(code = 200, message = "OK"), "OK": ResponseStatus(code = 200, message = "OK"),
"NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"), "NOT_FOUND": ResponseStatus(code = 404, message = "NOT FOUND"),
"REDIRECT": ResponseStatus(code = 302, message = "REDIRECT") "REDIRECT": ResponseStatus(code = 302, message = "REDIRECT")