250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
import SwaggerParser from "@apidevtools/swagger-parser";
|
||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||
|
||
/**
|
||
* Maps OpenAPI property types to our internal FieldType
|
||
*/
|
||
function mapOpenApiType(prop: any): FieldType {
|
||
const type = prop.type;
|
||
const format = prop.format;
|
||
|
||
if (format === "date-time") return "datetime";
|
||
if (format === "date") return "date";
|
||
if (prop.enum) return "enum";
|
||
if (
|
||
type === "string" &&
|
||
(prop.description?.toLowerCase().includes("image") ||
|
||
prop.name?.toLowerCase().includes("icon"))
|
||
)
|
||
return "image";
|
||
|
||
switch (type) {
|
||
case "integer":
|
||
case "number":
|
||
return "number";
|
||
case "boolean":
|
||
return "boolean";
|
||
case "object":
|
||
return "object";
|
||
case "array":
|
||
return "array";
|
||
default:
|
||
return "string";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recursively converts OpenAPI schemas to ResourceField map
|
||
*/
|
||
function mergeProperties(schema: any): { properties: Record<string, any>; required: string[] } {
|
||
let properties: Record<string, any> = {};
|
||
let required: string[] = [];
|
||
|
||
if (schema.allOf) {
|
||
for (const sub of schema.allOf) {
|
||
const merged = mergeProperties(sub);
|
||
properties = { ...properties, ...merged.properties };
|
||
required = [...required, ...merged.required];
|
||
}
|
||
}
|
||
if (schema.properties) {
|
||
properties = { ...properties, ...schema.properties };
|
||
}
|
||
if (schema.required) {
|
||
required = [...required, ...schema.required];
|
||
}
|
||
return { properties, required };
|
||
}
|
||
|
||
function parseSchemaFields(
|
||
schema: any,
|
||
resourceName: string,
|
||
schemaToResourceMap: Map<any, string>,
|
||
configuration: Record<string, any> = {}
|
||
): Record<string, ResourceField> {
|
||
const fields: Record<string, ResourceField> = {};
|
||
const { properties, required } = mergeProperties(schema);
|
||
const overrides = configuration[resourceName]?.fields || {};
|
||
// console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
|
||
|
||
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||
// Resolve oneOf/anyOf by merging all branch properties
|
||
let resolvedProp = prop;
|
||
if (prop.oneOf || prop.anyOf) {
|
||
const branches = prop.oneOf || prop.anyOf;
|
||
const merged = mergeProperties({ allOf: branches });
|
||
resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required };
|
||
}
|
||
|
||
const type = mapOpenApiType(resolvedProp);
|
||
if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) {
|
||
throw new Error(
|
||
`OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` +
|
||
`Add an "enum" array with at least one value to the OpenAPI schema definition.`
|
||
);
|
||
}
|
||
const override = overrides[key];
|
||
|
||
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||
if (key === "id" || override?.display === false) continue;
|
||
|
||
fields[key] = {
|
||
type,
|
||
label:
|
||
resolvedProp.title ||
|
||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||
required: required.includes(key),
|
||
options: resolvedProp.enum,
|
||
readOnly:
|
||
resolvedProp.readOnly ||
|
||
key === "created_at" ||
|
||
key === "updated_at",
|
||
...override,
|
||
};
|
||
|
||
// STRICT RELATION DETECTION
|
||
// A field is a relation ONLY if its schema object (or items schema)
|
||
// exactly matches a schema that is defined as a resource.
|
||
let targetSchema = resolvedProp;
|
||
if (type === "array" && resolvedProp.items) {
|
||
targetSchema = resolvedProp.items;
|
||
}
|
||
|
||
// Check if this schema object is registered as a resource
|
||
const relation = schemaToResourceMap.get(targetSchema);
|
||
if (relation) {
|
||
fields[key].relation = relation;
|
||
|
||
// Propagate enumOption from target resource config, or derive from target schema
|
||
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
|
||
// console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
|
||
if (explicitEnumOption) {
|
||
fields[key].enumOption = explicitEnumOption;
|
||
} else {
|
||
// No explicit enumOption supplied – this is a configuration error.
|
||
// We abort loading so the problem is visible immediately.
|
||
throw new Error(
|
||
`Missing enumOption for relation "${relation}" on field "${key}". ` +
|
||
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
|
||
);
|
||
}
|
||
|
||
}
|
||
|
||
// Recursively parse nested objects (only if not a relation)
|
||
if (fields[key].type === "object" && resolvedProp.properties && !relation) {
|
||
// console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
|
||
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
|
||
}
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/**
|
||
* Scans paths to identify resources and their basic configuration
|
||
*/
|
||
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
|
||
// console.log('init configuration...', configuration['accounts']['referenceOptions'])
|
||
// Use SwaggerParser to dereference the spec.
|
||
// Dereferencing preserves object identity for $ref targets.
|
||
const api = await SwaggerParser.dereference(
|
||
new URL("/openapi.json", baseUrl).href
|
||
);
|
||
|
||
const resources: ResourceConfig[] = [];
|
||
const paths = api.paths || {};
|
||
|
||
// Group paths by base resource name
|
||
const resourcePaths: Record<string, any> = {};
|
||
for (const path of Object.keys(paths)) {
|
||
const base = path.split("/")[1];
|
||
if (!base) continue;
|
||
|
||
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
||
const methods = Object.keys(paths[path] || {});
|
||
resourcePaths[base].methods.push(...methods);
|
||
|
||
// Identify the list endpoint for this resource
|
||
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||
resourcePaths[base].listPath = path;
|
||
}
|
||
}
|
||
|
||
// 1. Identify which schema objects correspond to which resources
|
||
const schemaToResourceMap = new Map<any, string>();
|
||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||
const listPath = info.listPath || `/${name}`;
|
||
const listOp = paths[listPath]?.get;
|
||
if (!listOp) continue;
|
||
|
||
// @ts-ignore
|
||
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||
let schemaObj = responseSchema;
|
||
if (responseSchema?.type === "array" && responseSchema.items) {
|
||
schemaObj = responseSchema.items;
|
||
}
|
||
|
||
if (schemaObj) {
|
||
schemaToResourceMap.set(schemaObj, name);
|
||
resourcePaths[name].schemaObj = schemaObj;
|
||
}
|
||
}
|
||
|
||
// 2. Generate ResourceConfig for each identified resource
|
||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||
const listPath = info.listPath || `/${name}`;
|
||
const listOp = paths[listPath]?.get;
|
||
// Always create a resource entry even if the list operation or schema is missing.
|
||
// This enables relation look‑ups for resources that only have overrides (e.g., accounts, tags).
|
||
// If we lack a schema we fall back to an empty field map.
|
||
const hasList = !!listOp;
|
||
const schema = info.schemaObj;
|
||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||
|
||
const fields = schema ? parseSchemaFields(schema, name, schemaToResourceMap, configuration) : {};
|
||
|
||
const resourceOverride = configuration[name] || {};
|
||
const fo = resourceOverride.filterOptions || {};
|
||
|
||
resources.push({
|
||
name,
|
||
label: schema?.title || label,
|
||
pluralLabel: pluralLabel,
|
||
endpoint: listPath,
|
||
primaryKey: "id",
|
||
fields,
|
||
pagination: resourceOverride.pagination,
|
||
hidden: resourceOverride.hidden,
|
||
filterOptions: {
|
||
mode: fo.mode || "server",
|
||
fields: fo.fields,
|
||
},
|
||
});
|
||
// console.log('Loaded resource:', name, 'endpoint:', listPath, 'fields count:', Object.keys(fields).length);
|
||
}
|
||
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
|
||
const enums: Record<string, string[]> = {};
|
||
const apiDoc = api as any;
|
||
if (apiDoc.components?.schemas) {
|
||
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
|
||
if (schema.enum) {
|
||
enums[name] = schema.enum;
|
||
}
|
||
}
|
||
}
|
||
|
||
// @ts-ignore
|
||
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
|
||
// @ts-ignore
|
||
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
|
||
return {
|
||
baseUrl: serverBaseUrl,
|
||
authBaseUrl: authBaseUrl,
|
||
resources,
|
||
enums,
|
||
profile: profileConfiguration,
|
||
};
|
||
}
|