Host API Docs in Django

Host API Docs in Django

Generate API docs from an OpenAPI spec and host them in Django

So you've found a reason to maintain an OpenAPI spec in your codebase, maybe to create agility among frontend and backend engineers. Whatever your reason may be, a helpful addition to your organization might be to host browsable API docs based on this spec.

In this article, I'll show you how to expose API docs in Django as an authenticated view, with content generated from an OpenAPI spec.

Create a Django Project

Let's start by creating a Django project with a separate api app:

pip install django==4.1.2
django-admin startproject django_api_docs

# If you prefer skewer-cased project names
mv django_api_docs django-api-docs

cd django-api-docs

python manage.py startapp api

At this point you should have the following project structure:

django-api-docs
├── api
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── django_api_docs
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

Create an OpenAPI Spec

Let's add an OpenAPI spec with an endpoint for fetching some resources. Our kiddo is 5 years old, so dinosaurs are on my mind. Let's document an endpoint to fetch all dinosaurs at GET /api/dinosaurs/. We'll store this spec at api/docs/openapi.yaml:

openapi: 3.1.0
info:
  title: RESTful dinosaurs
  version: 0.0.1
servers:
  - url: http://localhost:8000
    description: Local development environment
paths:
  /api/dinsosaurs/:
    get:
      summary: Retrieve a list of dinosaurs
      description: Retrieve a list of all dinosaurs
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    common_name:
                      type: string
                    scientific_name:
                      type: string

To explore the OpenAPI specification, checkout this tutorial.

Preview the Generated Docs with redoc-cli

Before we integrate generated documentation with Django, let's first take a peek at those docs. For this we'll use the redoc-cli from Redocly. This is a node-based tool that we'll use to generate documentation from the OpenAPI spec we created above.

To get started, you'll need the following installed:

  • node (I prefer to use nvm for this)

  • redoc-cli

Assuming you now have node installed, let's install redoc-cli:

npm i -g redoc-cli

To quickly experiment with the tool, let's have redoc-cli serve our docs:

redoc-cli serve api/docs/openapi.yaml

After doing this and navigating to localhost:8080, you should see the following (after expanding "200 OK"):

Screen Shot 2022-10-21 at 12.51.04 PM.png

If you aren't able to expand the response, or you don't see the response properties listed, then you may have an indentation problem in your spec. If the command fails altogether, check to see that you've saved the spec at api/docs/openapi.yaml and that you've accurately referenced this path in the redoc-cli serve command.

Configure Django to Serve Docs

To configure Django to serve our generated documentation at a request path of our choosing, we'll need to do the following:

  1. Generate documentation as a static HTML file

  2. Add a Django view that serves our static HTML content

  3. Add a URL to route requests to our new Django view

Let's get started.

Generate the documentation as a static HTML file

Generating static content is as easy as swapping out the redoc-cli serve command for the redoc-cli bundle command, while also specifying where we want the generated content to be saved:

redoc-cli bundle api/docs/openapi.yaml -o api/docs/generated-docs.html

After running this command, your project structure should look like this:

django-api-docs
├── api
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── docs
│   │   ├── generated-docs.html <= NEW DOCS!
│   │   └── openapi.yaml
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── django_api_docs
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

Add a Django view that serves our static HTML content

Let's keep it simple and use a function-based Django view. Replace the contents of api/views.py with the following:

import os

from django.http import Http404, HttpResponse


def docs(request):
    current_dir = os.path.dirname(os.path.realpath(__file__))
    docs_path = os.path.join(current_dir, "docs/generated-docs.html")
    try:
        with open(docs_path, "r") as f:
            content = f.read()
    except OSError:
        raise Http404("Page not found")
    else:
        return HttpResponse(content)

This view looks in the same directory as the views.py file for a file at docs/generated-docs.html. If it exists, its contents will be served. If not, a 404 response will be served.

Add a URL to route requests to our new Django view

In order to expose our docs in Django, the last step is to add to the urlpatterns of our api app, as well as to the django_api_docs app. Edit api/urls.py to contain the following:

from django.urls import path

from api import views

app_name = "api"

urlpatterns = [
    path("docs/", views.docs)
]

Because the DJANGO_SETTINGS_MODULE is set to django_api_docs.settings (see your generated manage.py file at the root of your project), only the URLs in django_api_docs/urls.py will be exposed. We need to add an entry in that file so that all paths beginning with api/ are routed to the api app. To accomplish this, edit django_api_docs/urls.py to contain the following:

from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("api.urls", namespace="api")),
]

Testing the API Docs

At this point, we're ready to see the results:

python manage.py runserver

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
October 21, 2022 - 18:20:50
Django version 4.1.2, using settings 'django_api_docs.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Upon navigating to localhost:8000/api/docs, you should see the same content we saw in our preview from above. If you don't, make sure the paths you added to both api/urls.py and django_api_docs/urls.py are accurate.

Requiring Authentication to View Docs

If the API we're exposing is not meant for public consumption, as a practice of the principle of least privilege, we should ensure that our API docs are accessible only by intended consumers of the API we're documenting.

The change to require authentication is quick: decorate the docs view function with @login_required in api/views.py:

import os

from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse


@login_required
def docs(request):
    current_dir = os.path.dirname(os.path.realpath(__file__))
    docs_path = os.path.join(current_dir, "docs/generated-docs.html")
    try:
        with open(docs_path, "r") as f:
            content = f.read()
    except OSError:
        raise Http404("Page not found")
    else:
        return HttpResponse(content)

Because we're now adding authentication to our Django app, we have a little more setup to ensure we have a user we can use for authentication:

  1. Create a superuser

  2. Add Django's default auth views to our URL patterns

Create a superuser

From the command line, run the following, which will prompt you for a username, email and password:

python manage.py createsuperuser

Add Django's default auth views to our URL patterns

So that we don't have to create our own login form, we'll use Django's default form. In order to expose the login form and a way to logout, we need to add those URL patterns to django_api_docs/urls.py:

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import path, include


urlpatterns = [
    path("admin/", admin.site.urls),
    path('accounts/login/', auth_views.LoginView.as_view(template_name="admin/login.html")),
    path('accounts/logout/', auth_views.LogoutView.as_view()),
    path("api/", include("api.urls", namespace="api")),
]

At this point we're ready to test our authenticated view!

Let's start the app again:

python manage.py runserver

Navigate to the docs at localhost:8000/api/docs, which should now redirect to http://localhost:8000/accounts/login/?next=/api/docs/:

Screen Shot 2022-10-21 at 4.07.34 PM.png

After entering the credentials you provided when running the createsuperuser management command, you should see the API docs!

Adding @login_required validates only that a client is an authenticated user. If you want finer-grained control, you'll need to modify this approach, possibly leveraging the @permission_required decorator.

Next Steps

In this article we've covered how to host API docs in Django with content generated with the redoc-cli from an OpenAPI spec. In my next article, I'll cover how to inject these docs into a Docker image using a CI pipeline with GitHub Actions.

Enjoy!