Skip to content

API v2 Standard

Every new endpoint is a v2 endpoint: a thin DRF view over a service, with Pydantic schemas on both sides and a generated OpenAPI description. The rules that need more room have their own pages: Response Contract, Error Handling, OpenAPI Generation. The same rules applied to one module from an empty folder: Build a Module.

dependencies = [
"django>=5.0",
"djangorestframework>=3.14",
"djangorestframework-simplejwt>=5.3",
"drf-spectacular>=0.27",
"pydantic>=2.0",
"entirius-django-utils>=2.2.0",
]
  • Django REST Framework for views, routing, permissions and throttles. ViewSets for resources, APIView for an endpoint that is not one.
  • Pydantic for every request and response schema. No DRF serializers, no Django forms.
  • drf-spectacular for the OpenAPI description. It is generated, never edited by hand.
  • djangorestframework-simplejwt for the admin JWT.
  • entirius-django-utils for the shared error handler.
api/{module}/v2/admin/{channel_idx}/{resource}/ # Admin endpoints (CMS)
api/{module}/v2/{channel_idx}/{resource}/ # Public endpoints (storefront)

Each module’s urls.py exposes its v2 URLs. The service urls.py includes them at the root.

Admin and public APIs serve different audiences with different schemas:

  • Admin (/admin/): consumed by the CMS. JWT and staff required. Full CRUD.
  • Public (no /admin/): consumed by the storefront. No user login. Catalog endpoints are read-only; a public endpoint that writes (carts and orders, contact forms, agreements) is guarded by a key in X-API-KEY or by a throttle.
  • Key routes: consumed by storefronts, widgets and server jobs that send an application token in X-API-KEY (from entirius-django-access 1.0.0).
{module}/
api/
├── admin/ # Admin endpoints (CMS)
│ ├── __init__.py
│ ├── views/ # one file per resource
│ ├── urls.py
│ ├── pagination.py # AdminPageNumberPagination
│ └── permissions.py # IsAdminUser
└── public/ # Public endpoints (storefront), only when the module has them
├── __init__.py
├── views/
└── urls.py

The version is in the URL, the audience is in the package: a new module has no api/v2/ package, and admin and public views never share a directory. A module with an admin API only (entirius-django-atlas) has no public/; a module with both audiences (entirius-django-faq) has both. Some earlier modules keep their v2 views in an api/v2/ package (entirius-django-matrix and entirius-django-checkout are two); they follow every other rule here.

Every view declares authentication_classes and permission_classes itself. Project defaults differ between services, so a view that inherits them is protected in one service and open in another.

  • Admin endpoints: JWTAuthentication and IsAdminUser. Never IsAuthenticated alone: a storefront customer is authenticated too.
  • Public endpoints: AllowAny with authentication_classes = [].
  • Key endpoints: an application token in X-API-KEY, checked with verify_api_key and a scope.

IsAdminUser means “staff or superuser”: the module’s own class in api/admin/permissions.py. DRF’s rest_framework.permissions.IsAdminUser checks is_staff only, so a superuser without the staff flag is refused where it is used.

api/admin/views/product_views.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from ..permissions import IsAdminUser # staff or superuser
class ProductViewSet(viewsets.ViewSet):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAdminUser]
access_area = "pim.products" # read by the access gate, from access 1.0.0
# api/public/views/product_views.py
class PublicProductViewSet(viewsets.ViewSet):
authentication_classes = []
permission_classes = [AllowAny]

Ships with entirius-django-access 1.0.0 and Platform 4.0.

IsAdminUser answers “is this staff?”. Which staff user may do what is decided once, in front of every admin view, by the access gate (AccessGateMiddleware). It maps each admin route to a module area (pim.products, checkout.orders, …) and each method to read (GET, HEAD, OPTIONS) or write (everything else; GET PII exports count as write), then checks the user’s roles. A route takes its area from the view’s access_area attribute (plus access_levels for a method that lies, such as a POST that only searches); how a module declares its areas is in Django Module Standard. Never add a second permission class that checks areas or roles in the view: the gate is the only decision point.

The gate refuses with the v2 error envelope (error: PERMISSION_DENIED) and one of three issues in details[]:

