Skip to content

Commit

Permalink
Refactor error enum and create api response decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
SamWinterhalder committed Sep 29, 2023
1 parent 3610a15 commit 2c70328
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 168 deletions.
67 changes: 0 additions & 67 deletions memberships/api.py

This file was deleted.

112 changes: 112 additions & 0 deletions memberships/api_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import functools

from jwt import encode
from rest_framework.parsers import JSONParser, ParseError
from datetime import datetime, timedelta
from django.conf import settings
from .responses import ErrorCodeEnum, APIError
from django.contrib.auth.models import User
from django.http import JsonResponse


def api_response(func):
"""
Decorator to allow API methods to have custom error responses while allowing web views to remain
"""
@functools.wraps(func)
def wrapper(*args, **kwargs) -> JsonResponse:
try:
response = func(*args, **kwargs)
except APIError as err:
err.log()
response = err.to_response()
except Exception as e:
print(e)
response = ErrorCodeEnum.INTERNAL_SERVER_ERROR.value.to_response()
return response

return wrapper


def verify_post_method(http_method: str) -> None:
"""
Verifies the HTTP method is 'POST'
:raises APIError: if http_method is not equal to 'POST'
"""
if http_method != "POST":
raise APIError(ErrorCodeEnum.METHOD_NOT_ALLOWED)


def json_from_request(request):
try:
return JSONParser().parse(request)
except ParseError:
raise APIError(ErrorCodeEnum.MALFORMED_JSON)


def verify_property_is_str(body: dict, key: str) -> bool:
if type(body.get(key)) != str:
return False
return True


def verify_password_signon_request_body(body):
if "email" not in body.keys() or "password" not in body.keys():
raise APIError(ErrorCodeEnum.MISSING_CREDENTIALS)

if not verify_property_is_str(body, "email"):
raise APIError(ErrorCodeEnum.INVALID_TYPE)

if not verify_property_is_str(body, "password"):
raise APIError(ErrorCodeEnum.INVALID_TYPE)


def check_password_for_user_with_email(email: str, password: str) -> bool:
"""
Checks whether the password is correct for the given users email
"""
user: User = User.objects.get(email=email)
if user.check_password(password):
return True
return False


def create_access_token(email: str) -> tuple[dict, str]:
"""
Creates an access token for the given email
:return: the access token and it's expiry
"""
token_expiry = str(
(
datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRY)
).isoformat()
)
access_token = encode(
{
"email": email,
"expiry": token_expiry,
},
settings.JWT_SECRET,
algorithm="HS256",
)

return access_token, token_expiry


def create_refresh_token(email: str) -> str:
"""
Creates a refresh token for a given email
:return: the refresh token
"""
return encode(
{
"email": email,
"expiry": str(
(
datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRY)
).isoformat()
),
},
settings.JWT_SECRET,
algorithm="HS256",
)
68 changes: 38 additions & 30 deletions memberships/responses.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,52 @@
from django.template.response import SimpleTemplateResponse
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.http import JsonResponse
from enum import Enum
from typing import Dict


class APIError(Exception):
def __init__(
self,
status=500,
errorCode=5999,
errorMessage="Internal Server Error",
cause=None,
):
class ErrorCode:
def __init__(self, status: int, code: int, message: str):
self.status = status
self.errorCode = errorCode
self.errorMessage = errorMessage
self.code = code
self.message = message


class ErrorCodeEnum(Enum):
MISSING_CREDENTIALS = ErrorCode(400, 4001, "Credential missing from JSON body")
MALFORMED_JSON = ErrorCode(400, 4002, "Malformed JSON body")
INVALID_TYPE = ErrorCode(400, 4003, "Property missing from JSON body")
FORBIDDEN = ErrorCode(403, 4031, "Forbidden")
METHOD_NOT_ALLOWED = ErrorCode(405, 4051, "Method not allowed")
INTERNAL_SERVER_ERROR = ErrorCode(500, 5999, "Internal Server Error")


class APIError(Exception):
"""
An error which can be thrown containing information about both the cause and the response to be returned.
"""

def __init__(self, error_code: ErrorCodeEnum, override_message: str = None, cause: Exception = None):
self.status = error_code.value.status
self.code = error_code.value.code
self.message = override_message if override_message else error_code.value.message
self.cause = cause

