react-resource-view
Types from OpenAPI
Export the schema your API publishes, and type the client and every resource from it.
One schema, one source of truth#
Every resource is typed — createViewResource<Article> — and the type has to come from somewhere. Written by hand, it is a copy of what the API returns, and a copy drifts: a field is renamed on the back, the interface still compiles, and the bug surfaces in a table cell.
// Copied by hand from the API — and wrong the day the API changes.
interface Article {
"@id": string
id: number
title: string
status: "draft" | "published"
}
const articles = createViewResource<Article>("articles", { … })Any API that publishes an OpenAPI schema already describes those types, field by field. The back describes the API; the front derives its types from that description, with api-dumper — which runs openapi-typescript under the hood — and a field renamed on the back becomes a compile error on the front.
The recipe is the same for any backend; only the URL of the schema changes. API Platform is the first example below, not a requirement.
From schema to types#
One dev dependency, one config file, one command. api-dumper downloads the schema, cleans it if asked, and generates the types and the enums from it:
pnpm add -D api-dumper typescriptimport { defineConfig } from "api-dumper"
export default defineConfig({
// Where the API publishes its schema — URL or file, JSON or YAML.
source: "http://localhost/api/docs.jsonopenapi",
outDir: "src/api-schema",
}){
"scripts": {
"api-schema": "api-dumper"
}
}pnpm run api-schema then leaves this behind, all of it to commit:
src/api-schema/
├── openapi.json # the schema, as it was read (and cleaned)
├── api-schema.ts # the types: paths, components, operations
└── enums/ # one file per enum of the schema
└── Gender.tsCommit openapi.json alongside the generated types. It is what the types were built from, a pull request that changes the API shows up as a diff in it, and the front builds without the API running. A file whose content has not changed is not rewritten, so a run against an unchanged API leaves the working tree clean.
Clean it, if needed
A schema describes every representation an operation can answer with — and openapi-typescript faithfully turns each of them into a member of a union. An API Platform collection that also exports to CSV answers application/ld+json or text/csv, and the typed response becomes a union of the two:
// The collection answers application/ld+json *or* text/csv…
const { data } = await client.GET("/api/articles")
// …so data is a union, and this no longer type-checks.
data?.memberIf the CSV is downloaded by a plain fetch rather than through the typed client — which is what react-resource-view's export button does — that content type is noise. Deleting it from the schema, before generating, keeps every response a single shape. strip.contentTypes removes it wherever it appears:
export default defineConfig({
source: "http://localhost/api/docs.jsonopenapi",
outDir: "src/api-schema",
// Removed from every request and response before the types are generated.
strip: { contentTypes: ["text/csv"] },
})What api-schema.ts exports
api-schema.ts exports three interfaces you will use:
| Name | Type | Default | Description |
|---|---|---|---|
paths | interface | — | Every route, its parameters and its responses — what a typed client is built on. |
components | interface | — | The reusable schemas, under components["schemas"] — the types your resources are. |
operations | interface | — | Each operation by its operationId — handy to type a filter form from a collection's query parameters. |
OpenAPI 3.0 or 3.1
api-dumper reads OpenAPI 3.0 and 3.1, and refuses a Swagger 2.0 document with an explicit error rather than generating broken types.
Wiring the types in#
On the client: paths
paths is what an OpenAPI-typed client takes — openapi-fetch, from the same authors, or one built on it:
import createClient from "openapi-fetch"
import type { paths } from "@/api-schema/api-schema"
export const client = createClient<paths>({ baseUrl: "https://api.example.com" })
// The path is checked against the schema, and data is typed from it.
const { data } = await client.GET("/api/articles/{id}", {
params: { path: { id: "42" } },
})On the resources: components
A resource's type parameter is the shape of one item. The schema has it, under components["schemas"]:
import { createViewResource } from "react-resource-view"
import type { components } from "@/api-schema/api-schema"
// "@id" is what createViewResource asks of an item — see the note below.
type Article = components["schemas"]["Article"] & { "@id": string }
export const articles = createViewResource<Article>("articles", {
name: "Articles",
path: "/api/articles",
// inputs, titleField, layout.board({ groupBy })… are checked against Article.
form: { inputs: { title: { label: "Title" } } },
})Why the "@id"
The item type of createViewResource has to carry an @id key — the library grew up on JSON-LD, where every item has one. API Platform's .jsonld schemas already include it, so there the generated type is used as is (see below). On a backend that sends no @id, add it to the type: it only satisfies the compiler, the dialect still addresses a row by its documentId or its primary key.
Next to the hand-written version, the declaration does not change — only where its type comes from:
// Before — an interface copied from the API, by hand.
interface Article {
"@id": string
id: number
title: string
}
createViewResource<Article>("articles", { … })
// After — the type the API itself declares.
type GeneratedArticle = components["schemas"]["Article"] & { "@id": string }
createViewResource<GeneratedArticle>("articles", { … })A one-line alias
components["schemas"]["…"] gets long by the third resource. Two aliases keep the autocompletion and drop the noise:
import type { components } from "@/api-schema/api-schema"
/** A schema of the API, by name — autocompleted, and a typo fails to compile. */
export type Schema<K extends keyof components["schemas"]> = components["schemas"][K]
/** The same schema as a resource item, with the "@id" createViewResource asks for. */
export type Item<K extends keyof components["schemas"]> = Schema<K> & { "@id": string }const articles = createViewResource<Item<"Article">>("articles", { … })
const authors = createViewResource<Item<"Author">>("authors", { … })Per backend#
What changes from one backend to the next is where the schema lives — and, sometimes, what the generated names look like.
API Platform
API Platform publishes an OpenAPI 3 document at /api/docs.jsonopenapi — the source the config above already points at. Add the api-platform preset:
import { defineConfig } from "api-dumper"
export default defineConfig({
source: "http://localhost/api/docs.jsonopenapi",
outDir: "src/api-schema",
preset: "api-platform",
strip: { contentTypes: ["text/csv"] },
})On top of the content type, the preset removes the schemas API Platform derives per format: stripping text/csv also drops Article.csv, Article.csv-read… which nothing references any more.
- The client. The JSON-LD dialect goes through the client of
jsonld-api-client(see installation). Build it once withpaths, and every call your own code makes through it is typed too:
import { createGenericClient } from "jsonld-api-client"
import type { paths } from "@/api-schema/api-schema"
// Typed against your schema, and the instance the JSON-LD dialect sends through.
export const client = createGenericClient<paths>({ baseUrl: "https://api.example.com" })- The schema names. API Platform emits one schema per format and per serialization group:
Article,Article.jsonld,Article.jsonld-read,Article.jsonMergePatch… The item a JSON-LD view receives is the.jsonldone, which carries@idand@type:
import type { components } from "@/api-schema/api-schema"
// The JSON-LD representation: the fields, plus @id and @type — nothing to add.
type Article = components["schemas"]["Article.jsonld"]
const articles = createViewResource<Article>("articles", { … })Exporting enums
OpenAPI types a field as an IRI string, but not the list of values it may take. API Platform can expose a PHP enum as a resource, and its collection then is that list:
#[ApiResource(operations: [new GetCollection(), new Get()])]
enum Gender: string
{
case MALE = 'MALE';
case FEMALE = 'FEMALE';
}The document does not carry those values: a relation to an enum is a plain iri-reference. Two ways to hand them to api-dumper:
1. From the document — recommended. Decorate API Platform's OpenApiFactory so that it adds, for each backed enum, a schema with enum, x-enum-name and x-enum-iris. Generation then works offline, from the exported file:
{
"Gender": {
"type": "string",
"enum": ["MALE", "FEMALE"],
"x-enum-name": "Gender",
"x-enum-iris": { "MALE": "/api/genders/MALE", "FEMALE": "/api/genders/FEMALE" }
}
}2. From the Hydra collections, fetched at generation time — member for the cases, @context for the name, @id for each case's IRI:
export default defineConfig({
source: "http://localhost/api/docs.jsonopenapi",
outDir: "src/api-schema",
preset: "api-platform",
apiPlatform: {
// The collections that list an enum's cases, fetched at generation time.
collections: { paths: ["/api/genders", "/api/article_statuses"] },
},
})Either way, each enum lands in its own file, under src/api-schema/enums/, and exports the cases, and three ways to name them:
| Name | Type | Default | Description |
|---|---|---|---|
XEnum | Record<case, value> | — | The enum's values, by case — for comparing, or a switch. |
XApiEnum | Record<case, IRI> | — | Each case's IRI — what a relation field holds, and what a select sends back. |
XValues | type | — | The union of the cases, to type a prop or a map. |
// src/api-schema/enums/Gender.ts — generated
export const Gender = ["MALE", "FEMALE"] as const
export const GenderEnum = { MALE: "MALE", FEMALE: "FEMALE" } as const
export const GenderApiEnum = { MALE: "/api/genders/MALE", FEMALE: "/api/genders/FEMALE" } as const
export type GenderValues = keyof typeof GenderEnum
// In the application
const genderOptions = [
{ label: "Male", value: GenderApiEnum.MALE },
{ label: "Female", value: GenderApiEnum.FEMALE },
]Only the IRIs are specific to API Platform
A named schema carrying the standard enum keyword becomes a file the same way, whatever the API — XEnum and XValues included. Only XApiEnum needs IRIs, from x-enum-iris or from the Hydra collections:
# Any OpenAPI 3 document — a named schema with the standard enum keyword
components:
schemas:
ArticleStatus:
type: string
enum: [draft, published]Strapi
Strapi 5 generates an OpenAPI 3.1 document from the command line, from the root of the Strapi project — no plugin needed:
# Strapi 5 — from the root of the Strapi project
npm run strapi openapi generate -- --output ./openapi.jsonPoint source at that file, or let api-dumper download it: Strapi does not serve it over HTTP by default, so opt in, and the document answers at /api/openapi.json:
// config/server.js — opt in to GET /api/openapi.json
module.exports = {
openapi: {
"content-api": { access: "public" },
},
}A public schema shows the whole Content API
Including the content types that are not publicly readable. If that is a concern, leave the endpoint off and copy the file the CLI generates into the front instead. Strapi 4 had no such command — the older Documentation plugin filled that role. See Strapi's OpenAPI page.
Supabase
Supabase's Data API — PostgREST — describes itself at the root, https://<project-ref>.supabase.co/rest/v1/, but two things keep it out of this recipe:
- the document is Swagger 2.0, which openapi-typescript 7 no longer reads;
- it only answers a secret key —
sb_secret_…or the legacyservice_role— and refuses the anon key.
Supabase's own generator reads the database instead, and is the documented way to type a Supabase project:
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.tsimport type { Tables } from "./database.types"
type Article = Tables<"articles"> & { "@id": string }
const articles = createViewResource<Article>("articles", {
path: "articles",
dialect: supabaseDialect({ apiKey }),
})Same idea, different tool
The types still come from the backend, still land in a committed file, and still get regenerated when the schema moves — only the command differs. See generating types in Supabase's documentation.
FastAPI
FastAPI publishes an OpenAPI 3.1 document at /openapi.json, generated from the routes and their Pydantic models — point source at it, on the running server or on a copy of the file:
export default defineConfig({
source: "http://localhost:8000/openapi.json",
outDir: "src/api-schema",
})Every Pydantic model becomes an entry of components["schemas"], named after its class — Item, ItemCreate, ItemUpdate. The records carry no @id, so intersect the schema with one:
import type { components } from "./api-schema"
// One Pydantic model, one schema — named after the class.
type Item = components["schemas"]["Item"] & { "@id": string }
const items = createViewResource<Item>("items", {
path: "items",
dialect: fastapiDialect(),
})Item-Input and Item-Output
When one model reads differently than it writes — a computed field, a default — Pydantic 2 publishes it twice, as Item-Input and Item-Output. Type the resource with the output one: it is what the list and the detail receive. The document only exists while openapi_url is left on; an application that turns it off in production can still generate from a local server.
Any other OpenAPI API
NestJS, Laravel, Spring, a hand-written spec: if it serves an OpenAPI 3.0 or 3.1 document, change source and nothing else. Keep strip if some content type turns responses into unions, drop it otherwise; the enums the schema declares come out without a preset.
When to regenerate#
Every time the API changes — a field, a route, a serialization group. Make it one command the whole team knows, whatever runs it:
api-schema:
pnpm run api-schema- run it against an API that has the change;
- commit
openapi.json,api-schema.tsandenums/with the change that needed them; - read the diff in review — it is the API's change, spelled out, and the compile errors it causes are the places the front has to follow.
And let the CI catch the regeneration someone forgot. --check writes nothing and exits with 1 when a generated file differs from what is committed:
# Writes nothing; exits 1 when the committed files are out of date.
pnpm exec api-dumper --checkIt reads source like any run, so point the CI at a schema it can reach — a file exported by the back and committed, with bin/console api:openapi:export on API Platform, or an API started in the job. Enums taken from the Hydra collections need that API too; enums carried by x-enum-iris do not.
A stale cache exports a stale schema
The schema is whatever the running API describes. On API Platform, its metadata cache can outlive a deploy and drop properties from the export without an error — clear the cache pools before exporting.