page.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  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. type: string;
  261. props: Record<string, unknown>;
  262. children: string[];
  263. }
  264. function schemaToSpec(
  265. schema: OpenAPISchema,
  266. name: string,
  267. required: string[] = [],
  268. parentKey: string = '',
  269. elements: Map<string, SpecElement> = new Map(),
  270. ): string {
  271. const key = parentKey ? \`\${parentKey}-\${name}\` : name;
  272. const isRequired = required.includes(name);
  273. const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
  274. if (schema.enum) {
  275. elements.set(key, {
  276. type: 'EnumField',
  277. props: {
  278. name,
  279. label,
  280. description: schema.description,
  281. required: isRequired,
  282. options: schema.enum.map(v => ({ value: v, label: v })),
  283. defaultValue: schema.default as string,
  284. },
  285. children: [],
  286. });
  287. } else if (schema.type === 'string') {
  288. elements.set(key, {
  289. type: 'StringField',
  290. props: {
  291. name,
  292. label,
  293. description: schema.description,
  294. required: isRequired,
  295. format: schema.format || 'text',
  296. minLength: schema.minLength,
  297. maxLength: schema.maxLength,
  298. defaultValue: schema.default as string,
  299. },
  300. children: [],
  301. });
  302. } else if (schema.type === 'integer' || schema.type === 'number') {
  303. elements.set(key, {
  304. type: 'NumberField',
  305. props: {
  306. name,
  307. label,
  308. description: schema.description,
  309. required: isRequired,
  310. type: schema.type,
  311. minimum: schema.minimum,
  312. maximum: schema.maximum,
  313. defaultValue: schema.default as number,
  314. },
  315. children: [],
  316. });
  317. } else if (schema.type === 'boolean') {
  318. elements.set(key, {
  319. type: 'BooleanField',
  320. props: {
  321. name,
  322. label,
  323. description: schema.description,
  324. defaultValue: schema.default as boolean,
  325. },
  326. children: [],
  327. });
  328. } else if (schema.type === 'array' && schema.items) {
  329. const childKeys: string[] = [];
  330. const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
  331. childKeys.push(itemKey);
  332. elements.set(key, {
  333. type: 'ArrayField',
  334. props: {
  335. name,
  336. label,
  337. description: schema.description,
  338. },
  339. children: childKeys,
  340. });
  341. } else if (schema.type === 'object' && schema.properties) {
  342. const childKeys: string[] = [];
  343. for (const [propName, propSchema] of Object.entries(schema.properties)) {
  344. const childKey = schemaToSpec(
  345. propSchema,
  346. propName,
  347. schema.required || [],
  348. key,
  349. elements,
  350. );
  351. childKeys.push(childKey);
  352. }
  353. elements.set(key, {
  354. type: 'ObjectField',
  355. props: {
  356. name,
  357. label,
  358. description: schema.description,
  359. },
  360. children: childKeys,
  361. });
  362. }
  363. return key;
  364. }
  365. // Convert full OpenAPI operation to spec
  366. export function operationToSpec(
  367. operationId: string,
  368. method: string,
  369. path: string,
  370. schema: OpenAPISchema,
  371. title?: string,
  372. description?: string,
  373. ) {
  374. const elements = new Map<string, SpecElement>();
  375. const rootKey = 'form';
  376. const childKeys: string[] = [];
  377. if (schema.properties) {
  378. for (const [name, propSchema] of Object.entries(schema.properties)) {
  379. const childKey = schemaToSpec(
  380. propSchema,
  381. name,
  382. schema.required || [],
  383. rootKey,
  384. elements,
  385. );
  386. childKeys.push(childKey);
  387. }
  388. }
  389. elements.set(rootKey, {
  390. type: 'Form',
  391. props: {
  392. operationId,
  393. endpoint: path,
  394. method: method.toUpperCase(),
  395. title,
  396. description,
  397. },
  398. children: childKeys,
  399. });
  400. return {
  401. root: rootKey,
  402. elements: Object.fromEntries(elements),
  403. };
  404. }`}</Code>
  405. <h2 className="text-xl font-semibold mt-12 mb-4">
  406. Build an OpenAPI Form Renderer
  407. </h2>
  408. <Code lang="tsx">{`'use client';
  409. import React, { useState } from 'react';
  410. interface FieldProps {
  411. name: string;
  412. value: unknown;
  413. onChange: (name: string, value: unknown) => void;
  414. }
  415. const fields: Record<string, React.FC<any>> = {
  416. StringField: ({ name, label, description, required, format, value, onChange }) => (
  417. <div className="space-y-1">
  418. <label className="text-sm font-medium">
  419. {label} {required && <span className="text-red-500">*</span>}
  420. </label>
  421. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  422. <input
  423. type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
  424. className="w-full px-3 py-2 border rounded text-sm"
  425. value={(value as string) || ''}
  426. onChange={(e) => onChange(name, e.target.value)}
  427. required={required}
  428. />
  429. </div>
  430. ),
  431. NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
  432. <div className="space-y-1">
  433. <label className="text-sm font-medium">
  434. {label} {required && <span className="text-red-500">*</span>}
  435. </label>
  436. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  437. <input
  438. type="number"
  439. className="w-full px-3 py-2 border rounded text-sm"
  440. value={(value as number) ?? ''}
  441. min={minimum}
  442. max={maximum}
  443. onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
  444. required={required}
  445. />
  446. </div>
  447. ),
  448. BooleanField: ({ name, label, description, value, onChange }) => (
  449. <div className="flex items-start gap-2">
  450. <input
  451. type="checkbox"
  452. id={name}
  453. checked={Boolean(value)}
  454. onChange={(e) => onChange(name, e.target.checked)}
  455. className="mt-1"
  456. />
  457. <div>
  458. <label htmlFor={name} className="text-sm font-medium">{label}</label>
  459. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  460. </div>
  461. </div>
  462. ),
  463. EnumField: ({ name, label, description, required, options, value, onChange }) => (
  464. <div className="space-y-1">
  465. <label className="text-sm font-medium">
  466. {label} {required && <span className="text-red-500">*</span>}
  467. </label>
  468. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  469. <select
  470. className="w-full px-3 py-2 border rounded text-sm"
  471. value={(value as string) || ''}
  472. onChange={(e) => onChange(name, e.target.value)}
  473. required={required}
  474. >
  475. <option value="">Select...</option>
  476. {options?.map((opt: any) => (
  477. <option key={opt.value} value={opt.value}>
  478. {opt.label || opt.value}
  479. </option>
  480. ))}
  481. </select>
  482. </div>
  483. ),
  484. ObjectField: ({ name, label, description, children }) => (
  485. <fieldset className="border rounded p-4 space-y-4">
  486. <legend className="text-sm font-medium px-2">{label}</legend>
  487. {description && <p className="text-xs text-muted-foreground">{description}</p>}
  488. {children}
  489. </fieldset>
  490. ),
  491. Form: ({ title, description, endpoint, method, children, onSubmit }) => (
  492. <form
  493. className="space-y-4 max-w-md"
  494. onSubmit={(e) => {
  495. e.preventDefault();
  496. onSubmit?.();
  497. }}
  498. >
  499. {title && <h2 className="text-lg font-semibold">{title}</h2>}
  500. {description && <p className="text-sm text-muted-foreground">{description}</p>}
  501. {children}
  502. <button
  503. type="submit"
  504. className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
  505. >
  506. {method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
  507. </button>
  508. </form>
  509. ),
  510. };
  511. interface OpenAPIFormProps {
  512. spec: {
  513. root: string;
  514. elements: Record<string, any>;
  515. };
  516. onSubmit: (data: Record<string, unknown>) => void;
  517. }
  518. export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
  519. const [formData, setFormData] = useState<Record<string, unknown>>({});
  520. const handleChange = (name: string, value: unknown) => {
  521. setFormData(prev => ({ ...prev, [name]: value }));
  522. };
  523. function renderElement(key: string): React.ReactNode {
  524. const element = spec.elements[key];
  525. if (!element) return null;
  526. const Field = fields[element.type];
  527. if (!Field) return null;
  528. const children = element.children?.map(renderElement);
  529. return (
  530. <Field
  531. key={key}
  532. {...element.props}
  533. value={formData[element.props.name]}
  534. onChange={handleChange}
  535. onSubmit={() => onSubmit(formData)}
  536. >
  537. {children}
  538. </Field>
  539. );
  540. }
  541. return <>{renderElement(spec.root)}</>;
  542. }`}</Code>
  543. <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
  544. <Code lang="tsx">{`'use client';
  545. import { OpenAPIForm } from './openapi-form';
  546. import { operationToSpec } from './openapi-to-spec';
  547. // Your OpenAPI schema (typically loaded from your API)
  548. const createUserSchema = {
  549. type: 'object',
  550. required: ['email', 'name'],
  551. properties: {
  552. name: { type: 'string', description: "User's full name" },
  553. email: { type: 'string', format: 'email', description: "User's email" },
  554. age: { type: 'integer', minimum: 0, maximum: 150 },
  555. role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
  556. },
  557. };
  558. // Convert to spec
  559. const spec = operationToSpec(
  560. 'createUser',
  561. 'POST',
  562. '/api/users',
  563. createUserSchema,
  564. 'Create User',
  565. 'Add a new user to the system',
  566. );
  567. export function CreateUserForm() {
  568. const handleSubmit = async (data: Record<string, unknown>) => {
  569. const response = await fetch('/api/users', {
  570. method: 'POST',
  571. headers: { 'Content-Type': 'application/json' },
  572. body: JSON.stringify(data),
  573. });
  574. if (response.ok) {
  575. console.log('User created!');
  576. }
  577. };
  578. return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
  579. }`}</Code>
  580. <h2 className="text-xl font-semibold mt-12 mb-4">
  581. Auto-generating from OpenAPI Document
  582. </h2>
  583. <p className="text-sm text-muted-foreground mb-4">
  584. Load and parse an OpenAPI document to generate forms for all operations:
  585. </p>
  586. <Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
  587. import { operationToSpec } from './openapi-to-spec';
  588. interface OpenAPIDocument {
  589. paths: Record<string, Record<string, {
  590. operationId?: string;
  591. summary?: string;
  592. description?: string;
  593. requestBody?: {
  594. content?: {
  595. 'application/json'?: {
  596. schema?: any;
  597. };
  598. };
  599. };
  600. }>>;
  601. components?: {
  602. schemas?: Record<string, any>;
  603. };
  604. }
  605. export async function loadOpenAPISpecs(specUrl: string) {
  606. const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
  607. const specs: Record<string, any> = {};
  608. for (const [path, methods] of Object.entries(api.paths)) {
  609. for (const [method, operation] of Object.entries(methods)) {
  610. if (!operation.requestBody?.content?.['application/json']?.schema) continue;
  611. const schema = operation.requestBody.content['application/json'].schema;
  612. const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
  613. specs[operationId] = operationToSpec(
  614. operationId,
  615. method,
  616. path,
  617. schema,
  618. operation.summary,
  619. operation.description,
  620. );
  621. }
  622. }
  623. return specs;
  624. }
  625. // Usage
  626. const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
  627. // specs.createUser, specs.updateUser, etc.`}</Code>
  628. <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
  629. <p className="text-sm text-muted-foreground">
  630. Learn about{" "}
  631. <Link
  632. href="/docs/streaming"
  633. className="text-foreground hover:underline"
  634. >
  635. streaming
  636. </Link>{" "}
  637. for progressive UI rendering.
  638. </p>
  639. </article>
  640. );
  641. }