iotagent-node-lib
Version:
IoT Agent library to interface with NGSI Context Broker
861 lines (782 loc) • 30.9 kB
JavaScript
/*
* Copyright 2020 Telefonica Investigación y Desarrollo, S.A.U
*
* This file is part of fiware-iotagent-lib
*
* fiware-iotagent-lib is free software: you can redistribute it and/or
* modify it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* fiware-iotagent-lib is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with fiware-iotagent-lib.
* If not, see http://www.gnu.org/licenses/.
*
* For those usages not covered by the GNU Affero General Public License
* please contact with::daniel.moranjimenez@telefonica.com
*
* Modified by: Jason Fox - FIWARE Foundation
*/
/* eslint-disable no-useless-escape */
/* eslint-disable no-unused-vars */
/* eslint-disable consistent-return */
const async = require('async');
const apply = async.apply;
const logger = require('logops');
const errors = require('../../errors');
const constants = require('../../constants');
const deviceService = require('../devices/deviceService');
const middlewares = require('../common/genericMiddleware');
const _ = require('underscore');
const context = {
op: 'IoTAgentNGSI.ContextServer-LD'
};
const updateContextTemplateNgsiLD = require('../../templates/updateContextNgsiLD.json');
const notificationTemplateNgsiLD = require('../../templates/notificationTemplateNgsiLD.json');
const contextServerUtils = require('./contextServerUtils');
const ngsiLD = require('../ngsi/entities-NGSI-LD');
const config = require('../../commonConfig');
const overwritePaths = ['/ngsi-ld/v1/entities/:entity/attrs', '/ngsi-ld/v1/entities/:entity/attrs/:attr'];
const updatePaths = ['/ngsi-ld/v1/entities/:entity/attrs', '/ngsi-ld/v1/entities/:entity/attrs/:attr'];
const queryPaths = ['/ngsi-ld/v1/entities/:entity'];
/**
* Replacement of NGSI-LD Null placeholders with real null values
*
*/
function replaceNGSILDNull(payload) {
Object.keys(payload).forEach((key) => {
const value = payload[key];
if (value === constants.NGSI_LD_NULL) {
payload[key] = null;
} else if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
payload[key] = replaceNGSILDNull(payload[key]);
}
});
return payload;
}
/**
* Check to see if the payload or its subattributes contain null values
*
*/
function containsNulls(payload, result) {
Object.keys(payload).forEach((key) => {
const value = payload[key];
if (value === null) {
result.nulls = true;
} else if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
containsNulls(payload[key], result);
}
});
return result;
}
/**
* An Express middleware for preprocessing NGSI-LD payloads. Converts NGSI-LD Nulls
* to real nulls and checks for the presence of null and datasetId
*
*/
function preprocessNGSILD(req, res, next) {
res.locals.hasDatasetId = false;
const payload = req.body;
if (payload && typeof payload === 'object') {
Object.keys(payload).forEach((key) => {
if (_.isArray(payload[key])) {
payload[key].forEach((obj) => {
if (obj.datasetId) {
res.locals.hasDatasetId = true;
}
});
} else if (payload[key] && payload[key].datasetId && payload[key].datasetId !== '@none') {
res.locals.hasDatasetId = true;
}
});
req.body = replaceNGSILDNull(payload);
const result = { nulls: false };
containsNulls(payload, result);
res.locals.hasNulls = result.nulls;
}
next();
}
/**
* A configurable Middleware that makes additional NGSI-LD checks within the payload.
*
* @param {Boolean} supportNull Whether to support NGSI-LD nulls in the payload
* @param {Boolean} supportDatasetId Whether to support multiattributes in the payload.
* @return {Object} Express middleware used in request validation.
*/
function validateNGSILD(supportNull, supportDatasetId) {
return function validate(req, res, next) {
if (!supportNull && res.locals.hasNulls) {
next(
new errors.BadRequest(
'NGSI-LD Null found within the payload. This IoT Agent does not support nulls for this endpoint.'
)
);
} else if (!supportDatasetId && res.locals.hasDatasetId) {
next(
new errors.BadRequest(
'datasetId found within the payload. This IoT Agent does not support multi-attribute requests.'
)
);
} else {
next();
}
};
}
/**
* Extract metadata attributes from input.
*
* @param {Object} obj input object
*/
function getMetaData(obj) {
const excludedKeys = ['datasetId', 'value', 'type'];
const metaData = {};
_.keys(obj).forEach((key) => {
if (!excludedKeys.includes(key)) {
metaData[key] = obj[key];
}
});
return !_.isEmpty(metaData) ? metaData : {};
}
/**
* Generate all the update actions corresponding to a update context request using Ngsi2.
* Update actions include updates in attributes and execution of commands.
*
* @param {Object} req Update request to generate Actions from
* @param {Object} contextElement Context Element whose actions will be extracted.
*/
function generateUpdateActionsNgsiLD(req, contextElement, callback) {
let entityId;
let entityType;
const attribute = req.params.attr;
const value = req.body.value;
const datasetId = req.body.datasetId;
const incomingAttrs = !req.params.attr ? _.keys(req.body) : [];
if (contextElement.id && contextElement.type) {
entityId = contextElement.id;
entityType = contextElement.type;
} else if (req.params.entity) {
entityId = req.params.entity;
}
function splitUpdates(device, callback) {
const attributes = [];
const commands = [];
let found;
if (device.commands) {
for (const j in device.commands) {
const name = device.commands[j].name;
if (attribute === name) {
if (_.isArray(req.body)) {
req.body[name].forEach((obj) => {
commands.push({
type: device.commands[j].type,
value: obj.value,
name: name,
datasetId: obj.datasetId,
metadata: getMetaData(obj)
});
});
} else {
commands.push({
type: device.commands[j].type,
value,
name,
datasetId,
metadata: getMetaData(req.body)
});
}
found = true;
} else if (incomingAttrs.includes(name)) {
if (_.isArray(req.body[name])) {
req.body[name].forEach((obj) => {
commands.push({
type: device.commands[j].type,
value: obj.value,
name: name,
datasetId: obj.datasetId,
metadata: getMetaData(obj)
});
});
} else {
const obj = req.body[name];
commands.push({
type: device.commands[j].type,
value: obj.value,
name: name,
datasetId: obj.datasetId,
metadata: getMetaData(obj)
});
}
found = true;
}
}
}
if (attribute && !found) {
attributes.push({
type: 'Property',
value,
name: attribute
});
}
callback(null, attributes, commands, device);
}
function createActionsArray(attributes, commands, device, callback) {
const updateActions = [];
if (!entityType) {
entityType = device.type;
}
if (contextServerUtils.updateHandler) {
updateActions.push(
async.apply(
contextServerUtils.updateHandler,
entityId,
entityType,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
attributes
)
);
}
if (contextServerUtils.commandHandler) {
if (device.polling) {
updateActions.push(
async.apply(
contextServerUtils.pushCommandsToQueue,
device,
entityId,
entityType,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
attributes
)
);
} else {
updateActions.push(
async.apply(
contextServerUtils.commandHandler,
entityId,
entityType,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
commands
)
);
}
}
updateActions.push(
async.apply(
contextServerUtils.executeUpdateSideEffects,
device,
entityId,
entityType,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
attributes
)
);
callback(null, updateActions);
}
deviceService.getDeviceByName(
entityId,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
function (error, deviceObj) {
if (error) {
callback(error);
} else {
async.waterfall(
[
apply(deviceService.findConfigurationGroup, deviceObj),
apply(
deviceService.mergeDeviceWithConfiguration,
['lazy', 'internalAttributes', 'active', 'staticAttributes', 'commands', 'subscriptions'],
[null, null, [], [], [], [], []],
deviceObj
),
splitUpdates,
createActionsArray
],
callback
);
}
}
);
}
/** Express middleware to manage incoming update requests using NGSI-LD.
*
* @param {Object} req Request that was handled in first place.
* @param {Object} res Response that will be sent.
*/
function handleUpdateNgsiLD(req, res, next) {
function reduceActions(actions, callback) {
callback(null, _.flatten(actions));
}
if (contextServerUtils.updateHandler || contextServerUtils.commandHandler) {
logger.debug(context, 'Handling LD update from [%s]', req.get('host'));
if (req.body) {
logger.debug(context, JSON.stringify(req.body, null, 4));
}
async.waterfall(
[apply(async.map, [req.body], apply(generateUpdateActionsNgsiLD, req)), reduceActions, async.series],
function (error, result) {
if (error) {
logger.debug(context, 'There was an error handling the update action: %s.', error);
next(error);
} else {
logger.debug(context, 'Update action from [%s] handled successfully.', req.get('host'));
res.status(204).json();
}
}
);
} else {
logger.error(context, 'Tried to handle an update request before the update handler was established.');
const errorNotFound = new Error({
message: 'Update handler not found'
});
next(errorNotFound);
}
}
/**
* Handle queries coming to the IoT Agent via de Context Provider API (as a consequence of a query to a passive
* attribute redirected by the Context Broker).
*
* @param {String} id Entity name of the selected entity in the query.
* @param {String} type Type of the entity.
* @param {String} service Service the device belongs to.
* @param {String} subservice Division inside the service.
* @param {Array} attributes List of attributes to read.
*/
function defaultQueryHandlerNgsiLD(id, type, service, subservice, attributes, callback) {
const contextElement = {
type,
id
};
deviceService.getDeviceByName(id, service, subservice, function (error, ngsiDevice) {
if (error) {
callback(error);
} else {
for (let i = 0; i < attributes.length; i++) {
const lazyAttribute = _.findWhere(ngsiDevice.lazy, { name: attributes[i] });
const command = _.findWhere(ngsiDevice.commands, { name: attributes[i] });
let attributeType = 'string';
if (command) {
attributeType = command.type;
} else if (lazyAttribute) {
attributeType = lazyAttribute.type;
}
contextElement[attributes[i]] = ngsiLD.convertAttrNGSILD({
type: attributeType,
value: ''
});
}
callback(null, contextElement);
}
});
}
/**
* Generate a merge-patch action corresponding to the request using NGSI-LD.
* Merge-patch is an NGSI-LD specific action.
*
* @param {Object} req Update request to generate Actions from
*/
function generateMergePatchActionNgsiLD(req, callback) {
const entityId = req.params.entity;
function addAttributes(deviceData, body, attributes) {
const keys = Object.keys(body);
for (const j in deviceData) {
if (keys.includes(deviceData[j].name)) {
const obj = body[deviceData[j].name];
if (obj === null) {
attributes.push({
type: deviceData[j].type,
value: null,
name: deviceData[j].name
});
} else {
attributes.push({
type: deviceData[j].type,
value: obj.value,
name: deviceData[j].name
});
}
}
}
return attributes;
}
deviceService.getDeviceByName(
entityId,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
function (error, deviceObj) {
if (error) {
callback(error);
} else {
const attributes = [];
addAttributes(deviceObj.commands, req.body, attributes);
addAttributes(deviceObj.lazy, req.body, attributes);
const executeMergePatchHandler = apply(
contextServerUtils.mergePatchHandler,
entityId,
deviceObj.type,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
attributes
);
async.waterfall([executeMergePatchHandler], callback());
}
}
);
}
/**
* Express middleware to manage incoming merge-patch requests using NGSI-LD.
*
* @param {Object} req Request that was handled in first place.
* @param {Object} res Response that will be sent.
*/
function handleMergePatchNgsiLD(req, res, next) {
function handleMergePatchRequest(error, result) {
if (error) {
logger.debug(context, 'There was an error handling the merge-patch: %s.', error);
next(error);
} else {
logger.debug(context, 'Merge-patch from [%s] handled successfully.', req.get('host'));
res.status(200).json(result);
}
}
logger.debug(context, 'Handling merge-patch from [%s]', req.get('host'));
if ((req.is('json') || req.is('application/ld+json')) === false) {
return handleMergePatchRequest(new errors.UnsupportedContentType(req.header('content-type')));
}
if (req.body) {
logger.debug(context, JSON.stringify(req.body, null, 4));
}
if (contextServerUtils.mergePatchHandler) {
generateMergePatchActionNgsiLD(req, handleMergePatchRequest);
} else {
return handleMergePatchRequest(new errors.MethodNotSupported(req.method, req.path));
}
}
/**
* Express middleware to manage incoming query context requests using NGSI-LD.
*
* @param {Object} req Request that was handled in first place.
* @param {Object} res Response that will be sent.
*/
function handleQueryNgsiLD(req, res, next) {
function getName(element) {
return element.name;
}
function addStaticAttributes(attributes, device, contextElement, callback) {
function inAttributes(item) {
return item.name && attributes.indexOf(item.name) >= 0;
}
if (device.staticAttributes) {
let selectedAttributes = [];
if (attributes === null || attributes === undefined || attributes.length === 0) {
selectedAttributes = device.staticAttributes;
} else {
selectedAttributes = device.staticAttributes.filter(inAttributes);
}
for (const att in selectedAttributes) {
contextElement[selectedAttributes[att].name] = {
type: selectedAttributes[att].type,
value: selectedAttributes[att].value
};
}
}
callback(null, contextElement);
}
function completeAttributes(attributes, device, callback) {
if (attributes && attributes.length !== 0) {
logger.debug(context, 'Handling received set of attributes: %j', attributes);
callback(null, attributes);
} else if (device.lazy) {
logger.debug(context, 'Handling stored set of attributes: %j', attributes);
const results = device.lazy.map(getName);
callback(null, results);
} else {
logger.debug(context, "Couldn't find any attributes. Handling with empty reference");
callback(null, []);
}
}
function finishQueryForDevice(attributes, contextEntity, actualHandler, device, callback) {
let contextId = contextEntity.id;
let contextType = contextEntity.type;
if (!contextId) {
contextId = device.id;
}
if (!contextType) {
contextType = device.type;
}
deviceService.findConfigurationGroup(device, function (error, group) {
const executeCompleteAttributes = apply(completeAttributes, attributes, group);
const executeQueryHandler = apply(
actualHandler,
contextId,
contextType,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req)
);
const executeAddStaticAttributes = apply(addStaticAttributes, attributes, group);
async.waterfall([executeCompleteAttributes, executeQueryHandler, executeAddStaticAttributes], callback);
});
}
function createQueryRequest(attributes, contextEntity, callback) {
let actualHandler;
let getFunction;
if (contextServerUtils.queryHandler) {
actualHandler = contextServerUtils.queryHandler;
} else {
actualHandler = defaultQueryHandlerNgsiLD;
}
if (contextEntity.id) {
getFunction = apply(
deviceService.getDeviceByName,
contextEntity.id,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req)
);
} else {
getFunction = apply(
deviceService.listDevicesWithType,
contextEntity.type,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
null,
null
);
}
getFunction(function handleFindDevice(error, innerDevice) {
let deviceList = [];
if (!innerDevice) {
return callback(new errors.DeviceNotFound(contextEntity.id, contextEntity));
}
if (innerDevice.count) {
if (innerDevice.count === 0) {
return callback(null, []);
}
deviceList = innerDevice.devices;
} else {
deviceList = [innerDevice];
}
async.map(
deviceList,
async.apply(finishQueryForDevice, attributes, contextEntity, actualHandler),
function (error, results) {
if (error) {
callback(error);
} else if (innerDevice.count) {
callback(null, results);
} else if (Array.isArray(results) && results.length > 1) {
callback(null, results);
} else {
callback(null, results[0]);
}
}
);
});
}
function handleQueryContextRequests(error, result) {
if (error) {
logger.debug(context, 'There was an error handling the query: %s.', error);
next(error);
} else {
logger.debug(context, 'Query from [%s] handled successfully.', req.get('host'));
res.status(200).json(result);
}
}
logger.debug(context, 'Handling query from [%s]', req.get('host'));
if ((req.is('json') || req.is('application/ld+json')) === false) {
return handleQueryContextRequests(new errors.UnsupportedContentType(req.header('content-type')));
}
if (req.body) {
logger.debug(context, JSON.stringify(req.body, null, 4));
}
const nss = req.params.entity.replace('urn:ngsi-ld:', '');
const contextEntity = {
id: req.params.entity,
type: nss.substring(0, nss.indexOf(':'))
};
createQueryRequest(req.query.attrs ? req.query.attrs.split(',') : null, contextEntity, handleQueryContextRequests);
}
/**
* Error handler for NGSI-LD context query requests.
*
* @param {Object} error Incoming error
* @param {Object} req Request that was handled in first place.
* @param {Object} res Response that will be sent.
*/
function ErrorHandlingNgsiLD(action) {
return function errorHandle(error, req, res, next) {
let code = 500;
logger.debug(context, action + ' NGSI-LD error [%s] handling request: %s', error.name, error.message);
if (error.code && String(error.code).match(/^[2345]\d\d$/)) {
code = error.code;
}
res.status(code).json({
error: error.name,
description: error.message.replace(/[<>\"\'=;\(\)]/g, '')
});
};
}
/**
* Express middleware to manage incoming notification requests using NGSI-LD.
*
* @param {Object} req Request that was handled in first place.
* @param {Object} res Response that will be sent.
*/
function handleNotificationNgsiLD(req, res, next) {
function extractInformation(dataElement, callback) {
const atts = [];
for (const key in dataElement) {
/* eslint-disable-next-line no-prototype-builtins */
if (dataElement.hasOwnProperty(key)) {
if (key !== 'id' && key !== 'type') {
if (_.isArray(dataElement[key])) {
dataElement[key].forEach((obj) => {
atts.push({
type: obj.type,
value: obj.value,
name: key,
datasetId: obj.datasetId,
metadata: getMetaData(obj)
});
});
} else {
atts.push({
type: dataElement[key].type,
value: dataElement[key].value,
name: key,
datasetId: dataElement[key].datasetId,
metadata: getMetaData(dataElement[key])
});
}
}
}
}
deviceService.getDeviceByName(
dataElement.id,
contextServerUtils.getLDTenant(req),
contextServerUtils.getLDPath(req),
function (error, device) {
if (error) {
callback(error);
} else {
callback(null, device, atts);
}
}
);
}
function applyNotificationMiddlewares(device, values, callback) {
if (contextServerUtils.notificationMiddlewares.length > 0) {
const firstMiddleware = contextServerUtils.notificationMiddlewares.slice(0, 1)[0];
const rest = contextServerUtils.notificationMiddlewares.slice(1);
const startMiddleware = apply(firstMiddleware, device, values);
const composedMiddlewares = [startMiddleware].concat(rest);
async.waterfall(composedMiddlewares, callback);
} else {
callback(null, device, values);
}
}
function createNotificationHandler(contextResponse, callback) {
async.waterfall(
[
apply(extractInformation, contextResponse),
applyNotificationMiddlewares,
contextServerUtils.notificationHandler
],
callback
);
}
function handleNotificationRequests(error) {
if (error) {
logger.error(context, 'Error found when processing notification: %j', error);
next(error);
} else {
res.status(200).json({});
}
}
if (contextServerUtils.notificationHandler) {
logger.debug(context, 'Handling notification from [%s]', req.get('host'));
async.map(req.body.data, createNotificationHandler, handleNotificationRequests);
} else {
const errorNotFound = new Error({
message: 'Notification handler not found'
});
logger.error(context, 'Tried to handle a notification before notification handler was established.');
next(errorNotFound);
}
}
/**
* Load unsupported NGSI-LD entity routes and return proper NGSI-LD not supported responses
*
* @param {Object} router Express request router object.
*/
function loadUnsupportedEndpointsNGSILD(router) {
const unsupportedEndpoint = function (req, res) {
return res.status(501).send(new errors.MethodNotSupported(req.method, req.path));
};
router.get('/ngsi-ld/v1/entities', unsupportedEndpoint);
router.post('/ngsi-ld/v1/entities', unsupportedEndpoint);
router.delete('/ngsi-ld/v1/entities/:entity', unsupportedEndpoint);
router.delete('/ngsi-ld/v1/entities/:entity/attrs/:attr', unsupportedEndpoint);
}
/**
* Load the routes related to context dispatching (NGSI10 calls).
*
* @param {Object} router Express request router object.
*/
function loadContextRoutesNGSILD(router) {
// In a more evolved implementation, more endpoints could be added to queryPathsNgsi2
// according to https://www.etsi.org/standards-search#page=1&search=GS%20CIM%20009
const support = config.getConfig().server.ldSupport;
let i;
logger.info(context, 'Loading NGSI-LD Context server routes');
// Update Patch endpoints - this endpoint may accept NGSI-LD Null
for (i = 0; i < updatePaths.length; i++) {
router.patch(updatePaths[i], [
middlewares.ensureType,
middlewares.validateJson(updateContextTemplateNgsiLD),
preprocessNGSILD,
validateNGSILD(support.null, support.datasetId),
handleUpdateNgsiLD,
ErrorHandlingNgsiLD('Partial Update')
]);
}
// Merge Patch endpoints - this endpoint may accept NGSI-LD Null
router.patch('/ngsi-ld/v1/entities/:entity', [
preprocessNGSILD,
validateNGSILD(support.null, support.datasetId),
handleMergePatchNgsiLD,
ErrorHandlingNgsiLD('Merge-Patch')
]);
// Overwrite/PUT endpoints - this endpoint does not accept NGSI-LD Null
for (i = 0; i < overwritePaths.length; i++) {
router.put(overwritePaths[i], [
middlewares.ensureType,
middlewares.validateJson(updateContextTemplateNgsiLD),
preprocessNGSILD,
validateNGSILD(false, support.datasetId),
handleUpdateNgsiLD,
ErrorHandlingNgsiLD('Overwrite')
]);
}
// Query/GET endpoints - no payload to check.
for (i = 0; i < queryPaths.length; i++) {
router.get(queryPaths[i], [handleQueryNgsiLD, ErrorHandlingNgsiLD('Query')]);
}
router.post('/notify', [
middlewares.ensureType,
middlewares.validateJson(notificationTemplateNgsiLD),
preprocessNGSILD,
validateNGSILD(false, support.datasetId),
handleNotificationNgsiLD,
ErrorHandlingNgsiLD('Notify')
]);
loadUnsupportedEndpointsNGSILD(router);
}
exports.loadContextRoutes = loadContextRoutesNGSILD;