Tutorial¶
A tiny library to make writing CBV-based APIs easier in Django.
Essentially, this just provides some sugar on top of the plain old
django.views.generic.base.View
class, all with the intent of making handling
JSON APIs easier (without the need for a full framework).
Let’s walk through adding it to an existing blogging application.
Setup¶
django-microapi
is easy to add to an existing project. Its only dependency
is Django itself, with pretty much any modern
release being supported.
Installation is easy:
$ pip install django-microapi
It doesn’t need to be added to INSTALLED_APPS
, and you can just import
microapi
wherever you need it.
Note
When importing, it’s just microapi
, even though the package
name is django-microapi
. This is done to prevent cluttering up the
PyPI namespace & make it clear what the package
works with.
The Existing Application¶
We’ll assume you’ve implemented a relatively straight-forward blogging
application. For brevity, we’ll assume the models.py
within the application
looks something like:
# blog/models.py
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.text import slugify
# Because who knows? Maybe you have a custom model...
User = get_user_model()
class BlogPost(models.Model):
title = models.CharField(max_length=128)
slug = models.SlugField(blank=True, db_index=True)
author = models.ForeignKey(
User,
related_name="blog_posts",
on_delete=models.CASCADE,
)
content = models.TextField(blank=True, default="")
created = models.DateTimeField(
auto_now_add=True,
blank=True,
db_index=True,
)
updated = models.DateTimeField(
auto_now=True,
blank=True,
db_index=True,
)
def __str__(self):
return f"{self.title}"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
return super().save(*args, **kwargs)
Your First Steps¶
We’ll get started integrating by creating a simple list endpoint that responds to an HTTP GET.
Where you put this code is up to you, as long as it’s importable into your
URLconf (e.g. blog/urls.py
). It’s fine to place it in views.py
if your
application already has HTML-based views.
Alternatively, I like to put them in a separate api.py
file within the app
(e.g. blog/api.py
), so that I’m not mixing the API & HTML code in the same
file. Regardless, there’s no “wrong” way to do it.
For now, let’s assume there’s already stuff in blog/views.py
, so we’ll create
an empty blog/api.py
file.
Then we’ll start with the following code:
# blog/api.py
from microapi import ApiView
from .models import BlogPost
class BlogPostListView(ApiView):
def get(self, request):
posts = BlogPost.objects.all().order_by("-created")
return self.render({
"success": True,
"posts": self.serialize_many(posts),
})
Let’s step through what we’ve added to the file.
First, we want to import the ApiView
class from microapi
. This class
builds on Django’s own django.views.generic.base.View
class-based view, but
provides a couple additional useful bits for our use.
We then create a new class, BlogPostListView
, and inherit from the
ApiView
class we imported. Like any other View
subclass, we can define
methods on it to handle specific HTTP verbs (e.g.
get
, post
, put
, delete
, etc.).
Because most RESTful APIs return a list when sending a GET
to the top-level
endpoint, we implement our logic in the BlogPostListView.get
method:
class BlogPostListView(ApiView):
# ...
# We get the `HttpRequest` just like normal.
# Optionally, we also accept any URLconf parameters (none in this
# example).
def get(self, request):
# We collect a list of all the blog posts from the database via the
# ORM, just like normal.
posts = BlogPost.objects.all().order_by("-created")
# Then we create a JSON response of all of them.
return self.render({
"success": True,
"posts": self.serialize_many(posts),
})
The most interesting part is the call to self.render(...)
. Similar to
Django’s django.shortcuts.render
, this takes some data & creates a
HttpResponse
to serve back to the user.
However, in this case, rather than rendering a template & generating HTML, this converts the data into an equivalent JSON response. We’ll see what the result looks like after we finish hooking up the endpoint, which we’ll do next.
Hook Up the API Endpoint¶
We’ve build the API endpoint, but we haven’t hooked it up to a URL yet.
So we’ll go to our URLconf (blog/urls.py
), and hook it in a familiar way:
# blog/urls.py
from django.urls import path
# Just import the new API class...
from .api import BlogPostListView
urlpatterns = [
# ...then hook it up like any other CBV.
path("api/v1/posts/", BlogPostListView.as_view()),
]
Now, assuming that’s included in the main URLconf (e.g.
path("", include("blog.urls")),
), the user can hit the endpoint in a
browser & get a list of the blog posts!
For example, visiting http://localhost:8000/api/v1/posts/ might yield something like:
{
"success": True,
"posts": [
{
"id": 2,
"title": "Status Update",
"slug": "status-update",
"content": "I just wanted to drop a quick update.",
"created": "2024-01-11-T11:35:12.000-0600",
"updated": "2024-01-11-T11:35:12.000-0600",
},
{
"id": 1,
"title": "Hello, world!",
"slug": "hello-world",
"content": "My first post to my blog!",
"created": "2024-01-09-T20:10:55.000-0600",
"updated": "2024-01-09-T20:10:55.000-0600",
}
]
}
Yay! With a relatively minimal amount of code, our first bit of API works!
Note
You may have noticed that something (author
) is missing from
the JSON output. This is actually intentional, as microapi
’s take on
serialization is a very simplistic one. We’ll talk more about serialization
next as part of the detail endpoint.
Adding a Detail Endpoint¶
Now that we can accept HTTP GET
requests for a list endpoint, a common
follow-up request is a HTTP GET
detail endpoint. Let’s add that now.
As opposed to many API frameworks, microapi.ApiView
is very
endpoint/URL-focused. As a result of this, because the detail endpoint (e.g.
/api/v1/posts/<int:pk>/
) is separate/distinct from the list endpoint (e.g.
/api/v1/posts/
), we’ll need a separate/distinct API view to handle it.
So, back within api.py
, we’ll add a second class:
# blog/api.py
from microapi import ApiView
from .models import BlogPost
# What we previously defined.
class BlogPostListView(ApiView):
def get(self, request):
posts = BlogPost.objects.all().order_by("-created")
return self.render({
"success": True,
"posts": self.serialize_many(posts),
})
# Here's where the new code is!
# Note that while similar, this is a different name from above.
class BlogPostDetailView(ApiView):
# ...and this method signature is different!
def get(self, request, pk):
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
return self.render({
"success": True,
"post": self.serialize(post),
})
Before we forget, let’s hook up the new endpoint, then we’ll talk about how this new endpoint is different:
# blog/urls.py
from django.urls import path
# Import both classes.
from .api import (
BlogPostListView,
BlogPostDetailView,
)
urlpatterns = [
# The previously added list endpoint...
path("api/v1/posts/", BlogPostListView.as_view()),
# ...and the new detail endpoint!
path("api/v1/posts/<int:pk>/", BlogPostDetailView.as_view()),
]
While this new code is very similar to the list endpoint, there are a couple key differences to talk about:
Different
get(...)
signatureCatching a failed lookup & returning an error with
render_error
The use of
serialize(...)
instead ofserialize_many(...)
Different get(...)
Signature¶
The different method signature on BlogPostDetailView.get(...)
comes down to
the addition of a new parameter, pk
.
Like a normal Django function-based (or class-based) view, we can accept
additional parameters from the URLconf. In this case, the URLconf captures the
blog post’s primary key as pk
, which then gets passed along for use to the
view method.
Note
microapi
doesn’t enforce any constraints on captures, so
you can capture/use as many parameters as you want from a URLconf.
This can be great for things like nested endpoints, or supporting more
complicated URLs.
Catching a Failed Lookup & Returning an Error¶
Let’s turn our attention to the lookup/fetch of the post:
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
One of the niceties built into microapi
is the ability to handle/return
errors in an API-friendly way.
Normally when Django encounters an error, depending on the value of
settings.DEBUG
, it’ll either return an HTML debug page or a rendered
HTML error page. When you’re working with an API client, neither of those
options are particularly friendly/natural, especially if you need to extract
information to present to the user.
microapi
improves on this by intercepting errors, and rendering a
JSON-based API error response instead! If the lookup fails, the user will get
an error like:
{
"success": False,
"errors": [
"Blog post not found"
]
}
Even if we hadn’t explicitly added try/except
handling, under the hood,
microapi
would’ve rendered a similar error including the exception message.
You can manually call self.render_error(...)
as many times as you want in
your view code, and you can supply either a single error string, or a list of
error strings! This can be great for validation situations, or when multiple
conditions failed.
The Use of serialize(...)
¶
The final notable change is the call to self.serialize(post)
.
Previously, in the list endpoint, we just quietly called
self.serialize_many(posts)
& didn’t really talk about serialization, letting
microapi
just handle things for us.
The rule of thumb here is to call serialize(model_obj)
when it’s a single
instance, and calling serialize_many(queryset_or_list)
when it’s a
collection of instances to serialize.
Note
serialize_many(...)
just iterates & makes calls to
serialize(...)
. So you can customize just the detail serialization in
serialize
, & the list version coming from serialize_many
will stay
in-sync.
This all leads us to a slight tangent: serialization in general.
Tangent: Serialization¶
When it comes to the format of data entering/leaving an API, there are a wide range of viewpoints. Some people subscribe to a minimalist view, meaning returning a small/flat structure of the data (which can lead to many simple requests). Others prefer deep/rich structures, including related data structures (a single request with a large/complex response). Still others want follow things like HATEOAS & return URLs to resources instead of PKs or nested structures.
To combat assumptions (& honestly complex feature bloat), microapi
takes a
simplistic approach to the default serialization, then makes it easy to
extend/override serialization to meet your needs.
By default, microapi
includes a ModelSerializer
, which we’ve been
conveniently/quietly using via ApiView.serialize(...)
/
ApiView.serialize_many(...)
.
ModelSerializer
will accept a model instance, collect all concrete
fields, and return a dictionary representation of that data. It will NOT
collect/return:
related fields/data
generated fields
virtual fields
While this is limited by default, this prevents excessively leaning on PKs or too-deeply-nested situations, as well as a whole host of edge-cases. It’s also easily extended, as we’re about to see.
Creating Data¶
Up until now, we’ve been only working on a read-only version of the API that
solely responds to HTTP GET
. But it’d be nice to be able to create new
blog posts via the API.
In most RESTful applications, the expected way to handle creating new data is to
perform an HTTP POST
to the list endpoint, so let’s add that:
# blog/api.py
# Don't forget to add this import!
from django.contrib.auth.decorators import login_required
# And this import changed!
from microapi import (
ApiView,
http,
)
from .models import BlogPost
# Omitting serialization for readability.
# ...
# Leave it there!
class BlogPostListView(ApiView):
def serialize(self, obj):
return serialize_post(obj)
def get(self, request):
posts = BlogPost.objects.all().order_by("-created")
return self.render({
"success": True,
"posts": self.serialize_many(posts),
})
# Here's the newly added code!
@login_required
def post(self, request):
data = self.read_json(request)
# TODO: Validate the data here.
post = self.serializer.from_dict(BlogPost(), data)
post.author = request.user
post.save()
return self.render({
"success": True,
"post": self.serialize(post),
}, status_code=http.CREATED)
We’ve added a new post
method to BlogPostListView
. The other new bits
here are the use of read_json(request)
& serializer.from_dict(...)
.
microapi
includes an ApiView.read_json(request)
method, which makes it
easy to extract a JSON payload from a request body. This is similar to how
you might use request.POST
in regular application code.
The other noteworthy code,
post = self.serializer.from_dict(BlogPost(), data)
, is a bit more involved,
so let’s deconstruct what’s going on.
ModelSerializer
includes a from_dict(model_obj, data)
method, which
takes a dict
of data & tries to assign the values to fields on a Model
instance. Since we’ve already grabbed the request data
fromthe JSON, and
we’re creating a new model object (BlogPost()
), we can just hand those two
off to self.serializer.from_dict(BlogPost(), data)
& it will populate that
fresh model instance for us.
Assign on the author
to the user that POST
’ed the data, remember to
save, and then we return a success message with the newly-created data. We
can supply the http.CREATED
status code to ensure the resulting response has
a 201 Created
HTTP status code associated with it!
Updating & Deleting Data¶
Finally, let’s add updating & deleting data. These are pretty straight-forward & largely just combine things we’ve already seen:
class BlogPostDetailView(ApiView):
def serialize(self, obj):
return serialize_post(obj)
def get(self, request, pk):
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
return self.render({
"success": True,
"post": self.serialize(post),
})
# New code starts here!
@login_required
def put(self, request, pk):
data = self.read_json(request)
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
post = self.serializer.from_dict(post, data)
post.save()
return self.render({
"success": True,
"post": self.serialize(post),
}, status_code=http.UPDATED)
@login_required
def delete(self, request, pk):
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
post.delete()
return self.render({
"success": True,
}, status_code=http.NO_CONTENT)
We add a put
method to handle updating an existing object via HTTP PUT
to the detail endpoint, and a delete
method to handle deleting an existing
object via HTTP DELETE
.
Both lookup/fetch the post as we have before. For the update, we read the JSON
payload from the request
body & update the object just like in the POST
example. And for the delete, all we need to do is delete the model via the ORM.
Final Code¶
When we’ve finished, our final API code should look like:
# blog/api.py
from django.contrib.auth.decorators import login_required
from microapi import (
ApiView,
http,
)
from .models import BlogPost
def serialize_author(serializer, author):
return {
"id": author.id,
"username": author.username,
"email": author.email,
"first_name": author.first_name,
"last_name": author.last_name,
}
def serialize_post(serializer, post):
data = serializer.to_dict(post)
data["author"] = serialize_author(serializer, post.author)
return data
class BlogPostListView(ApiView):
def serialize(self, obj):
return serialize_post(obj)
def get(self, request):
posts = BlogPost.objects.all().order_by("-created")
return self.render({
"success": True,
"posts": self.serialize_many(posts),
})
@login_required
def post(self, request):
data = self.read_json(request)
# TODO: Validate the data here.
post = self.serializer.from_dict(BlogPost(), data)
post.author = request.user
post.save()
return self.render({
"success": True,
"post": self.serialize(post),
}, status_code=http.CREATED)
class BlogPostDetailView(ApiView):
def serialize(self, obj):
return serialize_post(obj)
def get(self, request, pk):
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
return self.render({
"success": True,
"post": self.serialize(post),
})
@login_required
def put(self, request, pk):
data = self.read_json(request)
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
post = self.serializer.from_dict(post, data)
post.save()
return self.render({
"success": True,
"post": self.serialize(post),
}, status_code=http.UPDATED)
@login_required
def delete(self, request, pk):
try:
post = BlogPost.objects.get(pk=pk)
except BlogPost.DoesNotExist:
return self.render_error("Blog post not found")
post.delete()
return self.render({
"success": True,
}, status_code=http.NO_CONTENT)
And with that, plus the two URLconfs you added long ago, you have a RESTful API for inspecting/managing blog posts in your application! 🎉
Next Steps¶
This represents ~90%+ of the daily usage of microapi
, but the library
does include a handful of other tools/utilities to make crafting APIs
easier. Information on these can be found in the other Usage guides or the
Api docs.
Enjoy & happy API creation!