UNPKG

@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
'use strict'; /** * 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; } }