Django-Ninja - An Introduction

In this post, we will create an API using the django-ninja library, allowing us to retrieve data about music tracks and their artists.

django-ninja is an API package that is heavily inspired by the excellent FastAPI Python web framework. It provides a raft of nice features (many directly inspired by FastAPI), such as async support, type-hinted, schema-based request and response validation, automatic API documentation, and a simple, intuitive and Pythonic mechanism for defining REST API routes.

django-ninja represents an alternative way to define Django-based APIs, rather than using Django REST Framework. DRF is an excellent toolkit for Django APIs, but django-ninja is an appealing and lightweight alternative that may benefit your Django APIs in some instances.

In this post, we will create a simple GET-based API that will retrieve music tracks from a database. Some of the core django-ninja features will be outlined, and we will see how to create API routes, how to define schemas for response data, how to use path parameters in your URL to search for a specific resource, and how to use URL query parameters for filtering. We will also see how to automatically get OpenAPI documentation for our APIs. Very convenient!

In part 2 of the tutorial - after covering the basic features in this one - we will look at spinning this out into a full CRUD API. Let's get started!

The two videos accompanying this post can be found here:


Objectives

Our aim with this post is to:

  • Learn how to set up a basic API and define routes using django-ninja.
  • Learn how to use Schema objects for API request/response validation.
  • Learn how to leverage path-parameters and query-parameters to retrieve particular subsets of resources.
  • Learn how to access OpenAPI documentation generated by our django-ninja API routes.

Project Setup

To begin with - and optionally in a virtual environment - you can install the dependencies from the requirements.txt file with the following command:

pip install -r requirements.txt

This will install both Django, and django-ninja, into your environment.

Next, create a Django project called djninja, and create an app within that project called tracks.

django-admin startproject djninja
cd djninja
python manage.py startapp tracks

With that created, you should now add tracks to your INSTALLED_APPS list in Django's settings.py file.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'tracks',
]

With that setup, we are now ready to start playing around with django-ninja.

Creating a test route

Firstly, we'll create a test route to detail the process. This will be a simple GET route, that returns a success message.

Create an api.py file in the tracks app - this file will hold our API routes. In this file, we'll import the NinjaAPI object, instantiate it, and create a test route. The code for this is below.

from ninja import NinjaAPI

api = NinjaAPI()

@api.get("/test")
def test(request):
    return {'message': 'success!'}

We instantiate the NinjaAPI object on line 3. On line 5, we define our test API route as a GET request, at the endpoint /test. The view function, like a normal Django view, takes the request object as a parameter.

To wire this new URL up, we need to edit the djninja/urls.py file. We will import the api object defined above, and access its api.urls parameter that creates the URLs automatically. The djninja/urls.py file should look like below:

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

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

Line 7 adds these API routes, with the prefix api. This means our new test route is accessible at http://localhost:8000/api/test - try that out, and you should receive our success message response!

{"message": "success!"}

Nice one! We have created our first ninja route. But this isn't very useful - we want to create an API for the tracks data.

Track Model and data import

The tracks.json file that we are going to use to create our dataset of tracks can be found on Github.

Add this file to a data directory in your root folder.

This file contains some track/song data, which you can imagine has been captured from a music player such as Spotify. Each track has 5 different fields:

  1. id - an integer identifier
  2. title - the name of the song
  3. artist - the artist
  4. duration - the duration of the song in seconds (note: this can have fractional values).
  5. last_play - when was the song last played by a user

Let's create a Django model to represent this data. In the tracks/models.py file, create the following class.

from django.db import models

class Track(models.Model):
    title = models.CharField(max_length=250)
    artist = models.CharField(max_length=250)
    duration = models.FloatField()
    last_play = models.DateTimeField()

The id field is created automatically by Django. Surprisingly, duration is a FloatField (it can have values such as 180.5, for some reason). The last_play field represents a datetime so, we make that a DateTimeField

Once this is done, we need to create migrations and migrate to push our changes to the database and create the Track table.

python manage.py makemigrations
python manage.py migrate

Now, we want to import the JSON file and populate our new table. There are different ways to achieve this, but I like to create Django management commands that give us easy access to the powerful Django ORM. To create a management command:

  1. Within your tracks app, create a new folder with the name management.
  2. Create another folder called commands within the management folder
  3. Create an __init__.py file within the management/commands folder.
  4. Create another file in management/commands - an ingest_tracks.py file.

These steps can be achieved on Linux/Mac systems with the following commands.

mkdir -p tracks/management/commands
touch tracks/management/commands/__init__.py
touch tracks/management/commands/ingest_tracks.py

Now, add the following code to the ingest_tracks.py file. This will take care of importing the JSON data into the database.

from datetime import datetime
import json
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.timezone import make_aware

from tracks.models import Track

class Command(BaseCommand):
    help = 'Create tracks from JSON file'

    def handle(self, *args, **kwargs):
        # set the path to the datafile
        datafile = settings.BASE_DIR / 'data' / 'tracks.json'
        assert datafile.exists()

        # load the datafile
        with open(datafile, 'r') as f:
            data = json.load(f)
        
        # create tz-aware datetime object from the JSON string.
        DATE_FMT = "%Y-%m-%d %H:%M:%S"
        for track in data:
            track_date = datetime.strptime(track['last_play'], DATE_FMT)
            track['last_play'] = make_aware(track_date)

        # convert list of dictionaries to list of Track models, and bulk_create
        tracks = [Track(**track) for track in data]

        Track.objects.bulk_create(tracks)

The code above reads the JSON file, converts the strings in the last_play field to timezone-aware datetime objects, and bulk-creates Track models from all the records in the JSON file.

If you did not create a data directory that contains the tracks.json file, you will need to edit line 14 and provide the correct path to the file.

To run this command and ingest our data, you simply run:

python manage.py ingest_data

And voila! You now have a bunch of tracks in the database. We are ready to create our django-ninja API.

Tracks API

Let's head back to our tracks/api.py file. We are now going to create two RESTful routes:

  • /api/tracks - A route to retrieve ALL tracks in the database
  • /api/tracks/{track_id} - A route to retrieve an individual track in the database, by its ID.

We can also create a response schema. This is a django-ninja concept that outlines a route's response structure using a Schema class. For our track API, we want to exclude the ID from the response, but include all the other fields.

Create a schema.py file within your tracks app. In there, put the following code.

from datetime import datetime
from ninja import Schema

class TrackSchema(Schema):
    title: str
    artist: str
    duration: float
    last_play: datetime

class NotFoundSchema(Schema):
    message: str

You can see from the above that the schemas use Python's type-hinting syntax. 

  • The TrackSchema defines the fields we want to return when working with Track data
  • The NotFoundSchema defines a single field to be returned when a model object is not found, when an ID path-parameter is provided (i.e. in a 404 Not Found response).

We will now edit our tracks/api.py to add the two new routes (and we will remove our old /test route, for brevity).

from ninja import NinjaAPI
from tracks.models import Track
from tracks.schema import TrackSchema, NotFoundSchema

api = NinjaAPI()

@api.get("/tracks", response=List[TrackSchema])
def tracks(request):
    return Track.objects.all()

@api.get("/tracks/{track_id}", response={200: TrackSchema, 404: NotFoundSchema})
def track(request, track_id):
    try:
        track = Track.objects.get(pk=track_id)
        return 200, track
    except Track.DoesNotExist as e:
        return 404, {"message": "Could not find track"}

We import both our Track model from models.py file, and our TrackSchema and NotFoundSchema from the schema.py file.

On line 7, we define a HTTP GET route to the /api/tracks endpoint. We declare that the response should conform to the TrackSchema defined in schema.py, and that the response should contain a list of such objects. By notifying our route of the response schema, we are getting automatic validation (based on the schema types) and automatic documentation for the route. We also get automatic conversion from Django models to JSON (see below).

The body of the route's method is a simple ORM command to retrieve all tracks. The serialization of the Track QuerySet to JSON is taken care of by django-ninja, based on the TrackSchema fields' data types.

On line 11, we define the track-detail endpoint, /api/tracks/{track_id}. The {track_id} is a dynamic path parameter that is used to search for an individual track - for example, /api/tracks/1 would search for the track with ID 1.

Notice that we provide two response schemas in the @api.get decorator - the TrackSchema if we return a 200 response, and the NotFoundSchema if we return a 404 response.

The dynamic track_id is passed to the function as a parameter on line 12. In the body of the function, we look up a Track with the given track_id and return it that track if it is found (lines 14-15).

If no track with this ID exists, then we return a 404 status code with the given message (line 17).

These two endpoints demonstrate how simple it is to define django-ninja API routes and schemas, and return serialized ORM objects and QuerySets.

Query Parameters

We've seen how easy it is to look up dynamic path parameters such as the track_id in the previous example. These are defined in the route, and passed to the API function as a parameter. You can then perform lookups with the parameter, or any other logic you wish.

What about query parameters? These are key-value pairs that are appended to a resource URL. For example, we might want to search for all tracks that have "Moon" in the title. REST APIs often provide the facility to do this with query parameters.

Our API route to retrieve all tracks could have this name searching functionality with the following URL: /api/tracks?title=Moon. The key is 'title' and the value is 'Moon', in this example.

django-ninja can accept these query parameters as parameters to the API route's function. Any parameter that is not a path parameter defined explicity in the route decorator, such as the {track_id} syntax in the previous example - is taken to be a query parameter in the URL.

So to accept an optional title query parameter, we can extend our route that retrieves all tracks as below.

@api.get("/tracks", response=List[TrackSchema])
def tracks(request, title: Optional[str] = None):
    if title:
        return Track.objects.filter(title__icontains=title)
    return Track.objects.all()

We use the typing module's Optional construct to tell django-ninja that this is not a required field. Within the body, if there is a truthy title value, we filter down the Track queryset to only those tracks whose title (case-insensitive) contains whatever the user has searched (in our case "Moon").

The result?

[
   {
      "title":"Can't Fight The Moonlight",
      "artist":"Leann Rimes",
      "duration":213,
      "last_play":"2018-05-17T16:56:30Z"
   },
   {
      "title":"Dancing In The Moonlight",
      "artist":"Toploader",
      "duration":200,
      "last_play":"2017-10-18T00:27:59Z"
   },
   ...

]

We are only getting titles that contain the word "moon", as we wanted.

Query parameters give REST APIs an easy, URL-based mechanism for filtering down results based on certain fields and values - and django-ninja supports these constructs very easily!

Automatic API Documentation

django-ninja comes with another very useful, handy feature that comes automatically with the setup that we have above. API documentation!

The endpoint for the documentation is at: /api/docs.

A screenshot is provided below of the routes (and their HTTP method - all GET, for now!)

API documentation

You can drill into each of these API routes to find out what the response schema is, and to find out more about any path parameters and query parameters. This is incredibly useful for any other developers (particularly front-end React/Vue/Angular devs working against your APIs), and is also vital for companies who provide APIs as a service. You are able to share documentation like this with your clients or customers, so they can work with your APIs easily.

In addition, you can retrieve the OpenAPI JSON specification for your API at the following endpoint: /api/openapi.json.

For more on the automatic documentation, check this out.

Summary

We've covered the basics of django-ninja in this post, including:

  • Creating API routes
  • Creating validation/response schemas
  • Utilizing path parameters for detail views
  • Utilizing query parameters for queryset filtering
  • How to attach different response schemas, depending on the response status-code.
  • How to view the automatic API documentation that django-ninja creates.

We have also covered how to import JSON data into our database using a Django management command, in preparation for creating our API routes (this may be new to some!).

In the next post in this django-ninja tutorial, we are going to spin this out into a full CRUD API, giving us the option to add new tracks, change existing tracks, and delete tracks from our database.

Check out the next post here.

Thank you for reading! 

If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!

Please also consider buying us a coffee, to encourage us to create more posts and videos!

Suggested Extensions

If you want to challenge yourself, we would suggest writing the following extensions to our basic API.

  1. Using a query parameter, only return tracks who's duration is above (or below) a certain number. These could be chained - for example: /api/tracks?duration_gt=150&duration_lt=280.
  2. Add a query parameter to order the results by a particular field. For example: /api/tracks?order=title, /api/tracks?order=artist, or /api/tracks?order=last_play.
  3. Create a new API endpoint that returns, for each artist, the number of tracks they have in the database (hint: the Django queryset .annotate() method might help here). 

;