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,
APIViewfor 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.
URL Pattern
Section titled “URL Pattern”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 inX-API-KEYor by a throttle. - Key routes: consumed by storefronts, widgets and server jobs that send an application token in
X-API-KEY(fromentirius-django-access1.0.0).
Package Layout
Section titled “Package Layout”{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.pyThe 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.
Authentication and Permissions
Section titled “Authentication and Permissions”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:
JWTAuthenticationandIsAdminUser. NeverIsAuthenticatedalone: a storefront customer is authenticated too. - Public endpoints:
AllowAnywithauthentication_classes = []. - Key endpoints: an application token in
X-API-KEY, checked withverify_api_keyand 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.
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.pyclass PublicProductViewSet(viewsets.ViewSet): authentication_classes = [] permission_classes = [AllowAny]The Access Gate
Section titled “The Access Gate”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[]:
| Issue | When |
|---|---|
STAFF_ONLY | an authenticated user without staff status; names no area |
ACCESS_DENIED | staff without the route’s permission, or a write on a read-only area; the description names the permission (needs pim.products:write) |
UNMAPPED_ROUTE | an 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.
Key Endpoints
Section titled “Key Endpoints”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-KEYis an alias). NeverAuthorization: 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.
Throttling
Section titled “Throttling”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 ImproperlyConfiguredfrom 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_rateholds when it does not. - The fallback never sits in a class-level
rate:SimpleRateThrottle.__init__skipsget_rate()whenrateis already set, and the service override is ignored without a word. - In DRF
rate = Nonemeans “no throttling”.get_rate()never returnsNoneor a malformed rate. - Tests patch
SimpleRateThrottle.THROTTLE_RATES: DRF reads the setting at import, sooverride_settingsdoes not reach it.
A refused request answers 429 RATE_LIMITED (Error Handling).
Pydantic Schemas
Section titled “Pydantic Schemas”- Location:
schemas/requests/{entity}.py,schemas/responses/{entity}.py. ConfigDict(from_attributes=True)on every response schema; build it withModel.model_validate(instance).- Every
Fieldhasdescription=andexamples=: 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.
Service Layer
Section titled “Service Layer”- Views delegate to
services/. No ORM call in a view. - Services take primitives or Pydantic models and return QuerySets or instances.
- Services raise
DoesNotExistandValueError; the view turns them intoNotFoundandValidationError.
# api/admin/views/product_views.py: thindef 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 logicdef 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 qsTwo 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 noticeThe 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 orderingErrors
Section titled “Errors”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.
OpenAPI
Section titled “OpenAPI”Every ViewSet action carries @extend_schema with summary, description, parameters and responses. Tags,
operation naming and the schema commands: OpenAPI Generation.
Pagination
Section titled “Pagination”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 token403: authenticated user without staff status200: 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,
400on an invalid payload with the v2 error body,404on a missing entity - File responses:
200withContent-Disposition,404when the record is missing,404when the file is
Pagination
pageworksmax_page_sizecapspage_sizenextandpreviousare present
Checklist
Section titled “Checklist”-
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
IsAdminUserandaccess_areafor admin;AllowAnyfor public;verify_api_keywith a scope for key routes (access_areaandverify_api_keyfrom access 1.0.0) -
throttle_classeson every endpoint that creates state without a user login - Pagination configured
-
raise_pydantic_as_drf(exc)in everyexcept ValidationError; no hand-built error body -
@extend_schemawith asummaryon every action - Tags per the tag convention; public tags carry the
Publicprefix - No
_2-suffixed operation IDs in the generated spec -
python manage.py spectacular --validate --fail-on-warnpasses -
tests/test_admin_api.pywith auth, endpoint and pagination tests