react-resource-view

Backends & dialects

One declaration against API Platform, Spring Boot, Strapi, Supabase, FastAPI or Django — and how to add another.

What a dialect knows#

The views know a resource has rows, pages and filters. How a given backend spells those — the URL an item lives at, the query string a filter becomes, the envelope a collection arrives in, where the validation errors hide — is a dialect, set once at startup.

setup.ts
import { configureApi, strapiDialect } from "react-resource-view"

configureApi({
  baseUrl: "https://cms.example.com",
  getAuthToken: () => (isLogged() ? getUserToken() : undefined),
  dialect: strapiDialect(),
})
NameTypeDefaultDescription
jsonLdDialect()ApiDialectInterfacethe defaultAPI Platform and Hydra. member / totalItems, IRIs, page and itemsPerPage, Hydra violations, Mercure, CSV export.
strapiDialect()ApiDialectInterface—Strapi v4 and v5. pagination[page], filters[field][$eq], sort[0], populate, writes wrapped in data, documentId.
supabaseDialect()ApiDialectInterface—Supabase, over PostgREST. limit / offset, field=eq.value, order, the count read from Content-Range, rows addressed by their primary key.
fastapiDialect()ApiDialectInterface—FastAPI. skip / limit or page / size, plain query filters, order_by=-field, a bare array or { items, total }, 422 detail pinned on its field.
springDialect()ApiDialectInterface—Spring Boot. page (from 0) and size, sort=field,desc, a Page, a PagedModel or Spring Data REST's HAL, Bean Validation errors pinned on their field.
djangoDialect()ApiDialectInterface—Django REST Framework. page / page_size or limit / offset, django-filter query filters, ordering=-field, a { count, results } page or a bare array, field errors pinned on their input.

An API Platform application changes nothing

JSON-LD is the default, and it still goes through the client configured with configureClient — middleware, scope header and typed paths included. configureApi falls back to that client's settings when it is given none of its own, so nothing has to move.

Strapi#

setup.ts
import { configureApi, createViewResource, strapiDialect } from "react-resource-view"

configureApi({
  baseUrl: "https://cms.example.com",
  getAuthToken: () => getApiToken(),
  dialect: strapiDialect(),
})

const articles = createViewResource("articles", {
  path: "articles", // → /api/articles
  name: "Articles",
  form: { inputs: { title: { label: "Title" }, body: { label: "Body" } } },
  views: { list: { pageSize: 25 } },
})
NameTypeDefaultDescription
apiPathstring"/api"Prefix of the REST routes. A path that already carries it is left alone, so "articles" and "/api/articles" both work.
populatestring | string[] | false"*"Which relations come back. Without it the relation columns of a list are empty.
identifier"documentId" | "id""documentId"How an entry is addressed: documentId on v5, id on v4. A record without the preferred one falls back to the other.
defaultOperatorstring"$eq"What a plain filter value becomes. Pass "$containsi" to turn every text filter into a case-insensitive search.

The v4 { id, attributes } envelope — and the { data } wrapper around each relation — is flattened on the way in, so article.title and article.author.name read the same on both versions and a resource declared once works against either.

No CSV export

Strapi serves no CSV endpoint, so the export button of a list hides itself rather than offering a download that would 404. Nothing to configure.

Supabase#

setup.ts
import { configureApi, createViewResource, supabaseDialect } from "react-resource-view"

configureApi({
  baseUrl: "https://xyzcompany.supabase.co",
  getAuthToken: () => getSession()?.access_token,
  dialect: supabaseDialect({ apiKey: import.meta.env.VITE_SUPABASE_ANON_KEY }),
})

const articles = createViewResource("articles", {
  path: "articles", // → /rest/v1/articles
  name: "Articles",
  form: { inputs: { title: { label: "Title" } } },
})
NameTypeDefaultDescription
apiKeystring | (() => string | undefined)—The project's anon key, sent as apikey on every request alongside the signed-in user's token. A function is accepted, for a key that only exists once the environment is read.
primaryKeystring"id"How a row is addressed — PostgREST has no item route, so a single row is a filter on this column.
selectstring"*"Sent with every read. "*,author(*)" embeds a relation, the way Supabase joins.
schemastringpublicSent as Accept-Profile and Content-Profile, for a table outside public.
defaultTextOperator"eq" | "ilike" | "like""eq"What a plain text filter becomes. "ilike" turns the filter bar into a case-insensitive search bar.
restPathstring"/rest/v1"Prefix of the REST routes.

The count comes from a header

PostgREST counts only when asked, and answers in Content-Range. The dialect asks — Prefer: count=exact — and keeps the total in the collection it hands the views, since the header is long gone by the time the pagination renders. A list whose API reports no total renders no pagination rather than inventing a page count.

FastAPI#

setup.ts
import { configureApi, createViewResource, fastapiDialect } from "react-resource-view"

configureApi({
  baseUrl: "https://api.example.com",
  getAuthToken: () => getAccessToken(),
  dialect: fastapiDialect(),
})

