react-openapi
Auto-generates an admin panel (CRUD, datatable, relationship management) from an OpenAPI 3.1.0 specification at runtime.
How It Works
- You provide a
specConfigurationobject with the URL to your OpenAPI YAML spec and an auth callback. AppProviderfetches the spec, validates it againstreact-openapi's required extensions, and builds an internal resource configuration.- The
Admincomponent renders routes (list, detail, create, edit) for each resource detected in the spec. - All API calls go through an Axios client that injects the Bearer token from your callback.
Installation
npm install react-openapi
# peer dependencies (install these in your app):
npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
Quick Start
import { AppProvider, Admin } from "react-openapi";
import type { SpecConfiguration } from "react-openapi";
const specConfig: SpecConfiguration = {
specUrl: "/api/openapi.yaml",
baseApiUrl: "https://api.example.com/v1",
title: "Vet Clinic Admin",
getToken: () => localStorage.getItem("token"),
};
function App() {
return (
<AppProvider specConfiguration={specConfig}>
<Admin basePath="/admin" />
</AppProvider>
);
}
Exported API
Components
| Export | Purpose |
|---|---|
AppProvider |
Fetches the spec, validates it, initializes the API client, and provides context. Wrap your app with this. |
Admin |
Renders the admin layout (sidebar + routes) for all resources. Takes basePath. |
ListCellRenderer |
Renders a cell value in a list/table column. Respects FK display formats, inline refs, enums, booleans. |
DetailFieldRenderer |
Renders a read-only field value in a detail view (label + value pair). |
FormFieldRenderer |
Renders an editable form field based on FieldConfig. Dispatches to FkSelectField, BooleanField, etc. |
SseStreamView |
Renders a live SSE stream card with connection status, event counter, latest event preview, and snackbar. |
SseConnectionStatus |
Renders a green/red dot with "Connected"/"Disconnected" for an SSE resource. Reactive across all components. |
Hooks
| Export | Purpose |
|---|---|
useAppContext() |
Returns { config, resources, schemas, loading, errors, warnings }. Access all resource configs. |
useResource(resourceName) |
Returns { resource, components, list, get, create, update, remove, stream?, loading, error }. CRUD + streaming. |
The stream property is only present for SSE resources (resource.streaming === true). It accepts { onEvent, onError?, onOpen? } and returns { close }.
Utilities
| Export | Purpose |
|---|---|
getApi() |
Returns the configured Axios instance. Useful for custom API calls. |
applyDisplayFormat |
Formats an object using a template string like "{name} - #{id}". |
Types
| Export | Purpose |
|---|---|
SpecConfiguration |
{ specUrl, baseApiUrl?, title?, getToken? } |
ResourceConfig |
Internal representation of a resource after spec parsing |
FieldConfig |
Internal representation of a field after spec parsing |
FKFieldConfig |
{ resource, prefetch } |
ResourceRelationship |
{ fieldName, config, targetSchemaName } |
FilterComponentProps |
{ value, onChange, data?, labelOverride? } |
ResourceConfig Properties
| Property | Type | Description |
|---|---|---|
name |
string |
URL-friendly resource name (e.g. "pets", "calls") |
schemaName |
string |
OpenAPI schema key (e.g. "Pet", "Call") |
displayName |
string |
Human-readable name for UI labels (e.g. "Pets", "Calls", "Two Parts") |
path |
string |
API base path (e.g. "/pets", "/calls") |
streaming |
boolean (optional) |
true if this resource uses SSE streaming instead of REST CRUD |
SSE Streaming (Server-Sent Events)
Resources with x-sse: true on their OpenAPI spec path object are treated as streaming resources. Instead of CRUD, they expose:
stream()— accepts{ onEvent, onError?, onOpen? }and returns{ close }.- Connection status — reactive across
SseConnectionStatusandSseStreamView. - Auto-reconnect — SSE automatically reconnects on connection loss (browser-native).
OpenAPI extension:
/pets/events:
x-sse: true
get:
summary: Subscribe to Pet events
responses:
200:
description: SSE stream of Pet change events
content:
text/event-stream:
schema:
type: object
When x-sse: true is set, the resource is excluded from sidebars, list/detail routes, and CRUD hooks. It only appears in the streaming resource collection.
Custom UI example:
import { useAppContext, SseStreamView } from 'react-openapi';
function MyStreamPage() {
const { resources } = useAppContext();
const res = resources.find(r => r.name === 'calls');
if (!res) return null;
return <SseStreamView resource={res} />;
}
Loading Custom Components from Resources
Pass per-resource overrides via specConfiguration:
<AppProvider config={config} resourceComponents={{
pets: { list: PetList, detail: PetDetail, form: PetForm },
calls: { detail: CallStream }, // SSE: only detail is used
}} />
Within resource components, use components from useResource():
const { components } = useResource('pets');
const row = data.map(item => <components.ListCell field={field} value={item[field.name]} />);
OpenAPI Spec Authoring Guide
react-openapi uses custom x- extensions embedded in your OpenAPI 3.1.0 spec. This section is the single source of truth for writing a compatible spec.
Quick Reference
openapi: 3.1.0
info:
title: My API
version: 1.0.0
servers:
- url: https://api.example.com/v1
components:
schemas:
# --- Error schemas ---
ErrorBody:
type: object
properties:
detail:
type: string
required: [detail]
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
properties:
loc:
type: array
items:
type: string
msg:
type: string
type:
type: string
required: [loc, msg, type]
# --- Non-resource schema (skipped by validator) ---
Metadata:
type: object
properties:
createdOn:
type: string
format: date-time
updatedOn:
type: string
format: date-time
# --- Resource schema ---
Pet:
type: object
x-resource: pets # REQUIRED - maps to /pets path
x-primary-key: id # REQUIRED
x-display-format: "{name} - #{id}" # REQUIRED
x-list-columns: [name, species, age] # REQUIRED
properties:
id:
type: integer
readOnly: true
x-order: 0 # REQUIRED on every property
x-hidden: { form: true, list: true }
x-label: "ID" # REQUIRED on every property
name:
type: string
x-order: 1
x-label: "Pet Name"
x-description: "Name of the pet" # optional
x-filterable: true # optional
x-sortable: true # optional
species:
type: string
enum: [dog, cat, bird] # renders as select dropdown
x-order: 2
x-label: "Species"
weight:
type: number
format: float
x-order: 3
x-label: "Weight"
birthDate:
type: string
format: date # renders as date picker
x-order: 4
x-label: "Date of Birth"
photo:
type: string
format: binary
x-ui-type: image # renders as file upload
x-upload-url: /pets/{id}/photo # POST endpoint for upload
x-order: 5
x-label: "Photo"
owner:
$ref: '#/components/schemas/Parent'
x-fk: # renders as FK dropdown
resource: parents
prefetch: true # load all options on mount
x-order: 6
x-label: "Owner"
siblings:
type: array
items:
$ref: '#/components/schemas/Pet'
x-fk: # renders as multi-select
resource: pets
prefetch: false # lazy-load on focus
x-order: 7
x-label: "Siblings"
metadata:
$ref: '#/components/schemas/Metadata'
# NO x-fk — renders as inline read-only display
x-order: 8
x-label: "Metadata"
required: [id, name, owner]
# --- Error response schemas (REQUIRED for spec validity) ---
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
ValidationError:
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
InternalServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
paths:
# --- Collection paths ---
/pets:
get:
summary: List pets (paginated)
parameters:
- in: query
name: limit
schema: { type: integer, default: 20 } # default is REQUIRED if pagination params exist
- in: query
name: offset
schema: { type: integer, default: 0 }
responses:
'200':
description: Paginated list of pets
content:
application/json:
schema:
type: object
properties:
total:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/Pet'
# --- Error responses (RECOMMENDED on every operation) ---
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
post:
summary: Create a pet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'201':
description: Pet created
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
# --- Item paths ---
/pets/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Single pet
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
put:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'200':
description: Pet updated
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
delete:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'204':
description: Pet deleted
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
Schema-Level Extensions
These go on the schema object itself and mark it as a resource that generates the admin UI.
x-resource (REQUIRED)
Pet:
type: object
x-resource: pets # maps to the /pets path in paths
- Value must be a string matching the collection path segment (e.g.,
pets→/pets). - Schemas without
x-resourceare skipped entirely — no CRUD routes, no extension validation. Use this for shared schemas likeMetadata,ErrorBody, etc. - Validator error if the path
/petsdoes not exist inpaths.
x-primary-key (REQUIRED)
x-primary-key: id
- The name of the property that uniquely identifies each record.
- Used as the
:idpath parameter in routes and API calls. - Must reference a property that exists in
properties. - Validator error if missing.
x-display-format (REQUIRED)
x-display-format: "{name}"
x-display-format: "Dr. {name}"
x-display-format: "Apt #{id} - {date}"
- Template string with
{propertyName}placeholders. - Used to display a human-readable label for a record (list rows, detail title, FK dropdown options).
- Property names in
{braces}are replaced with the record's property values at render time. - Validator error if missing.
x-list-columns (REQUIRED)
x-list-columns: [name, species, age, owner]
- Array of property names to display as columns in the datatable.
- Each name must exist in
properties. - Column order follows the array order.
- The display value for each cell is determined by the
x-display-formatof the property's target resource (for FK fields) or the parent resource (for direct fields). - Validator error if missing or if any column name references a non-existent property.
Property-Level Extensions
These go on individual properties inside properties.
x-label (REQUIRED on every property)
x-label: "Pet Name"
- Human-readable label used in form fields, table headers, and detail views.
- Validator error if missing on any property of a resource schema.
x-order (REQUIRED on every property)
x-order: 1
- Integer determining field ordering in forms and detail views.
- Lower values appear first. Ties are broken by alphabetical property name.
- Validator error if missing or null on any property of a resource schema.
x-description (optional)
x-description: "Name of the pet"
- Shown as helper text below form fields and as placeholder text.
- Falls back to
x-labelif not provided.
x-hidden (optional)
x-hidden: { form: true, list: true, detail: true }
x-hidden: { form: true } # only hide in form
- Controls visibility in each view. Valid keys:
form,list,detail. - Any missing key defaults to
false(visible). - Useful for hiding auto-generated fields like
idfrom forms.
x-filterable (optional)
x-filterable: true
- Marks the field as filterable in the datatable (UI integration TBD — reserved for future use).
- Default:
false.
x-sortable (optional)
x-sortable: true
- Adds sort controls to the datatable column header.
- When clicked, sends
?sort=fieldName(ascending) or?sort=-fieldName(descending) to the list endpoint. - Default:
false.
x-fk (optional)
owner:
$ref: '#/components/schemas/Parent'
x-fk:
resource: parents
prefetch: true # load all options on mount
Marks a $ref property as a foreign key. The form renders a dropdown (single) or multi-select (if type: array, items: $ref).
| Sub-field | Type | Default | Description |
|---|---|---|---|
resource |
string | (required) | The x-resource value of the target schema. Uses the resource name, not the schema name. |
prefetch |
boolean | false |
If true, fetch all FK options on mount. If false, fetch on focus (lazy). |
How FK options are fetched:
- The library calls
GET /{targetResource}?limit=0(for paginated targets —limit=0is a server convention meaning "return all") orGET /{targetResource}(for non-paginated). - The response is parsed strictly by the target resource's pagination config.
- Each item is formatted using the target resource's
x-display-format.
Validator rules:
- The
resourcevalue must match some schema'sx-resourcein the spec. - The target schema must have
x-display-formatandx-primary-key.
$ref without x-fk — Inline Display
metadata:
$ref: '#/components/schemas/Metadata'
# no x-fk here
A $ref property without x-fk renders as read-only inline display:
- In list and detail views: uses
InlineRefFieldwhich shows key-value chips (or the referenced resource'sx-display-formatif the target is a resource schema). - In forms: shows a disabled
TextFieldwithJSON.stringify(value, null, 2).
The validator emits an info message for these to remind you they won't be editable.
x-ui-type (optional)
photo:
type: string
format: binary
x-ui-type: image
x-upload-url: /pets/{id}/photo
Currently, supports only "image". Changes the form field to a file upload component.
- If the record has an
idandx-upload-urlis set: POSTs the file tox-upload-url(with{id}replaced by the record's ID), then updates the field value with the response URL. - If no
idor nouploadUrl: falls back to base64 data-URL viaFileReader.
x-upload-url (optional, used with x-ui-type: image)
x-upload-url: /pets/{id}/photo
POST endpoint for file upload. The {id} placeholder is replaced with the current record ID.
Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer |
|---|---|---|
x-fk exists, type is NOT array |
FkSelectField (dropdown) |
ListCellRenderer with applyDisplayFormat |
x-fk exists, type: array |
FkMultiSelectField (Autocomplete multi) |
ListCellRenderer with chips |
enum is defined |
EnumField (select) |
Chip |
type: boolean |
BooleanField (Switch) |
Chip with "Yes"/"No" |
type: integer or type: number |
NumberField |
Typography |
format: date |
DateField (date picker) |
Typography |
format: date-time |
DateField (datetime-local picker) |
Typography |
x-ui-type: image |
ImageField (upload button + preview) |
Avatar |
$ref without x-fk |
Disabled TextField with JSON | InlineRefField (chips or displayFormat) |
| None of the above (default) | StringField (text input) |
Typography |
Path Conventions
Collection Path
Format: /{x-resource} (e.g., /pets)
| Operation | Required? | Purpose |
|---|---|---|
GET |
Yes | List endpoint — populates the datatable |
POST |
Yes | Create endpoint |
Item Path
Format: /{x-resource}/{id} (e.g., /pets/{id})
| Operation | Required? | Purpose |
|---|---|---|
GET |
Yes | Detail view |
PUT |
Yes | Edit form |
DELETE |
Yes | Delete action |
Response Shapes
The library expects strict response shapes that match the spec. No flexible fallbacks.
Paginated List (GET with limit/offset params)
{
"total": 42,
"items": [{ "id": 1, "name": "Fido" }, ...]
}
totalis optional; falls back toitems.lengthif absent.itemsis required and must be an array.- The library throws if the response is not
{ total, items }with an arrayitems.
Non-Paginated List (GET without limit/offset params)
[{ "id": 1, "name": "Vaccination" }, ...]
- Response must be a plain array.
- The library throws if the response is not an array.
Single Item (GET /{id})
{ "id": 1, "name": "Fido", ... }
- Returns the resource object directly.
- No special validation beyond standard API error handling.
Create / Update (POST / PUT)
{ "id": 1, "name": "Fido", ... }
- Returns the created/updated resource object directly.
Delete
- Returns HTTP 204 with no body.
Error Response Format
The library's parseError() handles two formats:
FastAPI ValidationError (422):
[
{ "loc": ["body", "name"], "msg": "field required", "type": "value_error" }
]
→ Rendered as: "field required"
Generic ErrorBody:
{ "detail": "Not found" }
→ Rendered as: "Not found"
Pagination Behavior
Detecting Pagination
A resource is considered paginated if its collection GET path has both a limit and offset query parameter. The limit parameter must have a schema.default value:
parameters:
- in: query
name: limit
schema: { type: integer, default: 20 } # default is REQUIRED
- in: query
name: offset
schema: { type: integer, default: 0 }
If only one of limit/offset is present, pagination is not detected (the path is treated as non-paginated).
Datatable Pagination
The datatable renders an MUI TablePagination component when resource.pagination is non-null. It sends ?limit={rowsPerPage}&offset={page * rowsPerPage} with each list request.
FK Options — Paginated Targets
When fetching FK options for a paginated target resource, the library sends ?limit=0. This is a server convention meaning "return all records" — not documented in the spec. The server must interpret limit=0 as "no limit."
Spec Validation
The validator (spec-validator.ts) runs at mount time and categorizes issues as:
| Level | Meaning | Impact |
|---|---|---|
error |
Missing required extension | Admin panel shows error screen — will not render |
warning |
Missing optional configuration | Admin panel renders, warning shown in snackbar |
info |
Informational note (e.g., non-FK $ref) |
Shown alongside warnings |
Complete Validation Rules
For each schema with x-resource:
x-primary-keymust be present.x-display-formatmust be present.x-list-columnsmust be an array, and every entry must reference a real property.- Every property must have
x-label. - Every property must have
x-order. - If a property uses
$refwithoutx-fk→infomessage. - If a property uses
x-fk, the referencedresourcemust exist as another schema'sx-resource, and that target must havex-display-formatandx-primary-key. - The
x-resourcepath must exist inpaths. - The collection path must have GET (list) and POST (create).
- The item path
/{resource}/{id}must exist with GET, PUT, and DELETE. - If the collection GET has pagination params (limit/offset), the
limitparam must have aschema.default.
Auth
Authentication is handled via the getToken callback in SpecConfiguration:
const specConfig: SpecConfiguration = {
specUrl: "/api/openapi.yaml",
getToken: () => localStorage.getItem("token"), // called on every request
};
- The token is injected as
Authorization: Bearer {token}on every API call. - If a 401 response is received and
getTokenreturns a token, the token is cleared from localStorage (for token refresh flows — customize in your wrapper).
Design Decisions
- Spec loaded at runtime — not built into the bundle. A single built UI works with any backend serving the spec at a known URL.
- Schema-to-path mapping is explicit — the
x-resourcevalue maps directly to the path segment. No magic pluralization or guessing. - No fallbacks for required extensions — if the spec is missing
x-label,x-order,x-primary-key,x-display-format, orx-list-columns, the library throws at transform time rather than silently defaulting. - FK options use
?limit=0for paginated resources — server convention, not spec-documented. - Response parsing is strict —
{ total, items }for paginated,[]for non-paginated. No fallback chains.
Example: Complete Minimal Spec
openapi: 3.1.0
info:
title: Minimal API
version: 1.0.0
servers:
- url: https://api.example.com/v1
components:
schemas:
ErrorBody:
type: object
properties:
detail: { type: string }
required: [detail]
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
properties:
loc:
type: array
items: { type: string }
msg: { type: string }
type: { type: string }
required: [loc, msg, type]
Widget:
type: object
x-resource: widgets
x-primary-key: id
x-display-format: "{name}"
x-list-columns: [name, status]
properties:
id:
type: integer
readOnly: true
x-order: 0
x-hidden: { form: true, list: true }
x-label: "ID"
name:
type: string
x-order: 1
x-label: "Widget Name"
status:
type: string
enum: [active, inactive, archived]
x-order: 2
x-label: "Status"
required: [id, name]
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
Forbidden:
description: Insufficient permissions
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
ValidationError:
description: Validation Error
content:
application/json:
schema: { $ref: '#/components/schemas/HTTPValidationError' }
InternalServerError:
description: Internal server error
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
paths:
/widgets:
get:
summary: List widgets
responses:
'200':
description: List of widgets
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
post:
summary: Create a widget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
responses:
'201':
description: Widget created
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
/widgets/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Single widget
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
put:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
responses:
'200':
description: Widget updated
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
delete:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'204':
description: Widget deleted
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }