@sap/xsodata
Version:
Expose data from a HANA database as OData V2 service with help of .xsodata files.
543 lines (480 loc) • 16.8 kB
JavaScript
;
/**
* Add <xsodata> attribute to <context> (for sample data see sampleXsodata)
* Uses <context.serviceDirectory>,<context.uriTree.xsoFile>
* For Sample output structure see /test/test_xsodata_file_parser/xs1testfiles/*
* @type {exports}
*/
//Include
const path = require('path');
const peg_parser = require('../parsers/peg_xsodata_parser');
const fs = require('fs');
const ModelFileError = require('./../utils/errors/modelFileError');
const Rwlock = require('rwlock');
const locks = new Rwlock();
const model = require('./model');
const async = require('async');
const utils = require('../utils/utils');
const InternalError = require('../utils/errors/internalError');
//Testing API
exports.parseContent = parseContent;
exports._validateModel = validateModel;
// external api
exports.loadXsodataConfiguration = loadXsodataConfiguration;
//Code
function parseContent(content, done) {
try {
const data = peg_parser.parse(content);
done(null, data);
} catch (ex) {
return done(ex);
}
}
/**
* Reads xsodata configuration.
*
* This method is a facade for calling the following internal methods:
*
* loadXsodataConfiguration()
* |
* +-- readXsodataConfigFromCache()
* |
* +-- readXsodataConfigFromFile()
* |
* +-- extendXsodataConfig()
* |
+-- extendNavigatesFromProperty()
-------------------------|
|
+-- createModelInstance()
* |
* +-- addConfigToCache()
*
* If reading the configuration model from internal cache succeeded the xsodata file won't be read.
* In this case the procesing stops and returns immediately. If not the xsodata will be read from file.
* All validations and default configuration extensions is done when the configuration is read from file storage
* once. After reading from file we can expect that the returning and cached configuration
* model is valid.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function loadXsodataConfiguration(context, asyncDone) {
// context is request context
context.logger.silly('model', 'loadXsodataConfiguration');
async.waterfall(
[
utils.injectContext(context),
// First try to find configuration in cache
utils.tryAndMeasure(
readXsodataConfigFromCache,
'readXsodataConfigFromCache'
),
// Read from file if model was not already< found in cache
utils.tryAndMeasure(
readXsodataConfigFromFile,
'readXsodataConfigFromFile'
),
// Extends the current configuration by default or nesseccary properties
utils.tryAndMeasure(extendXsodataConfig, 'extendXsodataConfig'),
// Creates the internal xsodata abstraction configuration model
utils.tryAndMeasure(createModelInstance, 'createModelInstance'),
// If the configuration was red it will be add to internal cache to avoid I/O for next call
utils.tryAndMeasure(addConfigToCache, 'addConfigToCache'),
],
function (endErr, context) {
const id = getConfigurationId(context);
context.logger.info('model', 'using model id: ' + id);
return asyncDone(endErr, context);
}
);
}
/**
* Read the xsodata configuration from cache and assign to context.gModel property
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function readXsodataConfigFromCache(context, asyncDone) {
const locale = context.locale;
context.logger.silly('model', 'readXsodataConfigFromCache');
if (!context.modelData[locale]) {
context.modelData[locale] = {};
}
const id = getConfigurationId(context);
context.gModel = context.modelData[locale][id];
return asyncDone(null, context);
}
/**
* Extending the configuration model by default or optional properties.
*
* Here is the place for any kind of xsodata configuration extension.
* Just add a new async.waterfall(...) function to extend new functionality.
* This works like a chain of responsibility pattern.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function extendXsodataConfig(context, asyncDone) {
if (context.gModel) {
// If configuration already exists we do not want to read from file
return asyncDone(null, context);
}
context.logger.silly('model', 'extendXsodataConfig');
async.waterfall(
[
utils.injectContext(context),
// Extends ... navigates ... from principal | dependent property
utils.try(extendNavigatesFromProperty),
utils.try(checkSchema),
],
function (endErr, context) {
return asyncDone(endErr, context);
}
);
}
/**
* Check if a schema is defined on a entity set and if yes, check if this schema is allowed
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function checkSchema(context, asyncDone) {
const parsedXsodata = context._initialParsedXsodata;
let entityTypes;
let entityType;
let key;
context.logger.silly('model', 'checkSchema');
if (!parsedXsodata || !parsedXsodata.service) {
return asyncDone(null, context);
}
if (!parsedXsodata.service.entityTypes) {
return asyncDone(null, context);
}
entityTypes = parsedXsodata.service.entityTypes;
// Run through entityTypes and check if navigates property id provided
for (key in entityTypes) {
if (entityTypes.hasOwnProperty(key) === true) {
entityType = entityTypes[key];
if (entityType.schema) {
if (!utils.schemaAllowed(entityType.schema)) {
return asyncDone(
new ModelFileError('Invalid schema', context),
context
);
}
}
}
}
return asyncDone(null, context);
}
/**
* Checks whether the parsed xsodata configuration is valid
*
* @param parsedXsodata {Object} Parsed xsodata configuration
* @returns true if parsed xsodata is valid, else false
*/
function isParsedXSODataValid(parsedXSOData) {
return (
parsedXSOData &&
parsedXSOData.service &&
parsedXSOData.service.entityTypes &&
parsedXSOData.service.associations
);
}
/**
* Extending the configuration model by adding navigates --> from principal|dependent
*
* Referring SAP HANA Develper Guide for SPS 11, paragraph 7.1.6.6 OData Associations
* the navigates ... from principal | dependent is optional and must be abbreviated
* from configuration and corresponding callee entity.
*
* This is done by convention if the caller entity type is defined as
* the principal in the association referenced by its navigates property
* the corresponding "navigates from" type will be
* "navigates ... from principal"
* and vice versa.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function extendNavigatesFromProperty(context, asyncDone) {
context.logger.silly('model', 'extendNavigatesFromProperty');
const parsedXSOData = context._initialParsedXsodata;
if (!isParsedXSODataValid(parsedXSOData)) {
return asyncDone(null, context);
}
const entityTypes = parsedXSOData.service.entityTypes;
const associations = parsedXSOData.service.associations;
const entityTypeKeys = Object.keys(entityTypes);
for (const entityTypeKey of entityTypeKeys) {
const entityType = entityTypes[entityTypeKey];
const err = processEntityTypeNavigations(
entityType,
associations,
context
);
if (err) {
return asyncDone(err, context);
}
}
return asyncDone(null, context);
}
/**
* Process navigations for a single entity type.
*
* @param entityType {Object}
* @param associations {Object}
* @param context {Object}
* @returns {Error|null}
*/
function processEntityTypeNavigations(entityType, associations, context) {
const navigates = entityType && entityType.navigates;
if (!navigates) {
return null;
}
const navKeys = Object.keys(navigates);
for (const navKey of navKeys) {
const navigation = navigates[navKey];
const err = processNavigation(
navigation,
entityType.name,
associations,
context
);
if (err) {
return err;
}
}
return null;
}
/**
* Process a single navigation entry.
*
* @param navigation {Object}
* @param entityTypeName {String}
* @param associations {Object}
* @param context {Object}
* @returns {Error|null}
*/
function processNavigation(navigation, entityTypeName, associations, context) {
if (!navigation) {
return null;
}
const associationName = navigation.association;
if (!associationName) {
return new InternalError(
"Missing association after 'navigates' property",
context
);
}
const association = associations[associationName];
if (!association) {
return new InternalError(
'Unknown association: ' + associationName,
context
);
}
if (Object.prototype.hasOwnProperty.call(navigation, 'from')) {
return null;
}
const from = determineFromByConvention(
entityTypeName,
association,
context
);
if (from instanceof Error) {
return from;
}
navigation.from = from;
return null;
}
/**
* Determine "from" by convention based on association and entity type.
*
* @param entityTypeName {String}
* @param association {Object}
* @param context {Object}
* @returns {{principal: boolean}|{dependent: boolean}|Error}
*/
function determineFromByConvention(entityTypeName, association, context) {
if (entityTypeName === association.principal.type) {
return { principal: true };
}
if (entityTypeName === association.dependent.type) {
return { dependent: true };
}
return new ModelFileError(
'Can not extend configuration model. Given entityType name does not match allowed associations',
context
);
}
/**
* Create the xsodata model instance and attach to context context.gModel property.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function createModelInstance(context, asyncDone) {
context.logger.silly('model', 'createModelInstance');
if (
!context ||
!context._initialParsedXsodata ||
!context._initialParsedXsodata.service
) {
return asyncDone(null, context);
}
context.gModel = new model.Model(context._initialParsedXsodata);
return asyncDone(null, context);
}
/**
* Validates the model context in dependency of the registered validators.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.3.2
*/
function validateModel(context, asyncDone) {
context.logger.silly('model', 'validateModel');
async.waterfall(
[
utils.injectContext(context),
// Validate xsodata configuration concurrencyToken
require(__dirname + '/validator/xsoDataConcurrencyTokenValidator')
.validate,
],
asyncDone
);
}
/**
* Adds the current xsodata configuratio to context cache. Att next service call
* the configuration will be loaded from internal cache and not read from file again.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
* @since v0.2.0-beta.3
*/
function addConfigToCache(context, asyncDone) {
const locale = context.locale;
context.logger.silly('model', 'addConfigToCache');
if (!context.gModel) {
return asyncDone(
new ModelFileError(
'Xsodata configuration model does not exist. Can not add to internal cache.',
context
),
context
);
}
const id = getConfigurationId(context);
context.modelData[locale][id] = context.gModel;
return asyncDone(null, context);
}
/**
* Read the xsodata from configuration file and parse the content to json. The result configuration will be assigend
* to context.gModel property. If the current context already has a context.gModel
* property this method returns immediately.
*
* @param context {Object} Current context object
* @param asyncDone {Function} Callback beeing called after finishing the function
*/
function readXsodataConfigFromFile(context, asyncDone) {
if (context.gModel) {
// If configuration already exists we do not want to read from file
return asyncDone(null, context);
}
const id = getConfigurationId(context);
context.logger.silly('model', 'readXsodataConfigFromFile');
context.logger.silly(
'model',
'serviceConfiguration : ' + context.serviceConfiguration
);
context.logger.silly('model', 'xsoFile: ' + context.uriTree.xsoFile);
let fullName;
if (context.uriTree.xsoFile) {
fullName = path.join(
context.serviceConfiguration,
context.uriTree.xsoFile
);
if (fullName.indexOf(context.serviceConfiguration) !== 0) {
//SAP productstandard security, but avoid dos, so a warning
context.logger.warn(
'SECURITY illegal access to ' + fullName + 'prevented'
);
return asyncDone(
new ModelFileError('File not found!', context),
context
);
}
} else {
fullName = context.serviceConfiguration;
}
context.logger.silly('model', 'full name: ' + fullName);
//lock on loading the content of fileName
locks.writeLock(id, function (release) {
context.logger.silly('model', 'loading model ' + fullName + ' file');
//load model
fs.readFile(fullName, 'utf-8', function (err, data) {
if (err) {
context.logger.info(
'model',
'file not found: ' +
fullName +
'err: ' +
JSON.stringify(err)
);
release();
return asyncDone(
new ModelFileError('xsodata file not found!', context, err),
context
);
}
try {
//for sample more model see test/test_xsodat_file_parser/xs1testfiles/model.json
context._initialParsedXsodata = peg_parser.parse(data);
context.logger.info('model', 'loaded ' + fullName + ' file');
return asyncDone(null, context);
} catch (ex) {
context.logger.error(
'model',
'Error while parsing xsodata file: ' +
fullName +
'\nErr:\n' +
JSON.stringify(ex, null, 4)
);
return asyncDone(
new ModelFileError(
'Error while parsing xsodata file.',
context,
ex
),
context
);
} finally {
release(); //other request may now use the metadata
}
});
});
}
/**
* Return the current configuration identifier. This is the xsodata file name by default or the
* context.serviceConfiguration property.
*
* @param context {Object} Current context object
* @return {String} Configuration identifier
* @since v0.2.0-beta.3
*/
function getConfigurationId(context) {
context.logger.silly('model', 'getConfigurationId');
if (context.uriTree.xsoFile) {
return context.uriTree.xsoFile;
} else {
return context.serviceConfiguration;
}
}