new react-openapi
This commit is contained in:
@@ -1,9 +1,88 @@
|
||||
import type { FieldConfig } from "../types";
|
||||
import type { FieldConfig, OneOfOption } from "../types";
|
||||
|
||||
const _validationErrors: string[] = [];
|
||||
|
||||
export function clearValidationErrors(): void {
|
||||
_validationErrors.length = 0;
|
||||
}
|
||||
|
||||
export function getValidationErrors(): string[] {
|
||||
return [..._validationErrors];
|
||||
}
|
||||
|
||||
function resolveRef(ref: string): string | undefined {
|
||||
return ref.split("/").pop();
|
||||
}
|
||||
|
||||
function resolveAllOf(schema: any, schemas: Record<string, any>): any {
|
||||
if (!schema || !schema.allOf) return schema;
|
||||
const merged: any = { type: "object", properties: {}, required: [] };
|
||||
for (const entry of schema.allOf) {
|
||||
const resolved = entry.$ref
|
||||
? resolveAllOf(schemas[resolveRef(entry.$ref)!] ?? {}, schemas)
|
||||
: entry;
|
||||
if (resolved.properties) {
|
||||
Object.assign(merged.properties, resolved.properties);
|
||||
}
|
||||
if (resolved.required) {
|
||||
merged.required.push(...resolved.required);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractOneOfOptions(schema: any, schemas: Record<string, any>, discriminatorProperty: string): OneOfOption[] {
|
||||
if (!schema.oneOf) return [];
|
||||
const result = schema.oneOf.flatMap((option: any) => {
|
||||
if (!option.$ref) return [];
|
||||
const variantName = resolveRef(option.$ref);
|
||||
if (!variantName) return [];
|
||||
const variantSchema = schemas[variantName];
|
||||
if (!variantSchema) return [];
|
||||
const merged = resolveAllOf(variantSchema, schemas);
|
||||
const props = merged.properties ?? {};
|
||||
const requiredFields: string[] = merged.required ?? [];
|
||||
const discriminatorProp = props[discriminatorProperty];
|
||||
if (!discriminatorProp?.enum?.[0]) return [];
|
||||
const value = discriminatorProp.enum[0];
|
||||
const label = variantName.replace(/Note$/, "").replace(/([A-Z])/g, " $1").trim() || value;
|
||||
const fields: FieldConfig[] = Object.entries(props)
|
||||
.filter(([k]) => k !== discriminatorProperty)
|
||||
.filter(([, p]: [string, any]) => p && typeof p === "object")
|
||||
.map(([name, prop]: [string, any]) => {
|
||||
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
|
||||
const isPlainString = !prop["x-fk"] && !prop.enum && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
|
||||
if (!autocomplete && isPlainString && prop["x-filterable"]) {
|
||||
autocomplete = "text";
|
||||
}
|
||||
if (autocomplete && !prop["x-filterable"]) {
|
||||
_validationErrors.push(`[field-config] field "${name}" in oneOf variant has x-autocomplete but is not x-filterable`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
label: prop["x-label"] ?? name,
|
||||
description: prop["x-description"] ?? "",
|
||||
type: prop.type ?? "string",
|
||||
format: prop.format,
|
||||
order: prop["x-order"] ?? Infinity,
|
||||
hidden: prop["x-hidden"] ?? {},
|
||||
filterable: prop["x-filterable"] ?? false,
|
||||
sortable: prop["x-sortable"] ?? false,
|
||||
readOnly: prop.readOnly ?? false,
|
||||
required: requiredFields.includes(name),
|
||||
enumValues: prop.enum,
|
||||
fk: prop["x-fk"],
|
||||
uiType: prop["x-ui-type"],
|
||||
uploadUrl: prop["x-upload-url"],
|
||||
isArray: prop.type === "array",
|
||||
autocomplete,
|
||||
};
|
||||
});
|
||||
return [{ value, label, fields }];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
|
||||
const props = schema.properties ?? {};
|
||||
const requiredFields: string[] = schema.required ?? [];
|
||||
@@ -30,13 +109,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
|
||||
? refSchema["x-display-format"]
|
||||
: undefined;
|
||||
|
||||
const isDiscriminatedUnion = isRef && refSchema?.oneOf && refSchema?.discriminator;
|
||||
const discriminatorProperty = isDiscriminatedUnion ? refSchema.discriminator.propertyName : undefined;
|
||||
const oneOfOptions = isDiscriminatedUnion ? extractOneOfOptions(refSchema, schemas, discriminatorProperty!) : undefined;
|
||||
|
||||
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
|
||||
const isPlainString = !prop["x-fk"] && !prop.enum && !isRef && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
|
||||
if (!autocomplete && isPlainString && prop["x-filterable"]) {
|
||||
autocomplete = "text";
|
||||
console.warn(`[field-config] missing x-autocomplete on "${name}" in schema "${schemaName}", defaulting to "text"`);
|
||||
}
|
||||
if (autocomplete && !prop["x-filterable"]) {
|
||||
_validationErrors.push(`[field-config] field "${name}" in schema "${schemaName}" has x-autocomplete but is not x-filterable`);
|
||||
}
|
||||
|
||||
const field: FieldConfig = {
|
||||
name,
|
||||
label: prop["x-label"],
|
||||
description: prop["x-description"] ?? prop["x-label"] ?? name,
|
||||
description: prop["x-description"] ?? "",
|
||||
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
|
||||
format: prop.format,
|
||||
order: prop["x-order"],
|
||||
order: prop["x-order"] ?? Infinity,
|
||||
hidden: prop["x-hidden"] ?? {},
|
||||
filterable: prop["x-filterable"] ?? false,
|
||||
sortable: prop["x-sortable"] ?? false,
|
||||
@@ -49,6 +142,9 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
|
||||
refSchema: refSchemaName,
|
||||
inlineDisplayFormat,
|
||||
isArray: prop.type === "array",
|
||||
oneOfOptions,
|
||||
discriminatorProperty,
|
||||
autocomplete,
|
||||
};
|
||||
|
||||
return field;
|
||||
|
||||
@@ -11,11 +11,7 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
|
||||
if (!prop["x-fk"]) continue;
|
||||
|
||||
const fkResource = prop["x-fk"].resource as string;
|
||||
const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
|
||||
const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
|
||||
|
||||
const prefetch = prop["x-fk"].prefetch ?? false;
|
||||
console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
|
||||
|
||||
rels.push({
|
||||
fieldName: name,
|
||||
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
|
||||
resource: fkResource,
|
||||
prefetch,
|
||||
},
|
||||
targetSchemaName,
|
||||
targetSchemaName: fkResource,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[FK] total relationships extracted: ${rels.length}`);
|
||||
return rels;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
|
||||
import { extractFields } from "./field-config";
|
||||
import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
|
||||
import { extractRelationships } from "./relationship-config";
|
||||
|
||||
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
|
||||
@@ -47,62 +47,147 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
|
||||
isArray: false,
|
||||
};
|
||||
|
||||
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"];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRef(ref: string): string {
|
||||
return ref.split("/").pop()!;
|
||||
}
|
||||
|
||||
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
clearValidationErrors();
|
||||
const schemas = spec.components?.schemas ?? {};
|
||||
const paths = spec.paths ?? {};
|
||||
const configs: ResourceConfig[] = [];
|
||||
const nameMap = new Map<string, ResourceConfig>();
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (!schema || typeof schema !== "object") continue;
|
||||
const sortedPaths = Object.keys(paths).sort(
|
||||
(a, b) => getSegments(a).length - getSegments(b).length
|
||||
);
|
||||
|
||||
const resourceName = schema["x-resource"];
|
||||
if (!resourceName || typeof resourceName !== "string") continue;
|
||||
for (const path of sortedPaths) {
|
||||
const segments = getSegments(path);
|
||||
const pathObj = paths[path];
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
const isItemPath = /^\{.*\}$/.test(lastSeg);
|
||||
const paramIdx = segments.findIndex(
|
||||
(s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)
|
||||
);
|
||||
|
||||
const resourcePath = `/${resourceName}`;
|
||||
const itemPath = `${resourcePath}/{id}`;
|
||||
const collectionPathObj = paths[resourcePath];
|
||||
const itemPathObj = paths[itemPath];
|
||||
if (isItemPath) {
|
||||
const parentName = segments[segments.length - 2];
|
||||
const parent = nameMap.get(parentName);
|
||||
if (!parent) continue;
|
||||
if (hasOperation(pathObj, "get")) parent.operations.get = true;
|
||||
if (hasOperation(pathObj, "put") || hasOperation(pathObj, "patch")) parent.operations.update = true;
|
||||
if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
|
||||
if (hasOperation(pathObj, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch";
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = extractFields(schemaName, schema, schemas);
|
||||
const relationships = extractRelationships(schema, schemas);
|
||||
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
|
||||
if (paramIdx >= 0) {
|
||||
const resourceName = lastSeg;
|
||||
const parentName = segments[paramIdx - 1];
|
||||
const pathParamName = segments[paramIdx].replace(/[{}]/g, "");
|
||||
|
||||
const responseRef = getResponseSchemaRef(pathObj);
|
||||
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
|
||||
const schema = schemaName ? schemas[schemaName] : undefined;
|
||||
|
||||
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
|
||||
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
||||
|
||||
const resource: ResourceConfig = {
|
||||
name: resourceName,
|
||||
schemaName: schemaName ?? resourceName,
|
||||
displayName: formatDisplayName(resourceName),
|
||||
path,
|
||||
primaryKey: schema?.["x-primary-key"] ?? "_received_at",
|
||||
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
|
||||
listColumns: schema?.["x-list-columns"] ?? [],
|
||||
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
|
||||
orderedFields: [],
|
||||
operations: hasSSE
|
||||
? { list: true, get: false, create: false, update: false, delete: false }
|
||||
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, delete: false },
|
||||
updateMethod: "put",
|
||||
pagination: hasSSE ? null : detectPagination(pathObj),
|
||||
relationships: [],
|
||||
streaming: hasSSE || undefined,
|
||||
parent: { resource: parentName, pathParam: pathParamName },
|
||||
};
|
||||
|
||||
resource.orderedFields = sortFields(resource.fields);
|
||||
if (hasSSE) {
|
||||
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||
resource.primaryKey = "_received_at";
|
||||
}
|
||||
|
||||
const parent = nameMap.get(parentName);
|
||||
if (parent) {
|
||||
parent.subResources = parent.subResources ?? [];
|
||||
parent.subResources.push(resourceName);
|
||||
}
|
||||
|
||||
nameMap.set(resourceName, resource);
|
||||
configs.push(resource);
|
||||
continue;
|
||||
}
|
||||
|
||||
const resourceName = lastSeg;
|
||||
const responseRef = getResponseSchemaRef(pathObj);
|
||||
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
|
||||
const schema = schemaName ? schemas[schemaName] : undefined;
|
||||
|
||||
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
|
||||
const relationships = schema ? extractRelationships(schema, schemas) : [];
|
||||
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
||||
|
||||
const resource: ResourceConfig = {
|
||||
name: resourceName,
|
||||
schemaName,
|
||||
schemaName: schemaName ?? resourceName,
|
||||
displayName: formatDisplayName(resourceName),
|
||||
path: resourcePath,
|
||||
primaryKey: schema["x-primary-key"],
|
||||
displayFormat: schema["x-display-format"],
|
||||
listColumns: schema["x-list-columns"],
|
||||
fields,
|
||||
orderedFields: sortFields(fields),
|
||||
operations: {
|
||||
list: hasOperation(collectionPathObj, "get"),
|
||||
get: hasOperation(itemPathObj, "get"),
|
||||
create: hasOperation(collectionPathObj, "post"),
|
||||
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
|
||||
delete: hasOperation(itemPathObj, "delete"),
|
||||
},
|
||||
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
|
||||
pagination: detectPagination(collectionPathObj),
|
||||
path,
|
||||
primaryKey: schema?.["x-primary-key"] ?? "id",
|
||||
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
|
||||
listColumns: schema?.["x-list-columns"] ?? [],
|
||||
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
|
||||
orderedFields: [],
|
||||
operations: hasSSE
|
||||
? { list: true, get: false, create: false, update: false, delete: false }
|
||||
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false },
|
||||
updateMethod: "put",
|
||||
pagination: hasSSE ? null : detectPagination(pathObj),
|
||||
relationships,
|
||||
streaming: hasSSE || undefined,
|
||||
};
|
||||
|
||||
resource.orderedFields = sortFields(resource.fields);
|
||||
if (hasSSE) {
|
||||
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
|
||||
resource.updateMethod = "put";
|
||||
resource.pagination = null;
|
||||
resource.relationships = [];
|
||||
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
|
||||
resource.orderedFields = sortFields(resource.fields);
|
||||
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||
resource.primaryKey = "_received_at";
|
||||
}
|
||||
|
||||
nameMap.set(resourceName, resource);
|
||||
configs.push(resource);
|
||||
}
|
||||
|
||||
const errors = getValidationErrors();
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join("\n"));
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user