Trying Out Django Ninja - Part 1

INTRODUCTION

Django Ninja is a new and exciting framework for building REST APIs in Django. I still consider myself at the beggining of my journey with Python and Django, and having read a couple of excellent books on Django REST Framework, I was somewhat disheartened to see a few grumblings here and there, (in the various forums which I lurk), about DRF's speed. 

Coupled with the emergence of FastAPI as the 'hot new thing' in Python web development, I was beggining to believe that going forward, if I were to build an http service, it would be unlikely to involve Django. This would've been a shame, given my investment in learning that particular framework, so I was understandbly excited to see Django Ninja appear, as it claims to be heavily inspired by FastAPI and also has some encouraging performance metrics (image taken from: https://django-ninja.rest-framework.com/).

Django Ninja REST Framework

The following is effectively a begginer tutorial for Django Ninja and then in the second part, i'll try and recreate exactly the same with Django REST Framework by way of a comparison. 

NOTE: The intended audience here is a beginner, so I will not be covering Django Ninja's Async capabilities. If you are interested in those, please see the docs.

PART 1: DJANGO NINJA


Create your Django project with your preffered type of virtual environment and then create an app for the ninja api, (I've called mine ninja_service)

python manage.py startapp ninja_service

...and then dont forget to add the app into the Django project's settings: 

# api_comparison/settings.py

...

INSTALLED_APPS = [

    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    "ninja_service.apps.NinjaServiceConfig",

    ]

...

We are going to create a 'Discogs' style service for musical artists. We will have 'Projects' and 'Releases'

# ninja_service/models.py
from django.db import models

class Project(models.Model):
    name = models.CharField(max_length=255)
    description = models.TextField(null=True)

class Release(models.Model):

    title = models.CharField(max_length=255)
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    press_release = models.TextField(null=True)
    release_date = models.DateField()

We are assuming that the musical artist represented by the service will have multiple projects and then each of these projects will, in turn, have many releases. A project will need to be identifiable by name, with an optional description. 

The relationship between a release and it's project is a ForeignKey field and the date of release is a DateField. Lets make the migrations: 

python manage.py makemigrations 

Which should result in: 

Migrations for 'ninja_service':
  ninja_service/migrations/0001_initial.py
    - Create model Project
    - Create model Release

...and apply them: 

python manage.py migrate

Now we are ready to install Django Ninja and create the API endpoints:

pip install django-ninja==0.10.2

DN reccomends making an api.py file for each module in your project. Create one under the ninja_service folder and lets start off with the 'Create' function, (seeing as we are making a CRUD app): 

# ninja_service/api.py

from ninja import NinjaAPI,Schema
from .models import Project

api = NinjaAPI()

class ProjectIn(Schema):
    name: str
    description: str = None

@api.post("/projects")
def create_project(request,payload:ProjectIn):
    project = Project.objects.create(**payload.dict())
    return {"message": f"successfully created project id: {project.id}"}

The key things here are the Schema class and the @api decorator. The first thing you may be drawn to in this file is the Schema Class. Its worth Mentioning here that a Schema in Django Ninja is actually a Pydantic model. Pydantic is primarily a validation library (massive over simiplification alert!), so the primary job of ProjectIn schema here is to check the data passed into the system conforms to the types we expect. Description on our model can be null, so I've set it to None as the default value, which means that the attribute will be optional in the request body. The decorator designates the route to the function as well as the http verb that the view will be accepting. 

If we go to our project level urls.py file: 

from django.contrib import admin
from django.urls import path
from ninja_service.api import api

urlpatterns = [
    path('admin/', admin.site.urls),
    path('ninja-api/',api.urls),
]


We can see that the routes passed to the api decorators means that Django Ninja gathers all the urls for us and we can set it simply with 'api.urls'. The other cool thing that the framework is doing, is automatically creating the OpenAPI documentation. Visit http://127.0.0.1:8000/ninja-api/docs and you should see: 



Now lets move onto the 'Read' function next. It should be pretty self explanatory: 

# ninja_service/api.py

from django.shortcuts import get_object_or_404

class ProjectOut(Schema):
    id: int
    name: str
    description: str = None

@api.get("/projects/{project_id}",response=ProjectOut)
def get_project(request,project_id:int):
    project = get_object_or_404(Project,id=project_id)
    return project

We create a different Schema for the Read operation, because we want to return the id that the system automatically assigns the object when we create it. Note the use of the Django shortcut and that we now have a parameter in our URL route. 

Posting some data:

curl -X POST "http://127.0.0.1:8000/ninja-api/projects" -H  "Content-Type: application/json" -d "{"name":"Project Number 1","description":"This is the first project"}"
{
  "message": "successfully created project id: 1"
}

Retrieving some data:

curl -X GET "http://127.0.0.1:8000/ninja-api/projects/1" -H  "accept: application/json"
{
  "id": 1,
  "name": "Project Number 1",
  "description": "This is the first project"
}

Now lets move onto the 'U' (update) part of our CRUD operations. Initally I created two functions like so: 

@api.put("/projects/{project_id}")
def full_update_project(request,project_id:int,payload: ProjectIn):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}

