updated sse supporting react-openapi
This commit is contained in:
862
react-openapi/README.md
Normal file
862
react-openapi/README.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# 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`. |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Export | Purpose |
|
||||
|-------------------------|---------------------------------------------------------------------------------------------------------|
|
||||
| `useAppContext()` | Returns `{ config, resources, loading, errors, warnings }`. Access all resource configs. |
|
||||
| `useResource(resource)` | Returns `{ list, get, create, update, remove, loading, error }` — CRUD methods for a specific resource. |
|
||||
|
||||
### 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 }` |
|
||||
|
||||
## 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' }
|
||||
```
|
||||
Reference in New Issue
Block a user