wagtail/docs/advanced_topics/api/django-ninja.md

250 wiersze
9.1 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

(api_ninja)=
# How to set up Django Ninja
While Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), 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.
```python
# 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).
```python
# 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.
```python
# 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](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#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:
```python
@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, lets add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) 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.
```python
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:
```python
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 define `content_type: str`, as any page type can use this base.
- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page. Here is the final version of `BasePageSchema`:
```python
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`):
```python
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 [](../../extending/rich_text_internals). They can be added to the schema as `str`, but its 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](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
```python
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](image_renditions_multiple) to retrieve the formatted images, and a custom `RenditionSchema` to define their API representation.
```python
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:
```python
@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](https://swagger.io/specification/) 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](https://django-ninja.dev/reference/operations-parameters/).