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 typescript
api-dumper.config.ts
import { 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",
})
package.json
{
  "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.ts

Commit 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?.member

If 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:

api-dumper.config.ts
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:

NameTypeDefaultDescription
pathsinterface—Every route, its parameters and its responses — what a typed client is built on.
componentsinterface—The reusable schemas, under components["schemas"] — the types your resources are.
operationsinterface—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:

client.ts
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"]:

resources/articles.ts
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:

api-schema/schema.ts
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:

api-dumper.config.ts
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 with paths, and every call your own code makes through it is typed too:
client.ts
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 .jsonld one, which carries @id and @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:

src/Entity/Gender.php
#[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:

components.schemas
{
  "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:

api-dumper.config.ts
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:

NameTypeDefaultDescription
XEnumRecord<case, value>—The enum's values, by case — for comparing, or a switch.
XApiEnumRecord<case, IRI>—Each case's IRI — what a relation field holds, and what a select sends back.
XValuestype—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.json

Point 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
// 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 legacy service_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.ts
import 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:

api-dumper.config.ts
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:

Makefile
api-schema:
	pnpm run api-schema
  • run it against an API that has the change;
  • commit openapi.json, api-schema.ts and enums/ 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 --check

It 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.