Transparent JSON:API¶
NinjaJsonAPI is a drop-in NinjaAPI subclass that makes JSON:API formatting invisible. Your view functions look exactly like plain Django Ninja views — the library handles wrapping responses and unwrapping request bodies automatically.
Quick example¶
from django_ninja_jsonapi import NinjaJsonAPI
api = NinjaJsonAPI()
@api.post("/articles", response={201: ArticleSchema})
def create_article(request, body: ArticleCreateSchema):
article = Article.objects.create(**body.model_dump())
return 201, article
Plain Pydantic schemas in, JSON:API documents out. No decorators, no body wrappers, no .data.attributes.
Setup¶
Replace NinjaAPI with NinjaJsonAPI:
from django_ninja_jsonapi import NinjaJsonAPI
api = NinjaJsonAPI(
urls_namespace="my-api",
# all standard NinjaAPI kwargs work: auth, title, version, etc.
)
NinjaJsonAPI automatically:
- Sets the
JSONAPIRenderer - Registers JSON:API exception handlers for
HTTPExceptionandObjectDoesNotExist - Wraps
response=schemas in JSON:API document structure (for OpenAPI docs) - Unwraps incoming JSON:API request bodies so views receive plain schemas
- Injects JSON:API resource config on each request
Schema configuration with JsonApiMeta¶
Attach a JsonApiMeta to your Pydantic schemas to declare JSON:API resource type and ID field:
from typing import ClassVar
from pydantic import BaseModel, ConfigDict
from django_ninja_jsonapi import JsonApiMeta
class ArticleSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
uuid: str
title: str
body: str
author: UserSchema | None = None
tags: list[TagSchema] = []
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(
resource_type="articles",
id_field="uuid",
)
Both parameters are optional — sensible defaults are inferred:
| Parameter | Default |
|---|---|
resource_type |
Schema class name, minus Schema/Create/Update suffix, dasherized, pluralized. IntakeConfigSchema → "intake-configs" |
id_field |
"id", or "uuid" if the schema has a uuid field but no id field |
Note
jsonapi_meta must be annotated as ClassVar[JsonApiMeta] so that Pydantic does not treat it as a model field.
Create / Update schemas¶
Create and update schemas inherit the resource type from the endpoint's response= schema, so they often don't need their own JsonApiMeta:
class ArticleCreateSchema(BaseModel):
title: str
body: str
# No jsonapi_meta needed — inferred from response schema
class ArticleUpdateSchema(BaseModel):
title: str | None = None
body: str | None = None
If a body schema is used on an endpoint with no response= schema, or you need to override the resource type, add an explicit JsonApiMeta:
class ArticleCreateSchema(BaseModel):
title: str
body: str
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(resource_type="articles")
Auto-detected relationships¶
Any schema field whose type is another BaseModel with a JsonApiMeta is automatically treated as a JSON:API relationship:
class UserSchema(BaseModel):
id: int
name: str
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(resource_type="people")
class TagSchema(BaseModel):
id: int
name: str
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(resource_type="tags")
class ArticleSchema(BaseModel):
id: int
title: str
author: UserSchema | None = None # → to-one relationship
tags: list[TagSchema] = [] # → to-many relationship
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(resource_type="articles")
These are rendered as JSON:API relationships in the response document. No manual relationship configuration needed.
CRUD endpoints¶
List¶
@api.get("/articles", response=list[ArticleSchema])
def list_articles(request):
return Article.objects.all()
Detail¶
@api.get("/articles/{id}", response=ArticleSchema)
def get_article(request, id: int):
return Article.objects.get(pk=id)
Create¶
@api.post("/articles", response={201: ArticleSchema})
def create_article(request, body: ArticleCreateSchema):
# body is a plain ArticleCreateSchema — not a JSON:API wrapper
article = Article.objects.create(**body.model_dump())
return 201, article
Update¶
from django_ninja_jsonapi import apply_attributes
@api.patch("/articles/{id}", response=ArticleSchema)
def update_article(request, id: int, body: ArticleUpdateSchema):
article = Article.objects.get(pk=id)
apply_attributes(article, body, extra_update_fields=["updated_dt"])
return article
Delete¶
@api.delete("/articles/{id}", response={204: None})
def delete_article(request, id: int):
Article.objects.get(pk=id).delete()
return 204, None
Relationship data in request bodies¶
When creating or updating resources with relationships, the client sends relationship data in the JSON:API body:
{
"data": {
"type": "articles",
"attributes": { "title": "Hello" },
"relationships": {
"author": { "data": { "type": "people", "id": "42" } }
}
}
}
Access the relationship IDs with get_rel_id() and get_rel_ids():
from django_ninja_jsonapi import get_rel_id, get_rel_ids
@api.post("/articles", response={201: ArticleSchema})
def create_article(request, body: ArticleCreateSchema):
author_id = get_rel_id(request, "author") # str | None
tag_ids = get_rel_ids(request, "tags") # list[str]
article = Article.objects.create(
title=body.title,
author_id=author_id,
)
article.tags.set(tag_ids)
return 201, article
Relationship data is stashed on the request during body unwrapping — it's always available but never in the way.
Note
Relationships accepted in the body are inherited from the response= schema's relationship fields. If ArticleSchema has an author relationship, the create endpoint for articles will accept relationships.author in the body even if ArticleCreateSchema doesn't have an author field.
Pagination, filtering, sorting¶
These helpers work unchanged — they're already clean:
from django_ninja_jsonapi import jsonapi_paginate, jsonapi_filter, jsonapi_sort
@api.get("/articles", response=list[ArticleSchema])
def list_articles(request):
qs = Article.objects.all()
qs = jsonapi_filter(request, qs, allowed_fields={"status", "author"})
qs = jsonapi_sort(request, qs, allowed_fields={"created_dt", "title"})
return jsonapi_paginate(request, qs)
Include / sideloading¶
from django_ninja_jsonapi import parse_include, jsonapi_include
@api.get("/articles", response=list[ArticleSchema])
def list_articles(request):
includes = parse_include(request)
qs = Article.objects.all()
if "author" in includes:
qs = qs.select_related("author")
qs = jsonapi_paginate(request, qs)
# Include related resources
for article in qs:
if "author" in includes and article.author:
jsonapi_include(
request, article.author,
resource_type="people",
)
return qs
Non-JSON:API endpoints¶
Schemas without jsonapi_meta are passed through unchanged (no JSON:API wrapping), so you can have both JSON:API and plain JSON endpoints on the same API.
Full example¶
from typing import ClassVar
from pydantic import BaseModel, ConfigDict
from django_ninja_jsonapi import (
JsonApiMeta,
NinjaJsonAPI,
apply_attributes,
get_rel_id,
jsonapi_filter,
jsonapi_paginate,
)
from django_ninja_jsonapi.exceptions import BadRequest, Forbidden
class OrganizationSchema(BaseModel):
id: int
name: str
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(resource_type="organizations")
class IntakeConfigSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
uuid: str
name: str
organization: OrganizationSchema | None = None
jsonapi_meta: ClassVar[JsonApiMeta] = JsonApiMeta(
resource_type="intake-configs", id_field="uuid",
)
class IntakeConfigCreateSchema(BaseModel):
name: str
organization_id: int | None = None
class IntakeConfigUpdateSchema(BaseModel):
name: str | None = None
api = NinjaJsonAPI(urls_namespace="intake-jsonapi")
@api.get("/intake-configs/", response=list[IntakeConfigSchema])
def list_intake_configs(request):
qs = IntakeConfig.objects.select_related("organization").all()
return jsonapi_paginate(request, qs)
@api.get("/intake-configs/{uuid}/", response=IntakeConfigSchema)
def get_intake_config(request, uuid: str):
return IntakeConfig.objects.select_related("organization").get(uuid=uuid)
@api.post("/intake-configs/", response={201: IntakeConfigSchema})
def create_intake_config(request, body: IntakeConfigCreateSchema):
config = IntakeConfig.objects.create(**body.model_dump(exclude_unset=True))
return 201, IntakeConfig.objects.select_related("organization").get(pk=config.pk)
@api.patch("/intake-configs/{uuid}/", response=IntakeConfigSchema)
def update_intake_config(request, uuid: str, body: IntakeConfigUpdateSchema):
config = IntakeConfig.objects.get(uuid=uuid)
apply_attributes(config, body, extra_update_fields=["updated_dt"])
return IntakeConfig.objects.select_related("organization").get(pk=config.pk)
@api.delete("/intake-configs/{uuid}/", response={204: None})
def delete_intake_config(request, uuid: str):
IntakeConfig.objects.get(uuid=uuid).delete()
return 204, None