import config from '../../config.js'; import log4js from 'log4js'; import mongoose from 'mongoose'; import { getModelByName, getAllModels } from './model.js'; import { listObjectsOData } from '../../database/odata.js'; import { getFilter } from '../../utils.js'; import { getModelFilterFields, parseOrderBy } from './export.js'; const logger = log4js.getLogger('OData'); logger.level = config.server.logLevel; const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v']; function mongooseTypeToEdm(path) { const instance = path?.instance; if (instance === 'String') return 'Edm.String'; if (instance === 'Number') return 'Edm.Decimal'; if (instance === 'Date') return 'Edm.DateTimeOffset'; if (instance === 'Boolean') return 'Edm.Boolean'; if (instance === 'ObjectID') return 'Edm.String'; if (path?.options?.type === mongoose.Schema.Types.ObjectId) return 'Edm.String'; if (instance === 'Object' || instance === 'Mixed') return 'Edm.String'; // JSON-serialized if (instance === 'Buffer') return 'Edm.Binary'; if (path?.options?.type === mongoose.Schema.Types.Mixed) return 'Edm.String'; return 'Edm.String'; } /** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */ function isEmbeddedObject(path) { if (!path) return false; if (path.options?.type === mongoose.Schema.Types.ObjectId) return false; if (path.instance === 'Array') return false; return path.schema != null || path.caster?.schema != null; } /** Check if path is an array of subdocuments (embedded schemas). */ function isArrayOfSubdocuments(path) { if (!path || path.instance !== 'Array') return false; const schema = path.caster?.schema; return schema != null && schema.paths != null; } /** Check if path is an array of ObjectIds. */ function isArrayOfObjectIds(path) { if (!path || path.instance !== 'Array') return false; return ( path.caster?.instance === 'ObjectID' || path.caster?.options?.type === mongoose.Schema.Types.ObjectId ); } /** Get nested properties from embedded schema for ComplexType */ function getEmbeddedProperties(path, parentPaths, parentKey) { const schema = path?.schema ?? path?.caster?.schema; if (schema?.paths) { return Object.entries(schema.paths) .filter(([key]) => !key.includes('.')) .map(([key, p]) => ({ name: key, edmType: mongooseTypeToEdm(p) })); } // Fallback: derive from parent schema's dotted paths (e.g. state.type, state.message) if (parentPaths && parentKey) { const prefix = parentKey + '.'; return Object.entries(parentPaths) .filter(([key]) => key.startsWith(prefix) && !key.slice(prefix.length).includes('.')) .map(([key, p]) => ({ name: key.slice(prefix.length), edmType: mongooseTypeToEdm(p) })); } return []; } function escapeXml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Build OData 4.0 CSDL $metadata document from Mongoose models. * Handles embedded objects (e.g. state) as ComplexTypes. */ function buildMetadataXml() { const baseUrl = config.app?.urlApi || ''; const namespace = 'FarmControl'; const schema = []; const complexTypes = new Map(); // pathKey -> { typeName, properties } const entries = getAllModels().filter((e) => e.model != null); // First pass: collect ComplexTypes from embedded paths and array-of-subdocument paths (merge props when same key) for (const entry of entries) { const paths = entry.model.schema.paths; for (const [key, path] of Object.entries(paths)) { if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false) continue; if (isEmbeddedObject(path)) { const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key); if (embeddedProps.length === 0) continue; const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type'; const existing = complexTypes.get(key); if (existing) { const propNames = new Set(existing.properties.map((p) => p.name)); for (const p of embeddedProps) { if (!propNames.has(p.name)) { existing.properties.push(p); propNames.add(p.name); } } } else { complexTypes.set(key, { typeName, properties: embeddedProps }); } } else if (isArrayOfSubdocuments(path)) { const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key); if (embeddedProps.length === 0) continue; const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type'; const existing = complexTypes.get(key); if (existing) { const propNames = new Set(existing.properties.map((p) => p.name)); for (const p of embeddedProps) { if (!propNames.has(p.name)) { existing.properties.push(p); propNames.add(p.name); } } } else { complexTypes.set(key, { typeName, properties: embeddedProps }); } } } } // Second pass: Mongoose sometimes omits parent path when nested object has 'type' as first key // (e.g. state: { type: String, progress: Number } yields state.type, state.progress but no state) // Derive ComplexTypes from dotted paths where parent is not a top-level path for (const entry of entries) { const paths = entry.model.schema.paths; const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.'))); for (const [key, path] of Object.entries(paths)) { if (!key.includes('.') || path.options?.select === false) continue; const dotIdx = key.indexOf('.'); const parentKey = key.slice(0, dotIdx); const childKey = key.slice(dotIdx + 1); if (childKey.includes('.')) continue; // only one level of nesting for this pass if (topLevelKeys.has(parentKey)) continue; // parent exists, already handled if (path.options?.type === mongoose.Schema.Types.ObjectId) continue; // skip ObjectId refs const typeName = parentKey.charAt(0).toUpperCase() + parentKey.slice(1) + 'Type'; const prop = { name: childKey, edmType: mongooseTypeToEdm(path) }; const existing = complexTypes.get(parentKey); if (existing) { const propNames = new Set(existing.properties.map((p) => p.name)); if (!propNames.has(prop.name)) { existing.properties.push(prop); } } else { complexTypes.set(parentKey, { typeName, properties: [prop] }); } } } // Emit ComplexType definitions for (const [, { typeName, properties }] of complexTypes) { const props = properties .map((p) => ` `) .join('\n'); schema.push(` `); schema.push(props); schema.push(` `); } // EntityTypes for (const entry of entries) { const modelName = entry.model.modelName; const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1); const paths = entry.model.schema.paths; const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.'))); const properties = []; const addedKeys = new Set(); for (const [key, path] of Object.entries(paths)) { if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false) continue; addedKeys.add(key); let edmType; if (isEmbeddedObject(path)) { const ct = complexTypes.get(key); if (!ct) continue; edmType = `${namespace}.${ct.typeName}`; } else if (isArrayOfSubdocuments(path)) { const ct = complexTypes.get(key); if (!ct) continue; edmType = `Collection(${namespace}.${ct.typeName})`; } else if (isArrayOfObjectIds(path)) { edmType = 'Collection(Edm.String)'; } else if (path.instance === 'Array' && path.caster) { edmType = `Collection(${mongooseTypeToEdm(path.caster)})`; } else { edmType = mongooseTypeToEdm(path); } properties.push(` `); } // Add properties for dotted-path-derived ComplexTypes (e.g. state from state.type, state.progress) for (const [key, path] of Object.entries(paths)) { if (!key.includes('.') || path?.options?.select === false) continue; const parentKey = key.slice(0, key.indexOf('.')); if (topLevelKeys.has(parentKey) || addedKeys.has(parentKey)) continue; const ct = complexTypes.get(parentKey); if (!ct) continue; if (!addedKeys.has(parentKey)) { addedKeys.add(parentKey); properties.push( ` ` ); } } if (properties.length === 0) continue; schema.push(` `); schema.push(` `); schema.push(properties.join('\n')); schema.push(` `); } const entitySets = entries .map((e) => { const modelName = e.model.modelName; const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1); return ` `; }) .filter(Boolean) .join('\n'); return ` ${schema.join('\n')} ${entitySets} `; } /** * Route handler for GET /odata/$metadata * Returns OData 4.0 CSDL metadata document for Power BI / OData clients. */ export const metadataODataRouteHandler = (req, res) => { const xml = buildMetadataXml(); res.set('OData-Version', '4.0'); res.set('Content-Type', 'application/xml'); res.send(xml); }; /** * Route handler for GET /odata/:objectType * Supports OData query options: $top, $skip, $orderby, $count, $select * and passes through allowed filters from query params. */ export const listODataRouteHandler = async (req, res) => { const objectType = req.params.objectType; const entry = getModelByName(objectType); if (!entry?.model) { logger.warn(`OData: unknown object type "${objectType}"`); return res.status(404).send({ error: `Unknown object type: ${objectType}` }); } const { $top, $skip, $orderby, $count, $select } = req.query; const limit = $top ? Math.min(parseInt($top, 10) || 25, 1000) : 25; const skip = $skip ? Math.max(0, parseInt($skip, 10) || 0) : 0; const page = Math.floor(skip / limit) + 1; const includeCount = $count === 'true' || $count === true; const { sort, order } = parseOrderBy($orderby); const rawProject = $select ? String($select) .split(',') .map((s) => s.trim()) .filter(Boolean) .join(' ') || undefined : undefined; // Always exclude __v (Mongoose version key) - causes OData client issues const project = rawProject ? `${rawProject} -__v` : '-__v'; // Build filter from query params - allow common filter keys per model const allowedFilters = getModelFilterFields(objectType); const filter = getFilter(req.query, allowedFilters); const result = await listObjectsOData({ model: entry.model, populate: [], page, limit, filter, sort, order, pagination: true, project, count: includeCount, }); if (result?.error) { logger.error('OData list error:', result.error); return res.status(result.code || 500).send(result); } res.set('OData-Version', '4.0'); res.set('Content-Type', 'application/json; odata.metadata=minimal'); res.send(result); };