@api.patch("/projects/{project_id}")
def partial_update_project(request,project_id:int,payload: ProjectIn):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}

One function is for a total update of an object (put) and one for a partial update (patch). We are iterating over the payload's dict method (as suggested in the DN docs) and using setattr to save the new attributes to the already existing model. However, if we try and update the description only on our project we encounter a 422 error.
 

curl -X PATCH "http://127.0.0.1:8000/ninja-api/projects/1"  -H  "Content-Type: application/json" -d "{"description":"update description only"}"
{
  "detail": [
    {
      "loc": [
        "body",
        "payload",
        "name"
      ],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

OK, so the fact that 'name' in our ProjectIn schema doesnt have a None default, is preventing a partial update from taking place. Our Schema is expecting a name. 

Lets make a schema just for updating, where we set every attribute with a default of None, to allow for PATCHING:
 

class ProjectUpdate(Schema):
    name: str = None
    description: str = None


So if we run our same call now, we get a 500 error. 
Looking at the stack trace we can see: 

django.db.utils.IntegrityError: NOT NULL constraint failed: ninja_service_project.name


So the error is now happening at the database level. Our code is trying to push a null value to the objects name, despite us only wanting to update the description. A default of 'None' in a schema is a default value, not an instruction to be skipped when we are looping through those values, even during a patch operation (remember what Pydantic is!). We can add a simple check to get our code to work as intended and our two update functions now look like: 
 

@api.put("/projects/{project_id}")
def full_update_project(request,project_id:int,payload: ProjectIn):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}

@api.patch("/projects/{project_id}")
def partial_update_project(request,project_id:int,payload: ProjectUpdate):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        if value:
            setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}

The patch function now uses a different Schema, meaning it can accept no value for any attribute. Our PATCH request now works: 

curl -X PATCH "http://127.0.0.1:8000/ninja-api/projects/1" -H  "accept: */*" -H  "Content-Type: application/json" -d "{"description":"Description only"}"
{
  "message": "successfully updated project id: 1"
}

Despite these two differences, these two functions look pretty similar and have introduced a fair bit of redundancy into the project. Single functions can handle multiple methods in Django Ninja, so we can actually refactor both put and patch into this: 
 

@api.api_operation(['PUT','PATCH'],"/projects/{project_id}")
def update_project(request,project_id:int,payload: ProjectUpdate):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        if value:
            setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}

I suppose this means that PUT and PATCH are now effectively interchangable, but its not uncommon in REST APIs that I have worked with for PUT to handle both complete and partial updates, so I can live with this!

Test out a PUT request for good measure: 

curl -X PUT "http://127.0.0.1:8000/ninja-api/projects/1" -H  "accept: */*" -H  "Content-Type: application/json" -d "{"name":"Project 1 renamed","description":"another new description"}"
{
  "message": "successfully updated project id: 1"
}

