iotagent-node-lib
Version:
IoT Agent library to interface with NGSI Context Broker
755 lines (694 loc) • 33.1 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: Federico M. Facca - Martel Innovate
* Modified by: Daniel Calvo - ATOS Research & Innovation
* Modified by: Jason Fox - FIWARE Foundation
*/
/* eslint-disable consistent-return */
const request = require('../../request-shim');
const alarms = require('../common/alarmManagement');
const errors = require('../../errors');
const pluginUtils = require('../../plugins/pluginUtils');
const constants = require('../../constants');
const jexlParser = require('../../plugins/jexlParser');
const expressionPlugin = require('../../plugins/expressionPlugin');
const moment = require('moment-timezone');
const NGSIUtils = require('./ngsiUtils');
const logger = require('logops');
const context = {
op: 'IoTAgentNGSI.Entities-v2'
};
/**
* Amends an NGSIv2 Geoattribute from String to GeoJSON format
*
* @param {Object} attr Attribute to be analyzed
* @return {Object} GeoJSON version of the attribute
*/
function formatGeoAttrs(attr) {
const obj = attr;
if (attr.type) {
switch (attr.type.toLowerCase()) {
// GeoProperties
case 'geo:json':
// FIXME: #1012
// case 'geoproperty':
// case 'point':
// case 'geo:point':
obj.type = 'geo:json';
obj.value = NGSIUtils.getLngLats('Point', attr.value);
break;
// FIXME: #1012
// case 'linestring':
// case 'geo:linestring':
// obj.type = 'geo:json';
// obj.value = NGSIUtils.getLngLats('LineString', attr.value);
// break;
// case 'polygon':
// case 'geo:polygon':
// obj.type = 'geo:json';
// obj.value = NGSIUtils.getLngLats('Polygon', attr.value);
// break;
// case 'multipoint':
// case 'geo:multipoint':
// obj.type = 'geo:json';
// obj.value = NGSIUtils.getLngLats('MultiPoint', attr.value);
// break;
// case 'multilinestring':
// case 'geo:multilinestring':
// obj.type = 'geo:json';
// obj.value = NGSIUtils.getLngLats('MultiLineString', attr.value);
// break;
// case 'multipolygon':
// case 'geo:multipolygon':
// obj.type = 'geo:json';
// obj.value = NGSIUtils.getLngLats('MultiPolygon', attr.value);
// break;
}
}
return obj;
}
/**
* Generate an operation handler for NGSIv2-based operations (query and update). The handler takes care of identifiying
* the errors and calling the appropriate callback with a success or a failure depending on how the operation ended.
*
* Most of the parameters are passed for debugging purposes mainly.
*
* @param {String} operationName Name of the NGSI operation being performed.
* @param {String} entityName Name of the entity that was the target of the operation.
* @param {Object} typeInformation Information about the device the entity represents.
* @param {String} token Security token used to access the entity.
* @param {Object} options Object holding all the information about the HTTP request.
* @return {Function} The generated handler.
*/
function generateNGSI2OperationHandler(operationName, entityName, typeInformation, token, options, callback) {
return function (error, response, body) {
if (error) {
logger.error(context, 'Error found executing ' + operationName + ' action in Context Broker: %s', error);
alarms.raise(constants.ORION_ALARM, error);
callback(error);
} else if (body && body.orionError) {
logger.debug(
context,
'Orion error found executing ' + operationName + ' action in Context Broker: %j',
body.orionError
);
callback(new errors.BadRequest(body.orionError.details));
} else if (response && operationName === 'update' && response.statusCode === 204) {
logger.info(context, 'Received the following response from the CB: Value updated successfully\n');
alarms.release(constants.ORION_ALARM);
callback(null, body);
} else if (response && operationName === 'query' && body !== undefined && response.statusCode === 200) {
logger.debug(
context,
'Received the following response from the CB:\n\n%s\n\n',
JSON.stringify(body, null, 4)
);
logger.debug(context, 'Value queried successfully');
alarms.release(constants.ORION_ALARM);
callback(null, body);
} else if (response && operationName === 'query' && response.statusCode === 204) {
logger.info(
context,
'Received the following response from the CB:\n\n%s\n\n',
JSON.stringify(body, null, 4)
);
logger.error(
context,
'Operation ' +
operationName +
' bad status code from the CB: 204.' +
'A query operation must always return a body'
);
callback(new errors.BadAnswer(response.statusCode, operationName));
} else if (response && (response.statusCode === 403 || response.statusCode === 401)) {
logger.debug(context, 'Access forbidden executing ' + operationName + ' operation');
callback(
new errors.AccessForbidden(
token,
options.headers['fiware-service'],
options.headers['fiware-servicepath']
)
);
} else if (response && body && response.statusCode === 404) {
logger.info(
context,
'Received the following response from the CB:\n\n%s\n\n',
JSON.stringify(body, null, 4)
);
logger.error(context, 'Operation ' + operationName + ' error connecting to the Context Broker: %j', body);
let errorField = body.error;
if (body.description) {
errorField += ':' + body.description;
}
if (errorField !== undefined) {
callback(new errors.DeviceNotFound(entityName, typeInformation));
} else {
callback(new errors.EntityGenericError(entityName, typeInformation.type, typeInformation, body));
}
} else {
logger.debug(context, 'Unknown error executing ' + operationName + ' operation');
if (!(body instanceof Array || body instanceof Object)) {
body = JSON.parse(body);
}
callback(
new errors.EntityGenericError(
entityName,
typeInformation.type,
typeInformation,
body,
response.statusCode
)
);
}
};
}
/**
* Makes a query to the Device's entity in the context broker using NGSIv2, with the list
* of attributes given by the 'attributes' array.
*
* @param {String} entityName Name of the entity to query.
* @param {Array} attributes Attribute array containing the names of the attributes to query.
* @param {Object} typeInformation Configuration information for the device.
* @param {String} token User token to identify against the PEP Proxies (optional).
*/
function sendQueryValueNgsi2(entityName, attributes, typeInformation, token, callback) {
let url = '/v2/entities/' + entityName + '/attrs';
if (attributes && attributes.length > 0) {
let attributesQueryParam = '';
for (let i = 0; i < attributes.length; i++) {
attributesQueryParam = attributesQueryParam + attributes[i];
if (i < attributes.length - 1) {
attributesQueryParam = attributesQueryParam + ',';
}
}
url = url + '?attrs=' + attributesQueryParam;
}
if (typeInformation.type) {
if (attributes && attributes.length > 0) {
url += '&type=' + typeInformation.type;
} else {
url += '?type=' + typeInformation.type;
}
}
const options = NGSIUtils.createRequestObject(url, typeInformation, token);
options.method = 'GET';
if (!typeInformation || !typeInformation.type) {
callback(new errors.TypeNotFound(null, entityName, typeInformation));
return;
}
logger.debug(context, 'Querying values of the device in the Context Broker at [%s]', options.url);
logger.debug(context, 'Using the following request:\n\n%s\n\n', JSON.stringify(options, null, 4));
request(
options,
generateNGSI2OperationHandler('query', entityName, typeInformation, token, options, function (error, result) {
if (error) {
callback(error);
} else {
NGSIUtils.applyMiddlewares(NGSIUtils.queryMiddleware, result, typeInformation, callback);
}
})
);
}
/**
* Makes an update in the Device's entity in the context broker, with the values given in the 'attributes' array. This
* array should comply to the NGSIv2's attribute format.
*
* @param {String} entityName Name of the entity to register.
* @param {Array} measures measure array containing the values to update.
* @param {Object} typeInformation Configuration information for the device.
* @param {String} token User token to identify against the PEP Proxies (optional).
*/
function sendUpdateValueNgsi2(entityName, originMeasures, originTypeInformation, token, callback) {
//aux functions used to builf JEXL context.
//it returns a flat object from an Attr array
function reduceAttrToPlainObject(attrs, initObj = {}) {
if (attrs !== undefined && Array.isArray(attrs)) {
return attrs.reduce((result, item) => {
result[item.name] = item.value;
return result;
}, initObj);
} else {
return initObj;
}
}
//it returns a metadata object using the same structure described
// at https://github.com/telefonicaid/fiware-orion/blob/master/doc/manuals/orion-api.md#metadata-support
function reduceMetadataAttrToPlainObject(attrs, initObj = {}) {
if (attrs !== undefined && Array.isArray(attrs)) {
return attrs.reduce((result, item) => {
if (result['metadata'] === undefined) {
result['metadata'] = {};
}
if (item.metadata !== undefined) {
result['metadata'][item.name] = {};
for (var meta in item.metadata) {
result['metadata'][item.name][meta] = item.metadata[meta].value;
}
}
return result;
}, initObj);
} else {
return initObj;
}
}
//Make a clone and overwrite
let idTypeSSSList = pluginUtils.getIdTypeServSubServiceFromDevice(originTypeInformation);
//Check mandatory information: type
if (!originTypeInformation || !originTypeInformation.type) {
callback(new errors.TypeNotFound(null, entityName, originTypeInformation));
return;
}
let payload = {}; //will store the final payload
let entities = {};
payload.actionType = 'append';
payload.entities = [];
const currentIsoDate = new Date().toISOString();
const currentMoment = moment(currentIsoDate);
//Managing timestamp (mustInsertTimeInstant flag to decide if we should insert Timestamp later on)
const mustInsertTimeInstant =
originTypeInformation.timestamp !== undefined ? originTypeInformation.timestamp : false;
// Check if measures is a single measure or a array of measures (a multimeasure)
if (originMeasures[0] && !originMeasures[0][0]) {
originMeasures = [originMeasures];
}
for (let measures of originMeasures) {
entities = {}; //{entityName:{entityType:[attrs]}} //SubGoal Populate entities data structure
let jexlctxt = {}; //will store the whole context (not just for JEXL)
let plainMeasures = null; //will contain measures POJO
//Make a clone and overwrite
let typeInformation = JSON.parse(JSON.stringify(originTypeInformation));
//Rename all measures with matches with id and type to measure_id and measure_type
for (let measure of measures) {
if (measure.name === 'id' || measure.name === 'type') {
measure.name = constants.MEASURE + measure.name;
}
}
//Make a copy of measures in an plain object: plainMeasures
plainMeasures = reduceAttrToPlainObject(measures);
//Build the initital JEXL Context
//All the measures (avoid references make another copy instead)
jexlctxt = reduceAttrToPlainObject(measures);
//All the static
jexlctxt = reduceAttrToPlainObject(typeInformation.staticAttributes, jexlctxt);
//id type Service and Subservice
jexlctxt = reduceAttrToPlainObject(idTypeSSSList, jexlctxt);
//metadata attributes
jexlctxt = reduceMetadataAttrToPlainObject(typeInformation.active, jexlctxt);
//metadata static attributes
jexlctxt = reduceMetadataAttrToPlainObject(typeInformation.staticAttributes, jexlctxt);
//recover oldctxt
if (typeInformation.oldCtxt) {
jexlctxt.oldCtxt = typeInformation.oldCtxt;
}
logger.debug(
context,
'sendUpdateValueNgsi2 loop with: entityName=%s, measures=%j, typeInformation=%j, initial jexlContext=%j, timestamp=%j',
entityName,
plainMeasures,
typeInformation,
jexlctxt,
mustInsertTimeInstant
);
//Now we can calculate the EntityName of primary entity
let entityNameCalc = null;
if (typeInformation.entityNameExp !== undefined && typeInformation.entityNameExp !== '') {
try {
logger.debug(context, 'sendUpdateValueNgsi2 entityNameExp %j', typeInformation.entityNameExp);
entityNameCalc = expressionPlugin.applyExpression(
typeInformation.entityNameExp,
jexlctxt,
typeInformation
);
} catch (e) {
logger.debug(
context,
'Error evaluating expression for entityName: %j with context: %j',
typeInformation.entityNameExp,
jexlctxt
);
}
}
entityName = entityNameCalc ? entityNameCalc : entityName;
//enrich JEXL context
jexlctxt['entity_name'] = entityName;
let preprocessedAttr = [];
//Add Raw Static, Lazy, Command and Actives attr attributes
if (typeInformation && typeInformation.staticAttributes) {
preprocessedAttr = preprocessedAttr.concat(typeInformation.staticAttributes);
}
if (typeInformation && typeInformation.lazy) {
preprocessedAttr = preprocessedAttr.concat(typeInformation.lazy);
}
if (typeInformation && typeInformation.active) {
preprocessedAttr = preprocessedAttr.concat(typeInformation.active);
}
//Proccess every proto Attribute to populate entities data steuture
entities[entityName] = {};
entities[entityName][typeInformation.type] = [];
for (let currentAttr of preprocessedAttr) {
let hitted = false; //any measure, expressiom or value hit the attr (avoid propagate "silent attr" with null values )
let attrEntityName = entityName;
let attrEntityType = typeInformation.type;
let valueExpression = null;
//manage active attr without object__id (name by default)
currentAttr.object_id = currentAttr.object_id ? currentAttr.object_id : currentAttr.name;
//Enrich the attr (skip, hit, value, meta-timeInstant)
currentAttr.skipValue = currentAttr.skipValue ? currentAttr.skipValue : null;
//determine AttrEntityName for multientity
if (
currentAttr.entity_name !== null &&
currentAttr.entity_name !== undefined &&
currentAttr.entity_name !== '' &&
typeof currentAttr.entity_name == 'string'
) {
try {
logger.debug(
context,
'Evaluating attribute: %j, for entity_name(exp):%j, with ctxt: %j',
currentAttr.name,
currentAttr.entity_name,
jexlctxt
);
attrEntityName = jexlParser.applyExpression(currentAttr.entity_name, jexlctxt, typeInformation);
if (!attrEntityName) {
attrEntityName = currentAttr.entity_name;
}
} catch (e) {
logger.debug(
context,
'Exception evaluating entityNameExp:%j, with jexlctxt: %j',
currentAttr.entity_name,
jexlctxt
);
attrEntityName = currentAttr.entity_name;
}
}
//determine AttrEntityType for multientity
if (
currentAttr.entity_type !== null &&
currentAttr.entity_type !== undefined &&
currentAttr.entity_type !== '' &&
typeof currentAttr.entity_type === 'string'
) {
attrEntityType = currentAttr.entity_type;
}
//PRE POPULATE CONTEXT
jexlctxt[currentAttr.name] = plainMeasures[currentAttr.object_id];
//determine Value
if (currentAttr.value !== undefined) {
//static attributes already have a value
hitted = true;
valueExpression = currentAttr.value;
} else if (plainMeasures[currentAttr.object_id] !== undefined) {
//we have got a meaure for that Attr
//actives ¿lazis?
hitted = true;
valueExpression = plainMeasures[currentAttr.object_id];
}
//remove measures that have been shadowed by an alias (some may be left and managed later)
//Maybe we must filter object_id if there is name == object_id
measures = measures.filter((item) => item.name !== currentAttr.object_id);
if (
currentAttr.expression !== undefined &&
currentAttr.expression !== '' &&
typeof currentAttr.expression == 'string'
) {
try {
hitted = true;
valueExpression = jexlParser.applyExpression(currentAttr.expression, jexlctxt, typeInformation);
//we fallback to null if anything unexpecte happend
if (valueExpression === null || valueExpression === undefined || Number.isNaN(valueExpression)) {
valueExpression = null;
}
} catch (e) {
valueExpression = null;
}
logger.debug(
context,
'Evaluated attr: %j, with expression: %j, and ctxt: %j resulting: %j',
currentAttr.name,
currentAttr.expression,
jexlctxt,
valueExpression
);
}
currentAttr.hitted = hitted;
currentAttr.value = valueExpression;
//store de New Attributte in entity data structure
if (hitted === true) {
if (entities[attrEntityName] === undefined) {
entities[attrEntityName] = {};
}
if (entities[attrEntityName][attrEntityType] === undefined) {
entities[attrEntityName][attrEntityType] = [];
}
//store de New Attributte
entities[attrEntityName][attrEntityType].push(currentAttr);
}
//RE-Populate de JEXLcontext (except for null or NaN we preffer undefined)
jexlctxt[currentAttr.name] = valueExpression;
// Expand metadata value expression
if (currentAttr.metadata) {
for (var metaKey in currentAttr.metadata) {
if (currentAttr.metadata[metaKey].expression && metaKey !== constants.TIMESTAMP_ATTRIBUTE) {
let newAttrMeta = {};
if (currentAttr.metadata[metaKey].type) {
newAttrMeta['type'] = currentAttr.metadata[metaKey].type;
}
let metaValueExpression;
try {
metaValueExpression = jexlParser.applyExpression(
currentAttr.metadata[metaKey].expression,
jexlctxt,
typeInformation
);
//we fallback to null if anything unexpecte happend
if (
metaValueExpression === null ||
metaValueExpression === undefined ||
Number.isNaN(metaValueExpression)
) {
metaValueExpression = null;
}
} catch (e) {
metaValueExpression = null;
}
newAttrMeta['value'] = metaValueExpression;
currentAttr.metadata[metaKey] = newAttrMeta;
//RE-Populate de JEXLcontext
// It is possible metadata is still not in ctxt
if (!jexlctxt.metadata) {
jexlctxt.metadata = {};
}
if (!jexlctxt.metadata[currentAttr.name]) {
jexlctxt.metadata[currentAttr.name] = {};
}
jexlctxt.metadata[currentAttr.name][currentAttr.metadata[metaKey]] = metaValueExpression;
}
}
}
}
//now we can compute explicit (Bool or Array) with the complete JexlContext
let explicit = false;
if (typeof typeInformation.explicitAttrs === 'string') {
try {
explicit = jexlParser.applyExpression(typeInformation.explicitAttrs, jexlctxt, typeInformation);
if (explicit instanceof Array && explicit.length > 0 && mustInsertTimeInstant) {
explicit.push(constants.TIMESTAMP_ATTRIBUTE);
}
logger.debug(
context,
'Calculated explicitAttrs with expression: %j and ctxt: %j resulting: %j',
typeInformation.explicitAttrs,
jexlctxt,
explicit
);
} catch (e) {
// nothing to do: exception is already logged at info level
}
} else if (typeof typeInformation.explicitAttrs == 'boolean') {
explicit = typeInformation.explicitAttrs;
}
//more mesures may be added to the attribute list (unnhandled/left mesaures) l
if (explicit === false && Object.keys(measures).length > 0) {
entities[entityName][typeInformation.type] = entities[entityName][typeInformation.type].concat(measures);
}
//PRE-PROCESSING FINISHED
//Explicit ATTRS and SKIPVALUES will be managed while we build NGSI payload
//Get ready to build and send NGSI payload (entities-->payload)
for (let ename in entities) {
for (let etype in entities[ename]) {
let e = {};
e.id = String(ename);
e.type = String(etype);
let timestamp = { type: constants.TIMESTAMP_TYPE_NGSI2 }; //timestamp scafold-attr for insertions.
let timestampAttrs = null;
if (mustInsertTimeInstant) {
// get timestamp for current entity
timestampAttrs = entities[ename][etype].filter(
(item) => item.name === constants.TIMESTAMP_ATTRIBUTE
);
if (timestampAttrs && timestampAttrs.length > 0) {
timestamp.value = timestampAttrs[0]['value'];
}
if (timestamp.value) {
if (!moment(timestamp.value, moment.ISO_8601, true).isValid()) {
callback(new errors.BadTimestamp(timestamp.value, entityName, typeInformation));
return;
}
} else {
if (!typeInformation.timezone) {
timestamp.value = currentIsoDate;
jexlctxt[constants.TIMESTAMP_ATTRIBUTE] = timestamp.value;
} else {
timestamp.value = currentMoment
.tz(typeInformation.timezone)
.format('YYYY-MM-DD[T]HH:mm:ss.SSSZ');
jexlctxt[constants.TIMESTAMP_ATTRIBUTE] = timestamp.value;
}
}
}
//extract attributes
let isEmpty = true;
for (let attr of entities[ename][etype]) {
if (
attr.name !== 'id' &&
attr.name !== 'type' &&
(attr.value !== attr.skipValue || attr.skipValue === undefined) &&
(attr.hitted || attr.hitted === undefined) && //undefined is for pure measures
(typeof explicit === 'boolean' || //true and false already handled
(explicit instanceof Array && //check the array version
(explicit.includes(attr.name) ||
explicit.some(
(item) => attr.object_id !== undefined && item.object_id === attr.object_id
))))
) {
isEmpty = false;
if (mustInsertTimeInstant) {
// Add TimeInstant to all attribute metadata of all entities
if (attr.name !== constants.TIMESTAMP_ATTRIBUTE) {
if (!attr.metadata) {
attr.metadata = {};
}
attr.metadata[constants.TIMESTAMP_ATTRIBUTE] = timestamp;
}
}
e[attr.name] = { type: attr.type, value: attr.value, metadata: attr.metadata };
}
}
if (!isEmpty) {
if (mustInsertTimeInstant) {
e[constants.TIMESTAMP_ATTRIBUTE] = timestamp;
}
payload.entities.push(e);
}
}
}
//Add jexlctxt to typeInformation without oldCtxt
const oldCJexlctxt = { ...jexlctxt };
delete oldCJexlctxt.oldCtxt;
originTypeInformation['oldCtxt'] = oldCJexlctxt;
} // end for (let measures of originMeasures)
let url = '/v2/op/update';
if (originTypeInformation.useCBflowControl) {
url += '?options=flowControl';
}
let options = NGSIUtils.createRequestObject(url, originTypeInformation, token);
options.json = payload;
// Prevent to update an entity with an empty payload: more than id and type
if (
Object.keys(options.json).length > 0 &&
(options.json.entities.length > 1 ||
(options.json.entities.length === 1 && Object.keys(options.json.entities[0]).length > 2))
) {
// Final check: (to keep tests unchanged) before do CB requests
// one entity -> request /v2/entities/ + entityName + /atts ?type=typeInformation.type
// multi entities -> request /v2/op/update
// Note that the options object is prepared for the second case (multi entity), so we "patch" it
// only in the first case
//Multi: multientity (more than one name o more than one type at primary entity)
// of multimeasure (originMeasures is an array of more than one element)
let multi =
Object.keys(entities).length > 1 ||
Object.keys(entities[entityName]).length > 1 ||
originMeasures.length > 1;
if (!multi) {
// recreate options object to use single entity update
url = '/v2/entities?options=upsert';
if (originTypeInformation.useCBflowControl) {
url += ',flowControl';
}
options = NGSIUtils.createRequestObject(url, originTypeInformation, token);
delete payload.actionType;
let entityAttrs = payload.entities[0];
const transformedObject = {};
for (let attrname in entityAttrs) {
let attr = entityAttrs[attrname];
transformedObject[attrname] = {
type: attr.type,
value: attr.value,
metadata: attr.metadata
};
}
transformedObject.id = entityAttrs.id;
transformedObject.type = entityAttrs.type;
options.json = transformedObject;
options.method = 'POST';
} else if (payload.entities.every((entity) => 'TimeInstant' in entity)) {
// Try sort entities by TimeInstant
payload.entities.sort(
(a, b) => new Date(a.TimeInstant.value).getTime() - new Date(b.TimeInstant.value).getTime()
);
options.json = payload;
} else {
// keep current options object created for a batch update
logger.debug(
context,
"some entities lack the 'TimeInstant' key. Sorting is not feasible: %j ",
payload.entities
);
}
//Send the NGSI request
logger.debug(context, 'Updating device value in the Context Broker at: %j', options.url);
logger.debug(context, 'Using the following NGSI v2 request: %j', options);
request(
options,
generateNGSI2OperationHandler('update', entityName, originTypeInformation, token, options, callback)
);
} else {
logger.debug(
context,
'Not updating device value in the Context Broker at: %j, due to empty payload: %j',
options.url,
options
);
callback(null);
}
}
exports.sendQueryValue = sendQueryValueNgsi2;
exports.sendUpdateValue = function (entityName, measures, typeInformation, token, callback) {
NGSIUtils.applyMiddlewares(NGSIUtils.updateMiddleware, measures, typeInformation, () => {
return sendUpdateValueNgsi2(entityName, measures, typeInformation, token, callback);
});
};
exports.formatGeoAttrs = formatGeoAttrs;