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.
import { configureApi, strapiDialect } from "react-resource-view"
configureApi({
baseUrl: "https://cms.example.com",
getAuthToken: () => (isLogged() ? getUserToken() : undefined),
dialect: strapiDialect(),
})| Name | Type | Default | Description |
|---|---|---|---|
jsonLdDialect() | ApiDialectInterface | the default | API 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#
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 } },
})| Name | Type | Default | Description |
|---|---|---|---|
apiPath | string | "/api" | Prefix of the REST routes. A path that already carries it is left alone, so "articles" and "/api/articles" both work. |
populate | string | 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. |
defaultOperator | string | "$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#
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" } } },
})| Name | Type | Default | Description |
|---|---|---|---|
apiKey | string | (() => 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. |
primaryKey | string | "id" | How a row is addressed — PostgREST has no item route, so a single row is a filter on this column. |
select | string | "*" | Sent with every read. "*,author(*)" embeds a relation, the way Supabase joins. |
schema | string | public | Sent 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. |
restPath | string | "/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#
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" } } },
})| Name | Type | Default | Description |
|---|---|---|---|
primaryKey | string | "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. |
defaultItemsPerPage | number | 30 | Rows per page when a list asks for none. |
orderParam | string | "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. |
trailingSlash | boolean | false | Ends 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:
@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.
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#
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" } } },
})| Name | Type | Default | Description |
|---|---|---|---|
primaryKey | string | "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. |
defaultItemsPerPage | number | 20 | Rows 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:
@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#
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" } } },
})| Name | Type | Default | Description |
|---|---|---|---|
primaryKey | string | "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. |
pageSizeParam | string | "page_size" | The page size parameter of page-number. DRF only reads one when the paginator sets page_size_query_param. |
defaultItemsPerPage | number | 30 | Rows per page when a list asks for none. |
orderParam | string | "ordering" | The query parameter the sort travels in, as -created_at,title: the one of DRF's OrderingFilter. |
trailingSlash | boolean | true | Ends 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:
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" },
},
},
}| Name | Type | Default | Description |
|---|---|---|---|
page | number | 1 | 1-based page. pagination[page] on Strapi, an offset on Supabase, skip (or page) on FastAPI, page on API Platform and Django. |
itemsPerPage | number | views.list.pageSize | Rows 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. |
order | Record<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:
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.