An annoyance of the development process for frontend teams consuming a JSON-based API is not getting a JSON-formatted response back when an error occurs. Getting Django's HTML-based error pages instead of something formed in JSON means more coding at their end to capture errors and top that off, the HTML itself is long and difficult to eyeball quickly.
Here's what I'm looking to replace:
$ curl -s localhost:8001/missing
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Page not found at /missing</title>
<meta name="robots" content="NONE,NOARCHIVE">
<style type="text/css">
html * { padding:0; margin:0; }
body * { padding:10px 20px; }
body * * { padding:0; }
body { font:small sans-serif; background:#eee; }
body>div { border-bottom:1px solid #ddd; }
h1 { font-weight:normal; margin-bottom:.4em; }
h1 span { font-size:60%; color:#666; font-weight:normal; }
table { border:none; border-collapse: collapse; width:100%; }
td, th { vertical-align:top; padding:2px 3px; }
th { width:12em; text-align:right; color:#666; padding-right:.5em; }
#info { background:#f6f6f6; }
#info ol { margin: 0.5em 4em; }
...
Being able to return both regular content and unexpected errors in JSON format helps remove the pains of dealing with errors during the development process.
django-jsonview is a package that gives you a decorator for your view methods that wraps their contents in a JSON response. It will also catch exceptions and return them in a consistent fashion and there is even support to modify HTTP headers if need be.
I'll walk you through it's usage.
Setup a Django project and application
First, I'll create a new project and app. The project will be called 'speak_json' and the app within it will be called 'photos':
$ pip install Django==1.7 django-jsonview
$ django-admin startproject speak_json
$ cd speak_json
$ django-admin startapp photos
Now I need to edit (and in some cases create) five files: The base settings file, the base urls file, a models file with an example model, the urls file for the app and finally, the view with our decorated views.
I added 'photos' to the INSTALLED_APPS tuple in speak_json/settings.py:
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'photos',
)
I added an entry for urls that begin with 'photos/' to route to the photos app in speak_json/urls.py:
urlpatterns = patterns('',
url(r'^photos/', include('photos.urls', namespace='photos')),
url(r'^admin/$', include(admin.site.urls)),
)
I created a small model in photos/models.py:
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)
After I created the model I created the migration and migrated the app so the sqlite3 database would have the corresponding table:
$ python manage.py makemigrations
$ python manage.py migrate
I then added in URL mappings for five views in photos/urls.py:
from django.conf.urls import patterns, url
from . import views
urlpatterns = patterns('',
url(r'^foo_bar/$', views.foo_bar),
url(r'^is_payment_needed/$', views.is_payment_needed),
url(r'^server_name/$', views.server_name),
url(r'^one_equals_two/$', views.one_equals_two),
url(r'^missing_person/$', views.missing_person),
)
Just to keep track, these are the files in their respective locations for this project:
$ find speak_json/
speak_json/db.sqlite3
speak_json/manage.py
speak_json/photos/__init__.py
speak_json/photos/admin.py
speak_json/photos/migrations
speak_json/photos/migrations/0001_initial.py
speak_json/photos/migrations/__init__.py
speak_json/photos/models.py
speak_json/photos/tests.py
speak_json/photos/urls.py
speak_json/photos/views.py
speak_json/speak_json
speak_json/speak_json/__init__.py
speak_json/speak_json/settings.py
speak_json/speak_json/urls.py
speak_json/speak_json/wsgi.py
Wrapping views with the json_view decorator
The fifth file I edited was photos/views.py which is where I added in five view methods wrapped with the json_view decorator:
from jsonview.decorators import json_view
from .models import Person
@json_view
def foo_bar(request):
return {
'foo': 'bar',
}
@json_view
def is_payment_needed(request):
if request.user and not request.user.is_authenticated():
# Send a 402 Payment Required status.
return {'subscribed': False}, 402
# Send a 200 OK.
return {'subscribed': True}
@json_view
def server_name(request):
return {}, 200, {'X-Server': 'myserver'}
@json_view
def one_equals_two(request):
assert 1 == 2, '1 is not equal to 2'
@json_view
def missing_person(request):
person = Person.objects.get(pk=1234)
return {'found': True, 'person_id': person.pk}
Testing HTTP responses
Now that the code is written I'll run the WSGI server and try out some of the endpoints:
$ python manage.py runserver 8001
The first method foo_bar returns a dictionary. Here it is with the correct HTTP 200 response code and 'application/json' content type.
$ curl -v localhost:8001/photos/foo_bar/
...
< HTTP/1.0 200 OK
< Content-Type: application/json
...
{"foo": "bar"}
The second method is_payment_needed can return a custom HTTP response code, in this case '402 PAYMENT REQUIRED':
$ curl -v localhost:8001/photos/is_payment_needed/
*...
< HTTP/1.0 402 PAYMENT REQUIRED
< Content-Type: application/json
...
{"subscribed": false}
The third method server_name returns a new header 'X-Server', along with an empty dictionary:
$ curl -v localhost:8001/photos/server_name/
...
< HTTP/1.0 200 OK
< X-Server: myserver
...
{}
The fourth method one_equals_two raises an AssertionError which when caught, returned as a string and uses HTTP 500 as it's error code:
$ curl -v localhost:8001/photos/one_equals_two/
...
< HTTP/1.0 500 INTERNAL SERVER ERROR
< Content-Type: application/json
...
{"message": "1 is not equal to 2", "error": 500}
The fifth method missing_person intentionally returns an error for a record that does not exist. Here you can see it returns HTTP 500 instead of 404:
$ curl -v localhost:8001/photos/missing_person/
...
< HTTP/1.0 500 INTERNAL SERVER ERROR
< Content-Type: application/json
...
{"message": "Person matching query does not exist.", "error": 500}
To get the response to return a 404 instead we need to import get_object_or_404 which will raise the proper exception json_view needs to see in order to return a 404. Here's the modified code:
from django.shortcuts import get_object_or_404
@json_view
def missing_person(request):
person = get_object_or_404(Person, pk=1234)
return {'found': True, 'person_id': person.pk}
And here is the output correctly identifying HTTP 404 as the response code:
$ curl -v localhost:8001/photos/missing_person/
...
< HTTP/1.0 404 NOT FOUND
< Content-Type: application/json
...
{"message": "No Person matches the given query.", "error": 404}