Django REST Framework versus Django Ninja
I’m a big fan of Django REST Framework (DRF), which I’ve been using since 2017 or so. I’ve also dabbled with Vapor (a web framework for Swift) and have written two articles comparing it to DRF: Vapor 3 versus Django REST Framework in 2019 and Vapor 4 versus Django REST Framework in 2021. Since that 2021 article I’ve exclusively used DRF for all my API projects.
Recently I became aware of Django Ninja, another API framework for Django, and decided to try it out. For this I am going to compare it with DRF using the following models, inspired by my real Dungeons & Dragons note-taking tool critical-notes.com:
models.py
from django.conf import settings
from django.db import models
class Campaign(models.Model):
name = models.CharField(max_length=50)
is_private = models.BooleanField(default=True, db_index=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="campaigns",
through="Membership",
)
class Membership(models.Model):
campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
is_dm = models.BooleanField(default=False)
class Character(models.Model):
campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)
name = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
is_hidden = models.BooleanField(default=False)
def __str__(self):
return self.name
As you can see, a D&D campaign has multiple members, and each member has a role: player or dungeon master (this is stored in the is_dm
boolean field). Campaigns be public or private (is_private
). Then there’s a Character
model, because of course we want to store the characters we play in our D&D campaigns.
Django REST Framework
I’m going to start by building the characters endpoints in DRF. I’m going to assume that people reading this article already have at least a basic knowledge of DRF so I am not going to explain every line of code in detail, but most things should be pretty clear.
There are two important rules for the character API:
- You can fetch a list of characters in a campaign as long as you’re a member of that campaign, or if it’s a public campaign - but only members can add characters or make other changes.
- A character can be marked as hidden (
is_hidden
), in which case only the DM should be able to fetch or modify it. Normal players should never know about the hidden characters.
But let’s start simple and not worry about these rules just yet.
views.py
from rest_framework import viewsets
from .models import Character
from .serializers import CharacterSerializer
class CharacterViewSet(viewsets.ModelViewSet):
serializer_class = CharacterSerializer
def get_queryset(self):
return Character.objects.filter(campaign_id=self.kwargs["campaign_id"])
serializers.py
from .models import Character
class CharacterSerializer(serializers.ModelSerializer):
class Meta:
model = Character
fields = "__all__"
urls.py
from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import SimpleRouter
from .views import CharacterViewSet
router = SimpleRouter(use_regex_path=False)
router.register(
"api/campaigns/<int:campaign_id>/characters",
CharacterViewSet,
basename="character",
)
urlpatterns = [
path("admin/", admin.site.urls),
path("", include(router.urls)),
]
With that little bit of code in place we can access the URL /api/campaigns/1/characters/
to fetch all characters that belong to campaign 1. We can POST to /api/campaigns/1/characters/
to create a new character, we can PUT to /api/campaigns/1/characters/1/
to edit a character and DELETE to /api/campaigns/1/characters/1/
to, you guessed it, delete a character. All this functionality which such a super simple ModelViewSet
subclass: this is absolutely one of the best features of DRF.
However, there are some problems to fix:
- We need to make sure that you’re allowed to access the characters (i.e. you need to be a logged-in member of the campaign, or it needs to be a public campaign).
- You should only be allowed to create characters or make other changes when you’re a member of the campaign.
- Only DMs should be able to see hidden characters.
- When you create a new character by POSTing to
/api/campaigns/1/characters/
, you shouldn’t be able to give a differentcampaign_id
in the POST payload: it needs to be “locked” to the campaign that’s in the URL. In the same way you shouldn’t be able to edit thecampaign_id
when you update a character.
Let’s tackle the first three points all at the same time, by creating a custom permissions class:
permissions.py
from rest_framework.permissions import SAFE_METHODS, BasePermission
from .models import Campaign, Membership
class CampaignMemberOrPublicReadOnlyPermission(BasePermission):
def has_permission(self, request, view, *args, **kwargs):
try:
request.campaign = Campaign.objects.get(pk=view.kwargs.get("campaign_id"))
except Campaign.DoesNotExist:
return False
if request.user.is_anonymous:
# User is not logged in, so check if it's a public campaign, in which case
# we can do GET requests only.
if not request.campaign.is_private:
return request.method in SAFE_METHODS
# Private campaign: no access at all
return False
try:
request.membership = Membership.objects.get(
user=request.user, campaign_id=view.kwargs.get("campaign_id")
)
return True
except Membership.DoesNotExist:
# Not a member, so check if it's a public campaign, in which case we can do
# GET requests only.
request.membership = Membership()
if not request.campaign.is_private:
return request.method in SAFE_METHODS
# Private campaign: no access at all
return False
And we change our view to make use of it:
views.py
from rest_framework import viewsets
from .models import Character
from .serializers import CharacterSerializer
from .permissions import CampaignMemberOrPublicReadOnlyPermission
class CharacterViewSet(viewsets.ModelViewSet):
permission_classes = (CampaignMemberOrPublicReadOnlyPermission,)
serializer_class = CharacterSerializer
def get_queryset(self):
qs = Character.objects.filter(campaign_id=self.kwargs["campaign_id"])
if not self.request.membership.is_dm:
qs = qs.filter(is_hidden=False)
return qs
With this change users can only fetch characters for campaigns they’re a member of, and of public campaigns. Furthermore, only members can make non-GET requests, meaning that only members can create new characters, edit characters, or delete characters. And only DMs can fetch hidden characters.
All that’s left to do is to make sure the campaign_id
can’t be changed when creating or updating a character. That’s very easy with a small addition to our CharacterViewSet
as well:
views.py
class CharacterViewSet(viewsets.ModelViewSet):
# ...
def perform_create(self, serializer):
serializer.save(campaign_id=self.kwargs["campaign_id"])
def perform_update(self, serializer):
serializer.save(campaign_id=self.kwargs["campaign_id"])
That’s our characters API done, in DRF. Now let’s recreate this with Ninja.
Django Ninja
To create the basic CRUD functionality for characters, a lot more code is needed.
views.py
from typing import List
from django.shortcuts import get_object_or_404
from ninja import NinjaAPI
from .models import Character
from .schemas import CharacterIn, CharacterOut
api = NinjaAPI()
@api.get("/campaigns/{int:campaign_id}/characters/", response=List[CharacterOut])
def character_list(request, campaign_id: int):
qs = Character.objects.filter(campaign_id=campaign_id)
return list(qs)
@api.post("/campaigns/{int:campaign_id}/characters/", response=CharacterOut)
def character_create(request, campaign_id: int, data: CharacterIn):
character = Character.objects.create(**data.dict(), campaign_id=campaign_id)
return character
@api.get("/campaigns/{int:campaign_id}/characters/{int:id}/", response=CharacterOut)
def character_detail(request, campaign_id: int, id: int):
return get_object_or_404(Character, campaign_id=campaign_id, id=id)
@api.put("/campaigns/{int:campaign_id}/characters/{int:id}/", response=CharacterOut)
def character_update(request, campaign_id: int, id: int, data: CharacterIn):
character = get_object_or_404(Character, campaign_id=campaign_id, id=id)
for attr, value in data.dict(exclude_unset=True).items():
setattr(character, attr, value)
character.save()
return character
@api.patch("/campaigns/{int:campaign_id}/characters/{int:id}/", response=CharacterOut)
def character_patch(request, campaign_id: int, id: int, data: CharacterIn):
character = get_object_or_404(Character, campaign_id=campaign_id, id=id)
for attr, value in data.dict().items():
setattr(character, attr, value)
character.save()
return character
@api.delete("/campaigns/{int:campaign_id}/characters/{int:id}/")
def character_delete(request, campaign_id: int, id: int):
character = get_object_or_404(Character, campaign_id=campaign_id, id=id)
character.delete()
return {"success": True}
schemas.py
from ninja import ModelSchema
from .models import Character
class CharacterOut(ModelSchema):
class Meta:
model = Character
fields = "__all__"
class CharacterIn(ModelSchema):
class Meta:
model = Character
exclude = ["id", "campaign"]
urls.py
from django.contrib import admin
from django.urls import include, path
from .views import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
Our basic CharacterViewSet
for DRF was literally four lines of code, and it did the same as what Ninja needs thirty lines for (excluding newlines, so in reality it’s even more). The problem is that Ninja doesn’t have something like a ViewSet
which bundles the CRUD operations; you need to write an endpoint for every operation. I also don’t really like how we need to use get_object_or_404
all over the place, because Ninja doesn’t handle exceptions itself, unlike DRF.
And we haven’t even started on permissions, making sure people only view characters of campaigns they have access to, making sure only the DM has access to the hidden characters, all the stuff we did before with DRF with very few lines of code. Then I came across a third party package called django-ninja-crud
, which aims to solve this boilerplate code. Let’s refactor our views.
Django Ninja CRUD
After reading Django Ninja CRUD’s documentation I changed my views and URL config as such:
views.py
from ninja import Router
from ninja_crud import views, viewsets
from .models import Character
from .schemas import CharacterIn, CharacterOut
router = Router()
class CharacterViewSet(viewsets.APIViewSet):
router = router
model = Character
default_request_body = CharacterIn
default_response_body = CharacterOut
list_characters = views.ListView()
create_character = views.CreateView()
read_character = views.ReadView()
update_character = views.UpdateView()
delete_character = views.DeleteView()
urls.py
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from .views import router as character_router
api = NinjaAPI()
api.add_router("/campaigns/{campaign_id}/characters/", character_router)
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
It’s definitely shorter than the code we had before, but it’s still a far cry from DRF (also, it’s missing a PATCH endpoint since there’s no view for that). And then my half-optimism got replaced by sadness because it’s not possible to access the campaign_id
path parameter from within the CharacterViewSet
. So that means that it’s impossible to filter characters, or to do any sort of campaign permission checks.
Instead the code has to be changed like so:
views.py
from ninja import Router
from ninja_crud import views, viewsets
from .models import Character
from .permissions import campaignMemberOrPublicReadOnlyPermission
from .schemas import CharacterIn, CharacterOut
router = Router()
class CharactersViewSet(viewsets.APIViewSet):
router = router
model = Character
default_request_body = CharacterIn
default_response_body = CharacterOut
def get_queryset(request, path_parameters):
return Character.objects.filter(campaign_id=path_parameters.campaign_id)
def init_model(request, path_parameters):
return Character(campaign_id=path_parameters.campaign_id)
list_characters = views.ListView(
path="/campaigns/{campaign_id}/characters/",
get_queryset=get_queryset,
decorators=[campaignMemberOrPublicReadOnlyPermission],
)
create_character = views.CreateView(
path="/campaigns/{campaign_id}/characters/",
init_model=init_model,
decorators=[campaignMemberOrPublicReadOnlyPermission],
)
read_character = views.ReadView(
path="/campaigns/{campaign_id}/characters/{id}",
decorators=[campaignMemberOrPublicReadOnlyPermission],
)
update_character = views.UpdateView(
path="/campaigns/{campaign_id}/characters/{id}",
decorators=[campaignMemberOrPublicReadOnlyPermission],
)
delete_character = views.DeleteView(
path="/campaigns/{campaign_id}/characters/{id}",
decorators=[campaignMemberOrPublicReadOnlyPermission],
)
permissions.py
from functools import wraps
from django.core.exceptions import PermissionDenied
from .models import Campaign, Membership
SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
def campaignMemberOrPublicReadOnlyPermission(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
campaign_id = kwargs.get("path_parameters").campaign_id
try:
request.campaign = Campaign.objects.get(pk=campaign_id)
except Campaign.DoesNotExist:
raise PermissionDenied()
if request.user.is_anonymous:
# User is not logged in, so check if it's a public campaign, in which case
# we can do GET requests only.
if not request.campaign.is_private:
if request.method in SAFE_METHODS:
return func(request, *args, **kwargs)
# Private campaign: no access at all
raise PermissionDenied()
try:
request.membership = Membership.objects.get(
user=request.user, campaign_id=campaign_id
)
return func(request, *args, **kwargs)
except Membership.DoesNotExist:
# Not a member, so check if it's a public campaign, in which case we can do
# GET requests only.
request.membership = Membership()
if not request.campaign.is_private:
if request.method in SAFE_METHODS:
return func(request, *args, **kwargs)
raise PermissionDenied()
return wrapper
urls.py
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from .views import router as character_router
api = NinjaAPI()
api.add_router("", character_router)
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
This is a huge bummer. Not only do we have to repeat the full path in every single CRUD operation for every single model, we also have to override a lot more code to make filtering and permissions work. There is no single method or thing to override which would check permissions, so we have to include the decorators
parameter into every CRUD operation as well.
And I haven’t even added things like only returning hidden characters to DMs. The problem with that is that request.membership
does not exist within the get_queryset
method, even though it’s been set inside of the campaignMemberOrPublicReadOnlyPermission
decorator. So we’d have to fetch the membership object yet again inside of get_queryset
, making yet another query, just because we can’t read the one we’ve already set.
Conclusion
It was at this moment that I made my conclusion: Django Ninja is not for me. While DRF certainly has its problems (there are just way too many View
and ViewSet
subclasses and mixins and multiple inheritance), it is super flexible, you can make any kind of API you want, and it’s very easy to centralize things like permission checks, filtering on querysets, etc. Creating nested endpoints such as /campaigns/{campaign_id}/characters/*
is absolutely no problem without having to repeat this prefix into every endpoint.
Django Ninja and the CRUD project have quite bad documentation and almost no examples. It’s just so much easier to get stuff done with DRF. Things like error handling, which “just works” with DRF, needs a bunch of custom code in Ninja.
For this article I was originally planning to also build the API endpoints for fetching and creating campaigns, which has its own list of rules and complexities to deal with, but I already know that this is fairly straightforward with DRF and a big problem with Ninja. Sure: anything is possible with Ninja as long as you don’t use its CRUD package and write every single endpoint by hand, but there is way too much boilerplate involved. There’s a django-ninja-extra
package which does have a class-based way of encapsulating multiple endpoints with shared behavior, for example for permissions, but then you still need to write every endpoint for every CRUD operation.
I think that if you have a very straightforward API with a few endpoints that don’t do too must custom logic, then Django Ninja could be very well suited for that. I really had some “oh, wow!” moments while playing around with it. But for anything more complex… I’m sticking with DRF.