Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
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, '"')
|
|
.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) => ` <Property Name="${escapeXml(p.name)}" Type="${p.edmType}" Nullable="true"/>`)
|
|
.join('\n');
|
|
schema.push(` <ComplexType Name="${escapeXml(typeName)}">`);
|
|
schema.push(props);
|
|
schema.push(` </ComplexType>`);
|
|
}
|
|
|
|
// 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(` <Property Name="${escapeXml(key)}" Type="${edmType}" Nullable="true"/>`);
|
|
}
|
|
// 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(
|
|
` <Property Name="${escapeXml(parentKey)}" Type="${namespace}.${ct.typeName}" Nullable="true"/>`
|
|
);
|
|
}
|
|
}
|
|
if (properties.length === 0) continue;
|
|
|
|
schema.push(` <EntityType Name="${escapeXml(entityName)}" OpenType="true">`);
|
|
schema.push(` <Key><PropertyRef Name="_id"/></Key>`);
|
|
schema.push(properties.join('\n'));
|
|
schema.push(` </EntityType>`);
|
|
}
|
|
|
|
const entitySets = entries
|
|
.map((e) => {
|
|
const modelName = e.model.modelName;
|
|
const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
|
|
return ` <EntitySet Name="${escapeXml(modelName)}" EntityType="${namespace}.${escapeXml(entityName)}"/>`;
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
return `<?xml version="1.0" encoding="utf-8"?>
|
|
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
|
|
<edmx:DataServices>
|
|
<Schema Namespace="${namespace}" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
|
${schema.join('\n')}
|
|
<EntityContainer Name="Container">
|
|
${entitySets}
|
|
</EntityContainer>
|
|
</Schema>
|
|
</edmx:DataServices>
|
|
</edmx:Edmx>`;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
};
|
|
|