import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types"; function getSegments(path: string): string[] { return path.split("/").filter(Boolean); } function getResponseSchemaRef(pathObj: any): string | undefined { const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"] ?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"]; const content = response?.content; if (!content) return; for (const mediaType of Object.values(content) as any[]) { if (mediaType?.schema?.$ref) return mediaType.schema.$ref; if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref; if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref; } } export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] { const messages: ValidationMessage[] = []; const schemas = (spec.components?.schemas ?? {}) as Record; const paths = spec.paths ?? {}; if (!spec.openapi) { messages.push({ type: "error", message: "Missing 'openapi' version field" }); } if (!spec.info?.title) { messages.push({ type: "error", message: "Missing 'info.title'" }); } if (!spec.servers?.[0]?.url && !specConfig?.baseApiUrl) { messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" }); } for (const [path, pathObj] of Object.entries(paths) as [string, any][]) { if (!pathObj || typeof pathObj !== "object") continue; const segments = getSegments(path); const lastSeg = segments[segments.length - 1]; const isItemPath = /^\{.*\}$/.test(lastSeg); const paramIdx = segments.findIndex((s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)); const isSubResource = paramIdx >= 0 && !isItemPath; const hasSSE = pathObj?.get?.["x-sse"] === true; if (hasSSE) continue; // Skip paths where all operations are profile-only (x-profile: true). const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters"); const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true); if (allProfile) continue; if (isItemPath || isSubResource) { const responseRef = getResponseSchemaRef(pathObj); if (responseRef) { const schemaName = responseRef.split("/").pop()!; if (!schemas[schemaName]) { messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` }); } } continue; } const responseRef = getResponseSchemaRef(pathObj); if (responseRef) { const schemaName = responseRef.split("/").pop()!; const schema = schemas[schemaName]; if (!schema) { messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` }); continue; } if (!schema["x-primary-key"]) { messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` }); } if (!schema["x-display-format"]) { messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` }); } if (!schema["x-list-columns"]) { messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-list-columns'` }); } if (Array.isArray(schema["x-list-columns"])) { const props = schema.properties ?? {}; for (const col of schema["x-list-columns"]) { if (!props[col]) { messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` }); } } } const props = schema.properties ?? {}; for (const [propName, _raw] of Object.entries(props)) { const prop = _raw as any; if (!prop || typeof prop !== "object") continue; if (!prop["x-label"]) { messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` }); } if (prop["x-order"] === undefined || prop["x-order"] === null) { messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` }); } if (prop["$ref"] && !prop["x-fk"]) { const refName = (prop["$ref"] as string).split("/").pop(); messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` }); } if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) { const refName = (prop.items.$ref as string).split("/").pop(); messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` }); } if (prop["x-fk"]) { const fkResource = prop["x-fk"].resource as string; const fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? "")); const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource); if (!targetExists) { messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` }); } } } } if (!pathObj?.get) { messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` }); } const listParams = pathObj?.get?.parameters ?? []; const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit"); const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); if (limitParam || offsetParam) { if (!limitParam?.schema?.default) { messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` }); } } if (!pathObj?.post) { messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` }); } } return messages; }