new react-openapi

This commit is contained in:
2026-07-13 04:35:58 +05:30
parent 617f6bea6c
commit 72e7e843a4
24 changed files with 1081 additions and 297 deletions

View File

@@ -1,5 +1,21 @@
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<string, any>;
@@ -17,114 +33,101 @@ export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration):
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;
for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
if (!pathObj || typeof pathObj !== "object") continue;
const isResource = typeof schema["x-resource"] === "string";
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;
if (!isResource) continue;
const hasSSE = pathObj?.get?.["x-sse"] === true;
if (hasSSE) 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` });
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` });
}
}
}
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<string, any>).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;
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 (!collectionPath?.get) {
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
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` });
}
}
}
}
const isSSE = collectionPath?.get?.["x-sse"] === true;
if (isSSE) continue;
if (!pathObj?.get) {
messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
}
const listParams = collectionPath?.get?.parameters ?? [];
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: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
messages.push({ type: "error", message: `"${path}.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` });
}
if (!pathObj?.post) {
messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
}
}