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.11.0] - Unreleased
Added
ActionInterruptedError(action_interrupted; 504 for reasontime_limit, 503 forterminatedandlost): the executor ended an execution of a deferred action that had started.ActionFailedError(500,action_failed, reasonserror,result,executor): a deferred action failed with an error that is not anHTTPException.detailnames the task only.ActionOutcomeUnknownError, the base ofResultTimeoutErrorandResultReaderStoppedError.ActionDispatchErrortakes areasonparam:send,serialization,unknown_task,rejected,revoked,expired.Middleware.executor_started(), awaited in every process of acelery_viewsetworker before it uses the middleware there, once on each event loop the process uses, in every pool type.Middleware.on_transition(transition)andActionTransition: a deferred call that was dispatched, did not run, was interrupted from outside its execution, or failed in the worker's own machinery. The worker's supervisor reports a hard time limit, a termination, a lost process, a revoked or expired call, an unknown task and a rejected message to the caller at once, instead of afterresult_timeout.TimeLimits(soft=, hard=), an@action_configurationkey: the time limitscelery_viewsetsends with each call of the action.push_result(..., task=)names the task in theActionFailedErrorit pushes for an error that is not anHTTPException.
Changed
- Breaking. No Celery, billiard or kombu exception reaches
Middleware.execute(), the caller or the application. A soft time limit reachesexecute()asActionInterruptedError, whether the action was running or suspended at anawait; the caller receives the same error. In ageventpool a hard time limit does the same and is reported toon_transition(). Any other Celery exception the action raises (Retry,Ignore,Reject) becomesActionFailedError. - Breaking. An error a deferred action raises that is not an
HTTPExceptionreaches the caller asActionFailedError(500, JSON body) instead of a bareException. ADfViewSetErrorthe worker raises reaches the caller as its own class whenfastapi_viewsets.exceptionsdefines it, with itscode,paramsand headers; anHTTPExceptionkeeps its headers. - Breaking. Arguments, context or request that cannot be serialized raise
ActionDispatchError(reasonserialization) before the task is sent. A model argument that fails validation in the worker raisesActionFailedError. - Breaking. A call the worker revokes, rejects or does not know ends with
ActionDispatchError, soIdempotencydeletes its record and a repeat runs the call again. - Breaking.
redis_clientis required bycelery_viewsetin worker mode and bycelery_viewset_server; without it they raiseValueError. - Breaking.
celery_viewset_serverregisters its tasks withacks_late=False,reject_on_worker_lost=Falseandstore_errors_even_if_ignored=False, overriding a globaltask_acks_late: a call is never delivered to the worker twice. - The result payload carries the error encoded with its class,
code,paramsand headers, plustask;http_status_codeandhttp_detailstay for a reader that does not decode it. Idempotencyreplays a recordedDfViewSetErroras its own class whenfastapi_viewsets.exceptionsdefines it.
Fixed
- A result the caller cannot deserialize fails the call with
ActionFailedError(reasonresult) instead of leaving it to wait forresult_timeout. - An exception that is not an
Exception(aBaseExceptionsubclass) raised by a deferred action is pushed to the caller;SystemExitandKeyboardInterruptare pushed asActionInterruptedError(reasonterminated) and still end the process.
[0.10.0] - 2026-10-16
Added
- npm:
onCallActivity(listener)reports every call issued through a proxy asstart,retryandsettleevents under its key;RequestOptions.backgroundmarks a call a busy indicator leaves out;pendingCalls()andpendingCallCount()list and count the calls that have not settled;retryNow()sends every waiting repeat at once.issueCall()takesbackgroundas a third argument. - npm: the event client sends a check poll of every subscribed topic over the open peer once per
checkIntervalMs(DEFAULT_CHECK_INTERVAL_MS, 60 s;0sends none) while the ladder is held, so a lost push of a topic's last event is caught without a later event of the topic.
Changed
- Breaking. Events:
RedisEventStorepublishes every event on one channel,{prefix}events, whichstart()subscribes once and returns after Redis has confirmed;deliver()pushes an event to the sockets of the process that hold its topic.EventStore.watchandEventStore.unwatchare removed, as areRedisEventStore.watch,unwatchandchannel(topic);RedisEventStore.channelis the channel's name.subscribeno longer calls the store, and a failed or slow RedisSUBSCRIBEno longer fails a subscribe or leaves a channel subscribed.start()raisesasyncio.TimeoutErrorwhen Redis does not confirm the SUBSCRIBE within 5 seconds. - npm:
watch({ fetch: true })passes the changes whoseretrieveanswers 404 to a secondonChangewithop: 'deleted', after the event's other changes, instead of dropping them, so a displayed record that is gone or no longer visible to the reader is removed. Idempotencyextends a running record toviewsets_idempotency_lock_ttlevery third of the lock TTL while the call runs, and the:attachedkey with it. A call that runs longer than the lock TTL keeps its record, and a repeat attaches to it instead of running the action again; the record expires only once the process running the call stops extending it.
Fixed
- Events:
pollrunsviewsets_event_delivery_filterson every event it answers, over REST and over muxws, and leaves out the events a filter refuses. Each polled event'sprevis the rev of the event before it in the answer, the cursor for the first; the answer'srevcovers the events left out.Subscription.peeris the socket a muxws poll arrived on,Nonefor a poll over REST.passes_filters()runs the filters. An event a filter refused on its push is no longer delivered by the gap poll that the next push triggers, and a REST-only client receives the same events as a subscribed socket. - npm: the event client applies a polled event whose
previs below the held rev and whose rev is above it, and moves the held rev to a poll answer'srevafter its events, so a poll answer that leaves out filtered events neither resyncs the topic nor polls it again.
[0.9.1] - 2026-10-16
Added
- Events,
fastapi_viewsets.eventsand the npm event client: the backend publishes an event to a topic, and every frontend following the topic receives it as a muxws push or on its next poll.- A viewset's topic is its routed base path; custom topics are declared with
source(). - Every event is written to a store before it is pushed:
MemoryEventStorefor one process,RedisEventStorefor several. Each event carries arev, theprevit follows and anevent_version; a frontend that misses events polls the short log or re-reads its state. register_events()servessubscribe,unsubscribeandpoll. Authorizers admit topics (ViewsetTopics()applies the viewset's own checks); delivery filters decide per socket.ChangeEvents, a command middleware, publishes the pks of every create, update and destroy of a viewset, globally or per viewset, also from a Celery worker.- npm:
createEventClient(),defineEvent(),watch()on every ViewSet withapplyChanges(), and the polling ladder the client polls with. - See Events and Events (Vue).
- A viewset's topic is its routed base path; custom topics are declared with
process_command(..., peer=)puts the socket in the request scope.- The demo's grid follows its viewset in every open tab.
Changed
CollectionViewSet.pk_field_nameis the viewset'spk_field; the cursor list shape orders by it.- The
muxwsfloor is 0.4.1 on PyPI and npm. - npm: the positional form of
route_restreturnsViewSetCalls<M>, withoutwatch().
[0.9.0] - 2026-10-16
Added
fastapi_viewsets.middleware.idempotency.Idempotency(redis_client=None, key_prefix="idempotency:"): a command middleware that answers a call repeated with the sameIdempotency-Key(a Structured Field string,draft-ietf-httpapi-idempotency-key-header-07) with the outcome of the first call. A repeat of a running call attaches to it. Records live in the instance, or in Redis withredis_client.GETandHEADoutcomes are stored only when a repeat attached; an outcome that sets a cookie is never stored.parse_idempotency_key,read_idempotency_key,request_fingerprintandIDEMPOTENCY_KEY_HEADERinfastapi_viewsets.middleware.idempotency.settings.viewsets_idempotency_ttl(seconds, default 86400) andsettings.viewsets_idempotency_lock_ttl(seconds, default 600).IdempotencyKeyInvalidError(400,idempotency_key_invalid) andIdempotencyKeyReusedError(422,idempotency_key_reused, paramkey).Middleware.execute(execution, call_next): an optional onion layer around the call of the viewset method, below every__call__, run once per execution where the action executes: in the FastAPI process for an in-process action, in the Celery worker for acelery_viewsetaction. Default: passes through.ActionExecution(viewset_class,viewset,action,args,kwargs,context,request,parent) andExecutionRef(viewset_class,action,parent) infastapi_viewsets.middleware.ActionDispatchError(503,action_dispatch_failed, paramtask): acelery_viewsetcall whose task could not be sent.Idempotencydoes not store it; a repeat runs the call again.- A
celery_viewsettask carries the call's context whether or not the action declarescontext, the call's request scope without the body, and the execution it was dispatched in (_context,_request,_parent). An action declaringrequestreceives aRequestbuilt from the scope in the worker. A call made inside another action's execution carries that execution's context and request unless it passes acontextof its own. - A
celery_viewsettask interrupted by Celery (a time limit) while the action awaits is cancelled and run until it has unwound, so itsfinallyblocks andexecute()see the interruption. - npm: every call through a proxy, REST and muxws, sends
Idempotency-Key, a new UUID per call as a Structured Field string.RequestOptions.keysets it for one call. - npm:
ViewSetCall<R>, aPromise<R>with the call'skey.request()returns it, and every method of a proxy setskeyon its promise when it issues a request before its firstawait.ViewSetActionCall<R>(the type of a declared action,keyoptional) andViewSetCalls<M>. - npm: a call whose attempt ends without an answer is repeated with the same key and with the headers of its first attempt — REST: an axios error without
response, or502,503,504whose body is not a JSON object withdetail; muxws:ConnectionLost,StreamTimeout,StreamRefused, andConnectionClosedorConnectionGoingAwaythrown byopen()while the peer has not closed for good, after the peer has reconnected. The repeated attempt is aborted (REST) or its stream cancelled (muxws). Optionsrepeat: RepeatOptions(withinMs, default 600000,0turns repeating off;delayMs, default 1000, doubled per repeat up to 30000) on every proxy and onRequestOptions, andattemptTimeoutMs(default 30000) on REST proxies. - npm:
IDEMPOTENCY_KEY_HEADER,newCallKey(),idempotencyKeyHeader(key)andissueCall(promise, key). - npm:
MuxwsStreamLike.cancel,MuxwsStreamLike.headersandMuxwsPeerLike.isOpen,onReconnect,onClose, all optional.
Removed
- Breaking:
set_celery_dispatch_hookandset_celery_kwargs_hook.Middleware.execute()runs where the action executes, and the call's context reaches the worker for every action.
Changed
- Breaking: a
celery_viewsettask carries the call's context as_context, no longer as thecontextkwarg. A worker and a FastAPI process of different versions do not understand each other's tasks. - Breaking (muxws):
process_commandruns the request as a task of its own. When the stream ends before the answer (the peer cancels it or the socket is lost), the request runs to completion and its answer is discarded; cancelling the stream no longer cancels the request. - Breaking (npm): a call that gets no answer is repeated, for up to 10 minutes by default, and a REST attempt times out after 30 s (
attemptTimeoutMs); before, a REST call had no timeout of its own and no call was repeated. WithoutIdempotencyon the backend a repeat runs the action again. - Breaking (npm):
MuxwsProxyOptions.timeoutMsbounds one attempt; an attempt that reaches it is repeated, up torepeat.withinMs, instead of rejecting the call withStreamTimeout. - Breaking: the
muxwsextra requiresmuxws>=0.4.0, which requireswebsockets>=14. - Breaking (npm): the optional peer dependency
muxwsis^0.4.0. A repeat over muxws carries the headers that the peer's open interceptors (Peer.interceptOpen, muxws 0.4.0) added to the first stream. - Breaking (npm):
Idempotency-Keyis sent on every call; a cross-origin REST backend has to allow the header, and every call to it is preflighted. - Breaking (npm): the actions of the mixin interfaces and of
ViewSetProxyBasereturnViewSetActionCall<R>,request()returnsViewSetCall<R>, andRestProxy<M>andMuxwsProxy<M>areViewSetCalls<M>. A transport of one's own returnsissueCall(promise, key)fromrequest(). - npm:
MuxwsProxyImpl.request()opens its stream synchronously once the peer has resolved. - npm:
ViewSetProxyBase's constructor binds every method of the instance's class chain as an own, non-enumerable property, which setskeyon the method's promise.
[0.8.0] - 2026-10-13
Added
RateLimiter(key_prefix=...): the prefix of its Redis keys, default"ratelimit:".toErrorDescription(body)converts a failed request's body into anErrorDescription(code,params,detail,origin: 'server'), the shape ofErrorDescriptionin@dynamicforms/vue-forms:detail_codebecomescode(''where the body has none),detail_paramsbecomesparams, anddetailis kept.toFieldErrors(body)converts FastAPI's 422 validation body intoErrorDescriptions keyed by field name (locwithout its source element, joined with.), with pydantic'stypeascode,ctxasparamsandmsgasdetail.- The
ErrorDescription,FieldErrorEntryandFieldErrorsBodytypes. - Every operation in a viewset's
/schema(REST and muxws) carriesx-viewset-action, the name of the viewset method that serves it. restViewSet<T>()(pk, [])andmuxwsViewSet<T>()(pk, [])build a ViewSet with no standard action, for a BE viewset with custom endpoints only. An emptydeclareswas a compile error (TS2507).ReadOnlyCollectionError(405, coderead_only), raised by a write to aCollectionViewSetover an immutable container.- The API reference has pages for
route_muxwsandsettings, and lists the request, error-body, muxws, filter and Celery helpers. celery_viewsetandcelery_viewset_clienttakeresult_timeout(seconds, default 3600;Nonewaits without a limit). A call whose result does not arrive in time raisesResultTimeoutError(504,result_timeout, paramstaskandtimeout).get_process_queue_key(queue_key): the Redis list the calling process receives a queue key's results on.RequestOptions.headers: headers for a singlerequest()call, sent by both transports; over muxws they override the proxy'sheaders.ListShapeMismatchErrorand theListShapetype.ResultReaderStoppedError(503,result_reader_stopped, paramqueue_key): acelery_viewsetcall pending when the result reader of itsqueue_keystops.
Changed
- Breaking (muxws): a command goes through the application that serves the WebSocket endpoint (
connection.scope["app"], orapp=), as an HTTP request to the same path does: its middleware, exception handlers (df_viewset_exception_handlerincluded), router dependencies,app.state, lifespan state anddependency_overridesapply to both transports. The library no longer builds an app of its own for muxws;get_dispatch_appis removed.process_commandwithoutconnectionorappraisesValueError. - Breaking (muxws): a command's
:pathis the path the application routes,include_routerprefixes included, so a client uses onebasePathfor both transports. - Breaking (muxws): each viewset endpoint is one route on the router given to
route_viewset, matching only the transports it is published on (fastapi_viewsets.routing.TransportRoute); the request's transport isscope["fastapi_viewsets.transport"]. The router also holds the routes published on muxws only; those are left out of the application's OpenAPI document. - A muxws command does not inherit the handshake's
connection,upgrade,sec-websocket-*andaccept-encodingheaders, so a compression middleware leaves the reply uncompressed. - An exception the application raises after it has started its response is logged, and over muxws the reply is that response (the application's own 500 handler included), as an HTTP client receives it.
- Breaking: requires Pydantic 2.11 or later.
- The body of
PATCH {pk}andPATCH bulkis a partial model: every field optional, the primary key left out, a stated field validated by its annotation, its constraints and the model's@field_validators.@model_validators are not applied to it.perform_updateandperform_bulk_updatereceive the model holding the stated fields only, sodata.model_dump(exclude_unset=True)is what the request stated; on acelery_viewsetthe worker receives the same. - Breaking (Celery): the JWT user crosses to a worker as the token and the position of its
JWTAuthBackendinsettings.viewsets_auth_processors, not the secret. The worker verifies the token with its own configured backend, so it needs the sameviewsets_auth_processors. - A filter value that will not convert (
?year__in=1999,abc) is a 422 withloc["query", parameter, index], on acelery_viewsetas well. limitbelow 1 andoffsetbelow 0 are a 422.- A muxws command whose
:method,:pathor:queryis malformed is answered with a 400. timeoutMsof a muxws ViewSet bounds the wait for the reply headers as well as the body.route_viewsetraisesValueErrorfor an unknownlifecycle.- npm:
vueandlodash-esare no longer peer dependencies; the package imports neither. - Breaking (Django): a relation field (
ForeignKey,OneToOneField) appears in aDjangoORMViewSet'sschemaunder its name and carries the related row's key (owner: int). The backend reads, writes, filters and sorts it on theowner_idcolumn. Aschemadeclaringowner_idraisesTypeErrorwhen the viewset class is defined, andpk_field_namefor a one-to-one primary key is the relation's name (artist).
Fixed
GET {base_path}/schemaover REST lists only the endpoints published on REST; it listed the endpoints published on muxws only as well.muxws commands carry the WebSocket connection's client and server address (
request.client) whenprocess_commandis givenconnection=. Every command had the client("muxws", 0), soRateLimiter's default key counted all muxws callers of a viewset as one.RateLimiterwithredis_clientincrements and sets the window's expiry in one Lua script and sets the expiry on any key that has none. A call interrupted betweenINCRandEXPIREleft a key that never expired, and awindow_secondsbelow 1 set an expiry of 0, which deleted the key. The expiry is in milliseconds. Keys are prefixed withkey_prefix(default"ratelimit:").RateLimiter's in-memory storage removes entries whose window has passed; it kept one entry for every identity key ever seen.list(),listPage()andlistCursor()sendX-List-Shapewith the shape they return, so each receives its own envelope from a viewset withlist_shapes. A reply in another shape throwsListShapeMismatchError; it was returned as the method's type with the envelope fieldsundefined.DjangoORMViewSetupdates run in one transaction that reads each row withSELECT ... FOR UPDATE, sets the stated fields on it, validates it asschema(@model_validators included) and saves it. Two concurrent updates of different fields of a row kept only the later one's fields, and a partial update was not checked by@model_validators against the stored fields; an invalid row answers 422 withloc["body", field](["body", pk, field]in bulk). A bulk update with a missing or invalid row writes none of its rows. A rowget_querysetexcludes is a 404 for an update as for a retrieve.DjangoORMViewSetcreate and update write relation fields; a create writes a natural or relation primary key from the body and leaves out only a key the database or Django assigns.celery_viewsetencodes an action's result like REST and muxws do: a dict is encoded recursively, and a value that is not JSON-native (datetime,Decimal,UUID, ...) takes the form FastAPI's response serialization gives it. A result such as{"record": Item(...)}failed withTypeError: Object of type Item is not JSON serializable. ASerializableObjectnested in a dict or list, including in aViewSetResultbody, is reconstructed in the FastAPI process.celery_viewsetwith more than one FastAPI process sharing a Redis (several workers or replicas): each process receives its results on its own list,{queue_key}:{host name}:{process id}. All processes read one list per prefix, and a process that popped another process's result discarded it, leaving that request waiting indefinitely. The worker renews a result list's expiry to one hour on every push, so the list of an exited process is removed.celery_viewsetclient: a call cancelled while it awaits its result removes its pending entry, which stayed in the registry.send_taskruns in a worker thread instead of blocking the event loop while it publishes to the broker.The body of a create with
pk_field_nameset is validated as the model declares it:Fieldconstraints, aliases,model_config(e.g.extra="forbid") and the model's validators apply, and the OpenAPI schema carries them. A body breaking them was accepted and passed toperform_createunvalidated. A primary key the body gives as anything but null answers 422 withpk_assigned_by_server; it was ignored.perform_createreceives the model with its fields as validated, without the primary key unless the model gives it a default, andmodel_fields_setnames the fields the body stated.celery_viewsetworker: a model argument is validated as its model, reading fields by name, where a value failing validation was passed on unvalidated throughmodel_constructwith nested models as dicts; a model with an aliased field always took that path. A partial update stating an aliased field failed on the worker. A create body without its primary key is validated as on the FastAPI side.The schema check matches a custom endpoint to a ViewSet method by the BE method's name (
x-viewset-action), in camelCase or as it is, where it read the first path segment: an endpoint under a path parameter (POST /items/{pk}/approve) asked for a{pk}()method and one with a hyphen (GET /items/top-rated) for atop-rated()method, and both were reported as missing. An operation withoutx-viewset-actionis not checked.A route whose endpoint declares no
contextparameter builds its context for the command middleware chain; withAuthorization(or any middleware reading the context) configured it answered 500.PATCHaccepts a body that leaves fields out; it answered 422 for any field missing.A custom endpoint that declares
request: Requestreceives the request; the call raisedTypeError(got multiple values for keyword argument 'request').CollectionViewSet: a partial update of a pydantic model item answered 500; a delete from a set never found the item; a partial update of a set's item replaced it with the request body.SerializeStateSlots.deserialize_statewhile nothing is stored (None) raised.The demo shows a failed call's server
detailandcodethroughtoErrorDescription.
Removed
- Breaking (npm):
translateApiError,translateStringsandtranslatableStrings, and the@dynamicforms/translatablepeer dependency. The package holds no translations; the application translates an error by itscode.
[0.7.3] - 2026-10-04
Removed
@dynamicforms/vue-formsis no longer a peer dependency of the npm package, which imports nothing from it.
[0.7.2] - 2026-09-25
Changed
- The npm package is ESM only: the UMD build is dropped.
exports["."]points at the ES module and states atypescondition first, so TypeScript withbundlerornode16module resolution findsdist/index.d.ts.
Removed
- The
./styles.cssexport, which named a file the package does not ship.
[0.7.1] - 2026-09-24
Added
- A registered route endpoint can return a
ViewSetResultdirectly instead of a plain body, to reachstatus_code/headers/cookiesitself without writing a command middleware - aperform_*hook cannot, since it isn't itself a registered route. ViewSetResultis now generic (ViewSetResult[T]) - an endpoint declared-> ViewSetResult[X]documents/validates exactly like a plain-> X;route_viewset/build_schemaunwrap it toXfor the OpenAPIresponse_model.ViewSetResultis now aSerializableObject, so acelery_viewset-dispatched action that returns one directly survives the worker boundary the same way any otherSerializableObjectdoes.fastapi_viewsets.context.serialize_value/deserialize_value- the tagged-payload conventionserialize_context/deserialize_contextalready use per context key, now available for a singleSerializableObjectvalue outside a context dict.fastapi_viewsets.context.to_jsonable- the shared JSON-safe conversion (Pydantic model dumped, list/tuple recursed into,SerializableObjecttagged)ViewSetResult's own serialization andcelery_viewset's worker/client both use, replacing three near-duplicate copies of the same logic.celery_viewset_client's call-argument serialization now also tags a bareSerializableObjectargument (not just one wrapped in aContext), which it previously did not.
[0.7.0] - 2026-09-13
Added
Middleware.modifies_response_shape(also settable as a plain attribute on a function-based middleware) lets a command middleware declare whether it changes the shape ofViewSetResult.body- defaults toFalse.
Fixed
- Command middleware no longer disables a route's
response_modelfor the whole application just because any middleware is configured insettings.viewsets_command_middleware. Previously, even a middleware that only attaches a cookie or header (never touchingViewSetResult.body) caused every route's OpenAPI response schema to go untyped and FastAPI's own response validation to be skipped.response_modelis now only disabled when a configured middleware declaresmodifies_response_shape=True.
[0.6.3] - 2026-09-03
Fixed
celery_viewset_clientnow sends its Celery task withignore_result=Trueexplicitly.celery_viewset_serveralready registers every task withignore_result=True, but Celery'ssend_task()defaults that setting toFalseper call, and a request-level value overrides the task's own - so every task's return value was still handed to Celery's own result backend, which raises a KombuEncodeErrorfor any non-JSON-safe value such as a raw Pydantic model.celery_viewset_server's task no longer returns the endpoint's raw result to Celery once that result has been pushed to the Redis queue -celery_viewset_clientnever reads a task's own Celery retval, only that queue entry, so handing the raw value (e.g. a Pydantic model) to Celery a second time served no purpose and is exactly whatignore_result=Trueabove should, but isn't guaranteed everywhere (e.g. worker task-succeeded events) to prevent Celery from trying to encode.celery_viewset_server's kwarg reconstruction now recurses intolist[Model]andtuple[Model, ...](including theirOptionalform), not just a bareModelhint. A parameter typed as a list of Pydantic models previously arrived at the endpoint as a list of plaindicts.
[0.6.2] - 2026-09-03
Fixed
celery_viewset_clientno longer raises a KombuEncodeErrorwhen an action argument is alist[BaseModel]ordict[str, BaseModel]. Serialization now recurses intolist,tuple, anddictvalues so nestedBaseModelinstances are converted at any depth before being handed tosend_task().
[0.6.1] - 2026-09-03
Fixed
typecast_to_original_modelno longer raises a 500 for a body parameter annotated asModel | None(orOptional[Model]): the union was previously passed straight intoisinstance()andmodel_validate()/model_construct(), none of which a union object supports. It is now unwrapped to itsBaseModelmember first.
[0.6.0] - 2026-09-01
Added
fastapi_viewsets.exceptions: everyHTTPExceptionthis package raises on its own behalf -NotFoundError,SessionExpiredError,NotAuthorizedError,RateLimitedError,UnsupportedListShapeError,CursorRequestError- now carries a stablecodeand theparamsits Englishdetailwas interpolated with, as attributes on the exception itself.detailstays a plain string, exactly as it always was - nothing changes in the response body by default.df_viewset_exception_handler, an opt-in exception handler forDfViewSetError(the common base every class above descends from). Registered once -app.add_exception_handler(DfViewSetError, df_viewset_exception_handler)- it addsdetail_codeanddetail_paramsalongsidedetailin the response body, so a frontend can translate or switch ondetail_codewithout parsing English prose out ofdetail. An application that does not register it sees exactly what it always has.- The Vue client gains
translateApiError(), which looks updetail_codein a@dynamicforms/translatabletable of English defaults and interpolatesdetail_paramsinto it - returningdetailunchanged whendetail_codeis absent (the handler above was not registered, or the error is a view's own plain-stringHTTPException) or names a code the table does not cover. CalltranslateStrings()(also exported) to supply translations for as many codes as the application has. - A new docs page, Error codes, covers the design and the frontend translation recipe in full.
Changed
- Bumps the
@dynamicforms/vue-formspeer range to^1.0.0and adds@dynamicforms/translatable(^0.1.0) as a peer dependency. NotFoundErrormoves fromfastapi_viewsets.response_classestofastapi_viewsets.exceptions, alongside the other exception classes above.
[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.9.1andvue-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.