To round this all this off, lets make a delete function and another 'get' function which returns a list of projects, as opposed to just a single object.  

@api.delete("/projects/{project_id}")
def delete_project(request, project_id: int):
    project = get_object_or_404(Project, id=project_id)
    project.delete()
    return {"message": f"successfully deleted project id: {project.id}"}

@api.get("/projects",response=List[ProjectOut])
def list_projects(request):
    qs = Project.objects.all()
    return qs

Delete is pretty self-explanatory, however notice that we are wrapping the 'ProjectOut' Schema in 'List', to indicate that we will be returning a list of objects which confirm to that particular Schema. A succesful reponse body to called that endpoint might look like:
 

[
  {
    "id": 1,
    "name": "Project  1",
    "description": "blah"
  },
  {
    "id": 2,
    "name": "Project  2",
    "description": "blah"
  },
  {
    "id": 3,
    "name": "Project  3",
    "description": "blah"
  }
]

Try adding more projects so you can meaningfully test the Project List endpoint and then also try deleting a project. Again, I've been using cURL here for examples as it feels suitably 'universal', but feel free to use the docs or a tool like Postman or the excellent HTTPie

One last thing to do, before we move onto the more complex release model, is look again at the docs page. 



You will notice that each endpoint is lumped under 'default', which looks bad. Thankfully Django Ninja provides a way of organising via 'tagging' the functions. Just pass tags=['tag name'] into the decorator. 
Your ninja_service/api.py file should now look like: 

from typing import List

from ninja import NinjaAPI,Schema
from django.shortcuts import get_object_or_404
from .models import Project

api = NinjaAPI()

class ProjectIn(Schema):
    name: str
    description: str = None

class ProjectUpdate(Schema):
    name: str = None
    description: str = None

class ProjectOut(Schema):
    id: int
    name: str
    description: str

@api.post("/projects", tags=["projects"])
def create_project(request,payload:ProjectIn):
    project = Project.objects.create(**payload.dict())
    return {"message": f"successfully created project id: {project.id}"}

@api.get("/projects/{project_id}",response=ProjectOut, tags=["projects"])
def get_project(request,project_id:int):
    project = get_object_or_404(Project,id=project_id)
    return project

@api.api_operation(['PUT','PATCH'],"/projects/{project_id}", tags=["projects"])
def update_project(request,project_id:int,payload: ProjectUpdate):
    project = get_object_or_404(Project, id=project_id)
    for attr,value in payload.dict().items():
        if value:
            setattr(project,attr,value)
    project.save()
    return {"message": f"successfully updated project id: {project.id}"}


@api.delete("/projects/{project_id}", tags=["projects"])
def delete_project(request, project_id: int):
    project = get_object_or_404(Project, id=project_id)
    project.delete()
    return {"message": f"successfully deleted project id: {project_id}"}

@api.get("/projects",response=List[ProjectOut], tags=["projects"])
def list_projects(request):
    qs = Project.objects.all()
    return qs

With the docs nicely organised under 'projects': 



Now lets move onto our slightly more complex Release model. 

We might be tempted to replicate exactly the same strcture, with the only change of note being that in the ReleaseOut Schema, we are going to nest the ProjectOut schema. 
 

from .models import Release
from datetime import date

...

class ReleaseIn(Schema):
    title: str
    project: int
    press_release: str = None
    release_date: date

class ReleaseUpdate(Schema):
    title: str = None
    project: int = None
    press_release: str = None
    release_date: date = None

class ReleaseOut(Schema):
    id: int
    title: str
    project: ProjectOut = None
    press_release: str = None
    release_date: date
    
@api.post("/releases", tags=["releases"])
def create_release(request,payload:ReleaseIn):
    release = Release.objects.create(**payload.dict())
    return {"message": f"successfully created release id: {release.id}"}

@api.get("/releases/{release_id}",response=ReleaseOut, tags=["releases"])
def get_release(request,release_id:int):
    release = get_object_or_404(Release,id=release_id)
    return release

