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; required: string[] } { let properties: Record = {}; 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, configuration: Record = {} ): Record { const fields: Record = {}; 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 = {}, profileConfiguration: any = {}): Promise { 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 = {}; 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(); 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; if (!listOp || !info.schemaObj) continue; const schema = info.schemaObj; const label = name.charAt(0).toUpperCase() + name.slice(1, -1); const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions']) const fields = 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, }, }); } // Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.) const enums: Record = {}; 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, }; }