import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types"; 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 [schemaName, schema] of Object.entries(schemas)) { if (!schema || typeof schema !== "object") continue; const isResource = typeof schema["x-resource"] === "string"; if (!isResource) continue; const resourcePath = `/${schema["x-resource"]}`; 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: `Resource schema "${schemaName}" is missing 'x-display-format'` }); } if (!schema["x-list-columns"]) { messages.push({ type: "error", message: `Resource 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 targetSchema = Object.entries(schemas as Record).find(([, s]) => s?.["x-resource"] === fkResource); if (!targetSchema) { messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` }); } else { const [, target] = targetSchema; if (!target["x-display-format"]) { messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` }); } if (!target["x-primary-key"]) { messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` }); } } } } if (!paths[resourcePath]) { messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` }); continue; } const collectionPath = paths[resourcePath] as any; if (!collectionPath?.get) { messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` }); } const isSSE = collectionPath?.get?.["x-sse"] === true; if (isSSE) continue; const listParams = collectionPath?.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: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` }); } } if (!collectionPath?.post) { messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` }); } const itemPath = paths[`${resourcePath}/{id}`] as any; if (!itemPath) { messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` }); } else { if (!itemPath?.get) { messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` }); } if (!itemPath?.put) { messages.push({ type: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` }); } if (!itemPath?.delete) { messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` }); } } } return messages; }