github wemake-services/django-modern-rest 0.16.0
Version 0.16.0

5 hours ago

Released as a part of our community event #opensource_september.
144 commits from 32 contributors, almost 20 new contributors 🎉

One big idea: every setting is now resolved by the "first explicit level wins" rule, auth rework, OpenAPI got a lot of very advanced features, new quality of life changes, and almost everything got measurably faster.

Consider this release as a somewhat v2.0, because we had to break quite a lot of the APIs. Sorry for that!
But, everything that we broke - we made much better. Code rewrites (especially with $dmr-upgrade) are rather cheap now,
but great design is priceless! We are almost ready for our first beta release!

Full list of changes: CHANGELOG.
Upgrading? Ask your coding agent to use $dmr-upgrade, see AI-guided upgrades.


Performance

This release is the fastest django-modern-rest ever. Highlights:

Area Improvement Issue
PydanticFastSerializer.deserialize x2.2 faster #1662
PydanticFastSerializer.serialize x1.33 faster #1662
New BodyMsgspec component x1.6 faster than Body #1661
MsgspecSerializer request context (msgspec.Struct, gc=False) x2 faster validation #1494
Content negotiation, exact header match x1.2 faster #1455
Content negotiation, full browser-style Accept up to x65 (renderers) and x15 (parsers) #1455
msgspec@0.22 much faster decoding in many cases #1653

