Django-Ninja, the Contender


Django-Ninja, the Contender

Django Rest Framework as been the top contender for Django API building for a very long time, and with good reason. Its flexible, stable and just works really well. However, Django-Ninja, now on v1.0 offers not just a new option, but a near totally different route to building API’s in Django and it’s GOOD!

The first thing you should know about Django-Ninja is that its inspired by FastAPI, HEAVILY. In this, I mean its not sort of like FastAPI, but as similar as it can get. That said, it has a Django inspired additions which, I think, pushes this library in to the AbFab category.

So, I’m not going to go into the whole “getting started” thing, but leap right into the more obvious stuff.

Path parameters

@api.get("/items/{item_id}")
def read_item(request, item_id: int):
return {"item_id": item_id}

Here is a simple example of a path parameter. You will notice that I’ve annotated item_id as an integer. Ninja will validate the parameter and reject it, if its not an integer and send back a error message. This results in the first obvious change between DRF and Ninja. DRF and Django would say that if you sent a string instead of a number, then the wouldn’t be a match and you would get a 404 error. Ninja however, sends back a validation error message.

{
"detail": [
{
"loc": [
"path",
"item_id"
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}

However, position of the view dictates the order the urls are matched, just as with Django, so if you wanted to match on /items/new for example in a different view it must come before this view

@api.get("/items/new")
def list_new(request):
return {"new_items":"new"}

@api.get("/items/{item_id}")
def read_item(request, item_id: int):
return {"item_id": item_id}

Query Parameters

If you have “get” view defined and parameters that are NOT defined in the URL, then Ninja assumes they are query parameters

@api.get("/items/{item_id}")
def list_items(request, item_id: int, limit: int, offset: int = 0):
return items[offset : offset + limit]

With Query parameters, if you set a default value, then they will be seen as optional. The “offset” value here is optional. Annotations, like Path parameters, are used for validation and as such, missing or invalid parameters will result in validation errors

Query Parameters via Schema

Ninja can process Query data via it’s Schema class, as it does with JSON and Form data.

import datetime
from typing import List

from pydantic import Field

from ninja import Query, Schema


class Filters(Schema):
limit: int = 100
offset: int = None
query: str = None
category__in: List[str] = Field(None, alias="categories")



@api.get("/filter")
def events(request, filters: Query[Filters]):
return {"filters": filters.dict()}

Post data

If your request is a post, Ninja will assume the values being sent are JSON values in the body, and not form data. The JSON data needs to be processed via a “Schema” class. This is a class inheriting from Pydantic.

from ninja import Schema


class Item(Schema):
name: str
description: str = None
price: float
quantity: int


@api.post("/items")
def create(request, item: Item):
return item
Note: In case you missed it, this assumes JSON data.

Form Data

Ninja does have the ability to process Form data. This is a function that exists in Ninja, but not in FastAPI.

from ninja import Form, Schema


class Item(Schema):
name: str
description: str = None
price: float
quantity: int


@api.post("/items")
def create(request, item: Form[Item]):
return item

Routers

If you create multiple NinjaAPI() objects, then you start to get complaints form the system at start-up, but they still work. A better method is to use the Router() object. This works nearly the same as APIRouter() in FastAPI

from ninja import Router
from .models import Event

router = Router()

@router.get('/')
def list_events(request):
return [
{"id": e.id, "title": e.title}
for e in Event.objects.all()
]

@router.get('/{event_id}')
def event_details(request, event_id: int):
event = Event.objects.get(id=event_id)
return {"title": event.title, "details": event.details}
from ninja import NinjaAPI
from events.api import router as events_router

api = NinjaAPI()

api.add_router("/events/", events_router)

Authentication

This is one of my favorite parts of Django-Ninja. It has a wonderful set of classes that you can use to create your own authentication routines, and attach then globally or locally to the API’s

“django_auth” uses Django’s normal authentication system. It does require that csrf is active to use it.

from ninja import NinjaAPI
from ninja.security import django_auth

api = NinjaAPI(csrf=True)


@api.get("/pets", auth=django_auth)
def pets(request):
return f"Authenticated user {request.auth}"

APIKeyQuery() is a base class that allows you to authenticate by an API key sent as a query parameter. Its very simple and easy to use

from ninja.security import APIKeyQuery
from someapp.models import Client


class ApiKey(APIKeyQuery):
param_name = "api_key"

def authenticate(self, request, key):
try:
return Client.objects.get(key=key)
except Client.DoesNotExist:
pass


api_key = ApiKey()


@api.get("/apikey", auth=api_key)
def apikey(request):
assert isinstance(request.auth, Client)
return f"Hello {request.auth}"

A more obvious choice for me, is authentication via a token in the Header

from ninja.security import APIKeyHeader


class ApiKey(APIKeyHeader):
param_name = "X-API-Key"

def authenticate(self, request, key):
if key == "supersecret":
return key


header_key = ApiKey()


@api.get("/headerkey", auth=header_key)
def apikey(request):
return f"Token = {request.auth}"

The last choice is to authenticate via a cookie

from ninja.security import APIKeyCookie


class CookieKey(APIKeyCookie):
def authenticate(self, request, key):
if key == "supersecret":
return key


cookie_key = CookieKey()


@api.get("/cookiekey", auth=cookie_key)
def apikey(request):
return f"Token = {request.auth}"

There are other options available, and you can set multiple authentication routines. You can also set authentication globally, by router, and on each view.

Error Handling

I’m not going to go through all of Ninja’s features, so this is my last one, and a good port from FastAPI.

You can write views that will be attached to a specific error and will be run when that error is raised. This makes a good system if you create a series of custom exceptions, and then create views for each exception.

api = NinjaAPI()

class ServiceUnavailableError(Exception):
pass


# initializing handler

@api.exception_handler(ServiceUnavailableError)
def service_unavailable(request, exc):
return api.create_response(
request,
{"message": "Please retry later"},
status=503,
)


# some logic that throws exception

@api.get("/service")
def some_operation(request):
if random.choice([True, False]):
raise ServiceUnavailableError()
return {"message": "Hello"}

Conclusion

I have a great liking for FastAPI and Django. They are very different creatures, but have the best features out there. Django-Ninja brings some of the best features of FastAPI into Django, thus allowing you to write MUCH better API’s. Even if your not into FastAPI, Ninja is a great tool in your Django toolkit, and one I think outshines DRF.