Skip to content

Commit

Permalink
API improvements (#23)
Browse files Browse the repository at this point in the history
* Fix (api): Removes duplicated entries from the patterns, and also fixes the schema url name to contain the namespace

* Typing (users): Add Token reverse accessor for User model

* Feat (users): Adds services to authenticate

* Feat (api): Adds authentication classes and openapi extensions

* Feat (api): Adds auth endpoints

* Fix (conf): Updates the default CSRF_TRUSTED_ORIGINS to point to the new ngrok domain

* Fix (conf): ngrok CSRF

* Feat (api): Adds metadata class that takes value from the Schema class defined from the extend_schema decorator

* Feat (app): Adds consts for HTTP statuses

* Fix (api): Openapi enhancements and requires documented responses for errors defined on raises

* Feat (dev): Add fixtures script and adds type checking code branches to coverage ignore

* Feat (users): adds api for current user and authentication. Adds tests

* Fix (dev): Updates launch configuration to overeride USE_DEBUG_TOOLBAR to false

* Fix (notifications): Adds a method for defining/removing a notification/token. Adds tests for the api and some fixes with them

* Fix (conf): Removes update last login from the drf-simple-jwt serializers, we take care of updating them
  • Loading branch information
leandrodesouzadev authored Nov 18, 2023
1 parent c3bd926 commit 4018105
Show file tree
Hide file tree
Showing 40 changed files with 1,193 additions and 30 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ exclude_lines =
@(abc\.)?abstractmethod
from abc *
class .*\b\(ABC\):
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
5 changes: 4 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
"-x",
"-vv"
],
"envFile": "${workspaceFolder}/.env.test"
"envFile": "${workspaceFolder}/.env.test",
"env": {
"USE_DEBUG_TOOLBAR": "0"
}
},
{
"name": "Django Debug",
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ server.cmd = "python manage.py runserver"
createsuperuser.cmd = "python manage.py createsuperuser"
makemessages.shell = "cd src && pdm run python manage.py makemessages"
compilemessages.shell = "cd src && pdm run python manage.py compilemessages"
fixtures = { env_file = ".env.test", shell = "pytest --fixtures" }
tests = { env_file = ".env.test", shell = "coverage run --source=src/ --rcfile=.coveragerc -m pytest tests/ -x -vv && coverage html && google-chrome htmlcov/index.html"}

[tool.pdm.dev-dependencies]
Expand Down
4 changes: 2 additions & 2 deletions src/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
urlpatterns = [
path("v1/", include((v1.urls, "api"), namespace="v1")),
path("schema/", drf_views.SpectacularAPIView.as_view(), name="schema"),
path("docs/", drf_views.SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
path("redoc/", drf_views.SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("docs/", drf_views.SpectacularSwaggerView.as_view(url_name="api:schema"), name="docs"),
path("redoc/", drf_views.SpectacularRedocView.as_view(url_name="api:schema"), name="redoc"),
]
1 change: 1 addition & 0 deletions src/api/v1/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import urls
51 changes: 51 additions & 0 deletions src/api/v1/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from rest_framework import serializers

from app.consts.i18n import Language, TimeZoneName
from app.drf.fields import create_choice_human_field
from users.models import User


class AuthenticationInputSchema(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField()


class StaticTokenSchema(serializers.Serializer):
type = serializers.CharField(default="Token")
access = serializers.CharField()
refresh = serializers.CharField(default=None)


class JwtTokenSchema(serializers.Serializer):
type = serializers.CharField(default="Bearer")
access = serializers.CharField()
refresh = serializers.CharField()


LanguageChoiceField = create_choice_human_field(Language)
TimeZoneNameChoiceField = create_choice_human_field(TimeZoneName)


class _AuthenticationOutputSchema(serializers.ModelSerializer):
language_code = LanguageChoiceField()
time_zone = TimeZoneNameChoiceField()
token = serializers.CharField(default=None)

class Meta:
model = User
fields = (
"id",
"email",
"full_name",
"language_code",
"time_zone",
"token",
)


class TokenAuthenticationOutputSchema(_AuthenticationOutputSchema):
token = StaticTokenSchema()


class JwtTokenAuthenticationOutputSchema(_AuthenticationOutputSchema):
token = JwtTokenSchema()
8 changes: 8 additions & 0 deletions src/api/v1/auth/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework.routers import SimpleRouter

from . import views

router = SimpleRouter(trailing_slash=False)
router.register("", views.AuthenticationViewSet, basename="auth")

urlpatterns = router.urls
85 changes: 85 additions & 0 deletions src/api/v1/auth/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from dataclasses import dataclass
from typing import Literal

from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt import serializers as jwt_schemas

from app.consts.http import HttpStatusCode
from app.drf.openapi import openapi_schema
from app.drf.viewsets import AppViewSet
from users.services import auth

from . import schemas


@dataclass
class TokenDTO:
type: Literal["Token", "Bearer"]
access: str
refresh: str | None = None


class AuthenticationViewSet(AppViewSet):
permission_classes = [AllowAny]

@openapi_schema(
summary="Token authentication",
description="Authenticates and returns a static Token that can be "
"used to access protected endpoints",
request=schemas.AuthenticationInputSchema,
responses={HttpStatusCode.HTTP_200_OK: schemas.TokenAuthenticationOutputSchema},
tags=["auth"],
operation_id="authentication:token",
add_bad_request_response=True,
add_unauthorized_response=True,
raises=[auth.InactiveOrInexistentAccount, auth.InvalidCredentials],
)
@action(methods=["POST"], detail=False, url_path="token")
def token(self, request: Request):
data = self.get_valid_data(srlzr_class=schemas.AuthenticationInputSchema)
user, token = auth.token_authenticate(**data)
user.token = TokenDTO(type="Token", access=token.key, refresh=None) # type: ignore
srlzr = schemas.TokenAuthenticationOutputSchema(instance=user)
return Response(data=srlzr.data, status=200)

@openapi_schema(
summary="JWT authentication",
description="Authenticates and returns a JWT Token that can be "
"used to access protected endpoints",
request=schemas.AuthenticationInputSchema,
responses={HttpStatusCode.HTTP_200_OK: schemas.JwtTokenAuthenticationOutputSchema},
tags=["auth"],
operation_id="authentication:jwt-token",
add_bad_request_response=True,
add_unauthorized_response=True,
raises=[auth.InactiveOrInexistentAccount, auth.InvalidCredentials],
)
@action(methods=["POST"], detail=False, url_path="jwt")
def jwt_token(self, request: Request):
data = self.get_valid_data(srlzr_class=schemas.AuthenticationInputSchema)
user = auth.authenticate(**data)

refresh = jwt_schemas.TokenObtainPairSerializer().get_token(user)
token = TokenDTO(type="Bearer", access=str(refresh.access_token), refresh=str(refresh))
user.token = token # type: ignore
srlzr = schemas.JwtTokenAuthenticationOutputSchema(instance=user)
return Response(data=srlzr.data, status=200)

@openapi_schema(
summary="JWT Refresh",
description="Refreshes the given access token",
request=jwt_schemas.TokenRefreshSerializer,
responses={HttpStatusCode.HTTP_200_OK: jwt_schemas.TokenRefreshSerializer},
tags=["auth"],
operation_id="authentication:jwt-refresh",
add_bad_request_response=True,
)
@action(methods=["POST"], detail=False, url_path="jwt/refresh")
def jwt_refresh(self, request: Request):
return Response(
data=self.get_valid_data(srlzr_class=jwt_schemas.TokenRefreshSerializer),
status=200,
)
4 changes: 4 additions & 0 deletions src/api/v1/push_notifications/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ class PushNotificationReadManyInputSchema(serializers.Serializer):

class PushNotificationReadManyOutputSchema(serializers.Serializer):
read = serializers.IntegerField()


class PushNotificationSetTokenInputSchema(serializers.Serializer):
notification_token = serializers.CharField()
2 changes: 1 addition & 1 deletion src/api/v1/push_notifications/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
from . import views

router = SimpleRouter(trailing_slash=False)
router.register("notifications", views.PushNotificationViewSet, basename="push_notifications")
router.register("", views.PushNotificationViewSet, basename="notifications")

urlpatterns = router.urls
42 changes: 37 additions & 5 deletions src/api/v1/push_notifications/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
from rest_framework.request import Request
from rest_framework.response import Response

from app.consts.http import HttpStatusCode
from app.drf.openapi import limit_offset_openapi_schema, openapi_schema
from app.drf.viewsets import AppViewSet
from push_notifications import selectors, services
from push_notifications import exc, selectors, services

from . import schemas

Expand Down Expand Up @@ -35,7 +36,7 @@ def list(self, request: Request) -> Response:
summary="Read many notifications",
description="Reads a list of notifications",
request=schemas.PushNotificationReadManyInputSchema,
responses={200: schemas.PushNotificationReadManyOutputSchema},
responses={HttpStatusCode.HTTP_200_OK: schemas.PushNotificationReadManyOutputSchema},
tags=["push notifications"],
operation_id="push-notifications-read",
add_bad_request_response=True,
Expand All @@ -51,19 +52,50 @@ def read_many(self, request: Request) -> Response:
summary="Read notification",
description="Reads a single notification",
request=None,
responses={200: schemas.PushNotificationOutputSchema},
responses={HttpStatusCode.HTTP_200_OK: schemas.PushNotificationOutputSchema},
tags=["push notifications"],
operation_id="push-notification-read",
add_not_found_response=True,
add_bad_request_response=True,
add_unauthorized_response=True,
)
@action(methods=["PATCH"], detail=True)
def read(self, request: Request, id: int) -> Response:
def read(self, request: Request, pk: int) -> Response:
notification = get_object_or_404(
selectors.push_notification_get_viewable_qs(user=request.user),
pk=id,
pk=pk,
)
notification = services.push_notification_read(push_notification=notification)
out_srlzr = schemas.PushNotificationOutputSchema(instance=notification)
return Response(data=out_srlzr.data)

@openapi_schema(
summary="Set Notification token",
description="Defines the notification for the current user",
request=schemas.PushNotificationSetTokenInputSchema,
responses={HttpStatusCode.HTTP_200_OK: None},
tags=["push notifications"],
operation_id="push-notification-set-token",
add_unauthorized_response=True,
add_bad_request_response=True,
raises=[exc.InvalidNotificationToken],
)
@action(methods=["PUT"], detail=False)
def token(self, request: Request) -> Response:
data = self.get_valid_data(schemas.PushNotificationSetTokenInputSchema)
services.push_notification_set_token(user=request.user, **data)
return Response(status=HttpStatusCode.HTTP_200_OK)

@openapi_schema(
summary="Delete Notification token",
description="Removes the notification token from the current user (Opt-out)",
request=None,
responses={HttpStatusCode.HTTP_200_OK: None},
tags=["push notifications"],
operation_id="push-notification-delete-token",
add_unauthorized_response=True,
)
@token.mapping.delete
def delete_token(self, request: Request) -> Response:
services.push_notification_set_token(user=request.user, notification_token=None)
return Response(status=HttpStatusCode.HTTP_200_OK)
4 changes: 3 additions & 1 deletion src/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django.urls import include, path

from . import push_notifications
from . import auth, push_notifications, users

urlpatterns = [
path("auth/", include((auth.urls, "auth"), namespace="auth")),
path(
"notifications/",
include((push_notifications.urls, "push_notifications"), namespace="push_notifications"),
),
path("users/", include((users.urls, "users"), namespace="users")),
]
1 change: 1 addition & 0 deletions src/api/v1/users/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import urls
23 changes: 23 additions & 0 deletions src/api/v1/users/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from rest_framework import serializers

from api.v1.auth.schemas import LanguageChoiceField, TimeZoneNameChoiceField
from users.models import User


class CurrentUserOutputSchema(serializers.ModelSerializer):
language_code = LanguageChoiceField()
time_zone = TimeZoneNameChoiceField()

class Meta:
model = User
fields = (
"id",
"email",
"full_name",
"notification_token",
"language_code",
"time_zone",
"date_joined",
"is_staff",
"is_superuser",
)
8 changes: 8 additions & 0 deletions src/api/v1/users/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework.routers import SimpleRouter

from . import views

router = SimpleRouter(trailing_slash=False)
router.register("me", views.CurrentUserViewSet, basename="me")

urlpatterns = router.urls
39 changes: 39 additions & 0 deletions src/api/v1/users/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response

from app.consts.http import HttpStatusCode
from app.drf.openapi import openapi_schema
from app.drf.viewsets import AppViewSet
from users.services import account

from . import schemas


class CurrentUserViewSet(AppViewSet):
@openapi_schema(
summary="Current user",
description="Returns details about the current user",
request=None,
responses={HttpStatusCode.HTTP_200_OK: schemas.CurrentUserOutputSchema},
tags=["users:me"],
operation_id="users-me",
add_unauthorized_response=True,
)
def list(self, request: Request) -> Response:
srlzr = schemas.CurrentUserOutputSchema(instance=request.user)
return Response(srlzr.data)

@openapi_schema(
summary="Delete account",
description="Deletes the account for the current user",
request=None,
responses={HttpStatusCode.HTTP_204_NO_CONTENT: None},
tags=["users:me"],
operation_id="users-me-delete-account",
add_unauthorized_response=True,
)
@action(methods=["DELETE"], detail=False, url_name="delete-account", url_path="account")
def delete_account(self, request: Request) -> Response:
account.user_delete_account(user=request.user)
return Response(status=204)
4 changes: 4 additions & 0 deletions src/app/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
class RootAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "app"

def ready(self) -> None:
# Load openapi extensions
from app.drf import extensions
Loading

0 comments on commit 4018105

Please sign in to comment.