finished new customer routes
This commit is contained in:
254
packages/openapi/utils/apiReferenceGenerator/generateFields.ts
Normal file
254
packages/openapi/utils/apiReferenceGenerator/generateFields.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { ParsedOperation, SchemaField } from "./parseOpenApi.js";
|
||||
|
||||
/**
|
||||
* Generate the MDX content for request body and response fields.
|
||||
*/
|
||||
export function generateFields({
|
||||
operation,
|
||||
}: {
|
||||
operation: ParsedOperation;
|
||||
}): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// Generate request body parameters
|
||||
if (operation.requestBody && operation.requestBody.length > 0) {
|
||||
sections.push("### Body Parameters\n");
|
||||
sections.push(
|
||||
generateParamFields({ fields: operation.requestBody, indent: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
// Generate response fields (use 200 or 201 response)
|
||||
const responseStatusCode = operation.responses?.["200"]
|
||||
? "200"
|
||||
: operation.responses?.["201"]
|
||||
? "201"
|
||||
: null;
|
||||
const responseFields = responseStatusCode
|
||||
? operation.responses?.[responseStatusCode]
|
||||
: null;
|
||||
|
||||
if (responseFields && responseFields.length > 0) {
|
||||
sections.push("\n### Response\n");
|
||||
sections.push(
|
||||
generateResponseFields({ fields: responseFields, indent: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
// Generate DynamicResponseExample with the actual example from OpenAPI spec
|
||||
// This example is already in snake_case - the component will convert to camelCase when needed
|
||||
const responseExample = responseStatusCode
|
||||
? operation.responseExamples?.[responseStatusCode]
|
||||
: null;
|
||||
|
||||
if (responseExample && typeof responseExample === "object") {
|
||||
sections.push(
|
||||
generateResponseExampleMarkdown({
|
||||
json: responseExample,
|
||||
statusCode: responseStatusCode!,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a ResponseExample with markdown code block.
|
||||
* Uses Mintlify's ResponseExample component which pins content to the sidebar.
|
||||
*/
|
||||
function generateResponseExampleMarkdown({
|
||||
json,
|
||||
statusCode,
|
||||
}: {
|
||||
json: unknown;
|
||||
statusCode: string;
|
||||
}): string {
|
||||
// Format the JSON with proper indentation
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
|
||||
// Generate markdown ResponseExample block
|
||||
// The triple backticks create a code block inside ResponseExample
|
||||
return `
|
||||
<ResponseExample>
|
||||
\`\`\`json ${statusCode}
|
||||
${jsonString}
|
||||
\`\`\`
|
||||
</ResponseExample>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DynamicParamField components for request body fields.
|
||||
*/
|
||||
function generateParamFields({
|
||||
fields,
|
||||
indent,
|
||||
}: {
|
||||
fields: SchemaField[];
|
||||
indent: number;
|
||||
}): string {
|
||||
const indentStr = " ".repeat(indent);
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const props = buildFieldProps({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
enumValues: field.enumValues,
|
||||
});
|
||||
|
||||
const description = escapeDescription(field.description);
|
||||
const hasChildren = field.children && field.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
// Field with nested children
|
||||
lines.push(`${indentStr}<DynamicParamField ${props}>`);
|
||||
if (description) {
|
||||
lines.push(`${indentStr} ${description}`);
|
||||
}
|
||||
lines.push(`${indentStr} <Expandable title="properties">`);
|
||||
lines.push(
|
||||
generateParamFields({ fields: field.children!, indent: indent + 2 }),
|
||||
);
|
||||
lines.push(`${indentStr} </Expandable>`);
|
||||
lines.push(`${indentStr}</DynamicParamField>\n`);
|
||||
} else {
|
||||
// Simple field
|
||||
if (description) {
|
||||
lines.push(`${indentStr}<DynamicParamField ${props}>`);
|
||||
lines.push(`${indentStr} ${description}`);
|
||||
lines.push(`${indentStr}</DynamicParamField>\n`);
|
||||
} else {
|
||||
lines.push(`${indentStr}<DynamicParamField ${props} />\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DynamicResponseField components for response fields.
|
||||
*/
|
||||
function generateResponseFields({
|
||||
fields,
|
||||
indent,
|
||||
}: {
|
||||
fields: SchemaField[];
|
||||
indent: number;
|
||||
}): string {
|
||||
const indentStr = " ".repeat(indent);
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const props = buildResponseFieldProps({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
enumValues: field.enumValues,
|
||||
});
|
||||
|
||||
const description = escapeDescription(field.description);
|
||||
const hasChildren = field.children && field.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
// Field with nested children
|
||||
lines.push(`${indentStr}<DynamicResponseField ${props}>`);
|
||||
if (description) {
|
||||
lines.push(`${indentStr} ${description}`);
|
||||
}
|
||||
lines.push(`${indentStr} <Expandable title="properties">`);
|
||||
lines.push(
|
||||
generateResponseFields({ fields: field.children!, indent: indent + 2 }),
|
||||
);
|
||||
lines.push(`${indentStr} </Expandable>`);
|
||||
lines.push(`${indentStr}</DynamicResponseField>\n`);
|
||||
} else {
|
||||
// Simple field
|
||||
if (description) {
|
||||
lines.push(`${indentStr}<DynamicResponseField ${props}>`);
|
||||
lines.push(`${indentStr} ${description}`);
|
||||
lines.push(`${indentStr}</DynamicResponseField>\n`);
|
||||
} else {
|
||||
lines.push(`${indentStr}<DynamicResponseField ${props} />\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the props string for a DynamicParamField component.
|
||||
*/
|
||||
function buildFieldProps({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
enumValues,
|
||||
}: {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
enumValues?: string[];
|
||||
}): string {
|
||||
const props: string[] = [
|
||||
`body="${name}"`,
|
||||
`type="${formatType(type, enumValues)}"`,
|
||||
];
|
||||
|
||||
if (required) {
|
||||
props.push("required");
|
||||
}
|
||||
|
||||
return props.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the props string for a DynamicResponseField component.
|
||||
*/
|
||||
function buildResponseFieldProps({
|
||||
name,
|
||||
type,
|
||||
enumValues,
|
||||
}: {
|
||||
name: string;
|
||||
type: string;
|
||||
enumValues?: string[];
|
||||
}): string {
|
||||
return `name="${name}" type="${formatType(type, enumValues)}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the type string, including enum values if present.
|
||||
*/
|
||||
function formatType(type: string, enumValues?: string[]): string {
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
// Show enum values inline if there are few, otherwise just show "enum"
|
||||
if (enumValues.length <= 5) {
|
||||
return enumValues.map((v) => `'${v}'`).join(" | ");
|
||||
}
|
||||
return "enum";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in description for MDX.
|
||||
*/
|
||||
function escapeDescription(description?: string): string {
|
||||
if (!description) return "";
|
||||
|
||||
return (
|
||||
description
|
||||
// Escape curly braces for JSX
|
||||
.replace(/\{/g, "\\{")
|
||||
.replace(/\}/g, "\\}")
|
||||
// Remove markdown code blocks that might cause issues
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
// Normalize whitespace
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
82
packages/openapi/utils/apiReferenceGenerator/index.ts
Normal file
82
packages/openapi/utils/apiReferenceGenerator/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { generateFields } from "./generateFields.js";
|
||||
import { mergeMdx } from "./mergeMdx.js";
|
||||
import { parseOpenApi } from "./parseOpenApi.js";
|
||||
|
||||
export interface GenerateApiReferenceOptions {
|
||||
openApiPath: string;
|
||||
manualMdxDir: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API reference MDX files from an OpenAPI spec.
|
||||
*
|
||||
* For each operation in the OpenAPI spec:
|
||||
* 1. Parse request body and response schemas
|
||||
* 2. Generate DynamicParamField/DynamicResponseField components
|
||||
* 3. Merge with manual MDX content (if exists)
|
||||
* 4. Write to output directory: {outputDir}/{tag}/{operationId}.mdx
|
||||
*/
|
||||
export async function generateApiReference({
|
||||
openApiPath,
|
||||
manualMdxDir,
|
||||
outputDir,
|
||||
}: GenerateApiReferenceOptions): Promise<void> {
|
||||
console.log(` Reading OpenAPI spec from: ${openApiPath}`);
|
||||
|
||||
// Parse OpenAPI spec
|
||||
const operations = parseOpenApi({ openApiPath });
|
||||
console.log(` Found ${operations.length} operations`);
|
||||
|
||||
let generated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const operation of operations) {
|
||||
const { tag, operationId } = operation;
|
||||
|
||||
// Determine file paths
|
||||
const manualMdxPath = path.join(manualMdxDir, tag, `${operationId}.mdx`);
|
||||
const outputPath = path.join(outputDir, tag, `${operationId}.mdx`);
|
||||
|
||||
// Check if output file already exists and there's no manual MDX
|
||||
// In that case, skip to avoid overwriting existing content
|
||||
if (existsSync(outputPath) && !existsSync(manualMdxPath)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate fields MDX
|
||||
const generatedContent = generateFields({ operation });
|
||||
|
||||
// Skip if no content was generated
|
||||
if (!generatedContent.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge with manual MDX (if exists)
|
||||
const finalMdx = mergeMdx({
|
||||
manualMdxPath,
|
||||
generatedContent,
|
||||
operation,
|
||||
});
|
||||
|
||||
// Ensure output directory exists
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Write output file
|
||||
writeFileSync(outputPath, finalMdx, "utf-8");
|
||||
generated++;
|
||||
|
||||
console.log(` Generated: ${tag}/${operationId}.mdx`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` API reference generation complete: ${generated} generated, ${skipped} skipped`,
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { ParsedOperation, SchemaField } from "./parseOpenApi.js";
|
||||
70
packages/openapi/utils/apiReferenceGenerator/mergeMdx.ts
Normal file
70
packages/openapi/utils/apiReferenceGenerator/mergeMdx.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { ParsedOperation } from "./parseOpenApi.js";
|
||||
|
||||
const IMPORTS = `import { DynamicParamField } from "/components/dynamic-param-field.jsx";
|
||||
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
|
||||
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";`;
|
||||
|
||||
/**
|
||||
* Merge manual MDX content with generated fields.
|
||||
* If manual MDX exists, append generated content after it.
|
||||
* If no manual MDX exists, generate minimal frontmatter + imports + generated content.
|
||||
*/
|
||||
export function mergeMdx({
|
||||
manualMdxPath,
|
||||
generatedContent,
|
||||
operation,
|
||||
}: {
|
||||
manualMdxPath: string;
|
||||
generatedContent: string;
|
||||
operation: ParsedOperation;
|
||||
}): string {
|
||||
if (existsSync(manualMdxPath)) {
|
||||
// Read manual MDX and append generated content
|
||||
const manualContent = readFileSync(manualMdxPath, "utf-8");
|
||||
|
||||
// Check if all imports already exist
|
||||
const hasAllImports =
|
||||
manualContent.includes("DynamicParamField") &&
|
||||
manualContent.includes("DynamicResponseField") &&
|
||||
manualContent.includes("DynamicResponseExample");
|
||||
|
||||
// If manual content has frontmatter but missing imports, add them after frontmatter
|
||||
if (!hasAllImports) {
|
||||
const frontmatterMatch = manualContent.match(/^---\n[\s\S]*?\n---\n/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatter = frontmatterMatch[0];
|
||||
const restContent = manualContent.slice(frontmatter.length).trim();
|
||||
return `${frontmatter}\n${IMPORTS}\n\n${restContent}\n\n${generatedContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${manualContent.trim()}\n\n${generatedContent}`;
|
||||
}
|
||||
|
||||
// Generate minimal frontmatter
|
||||
const title =
|
||||
operation.summary ?? formatOperationIdAsTitle(operation.operationId);
|
||||
const frontmatter = `---
|
||||
title: "${title}"
|
||||
openapi: "openapi ${operation.method} ${operation.path}"
|
||||
---`;
|
||||
|
||||
return `${frontmatter}\n\n${IMPORTS}\n\n${generatedContent}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert operationId to a human-readable title.
|
||||
* e.g., "getOrCreate" -> "Get Or Create"
|
||||
*/
|
||||
function formatOperationIdAsTitle(operationId: string): string {
|
||||
// Split on camelCase boundaries
|
||||
const words = operationId
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.split(/[\s_-]+/);
|
||||
|
||||
// Capitalize first letter of each word
|
||||
return words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
626
packages/openapi/utils/apiReferenceGenerator/parseOpenApi.ts
Normal file
626
packages/openapi/utils/apiReferenceGenerator/parseOpenApi.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import yaml from "yaml";
|
||||
|
||||
export interface SchemaField {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
children?: SchemaField[];
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
export interface ParsedOperation {
|
||||
operationId: string;
|
||||
tag: string;
|
||||
method: string;
|
||||
path: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
requestBody?: SchemaField[];
|
||||
responses?: {
|
||||
[statusCode: string]: SchemaField[];
|
||||
};
|
||||
/** Raw response schema for generating sample JSON */
|
||||
responseSchemas?: {
|
||||
[statusCode: string]: Record<string, unknown>;
|
||||
};
|
||||
/** Response examples extracted from the OpenAPI spec (already in snake_case) */
|
||||
responseExamples?: {
|
||||
[statusCode: string]: unknown;
|
||||
};
|
||||
/** Reference to all schemas for sample JSON generation */
|
||||
allSchemas?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface OpenApiDocument {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
paths?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an OpenAPI YAML file and extract operation details.
|
||||
*/
|
||||
export function parseOpenApi({
|
||||
openApiPath,
|
||||
}: {
|
||||
openApiPath: string;
|
||||
}): ParsedOperation[] {
|
||||
const content = readFileSync(openApiPath, "utf-8");
|
||||
const doc = yaml.parse(content) as OpenApiDocument;
|
||||
|
||||
const operations: ParsedOperation[] = [];
|
||||
const schemas = doc.components?.schemas ?? {};
|
||||
|
||||
for (const [path, pathItem] of Object.entries(doc.paths ?? {})) {
|
||||
for (const [method, operationObj] of Object.entries(pathItem)) {
|
||||
if (method === "parameters" || method === "$ref") continue;
|
||||
|
||||
const operation = operationObj as Record<string, unknown>;
|
||||
const operationId = operation.operationId as string | undefined;
|
||||
const tags = operation.tags as string[] | undefined;
|
||||
const tag = tags?.[0] ?? "misc";
|
||||
|
||||
if (!operationId) continue;
|
||||
|
||||
const parsed: ParsedOperation = {
|
||||
operationId,
|
||||
tag,
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: operation.summary as string | undefined,
|
||||
description: operation.description as string | undefined,
|
||||
};
|
||||
|
||||
// Parse request body
|
||||
const requestBody = operation.requestBody as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (requestBody) {
|
||||
const content = requestBody.content as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const jsonContent = content?.["application/json"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const schema = jsonContent?.schema as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (schema) {
|
||||
parsed.requestBody = parseSchema({
|
||||
schema,
|
||||
schemas,
|
||||
requiredFields: (schema.required as string[]) ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse responses
|
||||
const responses = operation.responses as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (responses) {
|
||||
parsed.responses = {};
|
||||
parsed.responseSchemas = {};
|
||||
parsed.responseExamples = {};
|
||||
|
||||
for (const [statusCode, responseObj] of Object.entries(responses)) {
|
||||
const response = responseObj as Record<string, unknown>;
|
||||
const content = response.content as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const jsonContent = content?.["application/json"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const schema = jsonContent?.schema as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (schema) {
|
||||
parsed.responses[statusCode] = parseSchema({
|
||||
schema,
|
||||
schemas,
|
||||
requiredFields: (schema.required as string[]) ?? [],
|
||||
});
|
||||
// Store raw schema for sample JSON generation
|
||||
parsed.responseSchemas[statusCode] = schema;
|
||||
}
|
||||
|
||||
// Extract response example (could be at content level or schema level)
|
||||
const example =
|
||||
jsonContent?.example ??
|
||||
jsonContent?.examples?.[0] ??
|
||||
resolveSchemaExample({ schema: schema ?? {}, schemas });
|
||||
if (example) {
|
||||
parsed.responseExamples[statusCode] = example;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store reference to all schemas for sample JSON generation
|
||||
parsed.allSchemas = schemas;
|
||||
|
||||
operations.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an example from a schema, following $ref if needed.
|
||||
*/
|
||||
function resolveSchemaExample({
|
||||
schema,
|
||||
schemas,
|
||||
}: {
|
||||
schema: Record<string, unknown>;
|
||||
schemas: Record<string, unknown>;
|
||||
}): unknown {
|
||||
// Check for examples array
|
||||
if (
|
||||
schema.examples &&
|
||||
Array.isArray(schema.examples) &&
|
||||
schema.examples.length > 0
|
||||
) {
|
||||
return schema.examples[0];
|
||||
}
|
||||
|
||||
// Check for single example
|
||||
if (schema.example !== undefined) {
|
||||
return schema.example;
|
||||
}
|
||||
|
||||
// Follow $ref
|
||||
if (schema.$ref && typeof schema.$ref === "string") {
|
||||
const refName = schema.$ref.replace("#/components/schemas/", "");
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
if (refSchema) {
|
||||
return resolveSchemaExample({ schema: refSchema, schemas });
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a schema and return a list of fields.
|
||||
*/
|
||||
function parseSchema({
|
||||
schema,
|
||||
schemas,
|
||||
requiredFields,
|
||||
visited = new Set<string>(),
|
||||
}: {
|
||||
schema: Record<string, unknown>;
|
||||
schemas: Record<string, unknown>;
|
||||
requiredFields: string[];
|
||||
visited?: Set<string>;
|
||||
}): SchemaField[] {
|
||||
// Handle $ref
|
||||
if (schema.$ref) {
|
||||
const refPath = schema.$ref as string;
|
||||
const refName = refPath.replace("#/components/schemas/", "");
|
||||
|
||||
// Prevent infinite recursion
|
||||
if (visited.has(refName)) {
|
||||
return [];
|
||||
}
|
||||
visited.add(refName);
|
||||
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
if (refSchema) {
|
||||
return parseSchema({
|
||||
schema: refSchema,
|
||||
schemas,
|
||||
requiredFields: (refSchema.required as string[]) ?? [],
|
||||
visited,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf (common for nullable types)
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = (schema.anyOf ?? schema.oneOf) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
// Find the non-null variant
|
||||
const nonNullVariant = variants.find(
|
||||
(v) => v.type !== "null" && !v.$ref?.toString().includes("null"),
|
||||
);
|
||||
if (nonNullVariant) {
|
||||
return parseSchema({
|
||||
schema: nonNullVariant,
|
||||
schemas,
|
||||
requiredFields,
|
||||
visited,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle object type
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
const fields: SchemaField[] = [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(properties)) {
|
||||
const prop = propSchema as Record<string, unknown>;
|
||||
const field = parseField({
|
||||
name: propName,
|
||||
schema: prop,
|
||||
schemas,
|
||||
required: requiredFields.includes(propName),
|
||||
visited: new Set(visited),
|
||||
});
|
||||
if (field) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// Handle array type - return the items as a single field
|
||||
if (schema.type === "array" && schema.items) {
|
||||
const items = schema.items as Record<string, unknown>;
|
||||
const itemFields = parseSchema({
|
||||
schema: items,
|
||||
schemas,
|
||||
requiredFields: (items.required as string[]) ?? [],
|
||||
visited,
|
||||
});
|
||||
|
||||
// Return array items as children of a virtual "items" field
|
||||
if (itemFields.length > 0) {
|
||||
return [
|
||||
{
|
||||
name: "items",
|
||||
type: "object",
|
||||
description: "Array item",
|
||||
required: false,
|
||||
children: itemFields,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single field from a schema property.
|
||||
*/
|
||||
function parseField({
|
||||
name,
|
||||
schema,
|
||||
schemas,
|
||||
required,
|
||||
visited,
|
||||
}: {
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
schemas: Record<string, unknown>;
|
||||
required: boolean;
|
||||
visited: Set<string>;
|
||||
}): SchemaField | null {
|
||||
let type = resolveType(schema, schemas);
|
||||
let description = schema.description as string | undefined;
|
||||
let children: SchemaField[] | undefined;
|
||||
let enumValues: string[] | undefined;
|
||||
|
||||
// Handle $ref
|
||||
if (schema.$ref) {
|
||||
const refPath = schema.$ref as string;
|
||||
const refName = refPath.replace("#/components/schemas/", "");
|
||||
|
||||
if (visited.has(refName)) {
|
||||
return { name, type: refName, description, required };
|
||||
}
|
||||
visited.add(refName);
|
||||
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
if (refSchema) {
|
||||
type = resolveType(refSchema, schemas);
|
||||
description =
|
||||
description ?? (refSchema.description as string | undefined);
|
||||
|
||||
// Check for enum
|
||||
if (refSchema.enum) {
|
||||
enumValues = refSchema.enum as string[];
|
||||
}
|
||||
|
||||
// Check for nested object
|
||||
if (refSchema.type === "object" && refSchema.properties) {
|
||||
children = parseSchema({
|
||||
schema: refSchema,
|
||||
schemas,
|
||||
requiredFields: (refSchema.required as string[]) ?? [],
|
||||
visited,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf (nullable types)
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = (schema.anyOf ?? schema.oneOf) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const hasNull = variants.some((v) => v.type === "null");
|
||||
const nonNullVariant = variants.find((v) => v.type !== "null");
|
||||
|
||||
if (nonNullVariant) {
|
||||
const innerField = parseField({
|
||||
name,
|
||||
schema: nonNullVariant,
|
||||
schemas,
|
||||
required,
|
||||
visited,
|
||||
});
|
||||
|
||||
if (innerField) {
|
||||
// Append "| null" if nullable
|
||||
if (hasNull) {
|
||||
innerField.type = `${innerField.type} | null`;
|
||||
}
|
||||
// Preserve description from parent schema if inner doesn't have one
|
||||
if (!innerField.description && description) {
|
||||
innerField.description = description;
|
||||
}
|
||||
return innerField;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: hasNull ? "any | null" : "any",
|
||||
description,
|
||||
required,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle enum
|
||||
if (schema.enum) {
|
||||
enumValues = schema.enum as string[];
|
||||
}
|
||||
|
||||
// Handle nested object
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
children = parseSchema({
|
||||
schema,
|
||||
schemas,
|
||||
requiredFields: (schema.required as string[]) ?? [],
|
||||
visited,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle array
|
||||
if (schema.type === "array" && schema.items) {
|
||||
const items = schema.items as Record<string, unknown>;
|
||||
const itemType = resolveType(items, schemas);
|
||||
type = `${itemType}[]`;
|
||||
|
||||
// Check if array items have properties
|
||||
if (items.type === "object" && items.properties) {
|
||||
children = parseSchema({
|
||||
schema: items,
|
||||
schemas,
|
||||
requiredFields: (items.required as string[]) ?? [],
|
||||
visited,
|
||||
});
|
||||
} else if (items.$ref) {
|
||||
const refPath = items.$ref as string;
|
||||
const refName = refPath.replace("#/components/schemas/", "");
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
|
||||
if (refSchema && refSchema.type === "object" && refSchema.properties) {
|
||||
children = parseSchema({
|
||||
schema: refSchema,
|
||||
schemas,
|
||||
requiredFields: (refSchema.required as string[]) ?? [],
|
||||
visited: new Set(visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
description,
|
||||
required,
|
||||
children,
|
||||
enumValues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the type string for a schema.
|
||||
*/
|
||||
function resolveType(
|
||||
schema: Record<string, unknown>,
|
||||
schemas: Record<string, unknown>,
|
||||
): string {
|
||||
if (schema.$ref) {
|
||||
const refPath = schema.$ref as string;
|
||||
const refName = refPath.replace("#/components/schemas/", "");
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
|
||||
if (refSchema) {
|
||||
// If it's an enum, return "enum"
|
||||
if (refSchema.enum) {
|
||||
return "enum";
|
||||
}
|
||||
// Otherwise return the underlying type
|
||||
return resolveType(refSchema, schemas);
|
||||
}
|
||||
return refName;
|
||||
}
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = (schema.anyOf ?? schema.oneOf) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const nonNullVariant = variants.find((v) => v.type !== "null");
|
||||
if (nonNullVariant) {
|
||||
return resolveType(nonNullVariant, schemas);
|
||||
}
|
||||
return "any";
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
const items = schema.items as Record<string, unknown> | undefined;
|
||||
if (items) {
|
||||
return `${resolveType(items, schemas)}[]`;
|
||||
}
|
||||
return "array";
|
||||
}
|
||||
|
||||
return (schema.type as string) ?? "any";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sample JSON object from a schema for documentation examples.
|
||||
* Returns a simplified sample that shows the structure without excessive nesting.
|
||||
*/
|
||||
export function generateSampleJson({
|
||||
schema,
|
||||
schemas,
|
||||
visited = new Set<string>(),
|
||||
depth = 0,
|
||||
}: {
|
||||
schema: Record<string, unknown>;
|
||||
schemas: Record<string, unknown>;
|
||||
visited?: Set<string>;
|
||||
depth?: number;
|
||||
}): unknown {
|
||||
// Prevent infinite recursion and excessive depth
|
||||
// For deep nesting, return placeholder to keep output manageable
|
||||
if (depth > 3) {
|
||||
return "...";
|
||||
}
|
||||
|
||||
// Check for examples defined on the schema (use first example if available)
|
||||
if (
|
||||
schema.examples &&
|
||||
Array.isArray(schema.examples) &&
|
||||
schema.examples.length > 0
|
||||
) {
|
||||
return schema.examples[0];
|
||||
}
|
||||
|
||||
// Check for single example
|
||||
if (schema.example !== undefined) {
|
||||
return schema.example;
|
||||
}
|
||||
|
||||
// Handle $ref
|
||||
if (schema.$ref) {
|
||||
const refPath = schema.$ref as string;
|
||||
const refName = refPath.replace("#/components/schemas/", "");
|
||||
|
||||
if (visited.has(refName)) {
|
||||
return "..."; // Circular reference placeholder
|
||||
}
|
||||
const newVisited = new Set(visited);
|
||||
newVisited.add(refName);
|
||||
|
||||
const refSchema = schemas[refName] as Record<string, unknown> | undefined;
|
||||
if (refSchema) {
|
||||
return generateSampleJson({
|
||||
schema: refSchema,
|
||||
schemas,
|
||||
visited: newVisited,
|
||||
depth: depth + 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf (pick non-null variant)
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = (schema.anyOf ?? schema.oneOf) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const nonNullVariant = variants.find(
|
||||
(v) => v.type !== "null" && !("const" in v && v.const === null),
|
||||
);
|
||||
if (nonNullVariant) {
|
||||
return generateSampleJson({
|
||||
schema: nonNullVariant,
|
||||
schemas,
|
||||
visited,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle enum - return first value
|
||||
if (schema.enum) {
|
||||
const enumValues = schema.enum as unknown[];
|
||||
return enumValues[0] ?? null;
|
||||
}
|
||||
|
||||
// Handle const
|
||||
if ("const" in schema) {
|
||||
return schema.const;
|
||||
}
|
||||
|
||||
// Handle object type
|
||||
if (schema.type === "object") {
|
||||
const properties = schema.properties as Record<string, unknown> | undefined;
|
||||
if (!properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [propName, propSchema] of Object.entries(properties)) {
|
||||
result[propName] = generateSampleJson({
|
||||
schema: propSchema as Record<string, unknown>,
|
||||
schemas,
|
||||
visited: new Set(visited), // Fresh set for each property to avoid false positives
|
||||
depth: depth + 1,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle array type
|
||||
if (schema.type === "array") {
|
||||
const items = schema.items as Record<string, unknown> | undefined;
|
||||
if (items) {
|
||||
return [
|
||||
generateSampleJson({
|
||||
schema: items,
|
||||
schemas,
|
||||
visited: new Set(visited),
|
||||
depth: depth + 1,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle primitive types with example values
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return "<string>";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 123;
|
||||
case "boolean":
|
||||
return true;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user