943 lines
33 KiB
Markdown
943 lines
33 KiB
Markdown
# react-openapi
|
|
|
|
Auto-generates an admin panel (CRUD, datatable, relationship management) from an OpenAPI 3.1.0 specification at runtime.
|
|
|
|
## How It Works
|
|
|
|
1. You provide a `specConfiguration` object with the URL to your OpenAPI YAML spec and an auth callback.
|
|
2. `AppProvider` fetches the spec, validates it against ``react-openapi``'s required extensions, and builds an internal resource configuration.
|
|
3. The `Admin` component renders routes (list, detail, create, edit) for each resource detected in the spec.
|
|
4. All API calls go through an Axios client that injects the Bearer token from your callback.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
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
|
|
|
|
```tsx
|
|
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 `SseConnectionStatus` and `SseStreamView`.
|
|
- **Auto-reconnect** — SSE automatically reconnects on connection loss (browser-native).
|
|
|
|
**OpenAPI extension:**
|
|
```yaml
|
|
/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:**
|
|
|
|
```tsx
|
|
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`:
|
|
|
|
```tsx
|
|
<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()`:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
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-resource` are skipped entirely — no CRUD routes, no extension validation. Use this for shared schemas like `Metadata`, `ErrorBody`, etc.
|
|
- **Validator error** if the path `/pets` does not exist in `paths`.
|
|
|
|
#### `x-primary-key` (REQUIRED)
|
|
|
|
```yaml
|
|
x-primary-key: id
|
|
```
|
|
|
|
- The name of the property that uniquely identifies each record.
|
|
- Used as the `:id` path parameter in routes and API calls.
|
|
- Must reference a property that exists in `properties`.
|
|
- **Validator error** if missing.
|
|
|
|
#### `x-display-format` (REQUIRED)
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
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-format` of 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)
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
x-description: "Name of the pet"
|
|
```
|
|
|
|
- Shown as helper text below form fields and as placeholder text.
|
|
- Falls back to `x-label` if not provided.
|
|
|
|
#### `x-hidden` (optional)
|
|
|
|
```yaml
|
|
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 `id` from forms.
|
|
|
|
#### `x-filterable` (optional)
|
|
|
|
```yaml
|
|
x-filterable: true
|
|
```
|
|
|
|
- Marks the field as filterable in the datatable (UI integration TBD — reserved for future use).
|
|
- Default: `false`.
|
|
|
|
#### `x-sortable` (optional)
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```yaml
|
|
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:**
|
|
1. The library calls `GET /{targetResource}?limit=0` (for paginated targets — `limit=0` is a server convention meaning "return all") or `GET /{targetResource}` (for non-paginated).
|
|
2. The response is parsed strictly by the target resource's pagination config.
|
|
3. Each item is formatted using the target resource's `x-display-format`.
|
|
|
|
**Validator rules:**
|
|
- The `resource` value must match some schema's `x-resource` in the spec.
|
|
- The target schema must have `x-display-format` and `x-primary-key`.
|
|
|
|
#### `$ref` without `x-fk` — Inline Display
|
|
|
|
```yaml
|
|
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 `InlineRefField` which shows key-value chips (or the referenced resource's `x-display-format` if the target is a resource schema).
|
|
- In **forms**: shows a disabled `TextField` with `JSON.stringify(value, null, 2)`.
|
|
|
|
The validator emits an `info` message for these to remind you they won't be editable.
|
|
|
|
#### `x-ui-type` (optional)
|
|
|
|
```yaml
|
|
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 `id` and `x-upload-url` is set: POSTs the file to `x-upload-url` (with `{id}` replaced by the record's ID), then updates the field value with the response URL.
|
|
- If no `id` or no `uploadUrl`: falls back to base64 data-URL via `FileReader`.
|
|
|
|
#### `x-upload-url` (optional, used with `x-ui-type: image`)
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```json
|
|
{
|
|
"total": 42,
|
|
"items": [{ "id": 1, "name": "Fido" }, ...]
|
|
}
|
|
```
|
|
|
|
- `total` is optional; falls back to `items.length` if absent.
|
|
- `items` is **required** and must be an array.
|
|
- The library **throws** if the response is not `{ total, items }` with an array `items`.
|
|
|
|
#### Non-Paginated List (GET without limit/offset params)
|
|
|
|
```json
|
|
[{ "id": 1, "name": "Vaccination" }, ...]
|
|
```
|
|
|
|
- Response must be a **plain array**.
|
|
- The library **throws** if the response is not an array.
|
|
|
|
#### Single Item (GET /{id})
|
|
|
|
```json
|
|
{ "id": 1, "name": "Fido", ... }
|
|
```
|
|
|
|
- Returns the resource object directly.
|
|
- No special validation beyond standard API error handling.
|
|
|
|
#### Create / Update (POST / PUT)
|
|
|
|
```json
|
|
{ "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):**
|
|
```json
|
|
[
|
|
{ "loc": ["body", "name"], "msg": "field required", "type": "value_error" }
|
|
]
|
|
```
|
|
→ Rendered as: `"field required"`
|
|
|
|
**Generic `ErrorBody`:**
|
|
```json
|
|
{ "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:
|
|
|
|
```yaml
|
|
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`:
|
|
|
|
1. **`x-primary-key`** must be present.
|
|
2. **`x-display-format`** must be present.
|
|
3. **`x-list-columns`** must be an array, and every entry must reference a real property.
|
|
4. Every property must have **`x-label`**.
|
|
5. Every property must have **`x-order`**.
|
|
6. If a property uses `$ref` without `x-fk` → `info` message.
|
|
7. If a property uses `x-fk`, the referenced `resource` must exist as another schema's `x-resource`, and that target must have `x-display-format` and `x-primary-key`.
|
|
8. The `x-resource` path must exist in `paths`.
|
|
9. The collection path must have **GET** (list) and **POST** (create).
|
|
10. The item path `/{resource}/{id}` must exist with **GET**, **PUT**, and **DELETE**.
|
|
11. If the collection GET has pagination params (limit/offset), the `limit` param must have a `schema.default`.
|
|
|
|
## Auth
|
|
|
|
Authentication is handled via the `getToken` callback in `SpecConfiguration`:
|
|
|
|
```ts
|
|
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 `getToken` returns 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-resource` value 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`, or `x-list-columns`, the library **throws at transform time** rather than silently defaulting.
|
|
- **FK options use `?limit=0`** for 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
|
|
|
|
```yaml
|
|
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' }
|
|
```
|