Skip to content

route_rest factory ​

route_rest is the frontend counterpart of the route_viewset decorator. It creates a fully typed HTTP client (backed by axios) that mirrors the operations of your backend viewset.

It is one of two forms. The other declares a ViewSet with the restViewSet factory: it hands back a class you extend, so the object is your own class — its methods exist at runtime — and an action the ViewSet did not declare is a compile error rather than a 404:

ts
import { restViewSet, BulkViewSetMixin, LookupMixin } from '@dynamicforms/fastapi-viewsets';

interface Item { id: number; name: string; price: number }

class ItemApi extends restViewSet<Item>()('id', [BulkViewSetMixin, LookupMixin]) {}

const itemsApi = new ItemApi({ basePath: '/items' });

The empty () is required: TypeScript has no partial type-argument inference, so Item cannot be given explicitly while the pk field and the mixin list are inferred from arguments in the same call (TS2558).

Signature ​

ts
function route_rest<M>(
  viewSetClass: ViewSetClass,
  basePath: string,
  pkFieldName: string,
  axiosInstance?: AxiosInstance,
): RestProxy<M>

// or with an options object:
function route_rest<M>(
  viewSetClass: ViewSetClass,
  options: RestProxyOptions,
): RestProxy<M>

Parameters ​

ParameterTypeDescription
viewSetClassclassThe viewset class (used only as a type token; not instantiated)
basePathstringBase URL path, e.g. '/items'
pkFieldNamestringName of the PK field on the model, e.g. 'id'
validateSchemabooleanSet false to skip the startup schema check
declaresreadonly ViewSetMixinDeclaration[]The mixins the ViewSet declares, for that check
axiosInstanceAxiosInstanceOptional custom axios instance (defaults to global axios)

Basic usage ​

ts
import { route_rest, BulkViewSetMixin, LookupMixin } from '@dynamicforms/fastapi-viewsets';

interface Item {
  id: number;
  name: string;
  price: number;
}

class ItemTypeToken extends BulkViewSetMixin<number, Item, 'id'> {}

const itemsApi = route_rest<BulkViewSetMixin<number, Item, 'id'> & LookupMixin>(
  ItemTypeToken,
  '/items',
  'id',
);

The returned object is a bare RestProxyImpl cast to M. ItemTypeToken is a type token only — it is never instantiated, and none of its own methods reach the proxy. A custom endpoint named in M therefore type-checks and is undefined when called; to have such a method exist, subclass RestProxyImpl (see Custom endpoints) or build the ViewSet with the factory.

Available methods ​

The returned proxy exposes all methods declared by the mixin type M:

ts
// List all items
const items: Item[] = await itemsApi.list();

// Retrieve one item
const item: Item = await itemsApi.retrieve(1);

// Create (PK field is omitted from the payload)
const created: Item = await itemsApi.create({ name: 'Widget', price: 9.99 });

// Full update
const updated: Item = await itemsApi.update(1, { id: 1, name: 'Widget v2', price: 12.00 });

// Partial update
const patched: Item = await itemsApi.partialUpdate(1, { price: 11.00 });

// Delete
const result = await itemsApi.destroy(1);

// Bulk create
const createdMany: Item[] = await itemsApi.bulkCreate([
  { name: 'A', price: 1 },
  { name: 'B', price: 2 },
]);

// Bulk update
const updatedMany: Item[] = await itemsApi.bulkUpdate({ 1: { id: 1, name: 'A2', price: 1.5 } });

// Bulk partial update
const patchedMany: Item[] = await itemsApi.bulkPartialUpdate({ 1: { price: 2.0 } });

// Bulk delete
const deletedMany = await itemsApi.bulkDestroy([1, 2, 3]);

// Lookup (requires LookupMixin)
const options = await itemsApi.lookup();

Using with LookupMixin ​

ts
import { route_rest, BulkViewSetMixin, LookupMixin } from '@dynamicforms/fastapi-viewsets';

const itemsApi = route_rest<BulkViewSetMixin<number, Item, 'id'> & LookupMixin>(
  ItemTypeToken,
  '/items',
  'id',
);

const lookupItems = await itemsApi.lookup();
// [{ group: null, pk: 1, title: 'Widget', icon: null }, ...]

Using a custom axios instance ​

ts
import axios from 'axios';

const http = axios.create({
  baseURL: 'https://api.example.com',
  headers: { Authorization: 'Bearer my-token' },
});

const itemsApi = route_rest<BulkViewSetMixin<number, Item, 'id'> & LookupMixin>(
  ItemTypeToken,
  '/items',
  'id',
  http,
);

Or with the options object form:

ts
const itemsApi = route_rest<BulkViewSetMixin<number, Item, 'id'> & LookupMixin>(
  ItemTypeToken,
  { basePath: '/items', pkFieldName: 'id', axiosInstance: http },
);

Direct instantiation via RestProxyImpl ​

Instead of using the route_rest factory, you can instantiate RestProxyImpl directly:

ts
import { RestProxyImpl } from '@dynamicforms/fastapi-viewsets';
import type { BulkViewSetMixin, LookupMixin } from '@dynamicforms/fastapi-viewsets';

let proxy: BulkViewSetMixin<number, Item, 'id'> & LookupMixin;

proxy = new RestProxyImpl<number, Item, 'id'>({
  axiosInstance: http,
  basePath: '/items',
  pkFieldName: 'id',
});

RestProxyImpl accepts a single RestProxyOptions object:

OptionTypeDescription
basePathstringBase URL path, e.g. '/items'
pkFieldNamestringName of the PK field on the model, e.g. 'id'
validateSchemabooleanSet false to skip the startup schema check
declaresreadonly ViewSetMixinDeclaration[]The mixins the ViewSet declares, for that check
axiosInstanceAxiosInstanceOptional custom axios instance (defaults to global axios)
attemptTimeoutMsnumberTimeout of one attempt in milliseconds, passed to axios as timeout; default 30000, 0 for none
repeatRepeatOptionsWhen a call whose attempt ended without an answer is repeated; default { withinMs: 600000, delayMs: 1000 }
eventsEventClientThe event client watch() subscribes on; without it, watch() throws

The difference between the two approaches:

route_rest(...)new RestProxyImpl(...)
Typical useApplication codeTests / advanced use
Type inferenceVia generic M parameterVia generic type parameters on class
ViewSet class argumentRequired (type token)Not needed
ResultRestProxy<M> (typed as M, methods returning ViewSetCall, and watch()); ViewSetCalls<M>, without watch(), from the positional formRestProxyImpl instance

Both produce the same underlying object — route_rest simply wraps new RestProxyImpl(options).

Idempotency-Key and repeats ​

Every call carries an Idempotency-Key header, a new UUID per call, and the promise it returns has that key as key:

ts
const call = itemsApi.create({ name: 'Widget', price: 9.99 });
call.key;                  // '8e03978e-40d5-43e8-bc93-6894a57f9324'
const created = await call;