@api.api_operation(['PUT','PATCH'],"/releases/{release_id}", tags=["releases"])
def update_release(request,release_id:int,payload: ReleaseUpdate):
    release = get_object_or_404(Release, id=release_id)
    for attr, value in payload.dict().items():
        if value:
            setattr(release, attr, value)
    release.save()
    return {"message": f"successfully updated release id: {release.id}"}

@api.delete("/releases/{release_id}", tags=["releases"])
def delete_release(request, release_id: int):
    release = get_object_or_404(Release, id=release_id)
    release.delete()
    return {"message": f"successfully deleted project id: {release.id}"}

@api.get("/releases",response=List[ReleaseOut], tags=["releases"])
def list_releases(request):
    qs = Release.objects.all()
    return qs

 

Lets try and get a POST request working...
 

curl -X POST "http://127.0.0.1:8000/ninja-api/releases" -H  "accept: */*" -H  "Content-Type: application/json" -d "{"title":"Totally New Release","project":1,"press_release":"Blah Blah Blah","release_date":"2021-02-14"}"

Immediately upon trying to create a release this way, we see another 500 error. If I check the terminal where I typed my 'runserver' command I can see clearly:

ValueError: Cannot assign "1": "Release.project" must be a "Project" instance.

This is at the database level again. In our code we are passing the project's foreign key to Release.objects.create(), however we need to be more explicit, (The DN docs also suggest this, if need be), and actually fetch the User object we need in this case. We can use that Django 'get_object_or_404' shortcut again.  

Our Create Release now looks like this: 

@api.post("/releases", tags=["releases"])
def create_release(request, payload: ReleaseIn):
    release = Release.objects.create(
        project=get_object_or_404(Project, id=payload.project),
        title=payload.title,
        press_release=payload.press_release,
        release_date=payload.release_date,
    )
    return {"message": f"successfully created release id: {release.id}"}

This gets the Create request passing:
 

{
  "message": "successfully created release id: 1"
}

This of course also means we will need to update the UPDATE function: 

@api.api_operation(["PUT", "PATCH"], "/releases/{release_id}", tags=["releases"])
def update_release(request, release_id: int, payload: ReleaseUpdate):
    release = get_object_or_404(Release, id=release_id)
    if payload.project:
        release.project = get_object_or_404(Project, id=release.project.id)
    if payload.title:
        release.title = payload.title
    if payload.press_release:
        release.press_release = payload.press_release
    if payload.release_date:
        release.release_date = payload.release_date
    release.save()
    return {"message": f"successfully updated release id: {release.id}"}

Hmmm things are starting to look a little less elegant now, but lets test that it at least works and we can revist for a better solution another time: 

curl -X PUT "http://127.0.0.1:8000/ninja-api/releases/1" -H  "accept: */*" -H  "Content-Type: application/json" -d "{"title":"New Name For Release","project":1,"press_release":"Rah Rah Rah","release_date":"2022-02-15"}"
curl -X PATCH "http://127.0.0.1:8000/ninja-api/releases/1" -H  "accept: */*" -H  "Content-Type: application/json" -d "{"title":"Patched Title"}"

...these now both work:

{
  "message": "successfully updated release id: 1"
}


The final thing I want to do here is provide some pagination for the release list. In our final app, whilst we assume the musical artist has a fairly small number of projects, we expect them to have a lot of releases, so we will paginate the results.

In Django REST Framework's 'Page Number' Pagination, lists of objects are returned under 'results' , with information about the pagination at the very top like so: 

{
    "count": 120
    "next": "https://example.com/examples/?page=3",
    "previous": "https://example.com/examples/?page=1",
    "results": [
       {"example":"value"},
       {"example":"value"},
       {"example":"value"},
       .... and so on.
    ]
}

We are going to aim to recreate something simiar for our app's releases list. we will create a new Paginated Schema, wrapping the nested ReleaseOut schema in List[], which will give us a list of objects conforming to that particular schema. 