IssueWhen
STAFF_ONLYan authenticated user without staff status; names no area
ACCESS_DENIEDstaff without the route’s permission, or a write on a read-only area; the description names the permission (needs pim.products:write)
UNMAPPED_ROUTEan admin route without an area; logged as an error

An anonymous request to a view that does not authenticate itself gets the gate’s own 401 (AUTHENTICATION_REQUIRED, issue NOT_AUTHENTICATED, WWW-Authenticate: Bearer realm="api"); a view on JWTAuthentication answers its own 401, which drives the CMS token refresh. Superusers pass, and each of their writes is audited. The kill switch is a setting only, ACCESS_GATE_MODE (enforce, observe, off); no API changes it. Tokens never open an admin route: the gate does not read X-API-KEY. The Django admin site is for superusers only: every role is refused there, Administrator included (ACCESS_DENIED, superuser only). Admin features belong in the CMS through the v2 admin API.

Ships with entirius-django-access 1.0.0 and a major of each module that reads a key; the versions before it still read their own key tables.

A route that a machine calls with a key (storefront carts, contact forms, GDPR erase, returns, reviews, vault) authenticates with an application token from entirius-django-access:

from django_access.services.tokens import verify_api_key
token = verify_api_key(request, "checkout.storefront", channel_idx=channel_idx)
if token is None:
raise NotAuthenticated() # one answer for every failure, never a status per reason
  • The token arrives in X-API-KEY (X-API-ADMIN-KEY is an alias). Never Authorization: storefronts send the customer’s JWT there on the same request.
  • The scope comes from the access catalogue. A module that needs a new one declares it in AppConfig.access_token_scopes.
  • Throttle per request.access_token.pk, never by the header value.
  • Never add a key table to a module. The legacy per-module tables are imported into access and deleted on demand.

Creating, sending and rotating tokens: API Tokens. Decision record: Access and API Tokens.

An endpoint that creates state without a user login (anonymous, or behind a key that ships to browsers) sets throttle_classes. A leaked widget key is an expected event; without a throttle it buys unlimited writes and unlimited calls to whatever upstream API the endpoint triggers.

from django.core.exceptions import ImproperlyConfigured
from rest_framework.throttling import AnonRateThrottle
class _BookingThrottle(AnonRateThrottle):
scope = "contact_forms_booking"
fallback_rate = "10/hour" # used when the service does not configure the scope
def __init__(self) -> None:
# DRF calls get_rate() only when `rate` is unset: resolve it first.
self.rate = self.get_rate()
super().__init__()
def get_rate(self) -> str:
try:
rate = super().get_rate()
except ImproperlyConfigured: # scope missing from DEFAULT_THROTTLE_RATES
return self.fallback_rate
if not rate or "/" not in rate: # a malformed rate must not disable throttling
return self.fallback_rate
return rate
class BookingViewSet(viewsets.ViewSet):
throttle_classes = [_BookingThrottle]
  • A service overrides the rate with DEFAULT_THROTTLE_RATES["contact_forms_booking"] = "30/hour"; fallback_rate holds when it does not.
  • The fallback never sits in a class-level rate: SimpleRateThrottle.__init__ skips get_rate() when rate is already set, and the service override is ignored without a word.
  • In DRF rate = None means “no throttling”. get_rate() never returns None or a malformed rate.
  • Tests patch SimpleRateThrottle.THROTTLE_RATES: DRF reads the setting at import, so override_settings does not reach it.

A refused request answers 429 RATE_LIMITED (Error Handling).

  • Location: schemas/requests/{entity}.py, schemas/responses/{entity}.py.
  • ConfigDict(from_attributes=True) on every response schema; build it with Model.model_validate(instance).
  • Every Field has description= and examples=: drf-spectacular reads both into the spec.
  • Validation constraints where they apply (min_length, ge, …).
  • The schema owns format and shape checks (enums, a slug pattern). The service does not repeat them: a duplicated check drifts, and it blocks internal callers (a management command, a bridge) that the schema never sees. Business rules belong in the service.
from pydantic import BaseModel, ConfigDict, Field
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
sku: str = Field(description="Product SKU identifier", examples=["CHAIR-001"])
name: str = Field(description="Product display name", examples=["Ergonomic Office Chair"])
is_active: bool = Field(description="Whether product is enabled", examples=[True])

