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_bodyUse-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_requirementscan now express bothANDandORstrategies,
previously onlyORwas representable (#1521)- Auth and throttling instances get a
validatehook that runs at endpoint
construction time, so misconfiguration fails at import, not at request (#1600) Controllersetslogin_required = Falseby default, so Django's
LoginRequiredMiddlewareno longer silently shadows yourauth(#1551)CookieJWT*Authnow uses thejwt_cookiescheme 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 NoneUse-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, andserverscan be set
on a controller as defaults for all its endpoints (#1434, #1660)- A controller's docstring becomes the
PathItemsummary and description,
just like an endpoint's docstring does for the operation (#1446) summaryanddescriptionof@modifyare 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 = TrueDocs: Customizing path items
· Customizing tags
Schema generation is deterministic and more correct
- Generated mappings are sorted by stable keys; seeded examples no longer
depend onPYTHONHASHSEEDor 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,Pathmodels are inlined (#1647) default: null,const: null, andexample: nullare kept (#1619)re_path()parameters get apattern,slugandpathconverters
describe themselves (#1439, #1441)json_schema_kwargsonPydanticSchemaGeneratorand aschema_hook
onMsgspecSchemaGeneratorfor custom types (#1462)OpenAPIConfig.json_schema_dialectfinally 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 Redisttldirectly (#1308) Router.include()andbuild_schemaraisedKeyErrorfor empty-pattern
routes likeRouter('users', [path('', ...)])(#1657)- Header lists are split RFC 9110 style:
X-Tag: 1, 2→['1', '2'](#1526) pydanticserializer dumps@dataclassinstances correctly without
msgspecinstalled (#1560)build_404_handler('api/')no longer matches/apiary/...(#1606)- Cookie-based auth no longer adds a
csrfrequirement to safe methods (#1572) ResponseSpecMetadataon a union member is no longer ignored (#1460)@modifyand@validateaccept anySequencefortags,servers,
andextra_responses, and.lazytyping 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
- @s3rius made their first contribution in #1433
- @wized2 made their first contribution in #1449
- @dzhalaevd made their first contribution in #1453
- @larryxs made their first contribution in #1498
- @hxxxi-malog made their first contribution in #1478
- @jarik2014 made their first contribution in #1501
- @soil0119 made their first contribution in #1503
- @adityabagla7 made their first contribution in #1442
- @Aditya-myst made their first contribution in #1517
- @helo060228 made their first contribution in #1463
- @kanagarajSCK made their first contribution in #1502
- @krisrajaryan27 made their first contribution in #1564
- @Shraddhameduri made their first contribution in #1602
- @vansh-nagar made their first contribution in #1609
- @bernalalexis-try made their first contribution in #1617
- @HarshRajSinghania made their first contribution in #1492
- @Laksopan23 made their first contribution in #1621
- @nikolaysm made their first contribution in #1566
- @qwist1233-cpu made their first contribution in #1658
Full Changelog: 0.15.0...0.16.0