Import Django's Paginator Class, as well as creating a new Schema: 

from django.core.paginator import Paginator

...

class PaginatedReleaseOut(Schema):
    total_releases: int
    total_pages: int
    per_page : int
    has_next: bool
    has_previous: bool
    results: List[ReleaseOut] = None


I have now updated the function like so: 

@api.get("/releases",response=PaginatedReleaseOut, tags=["releases"])
def list_releases(request,page:int=1):
    releases = Release.objects.all()
    paginator = Paginator(releases,3)
    page_number = page
    page_object = paginator.get_page(page_number)
    response = {}
    response["total_releases"] = page_object.paginator.count
    response["total_pages"] = page_object.paginator.num_pages
    response["per_page"] = page_object.paginator.per_page
    response["has_next"] = page_object.has_next()
    response["has_previous"] = page_object.has_previous()
    response["results"] = [i for i in page_object.object_list.values()]
    return response

Notice how there is now a 'page' Query Parameter being passed to the function. This defaults to 1 , as we assume that if no parameter is passed, we will want to begin 'at the start'. After that, we configure the paginator and build the response, culminating in a list comprehension to create the nested list of release objects. 

Add in enough,(i.e more than 3), releases so we can meaningfully test it.
 

http://127.0.0.1:8000/ninja-api/releases?page=1
{
  "total_releases": 5,
  "total_pages": 2,
  "per_page": 3,
  "has_next": true,
  "has_previous": false,
  "results": [
    {
      "id": 1,
      "title": "Release 1",
      "project": 1,
      "press_release": "My Release",
      "release_date": "2021-02-15"
    },
    {
      "id": 2,
      "title": "Release 2",
      "project": 1,
      "press_release": "blah blah blah",
      "release_date": "2021-02-16"
    },
    {
      "id": 3,
      "title": "Release 3",
      "project": 1,
      "press_release": "blah",
      "release_date": "2021-02-17"
    }
  ]
}
http://127.0.0.1:8000/ninja-api/releases?page=2
{
  "total_releases": 5,
  "total_pages": 2,
  "per_page": 3,
  "has_next": false,
  "has_previous": true,
  "results": [
    {
      "id": 4,
      "title": "Release 4",
      "project": 1,
      "press_release": "blah bleh bleh",
      "release_date": "2021-02-18"
    },
    {
      "id": 5,
      "title": "Release 5",
      "project": 1,
      "press_release": "blah",
      "release_date": "2021-02-19"
    }
  ]
}

This seems to work fine, (it returns a successful response and results), but if we look in our Terminal again: 

UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'ninja_service.models.Release'> QuerySet.


We need to explicitly order our releases. In this situation it entirely makes sense to order our artist's release by their release date: 

@api.get("/releases",response=PaginatedReleaseOut, tags=["releases"])
def list_releases(request,page:int=1):
    releases = Release.objects.all().order_by('-release_date')

...

Now we can retireve a list of releases without Django throwing a warning! 

The Docs that Django Ninja generated for us should now look like this: 


 

Thats a good place to wrap up this guide! You can check the full api.py file on Github. I really enjoyed teaching myself the basics of Django Ninja. Right before discovering the framework, I had just read Luke Plant's thought provoking article on Django Views, and was very much 'in the mood' for function based views. Whilst that article is actually very complimentary about Django REST Framework's Generic Class Based Views, CBVs in general could be argued to provide to greater level of abstraction nessecary for both a beginnner, or someone coming to a project midway through. Class based views are currently less of a thing in Django Ninja and whilst that might change in the future,  Django's docs define a view as 'a Python function that takes a Web request and returns a Web response', so really you dont need anything much more than that! Django Ninja is a ready to go framework which will help you create Open API documented, Pydantic validated and Fast APIs. 

In Part 2, I will build this exact set-up again, but using Django REST-Framework and its generic CBVs, just so that if you have followed along with this tutorial, you can try the other way of doing things and compare! 

You may also like: