Django-Ninja - Creating a CRUD API

This is the second part of a series of posts on django-ninja. Here, we are going to extend the music tracks API that we built in the previous post, to include giving users the ability to create, update and delete tracks in the database.

Previously, we created two GET endpoints.

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

We now want to extend this API to create the following routes.

  • POST /api/tracks - A route that allows JSON data to be sent in a POST request, and creates a new track in the database
  • PUT /api/tracks/{track_id} - A route that allows an existing track to be changed via a PUT request
  • DELETE /api/tracks/{track_id} - A route that takes care of deleting an existing track from the database.

Let's get started! The video accompanying this post can be found below:


Objectives

In this post, we will learn how to do the following with django-ninja:

  • Learn how to hook up POST requests to create new resources
  • Learn how to hook up PUT requests to change existing resources
  • Learn how to hook up DELETE requests to delete existing resources

Creating a new track - POST request

We will start with the API route that allows clients to create a new music track in the database with a POST request.

To do this, we will jump into our tracks/api.py file. This has the GET routes defined in the first part of this django-ninja series. 

Let's add a new route - this time, it will be a POST request to the /api/tracks endpoint.

@api.post("/tracks", response={201: TrackSchema})
def create_track(request, track: TrackSchema):
    Track.objects.create(**track.dict())
    return track

Let's break this route down line by line.

  • On line 1, the decorator specifies the route, and the response schema. The @api.post declaration denotes that this is a POST route. For this route, we return a HTTP 201 Created response code, along with a serialized TrackSchema object. django-ninja will take care of this serialization for us, based on the type-hinted TrackSchema defined in the previous post.
  • On line 2, we accept the track data from the POST request body. The request body should contain a JSON string with the fields required by the schema - artist, title, duration, and last_play. This is automatically converted to a TrackSchema object by django-ninja (under the hood, this uses the pydantic library, similarly to FastAPI). This object is made available as the second parameter to the route method.
  • On line 3, we use the Django ORM to create a new Track object. The TrackSchema object is converted to a dictionary - schemas have a .dict() method for this purpose. This dictionary's key/value pairs are then passed as keyword arguments to the ORM's .create() method, creating the new track in the database.
  • On line 4, we return the track. This is the parameter representing TrackSchema, and it is automatically serialized to JSON by django-ninja.

This route will allow us to add a new track to our database, if valid JSON is provided in the request body. To test this out, we'll use a curl command (available on Linux, Mac, Git Bash, etc). You can also use an API client such as Postman to test this out.

Make sure the Django server is also running before testing this!

curl -X POST -d '{"artist": "Sonic Youth", "title": "Silver Rocket", "last_play": "2017-10-18 15:15:26", "duration": 200}' http://localhost:8000/api/tracks

This will send a POST request, with the JSON string attached from the -d flag, to the endpoint we have just created.

When we send this request, we should get back the created resource and a 201 response code. The JSON response is shown below.

>> {"title": "Silver Rocket", "artist": "Sonic Youth", "duration": 200.0, "last_play": "2017-10-18T15:15:26"}

We can verify that the new track has been added by executing the following code in the Django shell.

from pprint import pprint
from tracks.models import Track

track = Track.objects.last()
pprint(track.__dict__)

This retrieves the last Track instance, and pretty-prints out the class dictionary with the fields. We should see the following output.

{'_state': <django.db.models.base.ModelState object at 0x000001B19CC0D460>,
 'artist': 'Sonic Youth',
 'duration': 200.0,
 'id': 7729,
 'last_play': datetime.datetime(2017, 10, 18, 15, 15, 26, tzinfo=<UTC>),
 'title': 'Silver Rocket'
}

Nice! We now have an API route that allows us to add new tracks to our collection. Next, we need to give clients a way to change existing tracks.

Changing an existing track - PUT request

PUT requests are the typical REST API mechanism for giving API clients the ability to change existing resources. In our case, we may want to edit any of the fields of a track - any of the artist, title, duration, and last_play fields.

PUT requests are similar to POST requests - the request body contains JSON that represents the entity's (i.e. the track's) state. However, we are not going to create a new track with a PUT request (although this is possible). Instead, we'll use it to change an existing track and replace it with the values specified in the JSON request body.

The track that we change is defined by a {track_id} path parameter. This gives you a way to dynamically look up a track in the database, and then replace its values with those in the request body.

Let's see how to create this PUT request in the following code block.

@api.put("/tracks/{track_id}", response={200: TrackSchema, 404: NotFoundSchema})
def change_track(request, track_id: int, data: TrackSchema):
    try:
        track = Track.objects.get(pk=track_id)
        for attribute, value in data.dict().items():
            setattr(track, attribute, value)
        track.save()
        return 200, track
    except Track.DoesNotExist as e:
        return 404, {"message": "Could not find track"}

In the decorator, we specify the path parameter {track_id}in the route's path. We denote the response schemas - 200 and a serialized TrackSchema object if successful, and 404 with a NotFoundSchema object if a track with this track ID is not found.

The data parameter to the route function represents the request body, which is converted to a TrackSchema object by django-ninja. This is the data we want our new track to have.

Within the try block, we look the existing track up by its ID (line 4). Then, we convert our TrackSchema object to a Python dictionary, and loop over its items to set the attributes of the existing track's model object (lines 5-6). Finally, we call .save() on the changed Django model object on line 7, to persist the new values to the database.

Let's test this new route out with curl. We want to change the track we created above - let's change the artist from Sonic Youth to The Fall.

curl -X PUT -d '{"artist": "The Fall", "title": "Silver Rocket", "last_play": "2017-10-18 15:15:26", "duration": 200}' http://localhost:8000/api/tracks/7729

Notice that the URL (at the end of the curl command) has the ID of the track we created with the POST request - 7729. You may need to change this to match your ID.

The response should look like below.

{"title": "Silver Rocket", "artist": "The Fall", "duration": 200.0, "last_play": "2017-10-18T15:15:26"}

With that in place, let's use the Django shell to verify that the change has taken place. Change the pk variable below to match the one you created.

from pprint import pprint
from tracks.models import Track

pk=7729
track = Track.objects.get(pk=pk)
pprint(track.__dict__)

>> {'_state': <django.db.models.base.ModelState object at 0x000002836063E3D0>,
>> 'artist': 'The Fall',
>> 'duration': 200.0,
>> 'id': 7729,
>> 'last_play': datetime.datetime(2017, 10, 18, 15, 15, 26, tzinfo=<UTC>),
>> 'title': 'Silver Rocket'}

We can now see that the artist has been successfully changed. The Fall performing Silver Rocket - imagine that?

The final HTTP method we need to implement is the DELETE method, to allow clients to delete tracks from the database.

Deleting an existing track - DELETE request

We will now create a DELETE endpoint using django-ninja. In some ways, this will be similar to our PUT endpoint, as it will contain a path parameter indicating the ID of the resource we want to delete.

There are a few differences, though. There will be no request body - since we are not updating anything, and are simply deleting an individual track. Therefore, we do not need to accept a request body schema when defining our route - in the POST and PUT endpoints, we accepted a TrackSchema that represented the data being sent to the endpoint. This won't be present on the DELETE route. Let's see below.

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

Notice that the response schema is set the None for a 200 response. When we delete a track, it is gone - we simply want to return a 200 response to the client indicating successful deletion. No data needs to be sent, thus no schema is defined. Therefore, on line 6, we return the 200 response code on its own.

As before, if the track we are looking up does not exist, we return a 404 Not Found response along with the NotFoundSchema that requires a message (line 8).

To actually delete the track, look at lines 4 and 5. We fetch the track by its ID, returning a Django model instance. This model has a .delete() method that allows you to delete the underlying database object. This removes the model from the table and your system, which is the desired action for DELETE requests to this endpoint.

Let's test this out, again with curl. No request body is needed, so we simply specify a DELETE request with the -x flag, and add the track ID to the end of the request URL.

curl -X DELETE http://localhost:8000/api/tracks/7729

This should return an empty 200 response, if the ID path-parameter at the end of the URL exists.

To verify the object is deleted, let's again head over to the Django shell.

from tracks.models import Track

pk=7729
print(Track.objects.filter(pk=pk).exists())

>> False

There are no tracks with this ID, thus the track has been successfully deleted.

That concludes our simple REST API for the track data. We have given our imaginary API clients the ability to:

  • Create new tracks
  • Update existing tracks
  • Delete existing tracks

Hopefully you have learned something from this post.

In the next post, we'll learn how to handle file-uploads with django-ninja.

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!

;