const items = createViewResource("items", {
  path: "items", // → /items, /items/{id}
  name: "Items",
  form: { inputs: { title: { label: "Title" } } },
})
NameTypeDefaultDescription
primaryKeystring"id"The field a record is addressed by in /items/{id}. It is left out of the body of a write, since it travels in the URL.
pagination"skip-limit" | "page-size""skip-limit"?skip=20&limit=10, as in the FastAPI tutorial, or ?page=3&size=10, as fastapi-pagination reads it.
defaultItemsPerPagenumber30Rows per page when a list asks for none.
orderParamstring"order_by"The query parameter the sort travels in, as -created_at,title: a - in front of a descending field, several fields separated by commas.
trailingSlashbooleanfalseEnds the collection route with a slash — /items/ — when the API declares it so. FastAPI answers the other spelling with a 307 redirect.

A CRUD router written as in the FastAPI tutorial needs nothing more:

main.py
@app.get("/items", response_model=list[Item])
def list_items(skip: int = 0, limit: int = 30, order_by: str | None = None):
    return repository.list(offset=skip, limit=limit, order_by=order_by)

@app.get("/items/{item_id}", response_model=Item)
def read_item(item_id: int): ...

@app.post("/items", response_model=Item)
def create_item(item: ItemCreate): ...

@app.patch("/items/{item_id}", response_model=Item)
def update_item(item_id: int, item: ItemUpdate): ...

@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int): ...

A bare array carries no total

The tutorial's list answers a plain array, so the dialect knows the rows but not how many there are: the pagination hides itself rather than inventing a page count. Answer a { items, total } page — what fastapi-pagination returns — and it counts its pages from total.

main.py
from fastapi_pagination import Page, add_pagination, paginate

@app.get("/items", response_model=Page[Item])
def list_items():
    return paginate(repository.all())

add_pagination(app)

# and on the React side:
# dialect: fastapiDialect({ pagination: "page-size" })

A 422 lands under the field its loc names, the leading body, query or path dropped and the rest joined with dots. A detail spelled as a sentence — raise HTTPException(404, "Item not found") — becomes the explanation of the error.

// What FastAPI answers with a 422…
{ "detail": [{ "loc": ["body", "address", "city"], "msg": "Field required" }] }

// …lands under the input named "address.city".

Spring Boot#

setup.ts
import { configureApi, createViewResource, springDialect } from "react-resource-view"

configureApi({
  baseUrl: "http://localhost:8080",
  getAuthToken: () => getAccessToken(),
  dialect: springDialect(),
})

const books = createViewResource("books", {
  path: "/api/books", // → /api/books?page=0&size=20, /api/books/{id}
  name: "Books",
  form: { inputs: { title: { label: "Title" } } },
})
NameTypeDefaultDescription
primaryKeystring"id"The field a record is addressed by in /books/{id}. When Spring Data REST keeps it out of the body, it is read from the record's _links.self.href.
defaultItemsPerPagenumber20Rows per page when a list asks for none — Spring's own default.
updateMethod"PUT" | "PATCH""PUT"The verb an edit goes out with. PUT is what a @PutMapping answers; Spring Data REST takes both.

A controller taking a Pageable and returning a Page needs nothing more — the dialect reads content and totalElements, or the page block of a PagedModel and of HAL:

BookController.java
@RestController
@RequestMapping("/api/books")
class BookController {
    @GetMapping
    Page<Book> list(@RequestParam(required = false) String title, Pageable pageable) { … }

    @GetMapping("/{id}") Book read(@PathVariable Long id) { … }
    @PostMapping Book create(@Valid @RequestBody Book book) { … }
    @PutMapping("/{id}") Book update(@PathVariable Long id, @Valid @RequestBody Book book) { … }
    @DeleteMapping("/{id}") void delete(@PathVariable Long id) { … }
}

A failed @Valid lands each errors[].field on its input; a ProblemDetail gives the error its title and explanation. springdoc-openapi publishes the schema at /v3/api-docs, which is what the starter dumps.

Django#

setup.ts
import { configureApi, createViewResource, djangoDialect } from "react-resource-view"

configureApi({
  baseUrl: "http://localhost:8000",
  getAuthToken: () => getAccessToken(),
  dialect: djangoDialect(),
})

const articles = createViewResource("articles", {
  path: "/api/articles", // → /api/articles/?page=1&page_size=30, /api/articles/{id}/
  name: "Articles",
  form: { inputs: { title: { label: "Title" } } },
})
NameTypeDefaultDescription
primaryKeystring"id"The field a record is addressed by in /articles/{id}/. It is left out of the body of a write, since it travels in the URL.
pagination"page-number" | "limit-offset""page-number"?page=3&page_size=10, DRF's PageNumberPagination, or ?limit=10&offset=20, its LimitOffsetPagination.
pageSizeParamstring"page_size"The page size parameter of page-number. DRF only reads one when the paginator sets page_size_query_param.
defaultItemsPerPagenumber30Rows per page when a list asks for none.
orderParamstring"ordering"The query parameter the sort travels in, as -created_at,title: the one of DRF's OrderingFilter.
trailingSlashbooleantrueEnds every route with a slash — /articles/, /articles/1/ — as DRF's routers declare them. Django redirects the other spelling, and a redirect loses the body of a POST. Pass false for a router built with trailing_slash=False.

A ModelViewSet with django-filter and OrderingFilter needs nothing more:

views.py
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_fields = ["status", "author"]   # ?status=published
    ordering_fields = ["created_at", "title"] # ?ordering=-created_at

class ArticlePagination(PageNumberPagination):
    page_size = 30
    page_size_query_param = "page_size"        # ?page_size=10

router = DefaultRouter()                       # /api/articles/, /api/articles/{id}/
router.register("articles", ArticleViewSet)

Without pagination, no total

A view with no paginator answers a bare array: the dialect knows the rows but not how many there are, so the pagination hides itself. A paginated one answers { count, next, previous, results } and the page count comes from count. CursorPagination has no count either.

A 400 maps each field to its messages: a nested serializer gives address.city, a list of them lines.1.qty, and non_field_errors the form itself. A detail — a 401, a 403, a 404 — becomes the explanation of the error. The schema comes from drf-spectacular, at /api/schema/?format=json, and the starter dumps it. Writing Python on both sides? The Python guide sets FastAPI and Django side by side.

// What DRF answers with a 400…
{ "title": ["This field is required."], "address": { "city": ["Too short."] } }

// …lands under the inputs named "title" and "address.city".
// { "non_field_errors": [...] } blames the form, { "detail": "..." } explains the error.

Two backends at once#

A resource may carry a dialect of its own, which wins over the configured one:

// Most of the application is on Strapi…
configureApi({ baseUrl, dialect: strapiDialect() })

// …and this one table is not.
const invoices = createViewResource("invoices", {
  path: "invoices",
  dialect: supabaseDialect({ apiKey }),
})

The dialect is read when the resource is built

createViewResource builds the resource's repository as it runs, so configureApi has to come first — in a file your entry point imports before it declares any resource.

Filters, pages and sorts#

They are written once, in the package's own vocabulary, and the dialect translates them. Three keys are reserved; everything else in a filter is a field of the resource.

views: {
  list: {
    // → itemsPerPage
    pageSize: 25,
    // → order
    sort: { fields: ["createdAt", "title"], initial: { createdAt: "desc" } },
    // → a field of the resource
    filters: {
      inputs: { title: { label: "Title" } },
      initial: { status: "published" },
    },
  },
}
NameTypeDefaultDescription
pagenumber11-based page. pagination[page] on Strapi, an offset on Supabase, skip (or page) on FastAPI, page on API Platform and Django.
itemsPerPagenumberviews.list.pageSizeRows per page. pagination[pageSize] on Strapi, limit on Supabase, limit (or size) on FastAPI, page_size on Django. The page size a list declares travels with the request, so the pagination counts the rows the API actually returned.
orderRecord<string, "asc" | "desc">—The sort, field by field. sort[0]=title:asc on Strapi, order=title.asc on Supabase, order_by=title (-title descending) on FastAPI, ordering=-title on Django.

A field filter takes the shape its value has:

  • a scalar is an equality — filters[title][$eq], title=eq.hello, title=hello;
  • an array is “any of” — filters[status][$in], status=in.(draft,published), status=draft&status=published;
  • an object carries its own operator through untouched — on Strapi and Supabase; FastAPI reads flat query parameters, so its filters stay scalars and arrays.
// Strapi — any operator the REST API accepts
filters: { initial: { title: { $containsi: "hello" } } }

// Supabase — any PostgREST operator
filters: { initial: { createdAt: { gte: "2024-01-01" } } }

An empty value is left out of the request entirely: an empty search box widens the list rather than filtering it down to rows whose field is the empty string.

Errors

Whatever the backend called it — Hydra violations, a Strapi error.details.errors, a PostgREST message, a FastAPI 422 detail, a DRF field list — the dialect reads it into one shape, and the form pins each message on the field that caused it. A failure with no field to blame, such as a unique constraint, becomes the toast's description instead. See validation & API errors.

Another API entirely#

A dialect is one object, and ApiDialectInterface is exported to implement it:

myDialect.ts
import type { ApiDialectInterface } from "react-resource-view"

const myDialect: ApiDialectInterface = {
  name: "my-api",
  buildRequest: ({ name, path, id, filter, item }) => ({ url: "…", method: "GET" }),
  readCollection: (payload) => ({ items: payload.rows, totalItems: payload.count }),
  readItem: (payload) => payload.row,
  getId: (item) => item?.uuid,
  getIdentifier: (item) => item?.uuid,
  normalizeError: (payload, status) => ({ status, detail: payload.message }),
  referencesAreIris: false,
}

buildRequest is called with one of six operations — getCollection, getItem, createItem, updateItem, replaceItem, removeItem — and describes the request rather than sending it; the package's own repository sends it, carrying the base URL, the token and the headers. Two optional members go further: exportRequest lights up the CSV button, and realtimeTopic subscribes a list to a push channel.

A resource that brings its own repository: { getCollection, getItem, … } still bypasses all of this, as it always could — see declaring a resource.