Changelog
All notable changes to fastapi-viewsets — published as dynamicforms-fastapi-viewsets on PyPI and @dynamicforms/fastapi-viewsets on npm — will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.5.7] - 2026-08-26
Fixed
muxwsis no longer imported at module load time.fastapi_viewsets.decorators(and therefore the whole package) now imports cleanly without themuxwsextra installed; code that actually builds a muxws response payload still requires the package and raisesModuleNotFoundErrorif it's missing.Authorizationno longer puts a callable@action_configurationvalue intocontext.authorization. A callable config is adepends()-only gate and was never meant to reachperform_*or cross into acelery_viewsetworker; doing so unconditionally made every request to a callable-gated, celery-served viewset fail withTypeError: Object of type function is not JSON serializableoncecontextwas serialized for the worker.
[0.5.5] - 2026-08-24
Changed
ViewSetProxyBase.pkFieldNameis now public instead of protected, so a caller holding a ViewSet instance without knowing its concrete class — a grid or table component asking which field identifies a row — can read it. A factory-built class's constructed instance type now includespkFieldName, typed as the literal field name (e.g.'id') rather than widened tostring.
[0.5.4] - 2026-08-23
Changed
set_celery_dispatch_hooknow receives the call's current kwargs (aContext, if the action declares one, included), instead of taking no arguments. This lets a registered hook read what a context processor already put there instead of needing its own request-level plumbing.
[0.5.3] - 2026-08-23
Changed
route_restandroute_muxwsnow fail to compile when passed a factory-built ViewSet class, instead of silently handing back an object missing that class's own methods. The error surfaces asTS2339on the first property or method accessed on the result, not on theroute_rest/route_muxwscall itself.- Restating
static declareson a subclass of a factory-built class (already a compile error) reports a shorter, clearerTS2417that stops atdeclaresitself instead of drilling into which mixin method is missing. STANDARD_FE_METHODSinproxy-base.tsis now derived from aRecord<ActionName, true>object literal, so an action added to (or removed from)ActionNamewithout a matching update fails to compile instead of silently narrowing what the FE/BE schema mismatch check can report.
Fixed
vite.config.tsno longer uses__dirname, whichvite's native config loader warns is unsupported and slated to become the default loader in a future major version.
[0.5.2] - 2026-08-23
Added
celery_viewset_serverandcelery_viewset_clienteach expose a hook slot for external packages to attach dispatch/execute logic without this repo depending on them.set_celery_kwargs_hookregisters aCallable[[Callable, dict], tuple[Callable, dict]]run on the worker side just before_reconstruct_kwargs, receiving the run-callable and the raw kwargs and returning both, possibly changed.set_celery_dispatch_hookregisters aCallable[[], Awaitable[dict]]awaited on the client side just beforesend_task, its returned dict merged into the task kwargs. Both default toNone, leaving current behavior unchanged.
[0.5.1] - 2026-08-22
Changed
vuetify-inputsin the demo moves to^0.10.0andvue-gridto^0.3.1. Dev dependencies@types/nodeandeslint-config-velismove to^26and^3.
[0.5.0] - 2026-08-22
Changed
- The
@dynamicforms/vue-formspeer dependency moves to^0.17.1,vue-gridin the demo to^0.3.0, andvuetify-inputsto^0.9.2. Build, lint and the Vue test suite pass unchanged against the new versions.
[0.4.0] - 2026-08-16
Added
muxws transport. A viewset registered with
route_viewsetis now also reachable over a muxws WebSocket alongside REST. A command is dispatched into a FastAPI app the library builds from the endpoints published on muxws, each carrying the same route kwargsroute_viewsetbuilt for the REST router — so validation, dependencies, response models andsettings.viewsets_command_middlewarebehave identically on both transports. That dispatch app is the library's own, not the application object you created: middleware installed on your app, exception handlers beyond the framework's defaults forHTTPExceptionandRequestValidationError,app.stateandapp.dependency_overridesare not reached by a command unless you passapp=toprocess_command. Registration is resolved at three levels, each free to defer to the next:@transports(...)per endpoint,register_rest/register_muxwsonroute_viewset, andsettings.viewsets_register_muxws(defaultTrue). The response status arrives on the first data frame a peer sends, read by awaitingreplyHeadersArrivedbefore consuming the body, so a caller does not have to read a whole streaming response before learning it failed.fastapi_viewsets.mux_wsexportsprocess_command,transports,register_viewsetand the resolvers behind the three registration levels; the registry warns when two viewset classes register the samebase_path, since the later one's endpoints become unreachable. Requires the newmuxwsextra (pip install "dynamicforms-fastapi-viewsets[muxws]", npm peermuxws@^0.3.1, needed for response headers on data frames).Vue client support for muxws.
route_muxws, alongsideroute_rest, builds aMuxwsProxyImplthat sends the same ViewSet calls over a muxws stream — the request line as pseudo-headers, the body as payload, the status read off the reply headers. The transport-independent parts of the proxy (bulk operations, lookup, request building) are factored into a sharedViewSetProxyBase, soRestProxyImplandMuxwsProxyImplimplement every ViewSet method identically and a proxy instance speaks exactly one transport.ViewSetRequestError, thrown on a failed call over muxws (there is no axios to raise anything there), mirrorsAxiosError's shape —error.response.status,.data,.headers— so error-handling code written against one transport keeps working against the other; unlikeAxiosError.response, it is always set. A custom endpoint method should call the sharedthis.request()rather thanthis.http, so the same body works on either transport (this.httpis unchanged onRestProxyImpl).A lazy list pipeline.
perform_listmay return any iterable or async iterable instead of a materialised list, so a source that cannot be cheaply enumerated no longer has to be built in memory up front. Filtering, sorting and paging areapply_filter/apply_sort/apply_paginationstages a subclass overrides and chains viasuper(); a stage that answers part of the query itself callsquery.mark_applied(...), and only what nothing marks applied still runs in memory:pythonasync def apply_sort(self, context, query: ListQuery, records: ListRecords) -> ListRecords: if query.has_sort: records = self.db.order_by(*(c.column_name for c in query.sort)) query.mark_applied("sort") return await super().apply_sort(context, query, records)ListRecords,materialize()andtake_page()are generic in the item type (ListRecords[TItem]), so a stage's return type states what its items are instead of erasing them toAny;perform_listitself stays annotated bare (ListRecords[Any]), since what a backend yields is its own row type and records areTonly onceto_record()has run over them.Three list response shapes. A viewset declares
list_shape("plain","paginated"or"cursor", defaulting tosettings.default_list_shape, itself"plain") and, optionally,list_shapes— the shapes a client may additionally request per call with anX-List-Shapeheader.route_viewsetbuilds the endpoint's parameters and response model from these: query parameters no allowed shape uses are dropped, the header is kept only when there is a genuine choice, and the response narrows to a union of exactly the models the viewset can produce — one model, no header, when only one shape is declared.PaginatedListMixinandCursorListMixinare one-line shorthands forlist_shape = "paginated"/"cursor". A plain-array response is nowListOf[T], a namedRootModel, so its OpenAPI schema gets a real component name instead of an auto-generated one; the JSON payload is unchanged, still a bare array.ListOfalso synthesizes a one-row OpenAPI example from the item model's schema, and a viewset declaring more than one shape gets one named example per shape, keyed by theX-List-Shapevalue that produces it.Offset pagination.
PaginatedListMixinanswers{results, offset, limit, count, has_more, has_previous}; pagination is opt-in per request, so an endpoint withoutlimitstill answers a plain list. Paging a lazy source reads onlylimit + 1rows regardless of source size, andcountisnullwhere the source cannot be counted without draining it — a backend that can count cheaply overridescount_records().Cursor pagination.
CursorListMixinpages by position: reaching a distant page does not re-read the rows in front of it, and a row inserted or deleted behind the client cannot make the next page repeat or skip a row. The cursor carries the full ordering-key tuple with the primary key appended, travels as real JSON values, and is coerced back through the response model's field types on read; it is fingerprinted against the ordering and the active filters, and a stale or mismatched cursor is a 400. The envelope exposesnext/previous(the page's edges, read exclusively) andfirst/last(the same two edges, read inclusively, present whenever the page is non-empty) rather than anchoring outside the page, so the anchors survive concurrent inserts; there is no total, since producing one would require draining the source. NULL placement followsListMixin.nulls("first", the default, or"last") under an ascending sort — descending reverses it — and the in-memory sort, the cursor's row comparison and a backend's own SQL agree on the same rule, which is what keeps a cursor walk from skipping NULL rows. The cursor predicate is itself a filter and reaches a backend through the same compilation registry as any other one.CursorListMixin/listCursor()is mirrored on the Vue client.Declarative filters. A filter is data — a registered operator name plus a value — evaluated in memory by a single
matches()implementation that is always available.fastapi_viewsets.filters.make_filter_model(model, declaration)builds a filter model from a field/operator declaration, and@compiles(backend, filter_type)lets a backend register its own translation of a given filter, keyed on(backend, filter): a new operator needs no backend release, a new backend needs no filter changes. Built-in operators:exact,iexact,contains,icontains,startswith,gt,gte,lt,lte,in,isnull,overlaps. Push-down is all-or-nothing per filter — a backend either translates it fully into its own query or declines and lets the in-memory fallback run.inandoverlapstake a comma-separated string rather than a repeated query parameter, matchingsort's existing convention, since FastAPI silently drops a list-typed field from aDepends()-expanded model.DjangoORMViewSet, a second concreteImplMixinbackend, infastapi_viewsets.backends.django_orm.perform_listreturns the queryset itself, unevaluated;apply_filtertranslates the filter model into.filter(**criteria)for every field that maps onto a concrete model field or its attribute name, and declines otherwise; sort pushes both directions down, including the correctnulls_first/nulls_lastplacement, rather than only ascending.to_record(), an overridable hook converting a Django row to the response model, runs on the final page alone for as long as no stage has landed the source — a declined filter or sort still runs it over whatever reached that stage, which can be the whole table.pk_field_nameis derived from the model's primary key rather than defaulting to the literal"id"; a composite primary key raisesTypeErrornaming the class and asking forpk_field_nameto be set explicitly. Requires the existingdjangoextra.@endpoint_docs({...})attaches a per-viewset summary, description, response_description,deprecatedflag and tags to an individual mixin-provided endpoint (list_items,create,retrieve, …), since those endpoints' own docstrings are shared library code and otherwise describe every viewset identically. It must sit below@route_viewseton the class — decorators apply bottom-up — and raisesValueErrorif placed above, or if it names an action the viewset does not have. A viewset's own docstring, where it declares one rather than inheriting a mixin's, becomes the OpenAPI tag description for its endpoint group;apply_viewset_tags(app, extra=...)applies the collected descriptions to aFastAPIapp, needed becauseget_openapi()does not readapp.openapi_tagsonce an application has replacedapp.openapi.Vue: mixin classes carry their action names.
CreateMixin,ListMixin,BulkViewSetMixinand the rest export astatic actions: readonly string[]naming the frontend method(s) they contribute, as real values rather than types, so aViewSetcan list them in astatic declares = [Mixin, ...]array read at runtime.restViewSet<T>()(pkFieldName, [Mixin, ...])andmuxwsViewSet<T>()(...)return a class to extend whose methods are both narrowed to the declared actions and actually callable — unlikeroute_rest's narrowed type, which could name a method the bare-proxy object it returned did not have:tsimport { restViewSet, CursorListMixin } from '@dynamicforms/fastapi-viewsets'; class TrackViewSet extends restViewSet<Track>()('id', [CursorListMixin]) {} const tracks = new TrackViewSet({ basePath: '/tracks' }); const first = await tracks.listCursor({ limit: 50 });route_restandroute_muxwsremain supported unchanged. AViewSetthat declares nothing is not schema-checked and does not pay for the schema request.
Changed
- Breaking:
ListMixin.setup_filter/setup_sort, the pre-filter/pre-sort hooks that mutated instance state ahead ofperform_list, are removed. Overrideapply_filter/apply_sortinstead — both now receive the query and the records together, so nothing needs to be stashed on the instance beforehand. - Vue: the internal endpoint-to-method table used by the startup schema check maps each REST path/method pair to the set of frontend action names that can satisfy it (
list,listPageandlistCursorall answerGET {base}, since the three list shapes are one backend endpoint) rather than a single fixed name.
Fixed
RestProxyImpl's startup schema check no longer warns about a backend endpoint being "missing" whenever a ViewSet is built from one mixin, or none.typeof this[action] === 'function'is true for every action on every proxy regardless of what the ViewSet actually declares, so the check always restated the schema it had just fetched; it is now driven by the ViewSet's ownstatic declareslist. It also now checkslistPage/listCursorin both directions and no longer misclassifies a nested path under/{pk}asretrieve.route_rest/route_muxwswere dropping the ViewSet class parameter, so no declaration reached the proxy for the schema check to read; the declaration is now carried across.- TypeVar resolution recognizes a parameterised pydantic generic. Pydantic's
M[X]is a class, not a typing alias, soget_origindid not see it andPaginatedList[T]reached the OpenAPI schema unresolved.
Documentation
- muxws is documented as dispatching into the library's own app rather than your application object — see the Added entry above for exactly what that leaves unreached, and
app=onprocess_commandfor reaching your application anyway. route_viewset's reference page documentsregister_restalongsideregister_muxws;ViewSetRequestErroris named on both the Python and Vue references, and its docstring no longer claims both transports throw it.- NULL placement is documented as first under an ascending sort by default, not last; the Python reference gains
PaginatedListMixin,CursorListMixin, and everything this release adds toListMixin— the class attributes, theoffset/limit/cursor/X-List-Shapeparameters and the pipeline hooks. - A backend-authoring guide states the contract a
@compilesimplementation follows:filter_set_for,filters_from,can_compile_all/compile_all,mark_applied/needs, andland()as the call that ends the lazy part. - New guide pages: muxws transport and The list pipeline (filters, offset pagination, cursor pagination).
[0.3.6] - 2026-08-01
Fixed
- A
BaseModelfield holding adateordatetimeno longer breaks acelery_viewsetcall. Both the client, serializing an argument beforesend_task(), and the server, serializing a result before pushing it to Redis, callmodel_dump(mode="json")rather thanmodel_dump(), so adate/datetimevalue becomes a JSON string instead of an objectjson.dumps()— what Kombu's transport uses — cannot encode. - A positional argument to a
celery_viewset_client-wrapped method is serialized the same way a keyword argument already was. Onlykwargswere passed through the serializer; aBaseModelorContextpassed positionally reachedsend_task()unconverted.
[0.3.5] - 2026-07-30
Added
check_result_readers()compares everyqueue_keyregistered by acelery_viewset_clientdecorator against the ones with a running result reader and logs a warning for each mismatch. Call it once, right after starting readers in the FastAPIlifespan, so a missing reader shows up as a log line at startup instead of a silent hang on the first request.get_registered_queue_keys()andget_running_queue_keys()are exported alongside it fromfastapi_viewsets.decorators.celery_viewset.- A Django Integration guide page: initializing Django in both the FastAPI and Celery worker processes,
autodiscover_tasksfor acelery_viewset-decorated class outside a conventionaltasks.py, and thesync_to_asyncconvention for Django ORM access fromperform_*.
Changed
start_result_reader(redis_client, queue_key)andstop_result_reader(queue_key=None)take aqueue_keyand keep one reader task per key instead of a single global one, so an app with more than onecelery_viewsetprefix runs a reader for each.stop_result_reader()with no argument stops every running reader; passed aqueue_keyit stops only that one.
Documentation
- Combining both decorators states the two valid ways to apply
celery_viewsetandroute_viewsetto the same viewset — subclassing inmain.pyversus stacking both decorators on one class — and the order stacking requires:celery_viewsetinner,route_viewsetouter. The reverse order leavescelery_viewsetiteratingroute_viewset's FastAPI-wrapped routes instead of the raw viewset methods, registering a bogusschemaCelery task.
[0.3.2] - 2026-07-13
Changed
- Breaking: the context serializer's type-tagging keys are
__fpv_type__and__fpv_value__, replacing__type__/__value__. The prefix avoids colliding with Kombu's own__type__/__value__convention for its JSON codec — without it, a Celery worker'sobject_hookintercepted aSerializableObjectpayload before it reacheddeserialize_context().
[0.1.0] - 2026-07-13
Added
- Initial implementation: Python mixin classes (
CreateMixin,ListMixin,RetrieveMixin,UpdateMixin,DestroyMixin, and bulk counterparts) that compose into FastAPI CRUD/bulk endpoints; theroute_viewsetdecorator, registering a mixin-composed ViewSet on a FastAPIAPIRouterin one call and building its OpenAPI schema;CollectionViewSet, a zero-boilerplate in-memory ViewSet backed by a list, set or dict; acelery_viewsetdecorator moving a ViewSet's execution to a Celery worker — task dispatch plus a Redis-backed result reader — with no changes to the ViewSet body; three instance lifecycle modes (singleton, per-request, instance-key) with optionalload_state/save_statehooks; a Vue/TypeScript client counterpart (route_restandRestProxyImpl) mirroring the mixin method names against a typed Axios client; a documentation site and a runnable demo app. RestProxyImplvalidates its declared methods against the backend's OpenAPI schema at construction: it fetches<basePath>/schemaand cross-checks the standard CRUD/bulk/lookup paths against the frontend method set, logging aconsole.warnfor a frontend method with no matching backend endpoint, a backend endpoint with no frontend method, or a non-standard backend endpoint. This costs one extraGETrequest per instantiation; a failure fetching the schema is swallowed and never affects normal proxy operation.- Context processors.
settings.viewsets_context_processorsis a list of async callables run on every request to build a per-requestContext, injected into any ViewSet method that declares acontext: Contextparameter. Field access onContext(context.foo/context["foo"]) is always awaitable, whether the underlying value is a plain eager one or aLazyObject— resolved on firstawait, memoized, sync or async. ASerializableObject/LazyObjectvalue can define__serialize__/__deserialize__, or forLazyObjecta recipe it can re-resolve from, so it survives the JSON round trip through Celery/Redis when the action it belongs to runs viacelery_viewset. - Command middleware.
settings.viewsets_command_middlewareis an ordered list ofCommandMiddleware— a plain async function, or aMiddlewaresubclass — forming an onion-style chain around every ViewSet action. Each layer receives(request, viewset, context, call_next)and returns aViewSetResult(body,headers,cookies,status_code); once the chain finishes,headers/cookiesare applied to the realResponseandstatus_codeoverrides the response status. AMiddlewaresubclass may additionally implementdepends(), bridged onto FastAPI's ownDepends()so it runs — and can reject with anHTTPException— before the request body is parsed. Middleware only runs for HTTP-routed requests; a Celery worker executing an action viacelery_viewsethas no liveResponseand skips the chain. @action_configuration({...})attaches per-viewset or per-method configuration, keyed by an identifier (a context-processor callable or aMiddleware(sub)class), read at request time viaContext.configuration_for/Middleware.config_from. Configuration merges in priority order:settings.default_action_configuration→ class-level@action_configuration→ method-level@action_configuration. A value can vary by action name viaByAction(default=..., **by_action), or itself be aMiddlewareinstance, injecting an extra middleware for just that call.- Authentication.
AuthBackendis an abstract base for recognizing credentials in a request;settings.viewsets_auth_processorsis tried in order, andauth_context_processorwires the first backend that claims a request intocontext.user(orNone). Three backends ship:StaticUserAuthBackend/StaticUserCookieAuthBackend(a fixed token-to-user mapping, via header or cookie),DjangoSessionAuthBackend(resolves a Django session key againstdjango.contrib.auth.get_user, requiring the newdjangoextra), andJWTAuthBackend(verifies a Bearer JWT's signature and expiry locally, requiring the newjwtextra). TheSessioncommand middleware rejects a request with 401 whencontext.userisNone; a viewset or method opts out with@action_configuration({Session: False}). - Authorization. The
Authorizationcommand middleware evaluates a configured check — a callablecheck(request, cls, context)rejects with 403 independs()before the action runs, or a plain value is exposed ascontext.authorizationfor aperform_*method to inspect and reject on its own once it has the fetched record. - Rate limiting. The
RateLimitercommand middleware enforces a fixed-window request count per identity key (default"<ViewSetClassName>:<client IP>", overridable viakey_func), backed by an in-memory dict or a sharedredis.asyncio.Redisclient for multi-process correctness; exceeding the limit rejects with 429 independs(). The per-viewset/method limit is set via@action_configuration({RateLimiter: <n>}). settings.viewsets_security_schemeattaches a FastAPI security scheme as an extra dependency on every viewset route, so Swagger/OpenAPI shows an Authorize lock icon and a testable flow; it has no other effect on request handling.- New optional install extras:
django(django,asgiref) forDjangoSessionAuthBackend, andjwt(PyJWT) forJWTAuthBackend. - Packaging for publishing: a full
hatchlingbuild configuration inpyproject.toml,__version__infastapi_viewsets/__init__.py, apy.typedmarker, and aREADME.md.
Changed
- Breaking: every
perform_*method (perform_create,perform_list,perform_retrieve,perform_update,perform_bulk_create,perform_bulk_update,perform_destroy,perform_bulk_destroy) takescontext: Contextas its first parameter, ahead of the existing ones —perform_retrieve(self, context: Context, pk: K) -> T.CollectionViewSet's built-in implementations already do; a hand-written override adds the parameter. - Breaking: the npm package is renamed from
@dynamicforms/viewsetsto@dynamicforms/fastapi-viewsets, and the GitHub repository fromdynamicforms/viewsetstodynamicforms/fastapi-viewsets; imports change accordingly —import { route_rest } from '@dynamicforms/fastapi-viewsets'. - Response-level side effects, such as setting a cookie, are produced by the command middleware chain rather than a per-viewset hook: a middleware sets
ViewSetResult.headers/cookies/status_code, applied to the realResponseonce the chain completes.route_viewsetalways injects aresponseparameter, and disables automaticresponse_modelinference for every route as soon as any command middleware is configured globally. route_viewsetattaches a middleware-bridgingDepends()to every registered route, so aMiddleware.depends()hook — session, authorization, rate-limit — runs before FastAPI parses the request body.
Fixed
route_sort_key, used to order registered routes, no longer raisesTypeError: '<' not supported between instances of 'int' and 'tuple'for a viewset with custom action paths of different depths sharing a common prefix (account/registeralongsideaccount/register/verify/resend). The path portion of the sort key is kept as a nested tuple instead of being flattened, so it compares consistently regardless of path depth.
Removed
- The unused
CeleryViewSetclass (fastapi_viewsets.celery_viewset) and its documentation pages. It was never wired intoroute_viewset, and itsperform_*methods returned raw, non-JSON-serializable CeleryAsyncResultobjects directly, so it could not serve as an HTTP viewset. Thecelery_viewsetdecorator is the supported way to back a viewset with Celery.
