How to set up Django Ninja¶
While Wagtail provides a built-in API module based on Django REST Framework, it is possible to use other API frameworks. Here is information on usage with Django Ninja, an API framework built on Python type hints and Pydantic, which includes built-in support for OpenAPI schemas.
Basic configuration¶
Install django-ninja
. Optionally you can also add ninja
to your INSTALLED_APPS
to avoid loading static files externally when using the OpenAPI documentation viewer.
Create the API¶
We will create a new api.py
module next to the existing urls.py
file in the project root, instantiate the router.
# api.py
from typing import Literal
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Field, ModelSchema, NinjaAPI
from wagtail.models import Page
api = NinjaAPI()
Next, register the URLs so Django can route requests into the API. To test this is working, navigate to /api/docs
, which displays the OpenAPI documentation (with no available endpoints yet).
# urls.py
from .api import api
urlpatterns = [
...
path("api/", api.urls),
...
# Ensure that the api line appears above the default Wagtail page serving route
path("", include(wagtail_urls)),
]
Our first endpoint¶
We will create a simple endpoint that returns a list of all pages in the site. We use the @api.get
operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
# api.py
class BasePageSchema(ModelSchema):
url: str = Field(None, alias="get_url")
class Config:
model = Page
model_fields = [
"id",
"title",
"slug",
]
@api.get("/pages/", response=list[BasePageSchema])
def list_pages(request: "HttpRequest"):
return Page.objects.live().public().exclude(id=1)
Our custom BasePageSchema
combines two techniques: schema generation from Django models, and calculated fields with aliases. Here, we use an alias to retrieve the page URL.
We can also add an extra child_of: int = None
parameter to our endpoint to filter the pages by their parent:
@api.get("/pages/", response=list[BasePageSchema])
def list_pages(request: "HttpRequest", child_of: int = None):
if child_of:
return get_object_or_404(Page, id=child_of).get_children().live().public()
# Exclude the page tree root.
return Page.objects.live().public().exclude(id=1)
Ninja treats every parameter of the list_pages
function as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
Adding custom page fields¶
Next, let’s add a “detail” API endpoint to return a single page of a specific type. We can use the path parameters from Ninja to retrieve our page_id
.
We also create a new schema for a specific page type: here, BlogPage
, with BasePageSchema
as a base.
from blog.models import BlogPage
class BlogPageSchema(BasePageSchema, ModelSchema):
class Config(BasePageSchema.Config):
model = BlogPage
model_fields = [
"intro",
]
@api.get("/pages/{page_id}/", response=BlogPageSchema)
def get_page(request: "HttpRequest", page_id: int):
return get_object_or_404(Page, id=page_id).specific
This works well, with the endpoint now returning generic Page
fields and the BlogPage
introduction.
But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
Combining multiple schemas¶
To reflect that our response may return multiple page types, we use the type hint union syntax to combine multiple schemas.
This allows us to return different page types from the same endpoint.
Here is an example with an additional schema for our HomePage
type:
from home.models import HomePage
class HomePageSchema(BasePageSchema, ModelSchema):
class Config(BasePageSchema.Config):
model = HomePage
@api.get("/pages/{page_id}/", response=BlogPageSchema | HomePageSchema)
def get_page(request: "HttpRequest", page_id: int):
return get_object_or_404(Page, id=page_id).specific
With this in place, we are still missing a way to determine which of the schemas to use for a given page.
We want to do this by page type, adding an extra content_type
class attribute annotation to our schemas.
For
BasePageSchema
, we definecontent_type: str
, as any page type can use this base.For
HomePageSchema
, we setcontent_type: Literal["homepage"]
.And for
BlogPageSchema
, we setcontent_type: Literal["blogpage"]
.
All we need now is to add a resolver calculated field to the BasePageSchema
, to return the correct content type for each page. Here is the final version of BasePageSchema
:
class BasePageSchema(ModelSchema):
url: str = Field(None, alias="get_url")
content_type: str
@staticmethod
def resolve_content_type(page: Page) -> str:
return page.specific_class._meta.model_name
class Config:
model = Page
model_fields = [
"id",
"title",
"slug",
]
With this in place, Pydantic is able to validate the page data returned in get_page
according to one of the schemas in the response
union.
It then serializes the data according to the specific schema.
Nested data¶
Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
Here is an example, adding blog page authors (a snippet with a ParentalManyToManyField
):
class BlogPageSchema(BasePageSchema, ModelSchema):
content_type: Literal["blogpage"]
authors: list[str] = []
class Config(BasePageSchema.Config):
model = BlogPage
model_fields = [
"intro",
]
@staticmethod
def resolve_authors(page: BlogPage, context) -> list[str]:
return [author.name for author in page.authors.all()]
This could also be done with the Field
class if the BlogPage
class had a method to retrieve author names directly: authors: list[str] = Field([], alias="get_author_names")
.
Rich text in the API¶
Rich text fields in Wagtail use a specific internal format, described in Rich text internals. They can be added to the schema as str
, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
This can also be done with Ninja resolvers. Here is an example with the HomePageSchema
:
from wagtail.rich_text import expand_db_html
class HomePageSchema(BasePageSchema, ModelSchema):
content_type: Literal["homepage"]
body: str
class Config(BasePageSchema.Config):
model = HomePage
@staticmethod
def resolve_body(page: HomePage, context) -> str:
return expand_db_html(page.body)
Here, body
is defined as a str
, and the resolver uses the expand_db_html
function to convert the internal representation to HTML.
Images in the API¶
We can use a similar technique for images, combining resolvers and aliases to generate the data.
We use the get_renditions()
method to retrieve the formatted images, and a custom RenditionSchema
to define their API representation.
from wagtail.images.models import AbstractRendition
class RenditionSchema(ModelSchema):
# We need to use the Field / alias API for properties
url: str = Field(None, alias="file.url")
alt: str = Field(None, alias="alt")
class Config:
model = AbstractRendition
model_fields = [
"width",
"height",
]
On the BlogPageSchema
, we define our image field as: main_image: list[RenditionSchema] = []
. Then add the resolver for it:
@staticmethod
def resolve_main_image(page: BlogPage) -> list[AbstractRendition]:
filters = [
"fill-800x600|format-webp",
"fill-800x600",
]
if image := page.main_image():
return image.get_renditions(*filters).values()
return []
In JSON, our main_image
is now represented as an array, where each item is an object with url
, alt
, width
, and height
properties.
OpenAPI documentation¶
Django Ninja generates OpenAPI documentation automatically, based on the defined operations and schemas.
This also includes a documentation viewer, with support to try out the API directly from the browser. With the above example, you can try accessing the docs at /api/docs
.
To make the most of this capability, consider the supported operations parameters.