Field naming, null handling and the list envelope: Response Contract.

  • Views delegate to services/. No ORM call in a view.
  • Services take primitives or Pydantic models and return QuerySets or instances.
  • Services raise DoesNotExist and ValueError; the view turns them into NotFound and ValidationError.
# api/admin/views/product_views.py: thin
def list(self, request, channel_idx=None, **kwargs):
queryset = product_service.list_products(channel_idx=channel_idx, search=request.query_params.get("search"))
page = self.paginate_queryset(queryset)
return self.get_paginated_response(...)
# services/product_service.py: the logic
def list_products(channel_idx: str, search: str | None = None) -> QuerySet:
qs = Product.objects.filter(channel__idx=channel_idx)
if search:
qs = qs.filter(Q(sku__icontains=search) | Q(name__icontains=search))
return qs

Two rules protect the service from its callers.

The field whitelist lives in the service. A function that takes updates: dict and applies it with setattr checks it against an explicit set first. The request schema is the first filter, and it does nothing for a management command or a signal handler that calls the service directly.

_EDITABLE_FIELDS = frozenset({"title", "body", "is_active", "position"})
def update_notice(*, notice: Notice, updates: dict[str, Any]) -> Notice:
invalid = set(updates) - _EDITABLE_FIELDS
if invalid:
raise ValueError(f"Fields not editable via update_notice: {sorted(invalid)}")
for field, value in updates.items():
setattr(notice, field, value)
notice.save()
return notice

The whitelist is the whole defense: no parallel if "status" in updates guard next to it. State fields (status) and foreign keys (owner, channel) are never whitelisted; a whitelisted foreign key lets the caller point the relation at any primary key. They change through their own service functions.

Sort fields are checked against an allowlist. A user-supplied ordering value never reaches order_by() directly: an unknown field raises an ORM error that names model fields.

_ORDERING_FIELDS = frozenset({"created_at", "name", "status"})
def _validate_ordering(ordering: str) -> str:
field = ordering[1:] if ordering.startswith("-") else ordering
if field not in _ORDERING_FIELDS:
raise ValueError(f"Invalid ordering field: {field!r}. Allowed: {sorted(_ORDERING_FIELDS)}")
return ordering

A view raises DRF exceptions and the shared handler formats them into { error, message, debug_id, details[] }. A Pydantic ValidationError goes through raise_pydantic_as_drf(); an unexpected exception is left to propagate. The codes, conflicts and the 5xx rules: Error Handling.

Every ViewSet action carries @extend_schema with summary, description, parameters and responses. Tags, operation naming and the schema commands: OpenAPI Generation.

Admin lists use the module’s AdminPageNumberPagination from api/admin/pagination.py (Response Contract for the rule, API Overview for what a client receives).

Files: tests/test_admin_api.py, and tests/test_public_api.py when the module has public endpoints.

Auth

  • 401: no token
  • 403: authenticated user without staff status
  • 200: staff or superuser

Endpoints

  • List: returns paginated results
  • Retrieve: returns one entity by its identifier
  • Filters: each one the endpoint accepts
  • The response matches the Pydantic model
  • Writes: success, 400 on an invalid payload with the v2 error body, 404 on a missing entity
  • File responses: 200 with Content-Disposition, 404 when the record is missing, 404 when the file is

Pagination

  • page works
  • max_page_size caps page_size
  • next and previous are present
  • api/admin/ package with views, urls, pagination, permissions
  • api/public/ package, when the module has public endpoints
  • Pydantic schemas with Field(description=, examples=)
  • Service layer, no ORM in views; field whitelist on updates, allowlist on sort fields
  • Auth declared on every view: JWT, the module’s IsAdminUser and access_area for admin; AllowAny for public; verify_api_key with a scope for key routes (access_area and verify_api_key from access 1.0.0)
  • throttle_classes on every endpoint that creates state without a user login
  • Pagination configured
  • raise_pydantic_as_drf(exc) in every except ValidationError; no hand-built error body
  • @extend_schema with a summary on every action
  • Tags per the tag convention; public tags carry the Public prefix
  • No _2-suffixed operation IDs in the generated spec
  • python manage.py spectacular --validate --fail-on-warn passes
  • tests/test_admin_api.py with auth, endpoint and pagination tests

Response Contract