All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.29.1 — 2026-07-29
-
KeyError: 'SERVER_NAME'from any serializer with aFileField, off the HTTP path. A 0.29.0 regression. That release started puttingrequestin the serializer context off HTTP — which is what makesrequest.userand friends work — but the synthesized request was a bareHttpRequestwith an emptyMETA, and Django resolves the host fromHTTP_HOST/SERVER_NAME. Sobuild_absolute_uri()raised, and DRF'sFileField.to_representationcalls it for every file value whenever a request is present. Before 0.29.0 the same field saw no request at all and returned a relative URL: supplying a hostless request was strictly worse than supplying none.The synthesized request is now an
OfflineHttpRequest, which returns the relative URL instead of raising when no host is configured — the same shape DRF's own file / hyperlinked fields fall back to, so it restores the pre-0.29.0 output. Transports that pass a realhttp_request(the MCP server) were never affected.
-
build_offline_context(host=…)— an origin for absolute URLs off HTTP. Accepts"example.com","example.com:8000", or a full origin like"https://example.com"(the scheme decides whether links are https). With it,build_absolute_uri()builds real absolute URLs from a toolset, a management command, or a worker.There is deliberately no default. Only the project knows its public origin, and inferring one — the first
ALLOWED_HOSTSentry, say, which is an authorization list and is routinely a wildcard or an internal load-balancer name — would emit confidently-wrong links that look valid. The value is not validated againstALLOWED_HOSTSeither: that check exists to reject spoofedHostheaders from untrusted clients, and there is no client here, so a worker can render links for a site it doesn't itself serve.hostis ignored whenhttp_requestis supplied, so a caller may pass both unconditionally. -
OfflineHttpRequest— the synthetic request type, exported so its behaviour is documented rather than implied.
0.29.0 — 2026-07-29
arender_spec_output— the async sibling ofrender_spec_output. Rendering is full of sync ORM work that isn't optional:serializer.dataiterates the value (evaluating the queryset whenmany=True, plus a query per untraversed relation per row), theoutput_serializer_contextprovider is user code documented as the place to run one batched query, and the no-serializer path list-coerces. An async caller that awaitedadispatch_spec— which returns aLISTresult as a deliberately lazy queryset — therefore could not render it inline without raisingSynchronousOnlyOperation, and had to know to wrap the call itself. The dispatch surface now offers the pair on both halves:dispatch_spec/adispatch_specandrender_spec_output/arender_spec_output. Same arguments, same result, executor instead of loop.
-
adispatch_specran a spec's sync callables on the event loop, so any of them that touched the ORM raisedSynchronousOnlyOperation. A spec is written once and dispatched over both transports, so none of its callables areasync def— the async path has to offload each one. It offloaded the selector / service (arun_callable), the permission guard, and input-serializer validation, but called the rest inline:shape_queryset— and with itextend_querysetandfilter_set. Afilter_<name>method that queries, or aModelChoiceFilterwhoseis_valid()resolves its choice against the DB, raised. Reported from the MCP transport, which awaitsadispatch_specdirectly on the loop.spec.kwargsproviders — including the nestedinstance_selector_spec/collection_selector_specones. The documented headline use of a provider is a tenant / role lookup, which is a query.input_serializer_contextproviders — documented as the place to run one batched query for the whole payload.- callable
success_status— user code that may read a relation off the result.
All four now go through the executor, thread-sensitive like the calls that already did, so they share one connection and see the same transaction state as on the sync path. Sync dispatch is unaffected — it was never on a loop.
-
Off-HTTP dispatch supplied no baseline serializer context, so
self.context["request"]raisedKeyError. Over HTTP every serializer DRF builds carriesGenericAPIView.get_serializer_context()—request/format/view— so serializers read those keys unguarded (build_absolute_uri,request.user, an ownership check in aSerializerMethodField).render_spec_outputanddispatch_spec's input validation passed the spec's*_serializer_contextprovider result instead of that baseline, and nothing at all when no provider was declared — so the same serializer that renders behind a view raisedKeyError: 'request'when the spec was dispatched from the MCP server, a Pydantic-AI toolset, or a management command. Both directions now start from the DRF baseline (the newbase_serializer_context) and merge the spec's provider over it, so a provider keeps the final say on every key. Off HTTP the values are the synthetic pairbuild_offline_contextbuilds —request.useris theuseryou passed it — andformatisNone. The HTTP bulk path (which renders throughrender_spec_output) gains the same baseline, from the real view'sget_serializer_context().
base_serializer_context— the DRF baseline context, exported for transports that build a serializer outsidedispatch_spec/render_spec_output(asdjangorestframework-mcp-serverdoes for its selector, chain, and resource renderers). Prefersview.get_serializer_context()when the view has it, else synthesizes{"request", "format": None, "view"}.
0.28.1 — 2026-07-28
validate_channel_nameswas uncallable from a type-checked adapter. Its internal declaration Protocol declared bare attributes (name: str), which declare mutable members — and bothUrlKwargandQueryParamare@dataclass(frozen=True), whose fields are read-only. Every adapter passing its own tuple failed type-checking at the call site. The Protocol members are now read-only properties. Runtime behaviour is unchanged; this was visible only to a type checker, and only from a consumer —tyis scoped to the package here and skipstests/, so the package's own suite could not see it.
0.28.0 — 2026-07-28
-
InputRequired— declare a reflected input required without breaking Protocol conformance. Since 0.26.0 a selector's**extras: Unpack[SomeExtras]keys are reflected into the input schema, and a requiredTypedDictkey populates the schema'srequiredlist. In practice no consumer could use that: under PEP 692 a required key inUnpack[...]makes the callable reject callers that omit it, so it stops being assignable toListSelector/RetrieveSelector/ the service Protocols — which is exactly whyHttpExtrasmandatestotal=False. Protocol conformance and an honest schema were mutually exclusive.InputRequiredcarries the signal inAnnotatedmetadata, which has no effect on the type system:class WidgetExtras(HttpExtras[MyUser], total=False): project_pk: Annotated[int, InputRequired]
The key stays
NotRequiredto the type checker and joins the schema'srequiredlist. Applies to ordinary parameters as well asTypedDictkeys. -
NotClientInput— hide a provider-owned input from the schema. The counterpart: a key aspec.kwargsprovider fills in from request state (a scopingteam_role) is dropped frompropertiesentirely, so a caller is never told it exists. Such a key is also excluded fromdeclared_input_keys, soUnknownArguments.REJECTtreats a caller that supplies it as passing an unknown argument. Delivery is unchanged — the provider still resolves it, and theSPREAD_AUTHOR_WINSprecedence remains the actual security property. Marking a key with both markers raisesImproperlyConfigured. -
UrlKwarg/QueryParam— the shared transport-channel declarations. Previously each adapter (djangorestframework-mcp-server,djangorestframework-pydantic-ai) carried its own copy, and the copies had already drifted into validating the same declaration against different reserved-name sets —UrlKwarg("order")was legal over MCP and rejected in the agent toolset,UrlKwarg("user")the reverse. One definition now lives here.UrlKwarggainsrequired: bool = False, the registered-declaration counterpart of theInputRequiredmarker.QueryParamdeliberately has norequired— a read-shaping param is optional by construction. -
validate_channel_names— shared fail-fast validation for a set ofUrlKwarg/QueryParamdeclarations: reserved-name collisions (always includingRESERVED_POOL_SEEDS, plus the transport's own pagination names), duplicates, and the contradictoryrequired=Truewith adefault. -
RESERVED_POOL_SEEDSis now public (rest_framework_services.types). Adapters need the same list — anything letting a caller route a value into the kwargs pool must refuse these names — and were keeping local copies of it.
dispatch_spec/adispatch_specnow enforceInputRequired. A marked key absent from every channel (params,build_offline_context(kwargs=…), aspec.kwargsprovider) raisesServiceValidationErrorbefore the callable runs. Previously the callable raised a bareKeyErrorfrom inside dispatch, which no transport maps: over MCP that surfaced as a 500 / JSON-RPC internal error rather than a failed tool result, and under an agent toolset it aborted the run instead of letting the model retry with the argument it omitted. A provider that declines withUNSETdoes not satisfy the requirement — declining removes the key from the pool. Enforcement is off-HTTP only: on the HTTP path the route is the guarantee, and a missing capture is a URLconf bug. ⚠ Behaviour change, though it can only fire on specs that opt in by using the new marker.
Annotated[...]no longer erases a property's type._python_type_to_schemadid not strip the wrapper, soAnnotated[int, "help text"]reflected as an untyped{}where bareintgives{"type": "integer"}. Any consumer already usingAnnotatedin an extrasTypedDictor a parameter annotation was silently losing the type. Independent of the markers, which merely made the idiom load-bearing.
0.27.0 — 2026-07-27
SpecRegistry— one declaration site for a project's spec set. A spec is already transport-neutral, but every transport has to be told which specs it exposes, so a project serving two or three of them writes the same list two or three times (register_service_tool(...),SpecToolset(specs={...}), an HTTP viewset'saction_specs). Those lists drift, and the same operation can end up named differently in each.SpecRegistryis aname → specmap with tags, registration order, and filtered views, which each transport reads instead of enumerating specs again. It holds only what is invariant across transports — which spec, its canonical name, its tags; per-transport knobs stay at the binding that already owns them, so a registry is a source for a transport's binding table rather than a layer between a caller anddispatch_spec.- Adoption needs no adapter support:
registry.specs()returns thedict[str, spec]today's adapters already accept. - There is no global registry. The consumer owns its instances and may hold
as many as it likes, with no shared state between them — either independent
registries (separate name namespaces, one per mount) or one registry with
many projections (
by_tag/subsetviews feeding several bindings). Names are unique within a registry;merge()is the only place cross-registry reuse becomes a conflict. - Derivations are snapshots.
by_tag/subset/mergeeach return a new registry holding the selected entries (spec objects shared, not copied), so a view never mutates its source and a laterregister()on the source does not appear in a view derived earlier.by_tagmatches any of the tags given, so intersection composes from it (reg.by_tag("read").by_tag("public")) while a union would not compose from an intersection. - Failures are loud, not silent. A duplicate name raises instead of
overwriting;
subset()raises on a name that isn't registered; andregister()rejects anything that is neither aServiceSpecnor aSelectorSpec— which is what letsmutations()/queries()discriminate by type (never a stored flag that could drift) and still cover every entry. APolymorphicServiceSpecis rejected with a pointer at the supported shape: register each variant under its own name, since a transport that projects one operation per name wants one operation per variant, not a union. - New public symbols:
SpecRegistry(inrest_framework_services.registry) and its entry typeRegisteredSpec(inrest_framework_services.types), both re-exported from the top-level package. Additive and opt-in — nothing existing changes, and every adapter keeps accepting a plain dict. See the recipe Declare specs once, project them to many transports.
- Adoption needs no adapter support:
rest_framework_services.conf— theREST_FRAMEWORK_SERVICESsettings dict and itsATOMIC_BY_DEFAULTkey. Nothing in the package ever imported this module, so the setting did nothing: the atomic default is theServiceSpec.atomicfield (True), and the supported opt-out is per-specatomic=False. SettingREST_FRAMEWORK_SERVICES = {"ATOMIC_BY_DEFAULT": False}silently had no effect, which is worse than not offering it — removed rather than wired up, since the per-spec knob already covers the case and is what the docs teach.
ServiceSpecparameterisation examples were missing their third type argument.ServiceSpecisGeneric[InputT, ResultT, ExtraT], but the README, the docs landing page, and the Typing guide all showed the two-argument formServiceSpec[AuthorIn, Author]— which raisesTypeErrorat runtime, since these are plainTypeVars with no PEP 696 defaults. Corrected, with a note that it is all-or-nothing andAnyis the rightExtraTfor a spec with nokwargs=provider.filter_set's 400-on-invalid-filter contract is now documented. Rejecting invalid filter input (rather than silently returning unfiltered rows, which is django-filter's default non-strict behaviour) is the half of "replacesDjangoFilterBackend" that most affects callers, and it appeared in neither the filtering recipe nor theSelectorSpec.filter_setreference. Both now cover it, including that it only applies when the duck-typed object exposesis_valid.PolymorphicServiceSpecgained a reference entry. It was the only public symbol with no autodoc entry on any reference page.
0.26.0 — 2026-07-24
- URL-kwarg & provider-state parity over the off-HTTP path. A spec
that works over HTTP is now equally usable off-HTTP (
build_offline_context+dispatch_spec— the path under every MCP tool, Pydantic-AI tool, management command, and task runner), closing two gaps where a spec depended on values that only existed on the HTTP transport:Unpack[TypedDict]extras are now reflected and discoverable. A selector whose keyword surface is the blessed strict-typing idiom**extras: Unpack[SomeExtras](seeHttpExtras) previously contributed nothing tospec_to_json_schema(phase="input"), so a schema-driven caller (an LLM, an MCP client) could not learn that a URL kwarg the selector reads from its extras (extras["parent_pk"]) even existed — the call failed with a bareKeyError. The schema now expands theTypedDict: one property per key, with theTypedDict's required keys populatingrequired. The inheritedrequest/userseeds (fromHttpExtras) are excluded from the expansion, never advertised as tool inputs. Required-key detection is robust underfrom __future__ import annotations(PEP 563), where the raw__required_keys__misclassifiesNotRequiredfields.build_offline_context(kwargs=…)is now the channel for URL-derived values, and it actually delivers.dispatch_spec/adispatch_specspread the offline view'skwargs(a nested route's captures, e.g.parent_pk) into the selector and target-resolution pools — the off-HTTP counterpart of the HTTPextra_url_kwargs=view.kwargs. Route captures are authoritative overparamson a key conflict (matching HTTP, where a route scope out-ranks client input) and sit below aspec.kwargsprovider (also matching HTTP). Reserved pool seeds are stripped from the view kwargs. Empty by default → no behaviour change for callers that pass nokwargs.
spec.kwargs/ provider hooks can decline a key withUNSET. A provider that returnsUNSETfor a key is declining to set it (not setting it toUNSET); the key is dropped from the resolved kwargs on both transports (resolve_provideroff-HTTP, theget_*_kwargs/input_data/ context hook chain on HTTP). This lets a provider that can't resolve a value off-HTTP (middleware state absent on the synthetic request) step aside so a caller-suppliedparamsvalue survives theSPREAD_AUTHOR_WINSmerge, instead of a fallbackNonesilently over-scoping the result. Declining is for benign keys only: a provider that owns a scoping key must always resolve it (off-HTTP, fromview.kwargs), since declining there would let the caller's value through.
UnknownArguments.REJECTnow enforces on a**extras: Unpack[TypedDict]selector (was previously a silent no-op). Because such a surface was treated as open (like a bare**kwargs),REJECThad nothing to reject and a URL kwarg was deliverable only by that accident of openness. TheTypedDictnow names the exact declared keyword surface, soREJECTaccepts those keys and rejects genuinely unknown ones — the same contract every other closed spec already had. A bare /Any**kwargsselector stays open, unchanged.
0.25.1 — 2026-07-17
SelectorSpec.filter_setnow receives therequest, matchingDjangoFilterBackend. Adjango-filterFilterSetused asfilter_setwas constructedfilter_set(data=…, queryset=…)with norequest, so any FilterSet that readself.request— caller scoping viaself.request.user, a request-awareModelChoiceFilter(queryset=lambda request: …), an__init__/qsoverride — sawself.request is Noneand raisedAttributeError(a 500) once moved off a view'sDjangoFilterBackendonto a spec. The dispatcher now forwards therequestinto the FilterSet whenever its constructor declares one (signature-gated, so the bare(data, queryset) -> .qsduck-typed contract is untouched). The request is the same object every other queryset-shaping hook already receives — real on the HTTP / MCP paths, a synthetic off-HTTP one whoseuserandquery_paramsare faithful. Seedocs/recipes/selector-filtering.md.
0.25.0 — 2026-07-16
PolymorphicServiceSpec— one action, N mutually exclusive payload shapes. A new spec accepted anywhere aServiceSpecis (anaction_specsentry and thespec=of@service_action). It bundles adiscriminatorcallable — resolved through the keyword pool{request, data, user, view}and returning a variant key — with aspecsmapping of fullServiceSpecvariants, each with its own input serializer and service. Dispatch resolves the key (memoized once per request) and proceeds through the chosen spec exactly as today; a rejected payload is the discriminator's to raise on (ServiceValidationError→ 400).permission_strategy("union"default /"discriminate"/"require_identical") controls howget_permissionstreats the variants, since DRF runs permissions before the body is parsed —"union"requires the deduplicated union of every variant'spermission_classes(the conservative default),"discriminate"reads the body early and applies only the chosen variant's,"require_identical"is validated to require matching classes. The resolved concrete spec flows through the shared action→spec chain, so the chosen variant's serializer context / kwargs / output pipeline all apply. OpenAPI renders the request body as the union of the variant input serializers (PolymorphicProxySerializer,resource_type_field_name=None).ServiceSpec.success_statusmay now be a callable. In addition to anintorNone, it accepts a callable resolved through the framework keyword pool — declaring any subset ofresult/instance/request/view(or**kwargs) — that returns the status code. The callable keys on the service's return value (result), so an upsert can answer201for a freshly created row and200for an existing one without a hand-rolled action method.Nonestill applies each surface's action-appropriate default (201 create / 200 update / 204 destroy), and a plainintis unchanged. Resolved uniformly across the viewset mixins, standalone views,@service_action, and the transport-neutraldispatch_spec/adispatch_specpaths. A callable can't be resolved statically, so the generated OpenAPI schema documents the action default for the dynamic case.@service_actionnow resolves the status per-request (previously it was fixed at decoration time), which is what makes the callable form work on custom actions.call_service(..., map_errors=True)(and its async twinacall_service) translates aServiceErrorraised by the delegated service into the same DRF exception the framework raises on the normal view path —ServiceValidationError→ValidationError(400), any otherServiceError→ 422 — so DRF's exception handler renders it as a proper response instead of a 500. The default (map_errors=False) still propagates the rawServiceErrorunchanged. Chosen over a top-levelmap_service_errorexport so the HTTP-to-error mapping stays acall_serviceconcern (the helper is already HTTP-scoped). Internallymap_service_errormoved to its own leaf moduleviews/mutation/map_service_error.pysocall_servicecan reuse it without importing the heavy mutation-flow module (no public-API change).ServiceSpec.response_finalizer— a post-serialization hook for HTTP response side effects (cookies, headers, or swapping the response). It runs on the 2xx path only, after the output serializer has built theResponseand before it is returned (pre-render); error paths bypass it. Resolved through the framework keyword pool, it declares any subset ofresponse/result/request/view/instance/data(or**kwargs) and returns aResponse(which replaces the built one) orNone(which keeps it) — solambda *, response: response.set_cookie(...) or responseattaches a cookie.resultis the service's return value, so the idiomatic pattern keeps services DRF-free (the service returns domain flags on its result DTO; the finalizer translates flags → transport effects). Applies to both the single and bulk HTTP flows; skipped on the transport-neutral path (dispatch_spec/call_service/ MCP), which builds noResponse. Unlike the service/selector pool it deliberately receivesview.@service_actionforwards it automatically.
collection_selector_specselectors now receive the view's URL kwargs. On the HTTP bulk path acollection_selector_specselector was resolved with a pool of{user, request}+ query params + body but not the route's URL kwargs, unlike the instance / retrieve selectors (which getextra_url_kwargs=view.kwargs). A nested-route bulk such as/parents/{parent_pk}/children/therefore couldn't scope byparent_pkwithout an extrakwargsprovider. The bulk view now foldsview.kwargsinto the flatparamsmapping it handsdispatch_spec(whose contract already documents that mapping as the union ofrequest.data/query_params/ URL kwargs), so a selector likelambda *, parent_pk: Child.objects.filter(...)resolves from the route. Route captures are authoritative — they win over a client-supplied query/body key of the same name, so a filter value can't override the route scope. The transport-neutraldispatch_specpath is unchanged (callers already pass URL kwargs inparams).
0.24.1 — 2026-07-13
SelectorSpec.filter_setnow validates the FilterSet before filtering.apply_queryset_shapingread.qswithout callingis_valid(), so in django-filter's default non-strict mode an invalid filter value (e.g. aChoiceFiltervalue outside its choices) silently returned the unfiltered queryset — answering200with unfiltered results instead of the400DRF'sDjangoFilterBackendgives by default. The FilterSet is now validated and aValidationError(400) is raised on invalid input, restoring parity with the backendfilter_setreplaces. Validation is gated on the FilterSet exposingis_valid(), so a bare duck-typed(data, queryset) -> .qsstand-in keeps its pass-through behaviour; valid and absent filter values are unchanged. Applies on both the HTTP view path and the transport-neutraldispatch_specpath.input_datano longer corrupts form-encoded / multipart request bodies. When a spec had aninput_dataprovider,build_input_serializermerged the extras with{**request.data, **extra_data}. For a form-encoded bodyrequest.datais aQueryDict({key: [values]}internally), so unpacking it exposed those value lists — turning every scalar field into a one-element list and failing validation (aChoiceFieldsaw['X']→invalid_choice). The merge now copies the QueryDict and sets extras through its native API, so scalars stay scalars and multi-value fields keep their lists. JSON bodies (plaindict) are unaffected.
0.24.0 — 2026-07-08
- Selector input schemas now reflect the selector callable's parameters.
spec_to_json_schema(spec, phase="input")for aSelectorSpecpreviously introspected onlyspec.filter_set, so a lookup selector likeget_widget(user, pk)advertised a bare{"type": "object"}with nopk— the tool leaned entirely on its docstring. It now merges the callable's own parameters (names → JSON type from their annotations, skipping therequest/user/viewtransport seeds) with thefilter_setfields. An un-annotated parameter is still surfaced by name (untyped{}); afilter_setfield wins over a callable parameter of the same name. Consumers building tool definitions off the schema (djangorestframework-pydantic-ai, the MCP server) inherit the fidelity for free — no code change on their side.
0.23.0 — 2026-07-08
build_offline_context(query_params=…)— seed the synthetic request'sGETQueryDict(the sourcerequest.query_paramsreads) when dispatching a spec off-HTTP. This is how read-shaping params that aren't spec inputs reach the serializer over the offline path:SelectorSpec.filter_set(when not handedfilter_dataanother way), and any serializer that branches onrequest.query_params(django-restql field selection, custom serializers). Scalars are stringified as on HTTP; a list/tuple value becomes a multi-valued param (getlist); the seededGETis immutable like a real request's. Defaults to empty → no behaviour change for existing callers. (QP-1; the seamdjangorestframework-pydantic-ai'sSpecToolsetbuilds request-level param registration on.) It does not make DRFfilter_backends(SearchFilter/OrderingFilter) run off-HTTP — the offline path never callsfilter_queryset.
0.22.0 — 2026-07-03
ServiceAutoSchemanow emits OpenAPI query parameters for aSelectorSpec.filter_set. Moving adjango-filterFilterSet off a view-levelfilterset_class+DjangoFilterBackendand ontospec.filter_set(which theas_view()guard requires dropping the backend for) previously dropped every filter query parameter — field filters, ordering, search — from the generated schema, silently regressing any client codegen or "schema must not change" gate even though runtime behaviour was unchanged. The schema now contributes those parameters from the spec by reusing drf-spectacular's ownDjangoFilterExtension, so a view→spec FilterSet move yields a byte-identical OpenAPI document — same param names, types, enums, ordering enum + description,style/explode, and required flags. Applies to list selectors (detail operations document no filter params in either configuration, matching drf-spectacular's list-only gate);@extend_schema(parameters=...)overrides still win. Introspection is guarded and duck-typed — a no-op whendjango-filterisn't installed or thefilter_setisn't a real FilterSet.
0.21.1 — 2026-07-02
many=Truedispatch now honoursunknown_argumentsper list element.dispatch_spec/adispatch_specpreviously acceptedunknown_argumentson a bulk (many=True) spec but silently ignored it — a caller passingREJECTon a bulk tool gotIGNORE. The bulk path now applies the policy to each item:REJECTraisesValidationErroron the first item carrying an undeclared key,PASSTHROUGHfolds each item's extras into that item's data,IGNOREdrops them (unchanged).argument_bindinghas no counterpart for a list payload — a bulk service receives the whole list as onedataargument, so there is nothing to spread — and passing a non-default binding withmany=Truenow raisesValueErrorrather than being silently ignored. The default (AUTO/IGNORE) behaviour is unchanged.output_selector_spec.filter_setnow applies on the HTTP mutation path too.dispatch_spec's output re-fetch applied the nested selector'sfilter_set, but the HTTP mutation view did not — the same spec produced different results per transport. The HTTP output re-fetch now appliesoutput_selector_spec.filter_setas well, withfilter_datafalling back torequest.query_params(the blessedfilter_setsource on HTTP), so a given spec filters its re-fetched output identically on both paths.
0.21.0 — 2026-07-02
on_target_resolvednow fires on selector dispatch.dispatch_spec/adispatch_specpreviously invoked the object-permission hook only on the service (mutation) paths, so object-level permissions on reads were unenforceable through it. The guard now fires onSelectorSpecdispatch too — with the resolved instance for aRETRIEVEand the resolved queryset for aLIST— soon_target_resolved=enforce_permissionsauthorizes selector reads as well as mutations. The default (None) is unchanged, so existing callers are unaffected.
enforce_permissionsis collection-safe. When the resolved target is a collection (the queryset a bulk /LISTdispatch produces) rather than a single row,enforce_permissionsnow runs only the class-levelhas_permissioncheck and skipshas_object_permission. Object permissions are a per-rowModelconcept — the previous behaviour calledhas_object_permission(request, view, <QuerySet>), which raisedAttributeError(or silently mis-authorized) on acollection_selector_specguarded with an ownership-style permission. This makeson_target_resolved=enforce_permissionsthe safe canonical guard for every dispatch mode; the per-set BULK authorization decision is unchanged.
0.20.0 — 2026-06-23
- Caller-side input policies for
dispatch_spec/adispatch_spec— three optional, additive parameters that let a non-HTTP transport map its wire onto a spec, finishingdispatch_specas the single transport-neutral entrypoint. The principle: a spec declares what (its inputs, filters, output shape, permissions); the caller declares how its flat input becomes callable arguments. Every default reproduces the pre-policy behaviour exactly, so existing callers are unaffected.argument_binding=ArgumentBinding.AUTO— whether client input lands as a singledatabundle (BUNDLE) or is spread as individual kwargs (SPREAD_AUTHOR_WINS/SPREAD_CALLER_WINS, differing in precedence against the author'skwargs).AUTOresolves per spec type — service →BUNDLE, selector →SPREAD_AUTHOR_WINS— matching what each did before. TheSPREAD_*modes strip the reserved pool seeds (request/user/data/serializer/instance/collection) from the spread, so a client can't poison transport-controlled state by naming an argument after one.unknown_arguments=UnknownArguments.IGNORE— strictness aboutparamskeys outside the spec's declared set:IGNOREdrops them (today's behaviour),REJECTraises aValidationError,PASSTHROUGHforwards them to the callable. The declared set is derived from the spec alone — a service'sinput_serializerfields plus the keys its nested target selectors consume; a selector'sselectorparameters — and a**kwargscallable or a duck-typedfilter_setmakes the set "open" (nothing is unknown).on_target_resolved=None— an optional object-permission hook (theTargetGuardprotocol) invoked with the resolved mutation target before the service runs. Its signature is identical toenforce_permissions, so that primitive is passed directly by name (nolambda): the guard receives the resolved row (update), the resolved set (bulk), orNone(create), restoringhas_object_permissionparity for off-HTTP transports.dispatch_specitself stays authorization-agnostic — it only invokes the supplied guard.
ArgumentBinding,UnknownArguments,TargetGuardare new top-level exports (and live underrest_framework_services.types).
0.19.0 — 2026-06-21
-
View-free JSON Schema generation (
rest_framework_services.jsonschema) — the first part of the off-HTTP surface. Three new top-level helpers turn a spec (or a bare serializer / dataclass) into a JSON Schema dict, with no view, request, ordrf-spectacularimport in the path — what an alternate transport (a Pydantic-AI toolset, the MCP server) builds tool definitions from:serializer_to_json_schema(serializer, *, partial=False)— input schema for a DRFSerializersubclass, a bare@dataclass, orNone;partialdrops therequiredlist (mirroringspec.partial).output_to_json_schema(output_serializer, *, kind=None, paginate=False)— output schema, orNonewhen undeclared;kind=LISTwraps the item schema in an array,paginate=Truein the{items, page, totalPages, hasNext}envelope.spec_to_json_schema(spec, *, phase="input"|"output")— reads the right serializer / kind off aServiceSpecorSelectorSpec. For aSelectorSpeccarrying afilter_set, the input schema'spropertiesare the filter fields.filterset_to_json_schema(filter_set)— maps adjango-filterFilterSetto JSON Schema properties. Behind a new[filter]extra (the core still imports nodjango-filter—SelectorSpec.filter_setis duck-typed); the import is lazy and only fires when afilter_setis actually introspected.
drf-spectacular
@extend_schema_field/@extend_schema_serializeroverrides are honoured by reading the stamped attribute viagetattr, so the support is cost-free whether or not spectacular is installed. Distinct fromrest_framework_services.openapi, which emits DRF serializer classes for DRF's own OpenAPI generators. -
Consumer-extensible mappings (
JsonSchemaRegistry) — every*_to_json_schemahelper takes an optionalregistry=of consumer rules so a project can map its own DRF field /django-filterfilter / Python types (or override a built-in) without forking the package.JsonSchemaRegistryis an immutable value carrier with three rule lists (fields,filters,python_types); build one withDEFAULT_JSON_SCHEMA_REGISTRY.extend(...)(rules are tried before the built-ins, first match wins) and pass it through. No global mutable state — there is nothing to register into and nothing to leak between callers or tests. -
Off-HTTP dispatch context (
build_offline_context,OfflineContext,OfflineServiceView) — synthesize therequest+viewa spec's callables (kwargsproviders,extend_queryset, context providers) expect, so a spec written for the HTTP transport can be driven from a Pydantic-AI toolset, the MCP server, a management command, or a task runner.build_offline_context(user, params, *, http_request=None, action=None, kwargs=None)setsrequest.user, seedsrequest.datafromparams, and returns the trio bundled as anOfflineContextready to pass intodispatch_spec(..., request=, view=). -
enforce_permissions(spec, context, *, instance=None)— run a spec'spermission_classesoff the HTTP path.dispatch_specdeliberately does not consultpermission_classes(authorization is the view's job on HTTP), so an alternate transport must call this before dispatching or it would skip authz entirely. Mirrors a DRF view'shas_permissioncheck against the synthetic request/view, additionally checkshas_object_permissionwhen aninstanceis supplied (object-level parity the HTTP path has), and raisesrest_framework.exceptions.PermissionDenied(carrying the permission'smessage/code) on the first failure.permission_classesofNoneor[]is a no-op.
0.18.0 — 2026-06-20
SelectorSpec.filter_set— transport-neutral filtering on the spec. Adjango-filterFilterSet(or any object honouring the same(data, queryset) -> .qscontract) set on a selector spec is applied to the selector's queryset after the shaping fields, reading the values offrequest.query_params. It composes withselect_related/prefetch_related/annotations/extend_queryset, works on bothLISTandRETRIEVEselectors — closing the retrieve-path gap whereRetrieveModelMixinruns no filter step — and requires the selector to return aQuerySet(same guards as the other shaping fields). Applied by duck typing, so it adds no dependency to the package. See the new Filter a selector withfilter_setrecipe.- Fail-fast guard for the list double-apply. A
LISTselector spec that carriesfilter_setwhile its view also listsDjangoFilterBackendinfilter_backendsnow raisesImproperlyConfiguredatas_view()time —filter_setreplacesDjangoFilterBackend(they apply the same FilterSet), so configuring both would filter the queryset twice. Retrieve is unaffected (its selector path overridesget_object()and never callsfilter_queryset). ServiceSpec.document_service_error— a tri-state OpenAPI flag (bool | None, defaultNone) controlling whether the generated schema documents the422ServiceErrorresponse.Nonegates it on whether the operation validates input;True/Falseforce it. Schema-only; runtime behaviour is unchanged.- Native nested / child-collection writes.
create_from_inputandupdate_from_input(plus theacreate/aupdateasync siblings) take achildren={relation: ChildSpec(...)}argument that writes reverse-FK ("one-to-many") collections fromdata[relation]— create / update matched / reconcile orphans — so a parent and its children persist from one request without delegating to a writable-nested serializer'ssave().replace(default) removes orphans (unlinking nullable-FK children likeSET_NULL, deleting the rest likeCASCADE);mergeupserts only. AChildSpecmay nest its ownchildrenfor grandchildren. The defaultcreate_model/update_model/delete_modelservices (and async siblings) forwardchildren=for declarative nested writes with no hand-written service;delete_modelremoves the declared collections before the parent.childrenstays on the helpers, never onServiceSpec. New publicChildSpecandChildCollectionChangetypes;ChangeResultgains an additivechildrentuple of per-collection deltas (created/updated/deleted/unlinkedchild pks) plusget_child_change(relation). See the new Nested / child-collection writes recipe. - Transport-neutral
dispatch_spec. A single, view-free execution path for aServiceSpecorSelectorSpec:dispatch_spec(spec, *, user, params, request=None, view=None)(and the asyncadispatch_spec) validates input, resolves the mutation target viainstance_selector_spec, runs the service / selector, applies queryset shaping (incl.filter_set, reading the new transport-supplied filter data), and materializes aRETRIEVE— returning aDispatchResult(value/kind/status) the caller formats for its wire.render_spec_outputis the paired blessed render step (output serializer + context). Ordering, pagination, and the response envelope stay transport concerns. This is the shared path the MCP server and the AG-UI bridge adopt to delete their re-implementations (the surface half of the long-standing dispatch triplication); the HTTP view layer keeps its own request-coupled orchestration. New blessed surface:dispatch_spec,adispatch_spec,render_spec_output,build_input_serializer_from_data, and theDispatchResulttype. - Bulk & collection mutations.
ServiceSpecgains two bulk shapes (mutually exclusive):many: boolvalidates a list request body (input_serializerrunsmany=True) and renders the result list — the service receives the validated list and loops itself (bulk_create/ comprehension); andcollection_selector_spec— the LIST-kind twin ofinstance_selector_spec— resolves a scoped set (queryset or any iterable, via the selector +filter_set) and seeds it into the service pool ascollectionfor an instance-less bulk delete / update. An empty set is a no-op; authorization is per-set; both run all-or-nothing underatomic=True(per-item partial-success responses are a planned follow-up). Works through the HTTP mutation views anddispatch_spec. New default servicedelete_collection/adelete_collection(collection.delete(), with asoft_deletehook). Fail-fast validation:manyxorcollection_selector_spec, and the collection spec must be LIST-kind with a selector. - List-shaped output for collection mutations.
output_selector_specnow honours itskind:RETRIEVE(the default) re-fetches a single instance as before, whilekind=LISTre-fetches and renders the affected set (many=True) — the output twin ofcollection_selector_spec, so a bulk update can respond with the rows it touched instead of only a summary. Valid only alongsidecollection_selector_spec(a single-instance mutation returns one representation); enforced atas_view()time. Runs through the HTTP mutation views anddispatch_spec/adispatch_spec. See the Bulk & collection mutations recipe.
apply_queryset_shaping(part of the stable dispatch surface) gained an optionalfilter_setkeyword argument (defaultNone) and now applies a spec's FilterSet as the final shaping step. Backward-compatible: existing callers that don't passfilter_setare unaffected.apply_queryset_shapingalso gained an optionalfilter_datakeyword (defaultNone): when set, the FilterSet reads it instead ofrequest.query_params, so a transport-neutral caller (dispatch_spec) supplies its own params. The HTTP view path passesNoneand keeps readingrequest.query_params— unchanged.build_input_serializer's data-extraction core is now exposed asbuild_input_serializer_from_data(data, …)(blessed), so validation runs the same way on and off the HTTP path.build_input_serializer(request, …)is a thin wrapper over it — its signature is unchanged.- Unified provider invocation. Every spec-level provider
(
kwargs,input_data,input_serializer_context,output_serializer_context) and the corresponding view hooks are now dispatched through the same signature-filtering keyword pool that services and selectors use. A provider declares only what it needs — any subset ofview/requestplus the resolved-data extras (result/instance/page), or**kwargs— instead of the previous mandatory positional(view, request). Providers using the documented(view, request)signature are unaffected. - The 422
ServiceErrorresponse is now gated in the OpenAPI schema. It was attached to every spec-driven mutation; it now defaults to operations that validate input (spec.input_serializer is not None), so a no-input mutation (e.g. a plain delete) no longer carries a spurious 422. Override per spec withdocument_service_error.
- Stale prefetched data after a mutating service. When a mutation
target was resolved through a prefetching
instance_selector_spec(or a prefetchingget_object()queryset) and the service changed a related collection via a path Django doesn't self-invalidate (e.g. creating reverse-FK rows), the rendered response could reflect the stale prefetch cache. The flow now clears_prefetched_objects_cacheon the resolved instance after the service runs, mirroring DRF'sUpdateModelMixin. A no-op on create and when nothing was prefetched; anoutput_selector_specre-fetch keeps its own intentionalprefetch_related.
- New recipe: Filter a selector with
filter_set— list and retrieve usage, the "replacesDjangoFilterBackend" rule, and thekwargs-vs-filter_setboundary for computed (non-queryset) results. - OpenAPI guide documents the 422 gating +
document_service_error; the serializer-context recipe notes the flexible provider-signature declaration.
0.17.0 — 2026-06-10
- The stable dispatch surface. The leaves an alternate
transport builds on are now documented public API, re-exported from the
top-level package with a semver stability promise — see the new
Dispatch (stable surface) reference page:
resolve_callable_kwargs,is_async(shared);run_service,arun_service,build_input_serializer,validate_input,resolve_mutation_instance(service side);run_selector,arun_selector,is_queryset,apply_queryset_shaping(selector side). Previously downstream packages (drf-mcp) imported these from private/utilspaths, so a within-range refactor could break them silently. An import-surface test pins the blessed exports.
- The private
_compatpackage.run_service/arun_servicemoved toservices/andis_asyncto the package root (all three are part of the blessed surface above). Breaking for anything importingrest_framework_services._compat.*— in practice only drf-mcp ≤0.7, whose dependency cap (<0.17) keeps it on 0.16; its next release re-points the imports and widens the pin.
- The view-coupled orchestrators (
dispatch_mutation_for_spec,dispatch_selector_for_spec) are explicitly not part of the stable surface — a transport-neutral spec dispatcher is future work that will compose the blessed leaves.
0.16.0 — 2026-06-10
ServiceSpec.instance_selector_spec— the input-side twin ofoutput_selector_spec. A nestedSelectorSpec(kind=RETRIEVE) embeds the update/destroy instance lookup in the spec itself, so a mutation spec is genuinely self-contained: standaloneServiceUpdateView/ServiceDeleteViewsubclasses need noqueryset/lookup_field, and viewsetupdate/partial_update/destroyactions and@service_action(detail=True)actions resolve the instance from the spec (precedence: spec selector →action_specs["retrieve"]selector → DRF defaultget_object()). The selector pool is{request, user}+ URL kwargs + the selector extras chain; queryset shaping applies;None/ missing → 404 (the nested spec'sallow_noneis ignored here); the nestedpermission_classes/output_serializer/output_serializer_contextare ignored. Spec-resolved instances run object-level permissions (check_object_permissions) — closing a gap the selector-drivenget_object()chain has.kind=LISTor configuring it on a create/non-detail action fails fast atas_view()time.ServiceSpec.partial— force or suppress partial validation regardless of HTTP verb.None(default) keeps the method-derived flag;partial=Falseon a PATCH action makesrequiredfields enforce like a PUT;partial=Truerelaxes any verb (including create). Applied once atdispatch_mutation_for_spec, so viewset mixins, standalone views, and@service_actionall honour it. The OpenAPIServiceAutoSchemareflects the override (a forced-full PATCH keeps itsrequiredlist instead of thePatched*component).action_specs["partial_update"]— PATCH now resolves a dedicated"partial_update"key first and falls back to"update", uniformly at all resolution sites (dispatch,get_permissions,ActionSerializerResolver, and schema-timeresolve_spec). Defining only"partial_update"yields a PATCH-only endpoint (PUT → 405).SelectorSpec.allow_none—RETRIEVE-only.Trueexpresses a nullable-resource contract: aNone/ missing resolution renders200with a JSONnullbody (output serializer skipped) onSelectorRetrieveViewand the retrieve viewset mixin. DefaultFalsekeeps theNotFoundbehaviour. Ignored on nested specs (output_selector_speckeeps authoritative-None→ 204;instance_selector_specalways 404s).- Bound serializer in the service kwarg pool — services may declare a
serializerparameter to receive the bound, validated input serializer (opt-in declare-to-receive, likerequest/user/data). With the instance-aware construction below,serializer.save()performs a DRF-correct create or update — the home for nested-write persistence that lives on the serializer. Declaringserializerwith noinput_serializeron the spec fails fast atas_view()time. input_dataproviders (spec-level and theget_input_data/get_<action>_input_dataview hooks) may declareinstanceto receive the resolved mutation target (Noneon create) — passed only when declared; legacy providers unaffected.- New public leaf helpers in
views/mutation/utils.py:build_input_serializer(the bound-serializer sibling ofvalidate_input) andresolve_mutation_instance.
- Behaviour change — instance-aware input validation. On update /
destroy flows the input serializer is now constructed DRF-style with the
resolved instance:
serializer(instance, data=..., partial=...). Code insidevalidate()/ field validators that readsself.instance(previously alwaysNonehere) starts seeing the actual row — that is the fix, and instance-aware validators (e.g.UniqueValidator) now correctly exclude the current row. But if a serializer branches onself.instanceto detect "create vs update", it now takes the update branch on update requests. Applies on both the spec-selector and legacyget_object()lookup paths. - Behaviour change / bugfix — PATCH permission fallback. Permission and
serializer resolution previously keyed on
self.action("partial_update"under PATCH) while dispatch resolvedaction_specs["update"]— so an"update"-keyed spec'spermission_classessilently did not apply to PATCH requests unless the spec was duplicated under a second key. All resolution sites now share one fallback chain; PATCH is guarded by the"update"spec's permissions out of the box. If you relied on PATCH bypassing spec permissions (unlikely, but it was the old behaviour), add an explicit"partial_update"entry.
- Concepts page now documents the full result-rendering matrix
(service return × output spec ×
success_status→ response shape), the stale fetch-time annotations pitfall (annotations resolved by the instance lookup reflect pre-mutation state; re-fetch viaoutput_selector_specfor the post-mutation truth), the PATCH-that-validates-like-PUT recipe, the standalone-no-queryset example, and an explicit non-goal: constant/no-logic endpoints are fine as plain DRF views.
0.15.1 — 2026-06-04
- A
destroy/DELETEServiceSpecwith a customsuccess_statuswhose service returnsNonenow renders an empty body at the configured status. Previously the update-in-place fallback misfired (it keyed off "status ≠ 204" as a proxy for "this is an update"), surfacing the stale post-delete instance as the response body — which then failed to serialize. The fallback is now driven by an explicit update-vs-destroy intent flag. - No-output mutations (no
output_selector_spec) whose service returnsNonenow always render an empty body instead of attempting to serialize the raw in-memory model instance. This makes no-input/no-outputupdateanddestroyservices work as expected. An explicitly-setspec.success_statusis honored for the empty-body response; otherwise it falls back to204. A selector that returnsNonestill renders204(its result is authoritative).
- The internal "is this a Django QuerySet?" check used by the selector and
mutation-output dispatch paths is now a single
is_queryset()predicate (isinstanceagainstQuerySet/Manager) instead of three scatteredhasattr(..., "first")/hasattr(..., "annotate")duck-typing checks. More precise (a domain object that merely exposes.first()is no longer mistaken for a queryset) and self-documenting. No public API change.
- Clarified that
LISTselectors may return any iterable (plainlist,tuple, hand-built dataclass sequences, …), not only a QuerySet. The only constraints are inherent: the queryset-only shaping fields cannot be combined with a non-QuerySet return, and lazy generators do not survive slicing/counting paginators. See the queryset-shaping recipe.
0.15.0 — 2026-06-02
- Output serializer-context providers can now receive the resolved data
about to be serialized, so they can populate context from a single
batched query instead of re-fetching. A provider (the
get_output_serializer_contextdirectional hook, aget_<action>_output_serializer_contextper-action hook, or a spec-leveloutput_serializer_context) may declare an extra keyword parameter —result(mutation output),instance(retrieve), orpage(list) — and it is passed only when declared.viewandrequeststay positional, so existing(view, request)providers are unaffected. The output context is now resolved after the service and output selector run, guaranteeing the value reflects exactly what will be rendered. See the serializer-context recipe.
0.14.0 — 2026-05-31
UnsetTypeis now part of the public API (re-exported fromrest_framework_services). It is the type of theUNSETsentinel, exported so callers can annotate sentinel-defaulted fields cleanly —bio: str | None | UnsetType = UNSET— instead of suppressing the type-checker with# type: ignore[assignment]. The class was previously private (_Unset); it has been renamed and promoted (the old name was never exported, so this is not a breaking change).
0.13.0 — 2026-05-22
SelectorSpecis now keyword-only and requires akind: SelectorKindfield (SelectorKind.LISTorSelectorKind.RETRIEVE). The kind tells the dispatcher whether to materialize the selector return as a list or as a single retrieve-shaped instance, and it is cross-checked against the mount point atas_view()time — aLISTspec onSelectorRetrieveView(or the"retrieve"action_specsentry) raisesImproperlyConfiguredfail-fast. Making the kind explicit lets a spec be reused outside an HTTP request (management command, cron job, non-DRF caller) without the semantics living implicitly in the call site.ServiceSpec's output pipeline is collapsed into a single nested field:output_selector_spec: SelectorSpec | None. The previous flat fieldsoutput_selector,output_serializer,output_serializer_context,select_related,prefetch_related,annotations, andextend_querysetare removed; they now live on the nested spec (whosekindmust beSelectorKind.RETRIEVE). The mutation dispatch path validates and consumes the nested spec, andActionSerializerResolverreads the response serializer fromoutput_selector_spec.output_serializer.OutputSelectorProtocol removed. The "post-mutation re-fetch" selector is now structurally aRetrieveSelector(nested underServiceSpec.output_selector_spec) and theresultkwarg, when needed, is declared directly on the user's function signature.@selector_action'sdetail=parameter is removed. The DRF URL shape is pinned fromspec.kind(LIST → detail=False,RETRIEVE → detail=True) — there is no longer a way to override it. If you need a URL shape that doesn't match the response shape (a detail action that returns a list, or a collection action that returns a single resource), fall back to DRF's plain@actionand write the dispatch yourself.@service_actionstill takesdetail=(mutation routing is decoupled from the read-shape distinction).- The internal
dispatch_retrieve_selectorhelper is folded intodispatch_selector_for_spec, which now branches onspec.kind. Callers outside the library should not be affected (the helpers live underselectors/utils.pyand were never part of the public API), but the symbol no longer exists.
0.12.0 — 2026-05-20
-
permission_classesfield onServiceSpecandSelectorSpec. Accepts a sequence of DRFBasePermissionsubclasses to override the calling view's class-levelpermission_classesfor the action the spec backs.None(the default) inherits the view-level permissions; an empty sequence means "no permissions" explicitly. Honored by_ActionSpecsMixin.get_permissions(covers all viewset mixins andServiceViewSet/SelectorViewSet), byMutationFlowMixin.get_permissions(standalone mutation views), by the selector views' newget_permissionsoverrides, and by the@service_action/@selector_actiondecorators which forward the value into DRF's@action(permission_classes=...). Misconfigurations (non-BasePermissionsubclasses, permission instances rather than classes) fail fast atas_view()time withImproperlyConfigured. -
input_serializer_contextandoutput_serializer_contextfields onServiceSpec, plusoutput_serializer_contextonSelectorSpec. Each takes aCallable[[ServiceView, Request], Mapping[str, Any]]returning extra context keys to merge into the serializer'scontext=dict. They sit at the most-specific layer of the existing resolution chain (DRF default → directionalget_<direction>_serializer_contexthook → per-actionget_<action>_<direction>_serializer_contexthook → spec callable), so the spec wins on overlapping keys. Wired throughdispatch_mutation_for_spec(input + output),selector_action(output), and a newget_serializer_contextoverride on_ActionSpecsMixinand the standaloneSelectorListView/SelectorRetrieveViewso the spec's output context is honored byListModelMixin/RetrieveModelMixindispatch. Closes the gap where the selector list/retrieve mixins previously ignored the per-actionget_<action>_output_serializer_contexthook entirely. -
Per-spec queryset shaping on both
SelectorSpecandServiceSpec. Four new fields cover the static and dynamic cases:select_related: Sequence[str] | None— forwarded toqs.select_related(*spec.select_related).prefetch_related: Sequence[str | Prefetch] | None— forwarded toqs.prefetch_related(*spec.prefetch_related); accepts plain names or fullPrefetchobjects.annotations: Mapping[str, Any] | None— merged into a singleqs.annotate(**spec.annotations)call.extend_queryset: Callable[[QuerySet, ServiceView, Request], QuerySet] | None— dynamic escape hatch, invoked after the declarative fields so it always sees the fully statically-shaped queryset. Synchronous only (no DB I/O — manipulates the lazy expression tree).
On
SelectorSpec, shaping runs insidedispatch_selector_for_spec, so both list and retrieve flows pick it up. OnServiceSpec, shaping applies to the QuerySet returned byoutput_selector— a typical pattern isoutput_selector=lambda result: Model.objects.filter(pk=result.pk)with the spec declaring the eager-loading. After shaping, a QuerySet return is materialized via.first()(the matchingdispatch_retrieve_selectorbehaviour also added below).Configuring shaping with no
selector(onSelectorSpec) or nooutput_selector(onServiceSpec) raisesImproperlyConfiguredatas_view()time. A non-QuerySet return when shaping is set raises at request time. -
dispatch_retrieve_selectornow materializes a QuerySet return via.first(), so retrieve selectors can return a filtered QuerySet and let the framework apply shaping before pulling the single object. Backward-compatible — selectors that already returned an instance continue to work unchanged.
- The field order on
ServiceSpecandSelectorSpechas been reorganized to group related fields together: the callable, then the input pipeline (input_*), then the output pipeline (output_*+ the new shaping fields), then the cross-cuttingkwargsandpermission_classes. Backward-compatible for keyword-argument call sites (which is every documented usage); positional construction must follow the new order.
0.11.0 — 2026-05-19
- Default model service factories — six new top-level exports that
return ready-made service callables for the common case where the
entire body is a one-line wrapper over the mutation helpers:
create_model(Model, *, field_map=, exclude_fields=, m2m=),update_model(Model, *, field_map=, exclude_fields=, m2m=, update_fields=), anddelete_model(Model, *, soft_delete=), plus the matchingacreate_model/aupdate_model/adelete_modelasync variants that wrapacreate_from_input/aupdate_from_input/await instance.adelete().m2maccepts either a static mapping or a callable receiving the validateddata— the typical shape when M2M values live on the input itself. The returned callables conform to the unifiedCreateService/UpdateService/DeleteServiceProtocols, so they absorb arbitrary framework-pool keys (request,user, URL kwargs,ServiceSpec.kwargsreturns) and the existing view layer routes them — sync or async — without changes.
- The pre-merge
StrictCreateService/StrictUpdateService/StrictDeleteService/StrictListSelector/StrictRetrieveSelector/StrictOutputSelectorclasses. Rename every import to its unified equivalent (StrictCreateService→CreateService, etc.) and drop the trailingExtraTtype argument from each call site (extras are now typed on the function signature instead — see Changed below). The@implements(...)decorator pattern keeps working unchanged once the names update. NoKwargs(the emptyTypedDictpreviously used as theExtraTslot of strict service Protocols). The slot no longer exists. Drop imports ofNoKwargs; if you were also writing**extras: Unpack[NoKwargs]in a service body, replace it with**extras: Any.
-
ChangeResultis now generic over the model type. The four mutation helpers (create_from_input,acreate_from_input,update_from_input,aupdate_from_input) thread the model TypeVar through their signatures, socreate_from_input(Author, ...)returns aChangeResult[Author]whose.instanceis typed asAuthor— removing thecast(Author, result.instance)boilerplate that used to be necessary. BareChangeResult(no parameter) keeps resolving toChangeResult[Model], so existing annotations continue to work unchanged. Runtime behaviour is identical; this is a typing-only change. -
Breaking (typing only): the lenient and strict service / selector Protocols are merged into a single shape per kind, with
**extrastyped asAny. The strict form's trailingExtraTtype argument is gone. Names and call sites:CreateService[InputT, ResultT].UpdateService[InputT, InstanceT, ResultT].DeleteService[InputT, InstanceT, ResultT].ListSelector[ResultT].RetrieveSelector[ResultT].OutputSelector[InT, OutT].
Strict-typed extras stay possible on your own function signature: declare a
TypedDictwithtotal=False(or per-fieldNotRequired) and annotate**extras: Unpack[YourKw]. Inside the function body,extras["foo"]is typed byYourKw. The Protocol does not enforce a kwargs-shape match — that cross-check only worked under one minor version of one type checker (ty0.0.32) and never undermypyorpyright. Putting the typing on the function instead works on every modern checker.Three migration notes:
- Drop the trailing
ExtraTfrom every parameterised call site:StrictCreateService[AuthorIn, MyKw, Author]→CreateService[AuthorIn, Author]. Keep**extras: Unpack[MyKw]on your function for typed extras. - Strict extras
TypedDicts must declare keys asNotRequired(or settotal=Falseon the class) — required keys would make the function reject callers that omit them, breaking Protocol conformance under PEP 692. - The lenient Protocols no longer name
requestanduseras fixed parameters — they flow through**extraslike any other framework-pool key (matching the strict Protocols, which already dropped these in 0.9.0). Services that declareddef fn(*, data, request, user, **kwargs)keep working at runtime; to satisfy the new Protocol annotation either drop the namedrequest/userparameters and read them off**extras, or subclassHttpExtras[YourUser](nowtotal=False) and use it as theUnpacktarget.
Runtime behaviour of every callable is unchanged. The merge unblocks the default model service factories above and restores Python 3.10+ support (the previous design needed PEP 728's
extra_items=Any, which is not in mypy yet and forced a 3.13 floor). -
HttpExtras[UserT]is now declaredtotal=False: every key is optional, matching the framework's runtime contract (the kwargs pool may or may not containrequest/userdepending on the caller). Subclass withtotal=False(or annotate fields asNotRequired) for the same reason — see migration note 2 above. -
Version is now tracked in a single source of truth at
rest_framework_services/version.py.pyproject.tomldeclaresdynamic = ["version"]and hatchling reads the value fromversion.pyat build time.rest_framework_services.__version__continues to re-export the same value. Pure refactor — no behaviour change.
0.10.0 — 2026-05-03
- DRF-style serializer context propagation for both input and output
serializers in service-backed views, viewset mixins,
@service_action, and@selector_action. Three layers, later wins on overlap: DRF'sget_serializer_context()→ directional fallback (get_input_serializer_context/get_output_serializer_context, defaulting toget_serializer_context()) → per-action override (get_<action>_input_serializer_context/get_<action>_output_serializer_context, viewsets only). StandaloneSelectorListView/SelectorRetrieveViewalready received DRF context throughself.get_serializer(...)and continue to do so.
0.9.0 — 2026-05-03
HttpExtras[UserT]— genericTypedDictfor strict services that wantrequest/userfrom the framework pool. Subclass it (with your own user model as the parameter) instead of redeclaring those keys on everyExtraT.default=Anykeeps the unparameterised form ergonomic.call_service/acall_serviceandcall_selector/acall_selector— HTTP-scope helpers for invoking a service or selector from another view, middleware, or custom action. They build the framework's kwargs pool (derivinguserfromrequest.user) and dispatch via the same signature filter the framework uses internally. Async callables are bridged transparently viaasync_to_sync(sync helper) or awaited (async helper).requestis required by type — outside HTTP scope, call the service callable directly.@selector_action— the GET-side companion to@service_action. Wraps a viewset method with selector dispatch (collection or detail), honoursspec.output_serializerwhen set, falls back toview.get_serializer(...), and integrates with DRF pagination on the collection path.ObjectDoesNotExist/Nonemap to 404 on detail.startserviceappnow scaffolds aspecs/package alongsideservices/andselectors/— a conventional home forServiceSpec/SelectorSpecinstances.
- Breaking (typing only): the strict service / selector Protocols
no longer include
requestanduserin their fixed signatures.requestanduserare still placed in the kwargs pool by the framework — services that read them now declare them on theirExtraTTypedDict(most cleanly viaHttpExtras[YourUser]), or omit them entirely if unused.- Migration: replace
def fn(*, data, request: Request, user: UserT, **extras: Unpack[MyKw])with eitherdef fn(*, data, **extras: Unpack[MyKw])(drop the params) orclass MyKw(HttpExtras[YourUser]): ...followed bydef fn(*, data, **extras: Unpack[MyKw])(extras carriesrequest/user). - Lenient Protocols are unchanged —
**kwargs: Anyalready lets services omitrequest/user.
- Migration: replace
- Stale doc reference removed:
docs/recipes/service-action.mdno longer listsviewas a pool key (it was never in the pool — seeviews/mutation/utils.py). - README and the install snippets in the docs now show
uv addalongsidepip install.
- The matrix
testjob now uploads a per-cell coverage artifact (coverage-py<py>-django<dj>withcoverage.xmland thehtmlcov/HTML report) for download from the workflow run. The 100% gate is unchanged.
0.8.1 — 2026-05-01
ServiceSpecandSelectorSpecdeclaredExtraTwithbound=dict[str, Any], which (per PEP 589) rejectsTypedDictsubclasses as type arguments — exactly the shape the docs recommend. Both bounds are nowMapping[str, object], so user-definedTypedDictkwargs (SelectorSpec[QuerySet[Team], TeamScopeKwargs], etc.) type-check cleanly undertyandmypy.
0.8.0 — 2026-05-01
ServiceSpec.input_dataplus the symmetric three-tier resolver (get_input_datacatch-all onMutationFlowMixin, per-actionget_<action>_input_data, and the per-spec callable). Returns a mapping merged on top ofrequest.databefore theinput_serializervalidates — purpose-built for lifting URL kwargs (parent IDs from nested routes, etc.) into the serializer's input for cross-field validation. Server-supplied keys win on conflict.data: InputTparameter on the lenientDeleteServiceand strictStrictDeleteServiceProtocols. Pairs withServiceSpec.input_serializerto type a delete-with-payload service end-to-end (e.g. a deletion reason). Default isEllipsis, so services that don't read a body remain Protocol-compliant — bindInputTto the newNoInputsentinel for clarity.NoKwargs(emptyTypedDict) andNoInput(sentinel class) underrest_framework_services.typesand re-exported from the package root. Saves projects from re-defining the same empty stubs whenever a strict service has no extras (NoKwargs) or no body (NoInput).ServiceAutoSchemanow emitsrequestBodyforDELETEendpoints whose spec carries aninput_serializer. drf-spectacular's defaultAutoSchemasuppresses bodies on DELETE; this override keeps the generated schema honest for delete-with-payload routes.
-
Type-parameter ordering on the delete service Protocols now leads with
InputTto matchStrictCreateService/StrictUpdateService:DeleteService[InputT, InstanceT, ResultT](wasDeleteService[InstanceT, ResultT]).StrictDeleteService[InputT, InstanceT, ExtraT, ResultT](wasStrictDeleteService[InstanceT, ExtraT, ResultT]).
Migration: at each parameterization site, prepend the input type. For services that don't read a body, use the new
NoInputsentinel:StrictDeleteService[NoInput, Author, NoKwargs, None].
0.7.0 — 2026-04-30
implements(Protocol[...])— identity decorator that attaches a strict service / selector Protocol shape directly to the decorated function. Replaces the_: StrictCreateService[...] = create_authorshim as the recommended way to assert conformance: the assertion lives on the function definition (survives renames), reads naturally, and is what CI now exercises against the strict Protocols. The shim form continues to work.tests/services/strict_drift_fixtures.pyplus amake type-check-strict-fixturestarget — known-bad usages that must producetydiagnostics, wired into the CI lint job to guard against regressions where strict-Protocol drift detection silently breaks.
-
Reordered the generic parameters on every strict service / selector Protocol so
ExtraTsits immediately before the result type — the parameter list now reads "input, extras, result" and mirrors the call shape:StrictCreateService[InputT, ExtraT, ResultT]StrictUpdateService[InputT, InstanceT, ExtraT, ResultT]StrictDeleteService[InstanceT, ExtraT, ResultT]StrictListSelector[ExtraT, ResultT]StrictRetrieveSelector[ExtraT, ResultT]StrictOutputSelector[InT, ExtraT, OutT]
Migration: swap the last two type arguments at every parameterization site. The non-strict Protocols (
CreateService,ListSelector, …) are unchanged. -
Dropped the
bound=dict[str, object]constraint fromExtraTon every strict Protocol.TypedDictsubclasses are now accepted as type arguments by bothtyandmypy(previously rejected with "Type argument … must be a subtype ofdict[str, object]"). This matches the documented intent of the strict Protocols.
0.6.1 — 2026-04-29
- README and docs index now surface lenient and strict service / selector
typing examples and link to the typing page. Minor wording fix:
"opt-out per view" → "opt-out per spec" (atomic lives on
ServiceSpec).
0.6.0 — 2026-04-28
- OpenAPI / Swagger integration via an opt-in
drf-spectacularadapter. New optional install extra:pip install djangorestframework-services[spectacular].rest_framework_services.openapi.enable_openapi()— wiresServiceAutoSchemaonto every library view class. Call once fromAppConfig.ready().ServiceAutoSchemareads eachServiceSpecto derive the request body (input_serializer), success response (output_serializer), success status, and a422response documentingServiceError.@extend_schemaannotations always win.- Bare
@dataclassinput_serializeris auto-wrapped inDataclassSerializer(mirroring the runtime), so dataclasses show up as typed schema components instead of bareobject. @service_actionhandlers stamp_service_specon the wrapped handler so the schema generator can recover the spec.ServiceErrorSerializeris exported for users who want to reuse the 422 component shape.- New documentation page:
docs/openapi.md.
- Lenient service / selector Protocols — opt-in shapes for IDE and
type-checker support. New top-level exports:
CreateService,UpdateService,DeleteService,ListSelector,RetrieveSelector,OutputSelector. Each is parameterized by input / instance / result type and keeps a**kwargs: Anyescape hatch. - Strict variants —
StrictCreateService,StrictUpdateService,StrictDeleteService,StrictListSelector,StrictRetrieveSelector,StrictOutputSelector— use PEP 692Unpack[TypedDict]to pin the extras delivered byServiceSpec.kwargs. Use these when you want signature drift to fail static checks. ServiceSpecandSelectorSpecare now generic over input / result and an optionalTypedDictof extra kwargs. Unparameterized usage is unchanged (Anyeverywhere).ServiceSpec.kwargsandSelectorSpec.kwargs— per-spec callable returning extra kwargs for the call. Co-locating the contract with the spec replacesif self.action == ...branches inget_service_kwargs.- Per-action hooks —
get_<action>_service_kwargsandget_<action>_selector_kwargsare now consulted in the kwargs resolution chain, so multi-action viewsets can split contracts by method name instead of branching onself.action. ServiceViewProtocol (inrest_framework_services.views) — narrow structural shape passed to per-spec kwargs providers, exposingrequest,kwargs,action.- Fail-fast spec validation.
as_view()now walks every spec and raisesdjango.core.exceptions.ImproperlyConfiguredon misconfigurations: service requiresdatawithout aninput_serializer, requiresinstanceon a create / list action, requiresresultoutside anoutput_selector, or has a required parameter that no kwargs source can supply.@service_actionvalidates at decoration time. - New documentation page:
docs/typing.md.
- Breaking. Services and selectors no longer receive
viewin their kwargs pool. They are plain business logic; pipe view state through a per-speckwargsprovider (which receives the view typed asServiceView) orget_<action>_service_kwargsinstead. Migration: read whatever you used offviewand surface it via one of the kwargs sources.
0.5.0 — 2026-04-28
SelectorSpec(inrest_framework_services.types) — frozen dataclass bundling per-action read config:selectorandoutput_serializer.ActionSerializerResolverviewset mixin — resolvesget_serializer_class()from the active action'saction_specsentry (output_serializeron either aSelectorSpecorServiceSpec), replacingMultiSerializerMixin.django-stubsanddjangorestframework-stubsadded to dev dependencies sotyresolves Django/DRF types directly.
update_from_input/aupdate_from_inputnow automatically includeauto_now=Truefields (e.g.updated_at) in the computedupdate_fieldslist whenupdate_fields=True(default). Previously those columns were silently skipped because Django only updatesauto_nowfields when they are explicitly listed inupdate_fields. Explicitupdate_fields=[...]lists are still passed through unchanged;update_fields=Falseis unaffected.
- Breaking.
service_specsrenamed toaction_specson all viewset mixins (SelectorListMixin,SelectorRetrieveMixin,ServiceCreateMixin,ServiceUpdateMixin,ServiceDestroyMixin) and onServiceViewSet/SelectorViewSet. - Breaking. Read-side entries in
action_specs("list","retrieve") now require aSelectorSpecinstance. Bare callables are no longer accepted and raiseImproperlyConfiguredat request time with a migration message. - Breaking.
serializer_classesmapping andMultiSerializerMixinremoved. Move per-action serializers intoaction_specsviaSelectorSpec(output_serializer=...)orServiceSpec(output_serializer=...). - Breaking.
SelectorListViewandSelectorRetrieveViewnow accept a singlespec: SelectorSpecattribute instead of separateselectorandserializer_classattributes.spec.output_serializeroverridesget_serializer_class();spec.selector = Nonekeeps DRF's defaultget_queryset()/get_object(). - A wrong-type
action_specsentry (e.g.SelectorSpecon a write action, orServiceSpecon a read action) now raisesImproperlyConfiguredat request time instead of silently falling back.
| 0.4.x | 0.5.x |
|---|---|
service_specs = {...} |
action_specs = {...} |
serializer_classes = {"list": S} |
action_specs["list"] = SelectorSpec(output_serializer=S) |
service_specs["list"] = list_fn |
action_specs["list"] = SelectorSpec(selector=list_fn) |
service_specs["list"] = list_fn + serializer_classes["list"] = S |
action_specs["list"] = SelectorSpec(selector=list_fn, output_serializer=S) |
class V(SelectorListView): selector = fn; serializer_class = S |
class V(SelectorListView): spec = SelectorSpec(selector=fn, output_serializer=S) |
MultiSerializerMixin in MRO |
ActionSerializerResolver in MRO |
0.4.0 — 2026-04-28
- mkdocs-material documentation site under
docs/, published to GitHub Pages on every tag release. Covers quickstart, concepts, mutation helpers, errors & atomic, async, recipes, and an autodoc reference section driven bymkdocstrings. - Tag-driven release pipeline (
.github/workflows/release.yml): on push of avX.Y.Ztag, runs the test suite, asserts the tag matches bothpyproject.tomland__version__, builds wheel + sdist, publishes to PyPI via OIDC trusted publishing, then deploys docs to thegh-pagesbranch. docsjob intests.ymlrunsmkdocs build --stricton every PR to catch broken links / missing autodoc targets before merge.make help,make init,make docs-serve,make docs-buildMakefile targets.
tests.ymlmodernised: switched toastral-sh/setup-uv@v6, added a cancel-in-progress concurrency group, and split lint / docs / test into separate jobs.CLAUDE.mdReleases section now describes the tag-driven pipeline and the one-time PyPI Trusted Publisher / GitHub Pages setup.
0.3.0 — 2026-04-27
ServiceSpec(inrest_framework_services.types) — frozen dataclass bundling per-action mutation config:service,input_serializer,output_serializer,output_selector,atomic,success_status.service_specsclass attribute onServiceViewSet(and the per-action mixins) — a single action-keyed mapping replacing the per-action flat attributes. Read actions ("list","retrieve") accept a bare callable (the selector); write actions ("create","update","destroy") accept aServiceSpec.
- Breaking. Removed flat per-action attributes from viewset mixins:
list_selector,retrieve_selector,create_service,create_input_serializer,create_output_serializer,create_output_selector,create_atomic, and the matchingupdate_*/destroy_*triplets. Move the values intoservice_specsinstead. - Breaking. Standalone mutation views (
ServiceCreateView,ServiceUpdateView,ServiceDeleteView) no longer accept individual flat attributes (service,input_serializer, …). They are now configured by setting a singlespecclass attribute to aServiceSpec. - Breaking.
@service_actionnow takes aServiceSpecas its first positional argument instead ofservice=/input_serializer=/etc. kwargs. DRF-action options (detail,methods,url_path,url_name, plus extras) are unchanged.
0.2.0 — 2026-04-27
- Renamed
input_dataclass→input_serializereverywhere it's configured (mutation views, viewset mixins,@service_action). The attribute now accepts a bare dataclass type (wrapped inDataclassSerializeron the fly), aDataclassSerializersubclass, or any otherSerializersubclass (e.g.ModelSerializer). - Services now receive the serializer's
validated_dataasdata(a dataclass instance for dataclass-based serializers, adictfor plainSerializer/ModelSerializersubclasses) instead of the result ofserializer.save(). Persistence is the service's responsibility.
0.1.0 — 2026-04-27
First public release. A service / selector layer for Django REST Framework: views, viewsets, mutation helpers, and a scaffolding command, with first-class sync + async support and 100% test coverage.
apply_input(instance, data, ...)— set attributes in memory, no save.create_from_input(model, data, ...)— build, save, optional M2M.update_from_input(instance, data, ...)— diff in-memory state vs. input, callsave(update_fields=[...])with only the fields that actually changed. Defaults can be overridden per call.acreate_from_input/aupdate_from_input— async siblings using Django 4.2+asave()/aset().- All accept
dataas a dataclass / dict /__dict__-bearing object, withfield_map,exclude_fields, andm2mkwargs. - All return a
ChangeResult(frozen dataclass) carryinginstance,created, and a tuple ofFieldChangerecords, plus achanged_fieldsproperty andget_field_change(name)lookup. - The
UNSETsentinel distinguishes "field omitted from input" from "field explicitly set toNone".
ServiceCreateView,ServiceUpdateView,ServiceDeleteView— single- purposeGenericAPIViewsubclasses, each composingMutationFlowMixin. Configure viaservice,input_serializer,output_serializer,output_selector,atomic,success_status.SelectorListView,SelectorRetrieveView— built on DRF'sListModelMixin/RetrieveModelMixin.selectoroverridesget_queryset()/get_object(); everything else (filter backends, pagination, serialization) is vanilla DRF.
ServiceViewSet— full-CRUD viewset composed ofServiceCreateMixin,ServiceUpdateMixin,ServiceDestroyMixin,SelectorListMixin,SelectorRetrieveMixin, andMultiSerializerMixin.SelectorViewSet— read-only viewset (list + retrieve only).- All per-action mixins are exported so you can compose only the actions you need.
MultiSerializerMixin— per-action serializer dispatch via a singleserializer_classes: dict[str, type[Serializer]]mapping; falls back to DRF'sserializer_classwhen the action isn't mapped.MutationFlowMixin— exported as the building block for service-backed action flow on bespoke shapes that don't fit the existing five mixins.@service_action— decorator wrapping DRF's@actionand routing the custom action through the same validate-dispatch-render flow as the standard mutations.
SelectorandAsyncSelector—runtime_checkableProtocols for type-safe documentation of selector callables.
ChangeResult,FieldChange,UNSET. Framework-agnostic; live in their own package and are re-exported at the top level.
ServiceErrorandServiceValidationError— framework-agnostic exceptions raised from service code. The view boundary translates them:ServiceValidationError→ DRFValidationError(HTTP 400);ServiceError→APIException(HTTP 422).- Services do not import from
rest_framework.
- Services and selectors can be
async def;inspect.iscoroutinefunctiondetects them and the view bridges toasync_to_syncunder sync dispatch. - Atomic transactions wrap async services via
sync_to_async(thread_sensitive=True)to keep ORM connection state on a consistent thread.
- Every mutation service runs inside
transaction.atomic()by default. - Opt-out per view via
atomic = False(or per-action viacreate_atomic/update_atomic/destroy_atomic).
- Subclass of
django.core.management.templates.TemplateCommand. - Scaffolds a service-oriented Django app with
services/,selectors/,validators/,serializers/,utils/as packages, plusmodels/andviews/as packages instead of single files. - The bundled template lives at
rest_framework_services/management/templates/service_app/and ships in the wheel. Add"rest_framework_services"toINSTALLED_APPSto make the command discoverable.
README.md— quick start (DataclassSerializer-first, ModelSerializer shown as alternative), mental model, mutation helpers, views, viewsets, errors, atomic, async,startserviceapp.examples/— runnable Django project with an invoices app demonstrating the full surface and an end-to-endAPITestCase.
- Python 3.10 – 3.14
- Django 4.2, 5.0, 5.1, 5.2, 6.0
- Django REST Framework ≥ 3.14
djangorestframework-dataclasses(hard dependency)