| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718 |
- import Link from "next/link";
- import { Code } from "@/components/code";
- export const metadata = {
- title: "OpenAPI Integration | json-render",
- };
- export default function OpenAPIPage() {
- return (
- <article>
- <h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
- <p className="text-muted-foreground mb-8">
- Use json-render to generate dynamic forms and UIs from{" "}
- <a
- href="https://swagger.io/specification/"
- target="_blank"
- rel="noopener noreferrer"
- className="text-foreground hover:underline"
- >
- OpenAPI/Swagger
- </a>{" "}
- schemas.
- </p>
- <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
- <p className="text-sm text-amber-700 dark:text-amber-300">
- <strong>Concept:</strong> This page demonstrates how json-render can
- support OpenAPI schemas. The examples are illustrative and may require
- adaptation for production use.
- </p>
- </div>
- <h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
- <p className="text-sm text-muted-foreground mb-4">
- OpenAPI specifications describe your API{"'"}s endpoints, request
- bodies, and response schemas. By converting OpenAPI schemas to
- json-render specs, you can:
- </p>
- <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
- <li>Automatically generate forms for API endpoints</li>
- <li>Display API responses with type-aware rendering</li>
- <li>Keep your UI in sync with your API schema</li>
- <li>Let AI generate UIs that match your API contracts</li>
- </ul>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Example OpenAPI Schema
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- A typical OpenAPI schema for a request body:
- </p>
- <Code lang="json">{`{
- "openapi": "3.0.0",
- "paths": {
- "/users": {
- "post": {
- "summary": "Create a new user",
- "operationId": "createUser",
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateUserRequest"
- }
- }
- }
- }
- }
- }
- },
- "components": {
- "schemas": {
- "CreateUserRequest": {
- "type": "object",
- "required": ["email", "name"],
- "properties": {
- "name": {
- "type": "string",
- "description": "User's full name",
- "minLength": 1,
- "maxLength": 100
- },
- "email": {
- "type": "string",
- "format": "email",
- "description": "User's email address"
- },
- "age": {
- "type": "integer",
- "minimum": 0,
- "maximum": 150,
- "description": "User's age"
- },
- "role": {
- "type": "string",
- "enum": ["admin", "user", "guest"],
- "default": "user",
- "description": "User's role"
- },
- "preferences": {
- "type": "object",
- "properties": {
- "newsletter": {
- "type": "boolean",
- "default": false
- },
- "theme": {
- "type": "string",
- "enum": ["light", "dark", "system"]
- }
- }
- }
- }
- }
- }
- }
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Define an OpenAPI-to-UI Catalog
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Create components that map to OpenAPI data types:
- </p>
- <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
- import { z } from 'zod';
- export const openapiCatalog = createCatalog({
- components: {
- // Form container
- Form: {
- description: 'API form container',
- props: z.object({
- operationId: z.string(),
- endpoint: z.string(),
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
- title: z.string().optional(),
- description: z.string().optional(),
- }),
- },
- // Field components mapped to OpenAPI types
- StringField: {
- description: 'String input field',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
- minLength: z.number().optional(),
- maxLength: z.number().optional(),
- pattern: z.string().optional(),
- placeholder: z.string().optional(),
- defaultValue: z.string().optional(),
- }),
- },
- NumberField: {
- description: 'Number input field',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- type: z.enum(['integer', 'number']).optional(),
- minimum: z.number().optional(),
- maximum: z.number().optional(),
- exclusiveMinimum: z.number().optional(),
- exclusiveMaximum: z.number().optional(),
- multipleOf: z.number().optional(),
- defaultValue: z.number().optional(),
- }),
- },
- BooleanField: {
- description: 'Boolean toggle field',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- defaultValue: z.boolean().optional(),
- }),
- },
- EnumField: {
- description: 'Enum selection field',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- options: z.array(z.object({
- value: z.string(),
- label: z.string().optional(),
- })),
- defaultValue: z.string().optional(),
- }),
- },
- ArrayField: {
- description: 'Array of items',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- minItems: z.number().optional(),
- maxItems: z.number().optional(),
- uniqueItems: z.boolean().optional(),
- }),
- },
- ObjectField: {
- description: 'Nested object group',
- props: z.object({
- name: z.string(),
- label: z.string(),
- description: z.string().optional(),
- collapsible: z.boolean().optional(),
- }),
- },
- // Response display components
- ResponseDisplay: {
- description: 'Displays API response',
- props: z.object({
- status: z.number(),
- statusText: z.string().optional(),
- }),
- },
- SchemaTable: {
- description: 'Displays data matching a schema',
- props: z.object({
- schema: z.string(),
- data: z.array(z.record(z.unknown())),
- }),
- },
- },
- actions: {
- submit: {
- description: 'Submit form to API endpoint',
- params: z.object({
- operationId: z.string(),
- }),
- },
- reset: {
- description: 'Reset form to defaults',
- params: z.object({}),
- },
- },
- });`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Convert OpenAPI Schema to Spec
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Transform OpenAPI schemas into json-render specs:
- </p>
- <Code lang="typescript">{`interface OpenAPISchema {
- type?: string;
- format?: string;
- enum?: string[];
- properties?: Record<string, OpenAPISchema>;
- items?: OpenAPISchema;
- required?: string[];
- description?: string;
- minimum?: number;
- maximum?: number;
- minLength?: number;
- maxLength?: number;
- default?: unknown;
- }
- interface SpecElement {
- key: string;
- type: string;
- props: Record<string, unknown>;
- children: string[];
- parentKey: string;
- }
- function schemaToSpec(
- schema: OpenAPISchema,
- name: string,
- required: string[] = [],
- parentKey: string = '',
- elements: Map<string, SpecElement> = new Map(),
- ): string {
- const key = parentKey ? \`\${parentKey}-\${name}\` : name;
- const isRequired = required.includes(name);
- const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
- if (schema.enum) {
- elements.set(key, {
- key,
- type: 'EnumField',
- props: {
- name,
- label,
- description: schema.description,
- required: isRequired,
- options: schema.enum.map(v => ({ value: v, label: v })),
- defaultValue: schema.default as string,
- },
- children: [],
- parentKey,
- });
- } else if (schema.type === 'string') {
- elements.set(key, {
- key,
- type: 'StringField',
- props: {
- name,
- label,
- description: schema.description,
- required: isRequired,
- format: schema.format || 'text',
- minLength: schema.minLength,
- maxLength: schema.maxLength,
- defaultValue: schema.default as string,
- },
- children: [],
- parentKey,
- });
- } else if (schema.type === 'integer' || schema.type === 'number') {
- elements.set(key, {
- key,
- type: 'NumberField',
- props: {
- name,
- label,
- description: schema.description,
- required: isRequired,
- type: schema.type,
- minimum: schema.minimum,
- maximum: schema.maximum,
- defaultValue: schema.default as number,
- },
- children: [],
- parentKey,
- });
- } else if (schema.type === 'boolean') {
- elements.set(key, {
- key,
- type: 'BooleanField',
- props: {
- name,
- label,
- description: schema.description,
- defaultValue: schema.default as boolean,
- },
- children: [],
- parentKey,
- });
- } else if (schema.type === 'array' && schema.items) {
- const childKeys: string[] = [];
- const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
- childKeys.push(itemKey);
- elements.set(key, {
- key,
- type: 'ArrayField',
- props: {
- name,
- label,
- description: schema.description,
- },
- children: childKeys,
- parentKey,
- });
- } else if (schema.type === 'object' && schema.properties) {
- const childKeys: string[] = [];
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
- const childKey = schemaToSpec(
- propSchema,
- propName,
- schema.required || [],
- key,
- elements,
- );
- childKeys.push(childKey);
- }
- elements.set(key, {
- key,
- type: 'ObjectField',
- props: {
- name,
- label,
- description: schema.description,
- },
- children: childKeys,
- parentKey,
- });
- }
- return key;
- }
- // Convert full OpenAPI operation to spec
- export function operationToSpec(
- operationId: string,
- method: string,
- path: string,
- schema: OpenAPISchema,
- title?: string,
- description?: string,
- ) {
- const elements = new Map<string, SpecElement>();
- const rootKey = 'form';
- const childKeys: string[] = [];
- if (schema.properties) {
- for (const [name, propSchema] of Object.entries(schema.properties)) {
- const childKey = schemaToSpec(
- propSchema,
- name,
- schema.required || [],
- rootKey,
- elements,
- );
- childKeys.push(childKey);
- }
- }
- elements.set(rootKey, {
- key: rootKey,
- type: 'Form',
- props: {
- operationId,
- endpoint: path,
- method: method.toUpperCase(),
- title,
- description,
- },
- children: childKeys,
- parentKey: '',
- });
- return {
- root: rootKey,
- elements: Object.fromEntries(elements),
- };
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Build an OpenAPI Form Renderer
- </h2>
- <Code lang="tsx">{`'use client';
- import React, { useState } from 'react';
- interface FieldProps {
- name: string;
- value: unknown;
- onChange: (name: string, value: unknown) => void;
- }
- const fields: Record<string, React.FC<any>> = {
- StringField: ({ name, label, description, required, format, value, onChange }) => (
- <div className="space-y-1">
- <label className="text-sm font-medium">
- {label} {required && <span className="text-red-500">*</span>}
- </label>
- {description && <p className="text-xs text-muted-foreground">{description}</p>}
- <input
- type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
- className="w-full px-3 py-2 border rounded text-sm"
- value={(value as string) || ''}
- onChange={(e) => onChange(name, e.target.value)}
- required={required}
- />
- </div>
- ),
- NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
- <div className="space-y-1">
- <label className="text-sm font-medium">
- {label} {required && <span className="text-red-500">*</span>}
- </label>
- {description && <p className="text-xs text-muted-foreground">{description}</p>}
- <input
- type="number"
- className="w-full px-3 py-2 border rounded text-sm"
- value={(value as number) ?? ''}
- min={minimum}
- max={maximum}
- onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
- required={required}
- />
- </div>
- ),
- BooleanField: ({ name, label, description, value, onChange }) => (
- <div className="flex items-start gap-2">
- <input
- type="checkbox"
- id={name}
- checked={Boolean(value)}
- onChange={(e) => onChange(name, e.target.checked)}
- className="mt-1"
- />
- <div>
- <label htmlFor={name} className="text-sm font-medium">{label}</label>
- {description && <p className="text-xs text-muted-foreground">{description}</p>}
- </div>
- </div>
- ),
- EnumField: ({ name, label, description, required, options, value, onChange }) => (
- <div className="space-y-1">
- <label className="text-sm font-medium">
- {label} {required && <span className="text-red-500">*</span>}
- </label>
- {description && <p className="text-xs text-muted-foreground">{description}</p>}
- <select
- className="w-full px-3 py-2 border rounded text-sm"
- value={(value as string) || ''}
- onChange={(e) => onChange(name, e.target.value)}
- required={required}
- >
- <option value="">Select...</option>
- {options?.map((opt: any) => (
- <option key={opt.value} value={opt.value}>
- {opt.label || opt.value}
- </option>
- ))}
- </select>
- </div>
- ),
- ObjectField: ({ name, label, description, children }) => (
- <fieldset className="border rounded p-4 space-y-4">
- <legend className="text-sm font-medium px-2">{label}</legend>
- {description && <p className="text-xs text-muted-foreground">{description}</p>}
- {children}
- </fieldset>
- ),
- Form: ({ title, description, endpoint, method, children, onSubmit }) => (
- <form
- className="space-y-4 max-w-md"
- onSubmit={(e) => {
- e.preventDefault();
- onSubmit?.();
- }}
- >
- {title && <h2 className="text-lg font-semibold">{title}</h2>}
- {description && <p className="text-sm text-muted-foreground">{description}</p>}
- {children}
- <button
- type="submit"
- className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
- >
- {method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
- </button>
- </form>
- ),
- };
- interface OpenAPIFormProps {
- spec: {
- root: string;
- elements: Record<string, any>;
- };
- onSubmit: (data: Record<string, unknown>) => void;
- }
- export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
- const [formData, setFormData] = useState<Record<string, unknown>>({});
- const handleChange = (name: string, value: unknown) => {
- setFormData(prev => ({ ...prev, [name]: value }));
- };
- function renderElement(key: string): React.ReactNode {
- const element = spec.elements[key];
- if (!element) return null;
- const Field = fields[element.type];
- if (!Field) return null;
- const children = element.children?.map(renderElement);
- return (
- <Field
- key={key}
- {...element.props}
- value={formData[element.props.name]}
- onChange={handleChange}
- onSubmit={() => onSubmit(formData)}
- >
- {children}
- </Field>
- );
- }
- return <>{renderElement(spec.root)}</>;
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
- <Code lang="tsx">{`'use client';
- import { OpenAPIForm } from './openapi-form';
- import { operationToSpec } from './openapi-to-spec';
- // Your OpenAPI schema (typically loaded from your API)
- const createUserSchema = {
- type: 'object',
- required: ['email', 'name'],
- properties: {
- name: { type: 'string', description: "User's full name" },
- email: { type: 'string', format: 'email', description: "User's email" },
- age: { type: 'integer', minimum: 0, maximum: 150 },
- role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
- },
- };
- // Convert to spec
- const spec = operationToSpec(
- 'createUser',
- 'POST',
- '/api/users',
- createUserSchema,
- 'Create User',
- 'Add a new user to the system',
- );
- export function CreateUserForm() {
- const handleSubmit = async (data: Record<string, unknown>) => {
- const response = await fetch('/api/users', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data),
- });
- if (response.ok) {
- console.log('User created!');
- }
- };
- return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
- }`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">
- Auto-generating from OpenAPI Document
- </h2>
- <p className="text-sm text-muted-foreground mb-4">
- Load and parse an OpenAPI document to generate forms for all operations:
- </p>
- <Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
- import { operationToSpec } from './openapi-to-spec';
- interface OpenAPIDocument {
- paths: Record<string, Record<string, {
- operationId?: string;
- summary?: string;
- description?: string;
- requestBody?: {
- content?: {
- 'application/json'?: {
- schema?: any;
- };
- };
- };
- }>>;
- components?: {
- schemas?: Record<string, any>;
- };
- }
- export async function loadOpenAPISpecs(specUrl: string) {
- const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
- const specs: Record<string, any> = {};
- for (const [path, methods] of Object.entries(api.paths)) {
- for (const [method, operation] of Object.entries(methods)) {
- if (!operation.requestBody?.content?.['application/json']?.schema) continue;
- const schema = operation.requestBody.content['application/json'].schema;
- const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
- specs[operationId] = operationToSpec(
- operationId,
- method,
- path,
- schema,
- operation.summary,
- operation.description,
- );
- }
- }
- return specs;
- }
- // Usage
- const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
- // specs.createUser, specs.updateUser, etc.`}</Code>
- <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
- <p className="text-sm text-muted-foreground">
- Learn about{" "}
- <Link
- href="/docs/streaming"
- className="text-foreground hover:underline"
- >
- streaming
- </Link>{" "}
- for progressive UI rendering.
- </p>
- </article>
- );
- }
|