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, range and filter.

  • The list route returns a plain JSON array, not a data envelope.

  • The list response has a Content-Range header 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

sort

JSON [field, direction]

sort=["name","ASC"]

range

JSON [start, end] (both included)

range=[0,24]

filter

JSON object

filter={"name":"foo"} or filter={"id":[1,2,3]}

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

/{id}

Get one (react-admin getOne)

inherited from AsyncRestView

POST

/

Create (react-admin create)

inherited from AsyncRestView

PUT

/{id}

Full update (react-admin update)

added by AsyncReactAdminView

PATCH

/{id}

Partial update

inherited from AsyncRestView

DELETE

/{id}

Delete (react-admin delete)

inherited from AsyncRestView

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"

Share these settings between views#

Put the shared settings in a base class of your project, and let your views inherit from it, as Share behavior with base views explains:

class ReactAdminBase(fr.AsyncReactAdminView):
    pagination = fr.NumberedPagination(default_page_size=100)

    def get_react_admin_range_unit(self) -> str:
        return "items"

@fr.include_view(app)
class ProductView(ReactAdminBase):
    prefix = "/products"
    model = Product

@fr.include_view(app)
class CustomerView(ReactAdminBase):
    prefix = "/customers"
    model = Customer

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.