Plus a long tail of micro-optimizations (#1454, #1456, #1448): checks that are
not configured for an endpoint are no longer called at all, as_view() is
cheaper for the default csrf_exempt=True, @modify responses skip
intermediate objects, cookie and header creation is faster, Django cache
throttles parse JSON faster, and the default DMR_MAX_CACHE_SIZE grew from
256 to 1024.


Components and serializers

BodyMsgspec: decode the request body straight into a model

msgspec can decode bytes directly into a Struct when it knows the shape,
skipping the intermediate dict. BodyMsgspec is a drop-in for Body
with MsgspecSerializer and is x1.6 faster in our benchmarks.

from dmr import Controller
from dmr.plugins.msgspec import BodyMsgspec, MsgspecSerializer

class UserController(Controller[MsgspecSerializer]):
    def put(self, parsed_body: BodyMsgspec[User]) -> User:
        return parsed_body

Use-case: write-heavy endpoints with large JSON or MessagePack payloads
where body parsing dominates the request time.

Docs: Fast body parsing with msgspec

Default values for components

Component parameters can now have real Python defaults. When a request has no
data for a component, the endpoint gets the default as-is, no parsing involved.
OpenAPI documents such bodies as required: false.

class ProductController(Controller[MsgspecSerializer]):
    def post(
        self,
        parsed_body: Body[ProductFilters | None] = None,
        parsed_query: Query[Pagination] = DEFAULT_PAGINATION,
    ) -> dict[str, str | int]: ...

Use-case: "search with optional filters" and "list with optional
pagination" endpoints without hand-written Optional models.

Docs: Default values


Auth and security

Ready-to-use login views with zero view code

Every auth flow gained a concrete_views module next to views: JWT, opaque
tokens, and Django sessions. They only need a serializer, everything else is
optional, and as_view is fully typed.

from dmr.security.token import concrete_views

path(
    'auth/',
    concrete_views.ObtainTokenSyncController.as_view(
        serializer=PydanticFastSerializer,
    ),
)

Use-case: a standard login/refresh/logout surface in one urls.py line;
custom payloads still go through the reusable controllers in views.

Docs: Token views
· JWT views
· Session views

First-class CSRF for REST controllers

Controllers with csrf_exempt = False now get the correct 403 response spec
and csrf security requirement in OpenAPI, and build_csrf_handler turns
Django's HTML CSRF_FAILURE_VIEW into a real REST error response.
CSRF_USE_SESSIONS = True is documented as a header apiKey scheme (#1521, #1608).

class ExampleController(Controller[PydanticFastSerializer]):
    csrf_exempt = False

    def post(self) -> str:
        return 'ok'

Use-case: session-authenticated SPAs that must keep Django's CSRF
protection and still get machine-readable errors.

Docs: Django session auth
· Error handling

Smaller auth improvements

  • security_requirements can now express both AND and OR strategies,
    previously only OR was representable (#1521)
  • Auth and throttling instances get a validate hook that runs at endpoint
    construction time, so misconfiguration fails at import, not at request (#1600)
  • Controller sets login_required = False by default, so Django's
    LoginRequiredMiddleware no longer silently shadows your auth (#1551)
  • CookieJWT*Auth now uses the jwt_cookie scheme name, so header and cookie
    JWT auth can live on the same endpoint (#1587)

Pagination

Cursor (keyset) pagination

dmr.pagination.CursorPaginator works directly on a QuerySet, has sync and
async APIs, and appends primary keys as tiebreakers automatically.

paginator = CursorPaginator(
    Entry.objects.order_by('rank', 'name'),
    per_page=parsed_query.limit,
)
page = paginator.page(parsed_query.cursor)  # or: await paginator.apage(...)
return CursorPaginated(next_cursor=page.next_cursor, per_page=..., page=[...])

Use-case: feeds and large tables where OFFSET gets slow and rows are
inserted while clients are paging.

Docs: Cursor pagination


Configuration

One rule for every setting: first explicit level wins

auth, throttling, parsers, renderers, responses, tags, security,
deprecated, servers, and every other value are no longer merged across
settings, router, controller, and endpoint. The most specific level that sets a
value wins; EMPTY (or not setting it) falls through; None and [] mean
"nothing on this level" (#1576, #1499, #1660).

class APIController(Controller[PydanticFastSerializer]):
    auth = (HeaderJWTSyncAuth(),)

    @modify(auth=[*auth, CookieJWTSyncAuth()])  # explicit merge
    def get(self) -> str: ...

    @modify(auth=None)  # explicit disable
    def post(self) -> str: ...

Use-case: predictable overrides. You can finally read a controller and know
exactly what an endpoint does without mentally merging four layers.
Need the old merging? Subclass MetadataMerger and set
Controller.metadata_merger_cls.

Docs: Configuration levels

extras=: typed custom parameters for your own @modify / @validate

Subclass dmr.endpoint.Extras, build typed decorators with
ModifyEndpoint(YourExtras), and assign extras = YourExtras(...) on the
controller. Streaming already uses it: extras=Streaming(ping_seconds=5).

modify: Final = ModifyEndpoint(SmartResponse)

class APIController(Controller[PydanticFastSerializer]):
    extras = SmartResponse()

    @modify(extras=SmartResponse(response_text='from endpoint'))
    def post(self) -> str:
        return SmartResponse.of(self)

Use-case: library authors and large codebases that ship their own
controller base classes with per-endpoint knobs, fully typed.

Docs: Providing extras for @modify and @validate

Settings.semantic_schema_providers

Register your own providers of "semantic" response specs and security
requirements that every endpoint gets when a condition is met, the same
mechanism we use for response validation and CSRF (#1521). Pair it with the new
semantic_schema, semantic_auth, and exclude_semantic_auth switches to
turn generated parts off per endpoint, controller, or globally (#1586).

Use-case: "every endpoint behind our gateway returns 429 with this
schema" documented once, not on every controller.

Docs: Settings reference
· Semantic schema


Controllers and reusable code

PEP 696 TypeVar defaults for reusable controllers

A subclass that omits some type args gets their defaults, exactly like a type
checker would. Defaults may even point to other type vars.

_RequestModelT = TypeVar('_RequestModelT', default=DefaultRequestModel)

class ReusableController(Controller[_SerializerT], Generic[_SerializerT, _RequestModelT]):
    def post(self, parsed_body: Body[_RequestModelT]) -> _RequestModelT: ...

class PydanticController(ReusableController[PydanticFastSerializer]): ...

Use-case: reusable CRUD or auth controllers with sensible defaults that
users override only when needed.

Docs: Type variable defaults

Explicit is_abstract = True

A controller with an exact serializer can now be marked abstract to be reused
without being routed; subclasses are concrete unless they say otherwise (#1458).
And as_view() on an abstract controller now raises EndpointMetadataError
instead of silently serving nothing (#1445).

Docs: Reusable controllers


Routing

external_re_path and nested external_path

External (non-dmr) views can be documented with regex routes, and
external_path can now sit anywhere in the URL tree, not only at the top (#1567).

external_re_path(r'^legacy/(?P<pk>\d+)/$', legacy_view, openapi=path_item)

Use-case: gradual migration of a legacy Django app where old views still
need to show up in the same OpenAPI document.

Docs: External views


Error handling

Handlers can re-raise a different error

An endpoint-level handler can now raise a new exception, and the controller
handler receives that new one, and so on to the global handler (#1561).

def endpoint_handler(endpoint, controller, exc: Exception) -> HttpResponse:
    if isinstance(exc, KeyError):
        # The controller's `handle_error` receives `LookupError`, not `KeyError`:
        raise LookupError(str(exc)) from exc
    raise exc from None

Use-case: translate low-level exceptions into domain errors at the edge
and keep one place that turns domain errors into responses.

ProblemDetailsModel class-level overrides

ProblemDetailsError subclasses can override the model fields at class
level, so you declare type, title, and status once per error class (#1556).

Docs: Error handling
· Problem Details


OpenAPI

x- specification extensions everywhere

Every OpenAPI object that allows extensions has x_extensions, with typed
entry points at each level: OpenAPIConfig, Controller, @modify /
@validate, ParameterMetadata, MediaTypeMetadata, and ResponseSpec.
Each one describes exactly one object and is never inherited (#1664).

class UserController(Controller[MsgspecSerializer]):
    x_extensions = {'x-owner': 'users-team'}  # on the path item

    @modify(x_extensions={'x-rate-limit': 100})  # on the operation
    def post(self, parsed_body: Body[UserModel]) -> UserModel: ...

Use-case: feeding gateway, linting, or codegen tools that key off
vendor extensions like x-internal, x-rate-limit, or x-codegen-*.

Docs: Specification extensions

security: document auth you do not implement

Describe API gateways, service meshes, or mTLS that protect your endpoints
outside Django. Works on settings, controller, and endpoint levels and merges
with what auth generates; by default as alternatives (#1499).

class UserController(Controller[MsgspecSerializer]):
    security = ({'gateway': []},)  # spec only, never enforced at runtime

    @modify(security=[{'mesh': []}])
    def post(self) -> str: ...

Use-case: accurate client SDKs and docs when the real check happens in
Kong, Envoy, or an API gateway.

Docs: Customizing security

OpenAPI 3.2 support

All missing 3.2 fields are available: $self, Server.name, Tag.parent
and Tag.kind, querystring parameters, Components.media_types,
Response.summary, Example.data_value, XML.node_type, OAuth device
authorization, and more (#1485). OpenAPI 3.0.x is explicitly rejected now;
it predates JSON Schema and never really worked (#1435).

Controller-level OpenAPI defaults

  • tags, deprecated, external_docs, callbacks, and servers can be set
    on a controller as defaults for all its endpoints (#1434, #1660)
  • A controller's docstring becomes the PathItem summary and description,
    just like an endpoint's docstring does for the operation (#1446)
  • summary and description of @modify are resolved one at a time:
    passing only one no longer drops the docstring (#1446)
class UserController(Controller[MsgspecSerializer]):
    """Users API.

    Everything about the users of our system.
    """

    tags = ['users']
    deprecated = True

Docs: Customizing path items
· Customizing tags

Schema generation is deterministic and more correct

  • Generated mappings are sorted by stable keys; seeded examples no longer
    depend on PYTHONHASHSEED or on the current time (#1557, #1629, #1632)
  • Examples of the same type now differ from each other instead of being
    identical copies (#1548)
  • Only models referenced from the final schema land in components.schemas;
    Query, Headers, Cookies, Path models are inlined (#1647)
  • default: null, const: null, and example: null are kept (#1619)
  • re_path() parameters get a pattern, slug and path converters
    describe themselves (#1439, #1441)
  • json_schema_kwargs on PydanticSchemaGenerator and a schema_hook
    on MsgspecSchemaGenerator for custom types (#1462)
  • OpenAPIConfig.json_schema_dialect finally reaches the document (#1486)
  • Formats from the OpenAPI Format Registry are known to OpenAPIFormat,
    unknown custom formats no longer crash (#1489)

Use-case: committing openapi.json to git and diffing it in CI without
noise.

Docs: OpenAPI
· Examples generation


Streaming

Controller.streaming_ping_seconds and validate_events moved into the new
extras= mechanism: extras = Streaming(ping_seconds=..., validate_events=...)
on the controller or per endpoint with dmr.streaming.modify (#1612, #1623).
SSEController still pings every 15 seconds by default.

Docs: Streaming


AI tooling and docs

Agent skills ship inside the package

dmr/.agents/skills is installed with the library, so uvx library-skills
links skills that match your installed version. New $dmr-upgrade skill
carries the migration prompts of the three latest breaking releases.

uvx library-skills --claude   # -> .agents/skills/dmr, .claude/skills/...

Use-case: upgrade from 0.15.0 by telling your agent "use $dmr-upgrade".

Docs: Agent skills
· dmr-upgrade

Markdown docs for humans and LLMs

Every docs page is also served as Markdown: replace .html with .md or press
M↓ next to the title. llms-full.txt now includes the code of every example.

Docs: Getting started, AI section


Notable bugfixes

  • Redis throttling reset values were sometimes 1 second above the window
    because of clock skew; the backend now uses Redis ttl directly (#1308)
  • Router.include() and build_schema raised KeyError for empty-pattern
    routes like Router('users', [path('', ...)]) (#1657)
  • Header lists are split RFC 9110 style: X-Tag: 1, 2 → ['1', '2'] (#1526)
  • pydantic serializer dumps @dataclass instances correctly without
    msgspec installed (#1560)
  • build_404_handler('api/') no longer matches /apiary/... (#1606)
  • Cookie-based auth no longer adds a csrf requirement to safe methods (#1572)
  • ResponseSpecMetadata on a union member is no longer ignored (#1460)
  • @modify and @validate accept any Sequence for tags, servers,
    and extra_responses, and .lazy typing is fixed (#1563, #1607)
  • Missing @sensitive_variables() added on auth and token internals (#1552)

Breaking changes you will most likely hit

The complete list lives in the
CHANGELOG,
the $dmr-upgrade skill applies most of these automatically.

Was Now
Settings merged across levels First explicit level wins; []/None disable, EMPTY falls through
deprecated combined with OR Any explicit value wins, including deprecated=False
Controller.streaming_ping_seconds, validate_events extras = Streaming(...)
Settings.throttling_allow_unsafe_cache SyncDjangoCache(allow_unsafe_cache=...)
jwt_ensure_csrf on JWT cookie views Removed, always on
BaseSerializer.is_supported BaseSerializer.validate(controller_cls, metadata)
security_schemes / security_requirement properties security_schemes() / security_requirements() methods
OpenAPIConfig(openapi_version='3.0.x') ValueError, 3.1.0 is the minimum
OpenAPIContext.register_schema Removed, use native serializer schema hooks

Internal generator and component APIs changed signatures to accept
controller_cls and metadata consistently; see the "Internals" section of
the changelog if you subclass SchemaGenerator, ResponseGenerator,
ParameterGenerator, ComponentParser, or OperationIdGenerator.


New Contributors

Full Changelog: 0.15.0...0.16.0

Don't miss a new django-modern-rest release

NewReleases is sending notifications on new releases.