import { readFile } from "node:fs/promises"; import { XMLParser } from "fast-xml-parser"; // Regex to extract entity type name from full type string // e.g., "com.filemaker.odata.WebData.fmp12.Addresses_" -> "Addresses_" const ENTITY_TYPE_NAME_REGEX = /\.([^.]+)$/; export interface FieldMetadata { $Type: string; $Nullable?: boolean; "@FieldID": string; "@Calculation"?: boolean; "@Global"?: boolean; "@Org.OData.Core.V1.Permissions"?: string; $DefaultValue?: string; "@AutoGenerated"?: boolean; "@Index"?: boolean; "@VersionID"?: boolean; "@FMComment"?: string; } export interface NavigationProperty { Name: string; Type: string; // e.g., "Collection(com.filemaker.odata.WebData.fmp12.Work_Orders_)" } export interface EntityType { Name: string; "@TableID": string; "@FMComment"?: string; $Key?: string[]; Properties: Map; NavigationProperties: NavigationProperty[]; } export interface EntitySet { Name: string; EntityType: string; // Full type name like "com.filemaker.odata.WebData.fmp12.Addresses_" } export interface ParsedMetadata { entityTypes: Map; entitySets: Map; namespace: string; } function ensureArray(value: T | T[] | undefined): T[] { if (!value) { return []; } return Array.isArray(value) ? value : [value]; } const RESPONSE_EXCERPT_LIMIT = 500; /** * Builds a diagnostic error message when the metadata response is missing the * expected `edmx:Edmx` root. Tries to recognize common failure modes (empty * body, JSON error payload, HTML login page) and always includes a short * excerpt of what was actually received so the cause is debuggable. */ function describeNonEdmxResponse(xmlString: string): string { const trimmed = xmlString.trim(); if (trimmed.length === 0) { return "OData metadata response was empty. Verify the server URL, database name, and credentials."; } const excerpt = trimmed.slice(0, RESPONSE_EXCERPT_LIMIT); const truncated = trimmed.length > RESPONSE_EXCERPT_LIMIT ? "…" : ""; const contextSuffix = ` First ${Math.min(trimmed.length, RESPONSE_EXCERPT_LIMIT)} chars of response:\n${excerpt}${truncated}`; if (trimmed.startsWith("{") || trimmed.startsWith("[")) { return `OData metadata endpoint returned JSON instead of XML. This usually means the server (or the fmodata client) responded to the request as JSON. Check that the request was made with Accept: application/xml.${contextSuffix}`; } const lower = trimmed.slice(0, 200).toLowerCase(); if (lower.startsWith("(); const entitySets = new Map(); let namespace = ""; // Defensive: callers sometimes hand us non-string payloads (e.g. an object // resulting from a JSON-typed response misrouted as XML). Stringify so the // diagnostic excerpt below is meaningful instead of "[object Object]". const xmlString = typeof xmlContent === "string" ? xmlContent : JSON.stringify(xmlContent); // Parse XML using fast-xml-parser const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", textNodeName: "#text", parseAttributeValue: true, trimValues: true, }); const parsed = parser.parse(xmlString); // Navigate to Schema element const edmx = parsed["edmx:Edmx"] || parsed.Edmx; if (!edmx) { throw new Error(describeNonEdmxResponse(xmlString)); } const dataServices = edmx["edmx:DataServices"] || edmx.DataServices; if (!dataServices) { throw new Error("No DataServices element found in XML"); } const schema = ensureArray(dataServices.Schema)[0]; if (!schema) { throw new Error("No Schema element found in XML"); } namespace = schema["@_Namespace"] || schema.Namespace || ""; // Extract EntityTypes const entityTypeList = ensureArray(schema.EntityType); for (const entityTypeEl of entityTypeList) { const entityTypeName = entityTypeEl["@_Name"] || entityTypeEl.Name; if (!entityTypeName) { continue; } // Get TableID and FMComment from Annotations let tableId = ""; let tableComment: string | undefined; const annotations = ensureArray(entityTypeEl.Annotation); for (const ann of annotations) { const term = ann["@_Term"] || ann.Term; if (term === "com.filemaker.odata.TableID") { tableId = ann["@_String"] || ann.String || ""; } else if (term === "com.filemaker.odata.FMComment") { tableComment = ann["@_String"] || ann.String || undefined; } } // Get Key fields const keyFields: string[] = []; if (entityTypeEl.Key) { const propertyRefs = ensureArray(entityTypeEl.Key.PropertyRef); for (const propRef of propertyRefs) { const name = propRef["@_Name"] || propRef.Name; if (name) { keyFields.push(name); } } } // Extract Properties const properties = new Map(); const propertyList = ensureArray(entityTypeEl.Property); for (const propEl of propertyList) { const propName = propEl["@_Name"] || propEl.Name; if (!propName) { continue; } const propType = propEl["@_Type"] || propEl.Type || ""; // Nullable is false only if explicitly set to "false", otherwise assume nullable // The parser converts "false" to boolean false, so check for both const nullableAttr = propEl["@_Nullable"] ?? propEl.Nullable; const isExplicitlyNotNullable = nullableAttr === "false" || nullableAttr === false; const defaultValue = propEl["@_DefaultValue"] || propEl.DefaultValue || undefined; // Get annotations let fieldId = ""; let isCalculation = false; let isGlobal = false; let isAutoGenerated = false; let hasIndex = false; let isVersionId = false; let permissions: string | undefined; let fieldComment: string | undefined; const propAnnotations = ensureArray(propEl.Annotation); for (const ann of propAnnotations) { const term = ann["@_Term"] || ann.Term; if (term === "com.filemaker.odata.FieldID") { fieldId = ann["@_String"] || ann.String || ""; } else if (term === "com.filemaker.odata.Calculation") { isCalculation = ann["@_Bool"] === "true" || ann.Bool === "true"; } else if (term === "com.filemaker.odata.Global") { isGlobal = ann["@_Bool"] === "true" || ann.Bool === "true"; } else if (term === "com.filemaker.odata.AutoGenerated") { isAutoGenerated = ann["@_Bool"] === "true" || ann.Bool === "true"; } else if (term === "com.filemaker.odata.Index") { hasIndex = ann["@_Bool"] === "true" || ann.Bool === "true"; } else if (term === "com.filemaker.odata.VersionID") { isVersionId = ann["@_Bool"] === "true" || ann.Bool === "true"; } else if (term === "com.filemaker.odata.FMComment") { fieldComment = ann["@_String"] || ann.String || undefined; } else if (term === "Org.OData.Core.V1.Permissions") { const enumMember = ann.EnumMember; if (enumMember) { permissions = typeof enumMember === "string" ? enumMember : enumMember["#text"] || undefined; } } } properties.set(propName, { $Type: propType, $Nullable: !isExplicitlyNotNullable, // true if not explicitly set to false "@FieldID": fieldId, "@Calculation": isCalculation, "@Global": isGlobal, "@Org.OData.Core.V1.Permissions": permissions, $DefaultValue: defaultValue, "@AutoGenerated": isAutoGenerated, "@Index": hasIndex, "@VersionID": isVersionId, "@FMComment": fieldComment, }); } // Extract NavigationProperties const navigationProperties: NavigationProperty[] = []; if (entityTypeEl.NavigationProperty) { const navPropList = ensureArray(entityTypeEl.NavigationProperty); for (const navPropEl of navPropList) { const navName = navPropEl["@_Name"] || navPropEl.Name; const navType = navPropEl["@_Type"] || navPropEl.Type; if (navName && navType) { navigationProperties.push({ Name: navName, Type: navType, }); } } } entityTypes.set(entityTypeName, { Name: entityTypeName, "@TableID": tableId, "@FMComment": tableComment, $Key: keyFields, Properties: properties, NavigationProperties: navigationProperties, }); } // Extract EntitySets from EntityContainer const entityContainer = ensureArray(schema.EntityContainer)[0]; if (entityContainer) { const entitySetList = ensureArray(entityContainer.EntitySet); for (const entitySetEl of entitySetList) { const setName = entitySetEl["@_Name"] || entitySetEl.Name; const entityType = entitySetEl["@_EntityType"] || entitySetEl.EntityType; if (setName && entityType) { // Extract just the entity type name from the full type string // e.g., "com.filemaker.odata.WebData.fmp12.Addresses_" -> "Addresses_" const typeNameMatch = entityType.match(ENTITY_TYPE_NAME_REGEX); const entityTypeName = typeNameMatch ? typeNameMatch[1] : entityType; entitySets.set(setName, { Name: setName, EntityType: entityTypeName, }); } } } return { entityTypes, entitySets, namespace }; } /** * Reads and parses metadata from a file path. * * @param filePath - The path to the XML metadata file * @returns Promise resolving to parsed metadata */ export async function parseMetadataFromFile(filePath: string): Promise { const xmlContent = await readFile(filePath, "utf-8"); return parseMetadata(xmlContent); }