FullStack JWT Auth: User serializers, Views, and Endpoints
John Owolabi Idogun
Posted on February 8, 2022
Introduction
This is the second article in this series which aim to build a full stack #transitionalapp. This article will be totally based on implementing the endpoints that will be consumed by our SvelteKit app.
Source code
The overall source code for this project can be accessed here:
This project was deployed on heroku (backend) and vercel (frontend) and its live version can be accessed here.
To run this application locally, you need to run both the backend and frontend projects. While the latter has some instructions already for spinning it up, the former can be spinned up following the instructions below.
This project was deployed on heroku (backend) and vercel (frontend) and its live version can be accessed here.
Step 1: User serializers
Create a serializers.py file in the accounts app and fill it with the following:
# backend -> accounts -> serializers.py
fromdjango.contrib.authimportauthenticatefromrest_frameworkimportexceptions,serializersfromrest_framework_simplejwt.tokensimportRefreshToken,TokenErrorfrom.modelsimportUserfrom.utilsimportvalidate_emailasemail_is_validclassRegistrationSerializer(serializers.ModelSerializer[User]):"""Serializers registration requests and creates a new user."""password=serializers.CharField(max_length=128,min_length=8,write_only=True)classMeta:model=Userfields=['email','username','password','bio','full_name',]defvalidate_email(self,value:str)->str:"""Normalize and validate email address."""valid,error_text=email_is_valid(value)ifnotvalid:raiseserializers.ValidationError(error_text)try:email_name,domain_part=value.strip().rsplit('@',1)exceptValueError:passelse:value='@'.join([email_name,domain_part.lower()])returnvaluedefcreate(self,validated_data):# type: ignore
"""Return user after creation."""user=User.objects.create_user(username=validated_data['username'],email=validated_data['email'],password=validated_data['password'])user.bio=validated_data.get('bio','')user.full_name=validated_data.get('full_name','')user.save(update_fields=['bio','full_name'])returnuserclassLoginSerializer(serializers.ModelSerializer[User]):email=serializers.CharField(max_length=255)username=serializers.CharField(max_length=255,read_only=True)password=serializers.CharField(max_length=128,write_only=True)is_staff=serializers.BooleanField(read_only=True)tokens=serializers.SerializerMethodField()defget_tokens(self,obj):# type: ignore
"""Get user token."""user=User.objects.get(email=obj.email)return{'refresh':user.tokens['refresh'],'access':user.tokens['access']}classMeta:model=Userfields=['email','username','password','tokens','is_staff']defvalidate(self,data):# type: ignore
"""Validate and return user login."""email=data.get('email',None)password=data.get('password',None)ifemailisNone:raiseserializers.ValidationError('An email address is required to log in.')ifpasswordisNone:raiseserializers.ValidationError('A password is required to log in.')user=authenticate(username=email,password=password)ifuserisNone:raiseserializers.ValidationError('A user with this email and password was not found.')ifnotuser.is_active:raiseserializers.ValidationError('This user is not currently activated.')returnuserclassUserSerializer(serializers.ModelSerializer[User]):"""Handle serialization and deserialization of User objects."""password=serializers.CharField(max_length=128,min_length=8,write_only=True)classMeta:model=Userfields=('email','username','password','tokens','bio','full_name','birth_date','is_staff',)read_only_fields=('tokens','is_staff')defupdate(self,instance,validated_data):# type: ignore
"""Perform an update on a User."""password=validated_data.pop('password',None)for (key,value)invalidated_data.items():setattr(instance,key,value)ifpasswordisnotNone:instance.set_password(password)instance.save()returninstanceclassLogoutSerializer(serializers.Serializer[User]):refresh=serializers.CharField()defvalidate(self,attrs):# type: ignore
"""Validate token."""self.token=attrs['refresh']returnattrsdefsave(self,**kwargs):# type: ignore
"""Validate save backlisted token."""try:RefreshToken(self.token).blacklist()exceptTokenErrorasex:raiseexceptions.AuthenticationFailed(ex)
That's a lot of snippets! However, if you are somewhat familiar with Django REST Framework, it shouldn't be hard to decipher. Let's zoom in on each serializer.
RegistrationSerializer: This is the default serializer for user registration. It expects email, username, password,bio, and full_name fields to be supplied during registration. As expected, password was made to be write_only to prevent making it readable to users. It houses a custom validation method, validate_email, and overrides default create method. The validate_email method ensures the inputted email address is truly an email address by using a method in utils.py file for proper checking. The content of this file is:
#backend -> accounts -> utils.py
fromdjango.core.exceptionsimportValidationErrorfromdjango.core.validatorsimportvalidate_emailasdjango_validate_emaildefvalidate_email(value:str)->tuple[bool,str]:"""Validate a single email."""message_invalid='Enter a valid email address.'ifnotvalue:returnFalse,message_invalid# Check the regex, using the validate_email from django.
try:django_validate_email(value)exceptValidationError:returnFalse,message_invalidreturnTrue,''
For the create method, it's pretty straightforward. We simply create a user using create_user method defined in our custom Manager in the previous article and then use the performant update_fields argument to save bio and full_name.
LoginSerializer: The app's default login serializer. It uses get_tokens method to fetch the requesting user's pair of tokens. It also ensures all inputted data are properly validated via the validate method.
UserSerializer will later be used to update the requesting user's data.
LogoutSerializer: This serializer tends to use Simple JWT's blacklist feature to ensure that such token is made invalid and can't be used for future requests. This is the reason we opted for the library. See the documentation for details about this.
Step 2: Views and Endpoints
Now that we have painstakingly defined our serializers, let's forge ahead to create the views that will handle all the requests. Open up accounts/views.py file and fill the following in:
#backend -> accounts -> views.py
fromtypingimportAny,Optionalfromdjango.confimportsettingsfromrest_frameworkimportstatusfromrest_framework.genericsimportRetrieveUpdateAPIViewfromrest_framework.permissionsimportAllowAny,IsAuthenticatedfromrest_framework.requestimportRequestfromrest_framework.responseimportResponsefromrest_framework.viewsimportAPIViewfrom.modelsimportUserfrom.renderersimportUserJSONRendererfrom.serializersimport(LoginSerializer,LogoutSerializer,RegistrationSerializer,UserSerializer,)classRegistrationAPIView(APIView):permission_classes=(AllowAny,)renderer_classes=(UserJSONRenderer,)serializer_class=RegistrationSerializerdefpost(self,request:Request)->Response:"""Return user response after a successful registration."""user_request=request.data.get('user',{})serializer=self.serializer_class(data=user_request)serializer.is_valid(raise_exception=True)serializer.save()returnResponse(serializer.data,status=status.HTTP_201_CREATED)classLoginAPIView(APIView):permission_classes=(AllowAny,)renderer_classes=(UserJSONRenderer,)serializer_class=LoginSerializerdefpost(self,request:Request)->Response:"""Return user after login."""user=request.data.get('user',{})serializer=self.serializer_class(data=user)ifnotserializer.is_valid():print(serializer.errors)returnResponse(serializer.errors,status=status.HTTP_400_BAD_REQUEST)returnResponse(serializer.data,status=status.HTTP_200_OK)classUserRetrieveUpdateAPIView(RetrieveUpdateAPIView):permission_classes=(IsAuthenticated,)renderer_classes=(UserJSONRenderer,)serializer_class=UserSerializerdefretrieve(self,request:Request,*args:dict[str,Any],**kwargs:dict[str,Any])->Response:"""Return user on GET request."""serializer=self.serializer_class(request.user,context={'request':request})returnResponse(serializer.data,status=status.HTTP_200_OK)defupdate(self,request:Request,*args:dict[str,Any],**kwargs:dict[str,Any])->Response:"""Return updated user."""serializer_data=request.data.get('user',{})serializer=self.serializer_class(request.user,data=serializer_data,partial=True,context={'request':request})serializer.is_valid(raise_exception=True)serializer.save()returnResponse(serializer.data,status=status.HTTP_200_OK)classLogoutAPIView(APIView):serializer_class=LogoutSerializerpermission_classes=(IsAuthenticated,)defpost(self,request:Request)->Response:"""Validate token and save."""serializer=self.serializer_class(data=request.data)serializer.is_valid(raise_exception=True)serializer.save()returnResponse(status=status.HTTP_204_NO_CONTENT)
They are simple views that inherit from APIView and some other generic classes shipped with Django REST Framework. It should be noted that we have a custom UserJSONRenderer class located in renderers.py with the following content:
#backend -> accounts -> renderers.py
importjsonfromtypingimportAny,Mapping,Optionalfromrest_framework.renderersimportJSONRendererclassUserJSONRenderer(JSONRenderer):"""Custom method."""charset='utf-8'defrender(self,data:dict[str,Any],media_type:Optional[str]=None,renderer_context:Optional[Mapping[str,Any]]=None,)->str:"""Return a well formatted user jSON."""errors=data.get('errors',None)token=data.get('token',None)iferrorsisnotNone:returnsuper(UserJSONRenderer,self).render(data)iftokenisnotNoneandisinstance(token,bytes):# Also as mentioned above, we will decode `token` if it is of type
# bytes.
data['token']=token.decode('utf-8')# Finally, we can render our data under the "user" namespace.
returnjson.dumps({'user':data})
The whole essence of this class is to give a custom formatting of the request and response data to our endpoints. If it was not defined, our endpoints would expect and respond with data in the following format:
{"email":"sirneij@xyz.com","username":"sirjon","password":"somepassword","bio":"I am a researcher","full_name":"John Owolabi Nelson"}
But with that renderer in place, the expected data format will be:
{"user":{"email":"sirneij@xyz.com","username":"sirjon","password":"somepassword","bio":"I am a researcher","full_name":"John Owolabi Nelson"}}
This is not required but preferred by me. If you don't want this, you can omit it and also remove this line from all the views:
...user=request.data.get('user',{})...
and point the data attribute of your serializer class to request.data instead.
Let's make our endpoints now. Create a urls.py in the accounts app and make the content look like:
The last path, path('token/refresh/', TokenRefreshView.as_view(),name='token_refresh'), is important to help recreate access tokens for existing users who want to login.
Now, let's make django aware of these patterns. Open up backend/urls.py and make it look like:
Voilà! Our endpoints are up and running! You can test them using Postman or anything. But I prefer this awesome VS Code extension, Thunder Client. It's nice and sleek. Ensure you send in the proper data to avoid errors. Concerning the error responses, let's make one last customization to our endpoint. Create a new file in the accounts app, call it whatever you like but I will pick exceptions.py. Fill it with the following:
#backend -> accounts -> exceptions.py
fromtypingimportAny,Optionalfromrest_framework.responseimportResponsefromrest_framework.viewsimportexception_handlerdefcore_exception_handler(exc:Exception,context:dict[str,Any])->Optional[Response]:"""Error handler for the API."""response=exception_handler(exc,context)handlers={'ValidationError':_handle_generic_error}exception_class=exc.__class__.__name__ifexception_classinhandlers:returnhandlers[exception_class](exc,context,response)returnresponsedef_handle_generic_error(exc:Exception,context:dict[str,Any],response:Optional[Response])->Optional[Response]:ifresponse:response.data={'errors':response.data}returnresponsereturnNone
Then go to to your settings.py file and point REST framework to use this custom error format: