page.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. import Link from "next/link";
  2. import { Code } from "@/components/code";
  3. export const metadata = {
  4. title: "OpenAPI Integration | json-render",
  5. };
  6. export default function OpenAPIPage() {
  7. return (
  8. <article>
  9. <h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
  10. <p className="text-muted-foreground mb-8">
  11. Use json-render to generate dynamic forms and UIs from{" "}
  12. <a
  13. href="https://swagger.io/specification/"
  14. target="_blank"
  15. rel="noopener noreferrer"
  16. className="text-foreground hover:underline"
  17. >
  18. OpenAPI/Swagger
  19. </a>{" "}
  20. schemas.
  21. </p>
  22. <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
  23. <p className="text-sm text-amber-700 dark:text-amber-300">
  24. <strong>Concept:</strong> This page demonstrates how json-render can
  25. support OpenAPI schemas. The examples are illustrative and may require
  26. adaptation for production use.
  27. </p>
  28. </div>
  29. <h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
  30. <p className="text-sm text-muted-foreground mb-4">
  31. OpenAPI specifications describe your API{"'"}s endpoints, request
  32. bodies, and response schemas. By converting OpenAPI schemas to
  33. json-render specs, you can:
  34. </p>
  35. <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
  36. <li>Automatically generate forms for API endpoints</li>
  37. <li>Display API responses with type-aware rendering</li>
  38. <li>Keep your UI in sync with your API schema</li>
  39. <li>Let AI generate UIs that match your API contracts</li>
  40. </ul>
  41. <h2 className="text-xl font-semibold mt-12 mb-4">
  42. Example OpenAPI Schema
  43. </h2>
  44. <p className="text-sm text-muted-foreground mb-4">
  45. A typical OpenAPI schema for a request body:
  46. </p>
  47. <Code lang="json">{`{
  48. "openapi": "3.0.0",
  49. "paths": {
  50. "/users": {
  51. "post": {
  52. "summary": "Create a new user",
  53. "operationId": "createUser",
  54. "requestBody": {
  55. "required": true,
  56. "content": {
  57. "application/json": {
  58. "schema": {
  59. "$ref": "#/components/schemas/CreateUserRequest"
  60. }
  61. }
  62. }
  63. }
  64. }
  65. }
  66. },
  67. "components": {
  68. "schemas": {
  69. "CreateUserRequest": {
  70. "type": "object",
  71. "required": ["email", "name"],
  72. "properties": {
  73. "name": {
  74. "type": "string",
  75. "description": "User's full name",
  76. "minLength": 1,
  77. "maxLength": 100
  78. },
  79. "email": {
  80. "type": "string",
  81. "format": "email",
  82. "description": "User's email address"
  83. },
  84. "age": {
  85. "type": "integer",
  86. "minimum": 0,
  87. "maximum": 150,
  88. "description": "User's age"
  89. },
  90. "role": {
  91. "type": "string",
  92. "enum": ["admin", "user", "guest"],
  93. "default": "user",
  94. "description": "User's role"
  95. },
  96. "preferences": {
  97. "type": "object",
  98. "properties": {
  99. "newsletter": {
  100. "type": "boolean",
  101. "default": false
  102. },
  103. "theme": {
  104. "type": "string",
  105. "enum": ["light", "dark", "system"]
  106. }
  107. }
  108. }
  109. }
  110. }
  111. }
  112. }
  113. }`}</Code>
  114. <h2 className="text-xl font-semibold mt-12 mb-4">
  115. Define an OpenAPI-to-UI Catalog
  116. </h2>
  117. <p className="text-sm text-muted-foreground mb-4">
  118. Create components that map to OpenAPI data types:
  119. </p>
  120. <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
  121. import { z } from 'zod';
  122. export const openapiCatalog = createCatalog({
  123. components: {
  124. // Form container
  125. Form: {
  126. description: 'API form container',
  127. props: z.object({
  128. operationId: z.string(),
  129. endpoint: z.string(),
  130. method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
  131. title: z.string().optional(),
  132. description: z.string().optional(),
  133. }),
  134. },
  135. // Field components mapped to OpenAPI types
  136. StringField: {
  137. description: 'String input field',
  138. props: z.object({
  139. name: z.string(),
  140. label: z.string(),
  141. description: z.string().optional(),
  142. required: z.boolean().optional(),
  143. format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
  144. minLength: z.number().optional(),
  145. maxLength: z.number().optional(),
  146. pattern: z.string().optional(),
  147. placeholder: z.string().optional(),
  148. defaultValue: z.string().optional(),
  149. }),
  150. },
  151. NumberField: {
  152. description: 'Number input field',
  153. props: z.object({
  154. name: z.string(),
  155. label: z.string(),
  156. description: z.string().optional(),
  157. required: z.boolean().optional(),
  158. type: z.enum(['integer', 'number']).optional(),
  159. minimum: z.number().optional(),
  160. maximum: z.number().optional(),
  161. exclusiveMinimum: z.number().optional(),
  162. exclusiveMaximum: z.number().optional(),
  163. multipleOf: z.number().optional(),
  164. defaultValue: z.number().optional(),
  165. }),
  166. },
  167. BooleanField: {
  168. description: 'Boolean toggle field',
  169. props: z.object({
  170. name: z.string(),
  171. label: z.string(),
  172. description: z.string().optional(),
  173. defaultValue: z.boolean().optional(),
  174. }),
  175. },
  176. EnumField: {
  177. description: 'Enum selection field',
  178. props: z.object({
  179. name: z.string(),
  180. label: z.string(),
  181. description: z.string().optional(),
  182. required: z.boolean().optional(),
  183. options: z.array(z.object({
  184. value: z.string(),
  185. label: z.string().optional(),
  186. })),
  187. defaultValue: z.string().optional(),
  188. }),
  189. },
  190. ArrayField: {
  191. description: 'Array of items',
  192. props: z.object({
  193. name: z.string(),
  194. label: z.string(),
  195. description: z.string().optional(),
  196. minItems: z.number().optional(),
  197. maxItems: z.number().optional(),
  198. uniqueItems: z.boolean().optional(),
  199. }),
  200. },
  201. ObjectField: {
  202. description: 'Nested object group',
  203. props: z.object({
  204. name: z.string(),
  205. label: z.string(),
  206. description: z.string().optional(),
  207. collapsible: z.boolean().optional(),
  208. }),
  209. },
  210. // Response display components
  211. ResponseDisplay: {
  212. description: 'Displays API response',
  213. props: z.object({
  214. status: z.number(),
  215. statusText: z.string().optional(),
  216. }),
  217. },
  218. SchemaTable: {
  219. description: 'Displays data matching a schema',
  220. props: z.object({
  221. schema: z.string(),
  222. data: z.array(z.record(z.unknown())),
  223. }),
  224. },
  225. },
  226. actions: {
  227. submit: {
  228. description: 'Submit form to API endpoint',
  229. params: z.object({
  230. operationId: z.string(),
  231. }),
  232. },
  233. reset: {
  234. description: 'Reset form to defaults',
  235. params: z.object({}),
  236. },
  237. },
  238. });`}</Code>
  239. <h2 className="text-xl font-semibold mt-12 mb-4">
  240. Convert OpenAPI Schema to Spec
  241. </h2>
  242. <p className="text-sm text-muted-foreground mb-4">
  243. Transform OpenAPI schemas into json-render specs:
  244. </p>
  245. <Code lang="typescript">{`interface OpenAPISchema {
  246. type?: string;
  247. format?: string;
  248. enum?: string[];
  249. properties?: Record<string, OpenAPISchema>;
  250. items?: OpenAPISchema;
  251. required?: string[];
  252. description?: string;
  253. minimum?: number;
  254. maximum?: number;
  255. minLength?: number;
  256. maxLength?: number;
  257. default?: unknown;
  258. }
  259. interface SpecElement {
  260. key: string;
  261. type: string;
  262. props: Record<string, unknown>;
  263. children: string[];
  264. parentKey: string;
  265. }
  266. function schemaToSpec(
  267. schema: OpenAPISchema,
  268. name: string,
  269. required: string[] = [],
  270. parentKey: string = '',
  271. elements: Map<string, SpecElement> = new Map(),
  272. ): string {
  273. const key = parentKey ? \`\${parentKey}-\${name}\` : name;
  274. const isRequired = required.includes(name);
  275. const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
  276. if (schema.enum) {
  277. elements.set(key, {
  278. key,
  279. type: 'EnumField',
  280. props: {
  281. name,
  282. label,
  283. description: schema.description,
  284. required: isRequired,
  285. options: schema.enum.map(v => ({ value: v, label: v })),
  286. defaultValue: schema.default as string,
  287. },
  288. children: [],
  289. parentKey,
  290. });
  291. } else if (schema.type === 'string') {
  292. elements.set(key, {
  293. key,
  294. type: 'StringField',
  295. props: {
  296. name,
  297. label,
  298. description: schema.description,
  299. required: isRequired,
  300. format: schema.format || 'text',
  301. minLength: schema.minLength,
  302. maxLength: schema.maxLength,
  303. defaultValue: schema.default as string,
  304. },
  305. children: [],
  306. parentKey,
  307. });
  308. } else if (schema.type === 'integer' || schema.type === 'number') {
  309. elements.set(key, {
  310. key,
  311. type: 'NumberField',
  312. props: {
  313. name,
  314. label,
  315. description: schema.description,
  316. required: isRequired,
  317. type: schema.type,
  318. minimum: schema.minimum,
  319. maximum: schema.maximum,
  320. defaultValue: schema.default as number,
  321. },
  322. children: [],
  323. parentKey,
  324. });
  325. } else if (schema.type === 'boolean') {
  326. elements.set(key, {
  327. key,
  328. type: 'BooleanField',
  329. props: {
  330. name,
  331. label,
  332. description: schema.description,
  333. defaultValue: schema.default as boolean,
  334. },
  335. children: [],
  336. parentKey,
  337. });
  338. } else if (schema.type === 'array' && schema.items) {
  339. const childKeys: string[] = [];
  340. const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
  341. childKeys.push(itemKey);
  342. elements.set(key, {
  343. key,
  344. type: 'ArrayField',
  345. props: {
  346. name,
  347. label,
  348. description: schema.description,
  349. },
  350. children: childKeys,
  351. parentKey,
  352. });
  353. } else if (schema.type === 'object' && schema.properties) {
  354. const childKeys: string[] = [];
  355. for (const [propName, propSchema] of Object.entries(schema.properties)) {
  356. const childKey = schemaToSpec(
  357. propSchema,
  358. propName,
  359. schema.required || [],
  360. key,
  361. elements,
  362. );
  363. childKeys.push(childKey);
  364. }
  365. elements.set(key, {
  366. key,
  367. type: 'ObjectField',
  368. props: {
  369. name,
  370. label,
  371. description: schema.description,
  372. },
  373. children: childKeys,
  374. parentKey,
  375. });
  376. }
  377. return key;
  378. }
  379. // Convert full OpenAPI operation to spec
  380. export function operationToSpec(
  381. operationId: string,
  382. method: string,
  383. path: string,
  384. schema: OpenAPISchema,
  385. title?: string,
  386. description?: string,
  387. ) {
  388. const elements = new Map<string, SpecElement>();
  389. const rootKey = 'form';
  390. const childKeys: string[] = [];
  391. if (schema.properties) {
  392. for (const [name, propSchema] of Object.entries(schema.properties)) {
  393. const childKey = schemaToSpec(
  394. propSchema,
  395. name,
  396. schema.required || [],
  397. rootKey,
  398. elements,
  399. );
  400. childKeys.push(childKey);
  401. }
  402. }
  403. elements.set(rootKey, {
  404. key: rootKey,
  405. type: 'Form',
  406. props: {
  407. operationId,
  408. endpoint: path,
  409. method: method.toUpperCase(),
  410. title,
  411. description,
  412. },
  413. children: childKeys,
  414. parentKey: '',
  415. });
  416. return {
  417. root: rootKey,
  418. elements: Object.fromEntries(elements),
  419. };
  420. }`}</Code>
  421. <h2 className="text-xl font-semibold mt-12 mb-4">
  422. Build an OpenAPI Form Renderer
  423. </h2>
  424. <Code lang="tsx">{`'use client';
  425. import React, { useState } from 'react';
  426. interface FieldProps {
  427. name: string;
  428. value: unknown;
  429. onChange: (name: string, value: unknown) => void;
  430. }
  431. const fields: Record<string, React.FC<any>> = {
  432. StringField: ({ name, label, description, required, format, value, onChange }) => (
  433. <div className="space-y-1">
  434. <label className="text-sm font-medium">
  435. {label} {required && <span className="text-red-500">*</span>}
  436. </label>
  437. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  438. <input
  439. type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
  440. className="w-full px-3 py-2 border rounded text-sm"
  441. value={(value as string) || ''}
  442. onChange={(e) => onChange(name, e.target.value)}
  443. required={required}
  444. />
  445. </div>
  446. ),
  447. NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
  448. <div className="space-y-1">
  449. <label className="text-sm font-medium">
  450. {label} {required && <span className="text-red-500">*</span>}
  451. </label>
  452. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  453. <input
  454. type="number"
  455. className="w-full px-3 py-2 border rounded text-sm"
  456. value={(value as number) ?? ''}
  457. min={minimum}
  458. max={maximum}
  459. onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
  460. required={required}
  461. />
  462. </div>
  463. ),
  464. BooleanField: ({ name, label, description, value, onChange }) => (
  465. <div className="flex items-start gap-2">
  466. <input
  467. type="checkbox"
  468. id={name}
  469. checked={Boolean(value)}
  470. onChange={(e) => onChange(name, e.target.checked)}
  471. className="mt-1"
  472. />
  473. <div>
  474. <label htmlFor={name} className="text-sm font-medium">{label}</label>
  475. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  476. </div>
  477. </div>
  478. ),
  479. EnumField: ({ name, label, description, required, options, value, onChange }) => (
  480. <div className="space-y-1">
  481. <label className="text-sm font-medium">
  482. {label} {required && <span className="text-red-500">*</span>}
  483. </label>
  484. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  485. <select
  486. className="w-full px-3 py-2 border rounded text-sm"
  487. value={(value as string) || ''}
  488. onChange={(e) => onChange(name, e.target.value)}
  489. required={required}
  490. >
  491. <option value="">Select...</option>
  492. {options?.map((opt: any) => (
  493. <option key={opt.value} value={opt.value}>
  494. {opt.label || opt.value}
  495. </option>
  496. ))}
  497. </select>
  498. </div>
  499. ),
  500. ObjectField: ({ name, label, description, children }) => (
  501. <fieldset className="border rounded p-4 space-y-4">
  502. <legend className="text-sm font-medium px-2">{label}</legend>
  503. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  504. {children}
  505. </fieldset>
  506. ),
  507. Form: ({ title, description, endpoint, method, children, onSubmit }) => (
  508. <form
  509. className="space-y-4 max-w-md"
  510. onSubmit={(e) => {
  511. e.preventDefault();
  512. onSubmit?.();
  513. }}
  514. >
  515. {title && <h2 className="text-lg font-semibold">{title}</h2>}
  516. {description && <p className="text-sm text-muted-foreground">{description}</p>}
  517. {children}
  518. <button
  519. type="submit"
  520. className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
  521. >
  522. {method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
  523. </button>
  524. </form>
  525. ),
  526. };
  527. interface OpenAPIFormProps {
  528. spec: {
  529. root: string;
  530. elements: Record<string, any>;
  531. };
  532. onSubmit: (data: Record<string, unknown>) => void;
  533. }
  534. export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
  535. const [formData, setFormData] = useState<Record<string, unknown>>({});
  536. const handleChange = (name: string, value: unknown) => {
  537. setFormData(prev => ({ ...prev, [name]: value }));
  538. };
  539. function renderElement(key: string): React.ReactNode {
  540. const element = spec.elements[key];
  541. if (!element) return null;
  542. const Field = fields[element.type];
  543. if (!Field) return null;
  544. const children = element.children?.map(renderElement);
  545. return (
  546. <Field
  547. key={key}
  548. {...element.props}
  549. value={formData[element.props.name]}
  550. onChange={handleChange}
  551. onSubmit={() => onSubmit(formData)}
  552. >
  553. {children}
  554. </Field>
  555. );
  556. }
  557. return <>{renderElement(spec.root)}</>;
  558. }`}</Code>
  559. <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
  560. <Code lang="tsx">{`'use client';
  561. import { OpenAPIForm } from './openapi-form';
  562. import { operationToSpec } from './openapi-to-spec';
  563. // Your OpenAPI schema (typically loaded from your API)
  564. const createUserSchema = {
  565. type: 'object',
  566. required: ['email', 'name'],
  567. properties: {
  568. name: { type: 'string', description: "User's full name" },
  569. email: { type: 'string', format: 'email', description: "User's email" },
  570. age: { type: 'integer', minimum: 0, maximum: 150 },
  571. role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
  572. },
  573. };
  574. // Convert to spec
  575. const spec = operationToSpec(
  576. 'createUser',
  577. 'POST',
  578. '/api/users',
  579. createUserSchema,
  580. 'Create User',
  581. 'Add a new user to the system',
  582. );
  583. export function CreateUserForm() {
  584. const handleSubmit = async (data: Record<string, unknown>) => {
  585. const response = await fetch('/api/users', {
  586. method: 'POST',
  587. headers: { 'Content-Type': 'application/json' },
  588. body: JSON.stringify(data),
  589. });
  590. if (response.ok) {
  591. console.log('User created!');
  592. }
  593. };
  594. return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
  595. }`}</Code>
  596. <h2 className="text-xl font-semibold mt-12 mb-4">
  597. Auto-generating from OpenAPI Document
  598. </h2>
  599. <p className="text-sm text-muted-foreground mb-4">
  600. Load and parse an OpenAPI document to generate forms for all operations:
  601. </p>
  602. <Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
  603. import { operationToSpec } from './openapi-to-spec';
  604. interface OpenAPIDocument {
  605. paths: Record<string, Record<string, {
  606. operationId?: string;
  607. summary?: string;
  608. description?: string;
  609. requestBody?: {
  610. content?: {
  611. 'application/json'?: {
  612. schema?: any;
  613. };
  614. };
  615. };
  616. }>>;
  617. components?: {
  618. schemas?: Record<string, any>;
  619. };
  620. }
  621. export async function loadOpenAPISpecs(specUrl: string) {
  622. const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
  623. const specs: Record<string, any> = {};
  624. for (const [path, methods] of Object.entries(api.paths)) {
  625. for (const [method, operation] of Object.entries(methods)) {
  626. if (!operation.requestBody?.content?.['application/json']?.schema) continue;
  627. const schema = operation.requestBody.content['application/json'].schema;
  628. const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
  629. specs[operationId] = operationToSpec(
  630. operationId,
  631. method,
  632. path,
  633. schema,
  634. operation.summary,
  635. operation.description,
  636. );
  637. }
  638. }
  639. return specs;
  640. }
  641. // Usage
  642. const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
  643. // specs.createUser, specs.updateUser, etc.`}</Code>
  644. <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
  645. <p className="text-sm text-muted-foreground">
  646. Learn about{" "}
  647. <Link
  648. href="/docs/streaming"
  649. className="text-foreground hover:underline"
  650. >
  651. streaming
  652. </Link>{" "}
  653. for progressive UI rendering.
  654. </p>
  655. </article>
  656. );
  657. }