def toResponse(self, headers=None):
def to_response(self, headers: Dict = None) -> JsonResponse:
"""
Helper method to create a JSON response based on the error thrown.
"""
return JsonResponse(
status=self.status,
headers=headers,
data={
"status": self.status,
"errorCode": str(self.errorCode),
"errorMessage": self.errorMessage,
"errorCode": str(self.code),
"errorMessage": self.message,
},
)

def log(self):
print(
f"Request failed with status: {self.status}, errorCode: {self.errorCode} and errorMessage: {self.errorMessage}. Cause: {self.cause}"
)


class ERROR_CODE_ENUM(Enum):
MISSING_CREDENTIALS = APIError(400, 4001, "Credentials missing from JSON body")
MALFORMED_JSON = APIError(400, 4002, "Malformed JSON body")
FORBIDDEN = APIError(403, 4031, "Forbidden")
METHOD_NOT_ALLOWED = APIError(405, 4051, "Method not allowed")
INTERNAL_SERVER_ERROR = APIError(500, 5999, "Internal Server Error")

def throw(self):
raise self.value
def log(self) -> None:
"""
Helper method to log details about the error response and cause.
"""
print(f"""Request failed with status: {self.status}, errorCode: {self.code} and errorMessage: {self.message}.
{'Cause: ' + str(self.cause) if self.cause else ''}""")
22 changes: 0 additions & 22 deletions memberships/tests/api_utils.py

This file was deleted.

31 changes: 20 additions & 11 deletions memberships/tests/test_signon_with_password.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
from json import loads
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIRequestFactory
from memberships.views import signon_with_password
from django.contrib.auth.models import User
from .api_utils import APITestCase
from .utils import APITestCase, is_token


class SignonWithPasswordTest(APITestCase):

def setUp(self):
self.factory = APIRequestFactory()
self.create_user('example', 'Secure123!', 'example@example.com')

self.create_member('example', 'Secure123!', 'example@example.com')

def test_valid_username_and_password_return_token(self):
request = self.factory.post(
'/memberships/signonWithPassword/',
{ 'email': 'example@example.com', 'password': 'Secure123!' },
resp = self.client.post(
reverse('signonWithPassword'),
{'email': 'example@example.com', 'password': 'Secure123!'},
format='json'
)
resp = signon_with_password(request)
data: dict = loads(resp.content.decode('utf-8'))

token = data["token"]
self.assertEqual(resp.status_code, 200)
self.assertTrue(self.is_token(token))
self.assertTrue(is_token(token), f"Token format not valid: {token}")

def test_missing_email_returns_bad_request(self):
resp = self.client.post(
reverse('signonWithPassword'),
{'password': 'Secure123!'},
format='json'
)
data: dict = loads(resp.content.decode('utf-8'))

# token = data["token"]
self.assertEqual(resp.status_code, 400)
# self.assertTrue(is_token(token), f"Token format not valid: {token}")
28 changes: 28 additions & 0 deletions memberships/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import re
from django.test import TransactionTestCase
from unittest import mock
from django.test import TestCase
from memberships.models import Member


class StripeTestCase(TransactionTestCase):
Expand Down Expand Up @@ -35,3 +38,28 @@ def _create_checkout_session(self):
self.create_checkout_session_patcher = self.patch("create_checkout_session")
self.create_checkout_session = self.create_checkout_session_patcher.start()
self.create_checkout_session.return_value = "example_session_id"


class APITestCase(TestCase):
JWT_REGEX = r"^([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_\-+/=]*)$"

def create_member(self, name: str, password: str, email: str) -> None:
"""
Create a member in the database
"""
self.member = Member.create(
full_name="test person",
preferred_name=name,
email=email,
password=password,
birth_date="1991-01-01",
)


def is_token(string: str) -> bool:
"""
Returns true if the input matches JTW format (using regex).
"""
if re.match(APITestCase.JWT_REGEX, string):
return True
return False
9 changes: 4 additions & 5 deletions memberships/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.urls import path
from . import views
from django.contrib.auth.views import *
from django.urls import path, include
from django.urls import path

urlpatterns = [
path("register/", views.register, name="register"),
Expand All @@ -23,9 +23,8 @@
LogoutView.as_view(template_name="memberships/logout.html"),
name="memberships_logout",
),
# API url's kepy separate for easy extraction
path(
"signonWithPassword/", views.signon_with_password, name="signon_with_password"
),

# API URLs
path("signonWithPassword/", views.signon_with_password, name="signonWithPassword"),
path("tokenRefresh/", views.token_refresh, name="token_refresh"),
]
Loading

0 comments on commit 2c70328

Please sign in to comment.