React Admin#
React-admin with
ra-data-simple-rest
expects routes that differ from the
default CRUD routes of Restly in four ways:
The list route reads three JSON-encoded query parameters:
sort,rangeandfilter.The list route returns a plain JSON array, not a
dataenvelope.The list response has a
Content-Rangeheader with the total number of rows.Updates use
PUT.
AsyncReactAdminView and
ReactAdminView serve these
routes, so ra-data-simple-rest works without a custom data provider. They
are a AsyncRestView and a
RestView with a different list
route and an added PUT route. Everything else is inherited: the other
routes, the business methods, the hooks, and the scope. So the rest of the
Guide applies to these views too.
Connect react-admin to a view#
Replace AsyncRestView with
AsyncReactAdminView, or
RestView with
ReactAdminView for sync
sessions. During development, the react-admin frontend runs on its own
origin, so the app also needs CORS middleware from the
start:
from contextlib import asynccontextmanager
import fastapi_restly as fr
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import DeclarativeBase, Mapped
fr.configure(async_database_url="sqlite+aiosqlite:///app.db")
class Base(fr.ModelDefaults, DeclarativeBase):
pass
class Product(fr.IDMixin, Base):
name: Mapped[str]
price: Mapped[float]
@asynccontextmanager
async def lifespan(_app: FastAPI):
await fr.db.async_create_all(Base) # dev tables; use Alembic in production
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # the react-admin dev server
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Range"],
)
@fr.include_view(app)
class ProductView(fr.AsyncReactAdminView):
prefix = "/products"
model = Product
On the frontend, point ra-data-simple-rest at your API:
import { Admin, Resource, ListGuesser } from "react-admin";
import simpleRestProvider from "ra-data-simple-rest";
const dataProvider = simpleRestProvider("http://localhost:8000");
export default () => (
<Admin dataProvider={dataProvider}>
<Resource name="products" list={ListGuesser} />
</Admin>
);
ListGuesser finds the list columns from the response. Replace it with your
own list component as the UI grows.
The shop example
runs this setup. Its API in
shop/main.py
has three resources with relationships, and products with UUID ids. Its
react-admin frontend is in
ui/test-admin/.
Its README shows how to run both.
What a react-admin view serves#
The list route: GET /resource#
The list route reads the three JSON-encoded query parameters of react-admin:
Query parameter |
Format |
Example |
|---|---|---|
|
JSON |
|
|
JSON |
|
|
JSON object |
|
These three are the whole contract.
The response body is a plain JSON array, and the Content-Range header
holds the total:
Content-Range: items 0-24/315
Details
Any other query key gets a 422, so a typo cannot return more rows than you
asked for without notice. The page, page_size and field filters of the
list params that Restly generates are unknown keys here too. A key that the
route declares, on the endpoint method or in a dependency, is accepted, and
so is an APIKeyQuery key; see
Accept extra query parameters.
To accept every key, set
unknown_query_params
to "ignore", as on the other views.
ra-data-simple-rest requests the collection path without a trailing slash
(GET /products?sort=...). This is the main path of a Restly collection, so
no redirect happens.
JSON that does not have the shape of sort, range or filter gets a
422 that names the place, as a bad value of other list params does.
React-admin uses the id array form of filter ({"id": [1, 2, 3]}) for
its getMany calls. It becomes WHERE id IN (1, 2, 3). Other filter values
must be equal to the column value. A value goes through the validation of
its field, as in the generated filters, so the validators of the field run
too. They get only this field, so info.data is {}; see
Filter values go through the field’s validation.
null finds the rows without a value when the field allows None, also in
a list: {"size": [null, 3]} finds the rows without a size and the rows
with size 3. A field on a JSON column takes only null, as in the
generated filters; see Filtering, Sorting & Pagination.
A time follows the same rule as in the other filters; see
Keep times in UTC.
Only fields that the view’s response class sends are accepted, by their
alias when they have one. The model must have the field in SQL: as a
column, a column_property or a hybrid property with an SQL expression.
Other fields, also a Python @property, a hybrid property that is plain
Python, and react-admin’s full-text search parameter q, get a 400.
Restly cannot see a value that a serializer hides, such as an email shown as
a***@example.com. A filter or a sort on such a field compares the stored
value, so a client can find the value out. Type such a field as a subclass
of pydantic.Secret with its own
_display(). Then it is no filter or sort key, as a SecretStr is not.
For a search on part of a value, or a full-text search, override the view’s
apply_filters. It gets
the list params with filter as a dict. Take your keys out, and pass the
rest on:
@fr.include_view(app)
class ProductView(fr.AsyncReactAdminView):
prefix = "/products"
model = Product
async def apply_filters(self, stmt, list_params):
filters = dict(list_params.filter or {})
q = filters.pop("q", None)
rest = list_params.model_copy(update={"filter": filters})
stmt = await super().apply_filters(stmt, rest)
if q:
stmt = stmt.where(Product.name.icontains(q, autoescape=True))
return stmt
The settings that choose the generated list params,
include_filters,
exclude_filters, include_sort_keys, exclude_sort_keys and
sort_query_param, and fr.list_filter,
do not apply to react-admin’s list params. A react-admin view that has one
fails when it registers. When it inherits a setting from a base view, it
sets the default again, such as exclude_filters = (), to show that the
setting does not apply.
The other routes#
Four of the other routes serve the other data provider calls of react-admin.
The standard PATCH route stays, for clients that send partial updates:
Method |
Path |
Purpose |
Source |
|---|---|---|---|
|
|
Get one (react-admin |
inherited from |
|
|
Create (react-admin |
inherited from |
|
|
Full update (react-admin |
added by |
|
|
Partial update |
inherited from |
|
|
Delete (react-admin |
inherited from |
The views add PUT /{id}, because the default update method of
ra-data-simple-rest sends a PUT request. React-admin sends the complete
record on update, with id and the read-only fields. The update schema drops
the fields that it does not accept, and does not reject the request.
Give PUT its own rules#
The PUT route calls the same
handle_update handler
as PATCH, with the view’s standard
schema_update
payload. So an update business
method that you override changes both routes. To give PUT its own rules,
replace its endpoint method put. This version
requires the complete record, while PATCH still accepts a part of it:
class ProductReplace(fr.BaseSchema):
name: str
price: float
@fr.include_view(app)
class ProductView(fr.AsyncReactAdminView):
prefix = "/products"
model = Product
@fr.put("/{id}")
async def put(self, id: int, payload: ProductReplace):
obj = await self.handle_update(id, payload)
return self.to_response(obj)
A PUT with a missing field returns 422. Keys that the schema does not
declare, such as the id that react-admin sends, are ignored.
Send to-many references as id arrays#
ra-data-simple-rest expects a to-many reference as a plain array of ids
("products": [1, 2, 3]). Declare the field as
list[fr.IDRef[Model]]. The shop
example does this for the products of an order:
class OrderSchema(fr.TimestampsSchemaMixin, fr.IDSchema):
customer_id: fr.MustExist[int, Customer]
products: list[fr.IDRef[Product]]
Reference several rows describes lists of references in full.
Set up CORS for the frontend#
During development, the react-admin app and the API run on different
origins, so the browser applies CORS to every request. Two things must be
true. The API must allow the origin of the frontend. And the Content-Range
header must be exposed to JavaScript, because browsers hide most response
headers from scripts. Add CORSMiddleware with Content-Range in
expose_headers to do both:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # your frontend origin
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Range"],
)
The react-admin views also set Access-Control-Expose-Headers: Content-Range
on their list responses, so pagination still works when expose_headers
lacks the entry. Do not depend on that. Middleware or a proxy that sets its
own Access-Control-Expose-Headers replaces the header of the view, and one
that removes it hides the total from the frontend. Declare the header in
configuration that you control. FastAPI’s
CORS guide explains the
middleware.
Fix a list that shows no rows#
A react-admin list can show “No Products found” while the API has rows. This is usually one of three configuration errors, and the browser console tells you which one. Open the console in the developer tools and find the error:
“The Content-Range header is missing in the HTTP Response” comes from
ra-data-simple-rest. The response reached the browser, but JavaScript
cannot read the Content-Range header. Add Content-Range to
expose_headers, as Set up CORS shows. Then look for
middleware or proxies that replace or remove Access-Control-Expose-Headers.
curl -i shows the header, but that proves nothing here: only what the
browser exposes to scripts matters.
“TypeError: Failed to fetch”, with a CORS policy error above it, means
that the browser blocked the request: the API sends no
Access-Control-Allow-Origin for the origin of the frontend. Add
CORSMiddleware with the frontend origin in allow_origins.
“422 (Unprocessable Content)” on the list request means that the route
rejected the query of react-admin, so the view is a plain
AsyncRestView: its list
params do not accept the JSON-encoded sort, range and filter
parameters. Change the view to
AsyncReactAdminView.
Change the defaults#
A few settings on the view change the defaults above, and a base class can share them between views.
Set the default page size#
When the frontend sends no range query parameter, the react-admin views
return the first 25 rows. This replaces the default
default_page_size
of 50 for other views. To choose another value, set it in the view’s
pagination:
@fr.include_view(app)
class ProductView(fr.AsyncReactAdminView):
prefix = "/products"
model = Product
pagination = fr.NumberedPagination(default_page_size=50)
A react-admin view uses two pagination settings: default_page_size, and
max_page_size, the most rows that one range may ask for. A wider
range, or one with a negative start or an end before its start, gets a
422. The view pages with range and reports the total in the
Content-Range header, so other settings, such as parameter names or an
envelope, raise an error when Python defines the view class. So does
pagination = None, because the header needs the total count.
Change the Content-Range unit#
ra-data-simple-rest ignores the unit in the header; it only reads the
numbers. If another client needs a unit, override
get_react_admin_range_unit:
class ProductView(fr.AsyncReactAdminView):
prefix = "/products"
model = Product
def get_react_admin_range_unit(self) -> str:
return "products"
Internals#
AsyncReactAdminView is a
small subclass of
AsyncRestView with
list params of its own. Its
schema_list_params
is ReactAdminListParams,
a Pydantic model with three fields: sort, range and filter. React-admin
sends each one as JSON. The fields are
pydantic.Json, so Pydantic parses the JSON.
The list route declares list_params, as every list route does. So OpenAPI
shows the three keys with the shape of their JSON, and an unknown key gets
422. A route of your own that declares list_params gets them too.
The views use the parsed values where they apply them: in
apply_list_params
(sort and range), in
apply_filters
(filter) and in
to_list_response
(a plain array body and Content-Range). An override of these methods gets
the same values: sort as ("name", "DESC"), range as (0, 24) and
filter as a dict. These views do not use the list params that Restly
generates, which Filtering, Sorting & Pagination
describes. They find each field of filter and sort with
fr.query.FilterKey and
fr.query.SortKey, which a view with
list params of its own can use too; see
Filter and sort on the fields of the response.
To add a query key, subclass ReactAdminListParams and set the subclass as
schema_list_params. A subclass of the view keeps it. List params that are
not a ReactAdminListParams fail when Python defines the view class.
The views replace the endpoint method
get_many_endpoint,
so that OpenAPI does not show the envelope as the response. It calls the
standard handle_get_many
and the get_many load. The
views also add a PUT /{id} route that calls the standard
handle_update handler.
The endpoint methods of the other CRUD routes (GET /{id}, POST /,
PATCH /{id}, DELETE /{id}) and the business methods for writes are
inherited without change.
The helpers that build the query and the response are internal.