An attempt that ends without an answer — an axios error without response (a network error, the attempt's attemptTimeoutMs), or 502, 503, 504 from a gateway — is aborted and repeated with the same key, and with the headers the first attempt was sent with; the backend's Idempotency middleware answers the repeat with the outcome of the first attempt. The rules and the options are in Idempotent Calls.

HTTP mapping ​

MethodHTTP call
list()GET /items
retrieve(pk)GET /items/{pk}
create(data)POST /items
update(pk, data)PUT /items/{pk}
partialUpdate(pk, data)PATCH /items/{pk}
destroy(pk)DELETE /items/{pk}
bulkCreate(data[])POST /items/bulk
bulkUpdate(records)PUT /items/bulk
bulkPartialUpdate(records)PATCH /items/bulk
bulkDestroy(pks[])DELETE /items/bulk
lookup()GET /items/lookup

watch(options) sends no request to /items: it subscribes the topic /items on the proxy's events client; see Events (Vue).

Schema validation ​

A ViewSet lists the mixins it is made of, and the proxy checks that list against the BE's schema when it is constructed:

ts
import { BulkViewSetMixin, LookupMixin, RestProxyImpl } from '@dynamicforms/fastapi-viewsets';

class ItemViewSet extends RestProxyImpl<number, Item, 'id'> {
  static declares = [BulkViewSetMixin, LookupMixin];
}

declares is the FE counterpart of a BE viewset's base classes, and it exists because a TypeScript implements clause cannot do the job: it is erased before anything runs. On these classes it does not even narrow the type — the proxy base implements every action, so any subset is satisfied trivially — which is why declares replaces the implements clause rather than repeating it.

A factory-built ViewSet is checked the same way: the factory puts the mixin list it was given on the returned class as static declares. That one list is what types the class and what the check reads, so the two cannot disagree - which is why restating static declares on a subclass of a factory-built class is a compile error (TS2417).

The proxy fetches GET {basePath}/schema in the background and emits one console.warn listing every disagreement. The check is non-critical: an unreachable schema endpoint or an unexpected format is ignored silently.

What gets checked ​

SituationWarning message
The ViewSet declares an action the BE viewset does not servedeclares 'create' but the BE viewset serves no such endpoint
The BE serves a standard endpoint the ViewSet never declaredBE serves 'POST /items' but the ViewSet declares no create
The BE serves a custom endpoint the ViewSet has no method forBE serves 'GET /items/top-rated' but the ViewSet has no 'topRated()' or 'top_rated()' method

GET {basePath} is one endpoint that answers in whichever shape the BE viewset declared, so ListMixin, PaginatedListMixin and CursorListMixin each satisfy it — declaring listCursor and being served GET /items is agreement, not a mismatch.

Custom endpoints are the one place the check asks whether a method exists on the object, because there the answer means something: the base implements no custom actions, so whatever answers is something the ViewSet's own author wrote.

A custom endpoint is matched by the name of the BE method that serves it, which the schema states in the operation's x-viewset-action extension; the path is not read for it. The FE method is that name in camelCase or the name itself:

BE endpointBE method (x-viewset-action)FE method
GET /items/searchsearchsearch()
GET /items/top-ratedtop_ratedtopRated() or top_rated()
POST /items/{pk}/approveapproveapprove()

An operation without x-viewset-action is not checked.

Example output ​

text
[ViewSet /items] FE/BE definition mismatch:
  • declares 'create' but the BE viewset serves no such endpoint
  • declares 'update' but the BE viewset serves no such endpoint
  • BE serves 'GET /items/top-rated' but the ViewSet has no 'topRated()' or 'top_rated()' method

A ViewSet that declares nothing ​

It is not checked, and does not pay for the schema request: a ViewSet that never said what it has cannot be caught contradicting itself, and a guess on its behalf would report actions nobody claimed.

An empty list, static declares = [], is a statement: the ViewSet has no standard action. It is checked, so a standard endpoint the BE serves is reported, and every custom endpoint is matched against the ViewSet's methods. This is the declaration of a ViewSet with custom endpoints only; see Custom endpoints.

A subclass of RestProxyImpl may state a smaller list, which replaces the parent's by ordinary static lookup. TypeScript checks the subclass's static side against its base, so the new list's element type must still be assignable to the parent's — dropping a mixin is always fine, swapping in an unrelated one is TS2417:

ts
/** ItemViewSet declares [BulkViewSetMixin, LookupMixin]; this one drops the lookup. */
class ItemNoLookupViewSet extends ItemViewSet {
  static declares = [BulkViewSetMixin];
}

For a list the parent's type does not cover, declare that ViewSet separately — or use the factory, which narrows by being called again and has no such restriction:

ts
class ItemDbApi extends restViewSet<Item>()('id', [CursorListMixin]) {}

Set validateSchema: false to skip the check entirely.

Released under the MIT License.