UNPKG

x2node-ws-resources

Version:
516 lines (455 loc) 15.1 kB
'use strict'; const common = require('x2node-common'); const ws = require('x2node-ws'); const dbos = require('x2node-dbos'); const patches = require('x2node-patches'); const TransactionContext = require('./transaction-context.js'); /** * Options object sections for different methods. * * @private * @constant {Array.<string>} */ const METHOD_OPTIONS = [ 'get', 'post', 'patch', 'put', 'delete' ]; // symbols used to store stuff on the transaction context const RESPONSE_ETAG = Symbol('ETag'); const RESPONSE_LASTMODIFIED = Symbol('Last-Modified'); /** * Abstract parent for standard resource web wervice endpoint handlers. * * @memberof module:x2node-ws-resources * @inner */ class AbstractResourceHandler { /** * Create new handler. * * @protected * @param {module:x2node-dbos.DataSource} ds Data source. * @param {module:x2node-dbos~DBOFactory} dboFactory DBO factory. * @param {string} rsrcPath Resource path specification. * @param {Object} [options] Options. * @throws {common.X2UsageError} If the resource path is invalid. */ constructor(ds, dboFactory, rsrcPath, options) { /** * Debug logger. * * @member {function} module:x2node-ws-resources~AbstractResourceHandler#_log */ this._log = common.getDebugLogger('X2_APP'); /** * Options. Always present, always has sections for the methods. * * @member {Object} module:x2node-ws-resources~AbstractResourceHandler#_options */ this._options = (options || new Object()); for (let section of METHOD_OPTIONS) { if (!this._options[section]) this._options[section] = new Object(); } /** * The DBO factory. * * @member {module:x2node-dbos~DBOFactory} module:x2node-ws-resources~AbstractResourceHandler#_dboFactory */ this._dboFactory = dboFactory; /** * The transaction factory. * * @member {module:x2node-dbos~TxFactory} module:x2node-ws-resources~AbstractResourceHandler#_txFactory */ this._txFactory = dboFactory.createTxFactory(ds); const rsrcPathParts = rsrcPath.split('<-'); /** * Record type name. * * @member {string} module:x2node-ws-resources~AbstractResourceHandler#_recordTypeName */ this._recordTypeName = rsrcPathParts[rsrcPathParts.length - 1]; /** * Record types library. * * @member {module:x2node-records~RecordTypesLibrary} module:x2node-ws-resources~AbstractResourceHandler#_recordTypes */ this._recordTypes = dboFactory.recordTypes; /** * Record type descriptor. * * @member {module:x2node-records~RecordTypeDescriptor} module:x2node-ws-resources~AbstractResourceHandler#_recordTypeDesc */ this._recordTypeDesc = this._recordTypes.getRecordTypeDesc( this._recordTypeName); /** * Resource path uplink chain. * * @protected * @member {Array.<Object>} */ this._uplinkChain = new Array(); let recordTypeDesc = this._recordTypeDesc, uplinkPropPath = ''; let uriParamOffset = 0; for (let i = rsrcPathParts.length - 2; i >= 0; i--) { const uplinkRef = rsrcPathParts[i]; const uplinkRefParts = uplinkRef.split('.'); for (let j = 0, len = uplinkRefParts.length; j < len; j++) { const uplinkPropName = uplinkRefParts[j]; if (uplinkPropPath.length > 0) uplinkPropPath += '.'; uplinkPropPath += uplinkPropName; const uplinkPropDesc = recordTypeDesc.getPropertyDesc( uplinkPropName); if (!uplinkPropDesc.isRef() || !uplinkPropDesc.isScalar() || uplinkPropDesc.reverseRefPropertyName || uplinkPropDesc.modifiable) throw new common.X2UsageError( `Uplink property ${uplinkPropPath} is not a stored,` + ` non-modifiable, scalar reference.`); const uplinkRecordTypeDesc = uplinkPropDesc.nestedProperties; let uplinkValueFunc; if (j === len - 1) { const uplinkIdPropDesc = uplinkRecordTypeDesc.getPropertyDesc( uplinkRecordTypeDesc.idPropertyName); if (uplinkIdPropDesc.scalarValueType === 'number') uplinkValueFunc = function(v) { return Number(v); }; else uplinkValueFunc = function(v) { return v; }; } this._uplinkChain.push({ propPath: uplinkPropPath, recordTypeDesc: uplinkRecordTypeDesc, uriParamOffset: (j === len - 1 ? uriParamOffset-- : null), value: uplinkValueFunc }); recordTypeDesc = uplinkRecordTypeDesc; } } // build record version information properties descriptor const videsc = { versionProps: new Array(), versionPropName: this._recordTypeDesc.getRecordMetaInfoPropName('version'), lastModifiedPropName: this._recordTypeDesc.getRecordMetaInfoPropName( 'modificationTimestamp') }; if (videsc.versionPropName) { videsc.versionProps.push(videsc.versionPropName); videsc.versionPropDesc = this._recordTypeDesc.getPropertyDesc( videsc.versionPropName); } if (videsc.lastModifiedPropName) { videsc.versionProps.push(videsc.lastModifiedPropName); videsc.lastModifiedPropDesc = this._recordTypeDesc.getPropertyDesc( videsc.lastModifiedPropName); } if (videsc.versionProps.length > 0) videsc.versionInfoFetchDBO = this._dboFactory.buildFetch( this._recordTypeName, { props: videsc.versionProps, filter: [ [ this._recordTypeDesc.idPropertyName, dbos.param('id') ] ], lock: 'shared' }); /** * Record version information properties descriptor. * * @protected * @member {Object} */ this._recordVersionInfoDesc = videsc; // configure handler extension if ((typeof this.configure) === 'function') this.configure(); } /** * Add uplink filters to the provided filters list. * * @protected * @param {module:x2node-ws~ServiceCall} call The call. * @param {number} lastUplinkParamInd Index of the last uplink URI parameter * from the end (-1 is for the last URI param, -2 is for the one before the * last, etc.). * @param {Array} filters The filters list to add to. * @param {Object.<string,*>} queryParams Query parameters object. * @returns {Array} The filters list passed into the method. */ _addUplinkFilters(call, lastUplinkParamInd, filters, queryParams) { const uriParams = call.uriParams; lastUplinkParamInd += uriParams.length; for (let uplink of this._uplinkChain) { if (uplink.uriParamOffset !== null) { const uriParamInd = lastUplinkParamInd + uplink.uriParamOffset; const paramName = 'uri' + uriParamInd; queryParams[paramName] = uplink.value(uriParams[uriParamInd]); filters.push([ uplink.propPath, dbos.param(paramName) ]); } } return filters; } /** * Create new transaction context object. * * @param {module:x2node-ws~ServiceCall} call The call. * @returns {module:x2node-ws-resources~TransactionContext} New context. */ _createTransactionContext(call) { return new TransactionContext(call, this._dboFactory); } /** * Transaction phase function. * * @callback module:x2node-ws-resources~AbstractResourceHandler~transactionPhase * @param {module:x2node-dbos~Transaction} tx The active transaction. * @param {module:x2node-ws-resources~TransactionContext} txCtx Transaction * context. * @param {*} [result] Resolved result of the previous phase. * @returns {Promise} Phase result promise. If resolved, the transaction * proceeds to the next phase. The last phase's result is the handler * response. If rejected, no subsequent phases are executed, the transaction * is rolled back (unless explicitely committed by the phase) and the handler * call is rejected with the phase's rejection object. */ /** * Execute handler action in a transaction. * * @protected * @param {module:x2node-ws-resources~TransactionContext} txCtx Transaction * context. * @param {Array.<module:x2node-ws-resources~AbstractResourceHandler~transactionPhase>} phases * Transaction phases. * @returns {Promise} Promise of the transaction result. */ _executeTransaction(txCtx, phases) { // execute the phases chain in a transaction return this._txFactory.executeTransaction(tx => { // set transaction in the context txCtx._tx = tx; // queue up transaction phases let promiseChain = Promise.resolve(); for (let phase of phases) { promiseChain = promiseChain.then(result => { // skip the rest of the phases if transaction is complete if (txCtx.complete) return result; // execute the phase return phase(tx, txCtx, result); }); } // add transaction cleanup from the context return promiseChain.then( result => (delete txCtx._tx, result), err => (delete txCtx._tx, Promise.reject(err)) ); }); } /** * Tell if conditional HTTP request. * * @protected * @param {module:x2node-ws.ServiceCall} call The call. * @returns {boolean} <code>true</code> if has conditional headers. */ _isConditionalRequest(call) { const requestHeaders = call.httpRequest.headers; for (let h of [ 'if-match', 'if-unmodified-since', 'if-none-match', 'if-modified-since', ]) { if (requestHeaders[h]) return true; } return false; } /** * Evaluate preconditions in a conditional HTTP request. * * @protected * @param {module:x2node-ws.ServiceCall} call The call. * @param {string} [etag] The matching ETag, if any. * @param {Date} [lastModified] The mathing last modification timestamp, if * any. * @returns {module:x2node-ws.ServiceResponse} Service response if execution * of the call can/must be stoped, or nothing if it needs to continue. */ _evaluatePreconditions(call, etag, lastModified) { const requestHeaders = call.httpRequest.headers; let val; if ((val = requestHeaders['if-match']) !== undefined) { if (!this._matchETag(val, etag, false)) return ws.createResponse(412).setEntity({ errorCode: 'X2-RSRC-412-1', errorMessage: 'If-Match precondition failed.' }); } else if ((val = requestHeaders['if-unmodified-since']) !== undefined) { const date = (new Date(val)).getTime(); if (!Number.isNaN(date) && (!lastModified || lastModified.getTime() > date)) return ws.createResponse(412).setEntity({ errorCode: 'X2-RSRC-412-2', errorMessage: 'If-Unmodified-Since precondition failed.' }); } const httpMethod = call.httpRequest.method; if ((val = requestHeaders['if-none-match']) !== undefined) { if (this._matchETag(val, etag, true)) { if ((httpMethod === 'GET') || (httpMethod === 'HEAD')) { const response = ws.createResponse(304); response.setHeader('ETag', etag); if (lastModified) response.setHeader('Last-Modified', lastModified); return response; } return ws.createResponse(412).setEntity({ errorCode: 'X2-RSRC-412-3', errorMessage: 'If-None-Match precondition failed.' }); } } else if ((val = requestHeaders['if-modified-since']) !== undefined) { if ((httpMethod === 'GET') || (httpMethod === 'HEAD')) { const date = (new Date(val)).getTime(); if (!Number.isNaN(date) && lastModified && (lastModified.getTime() > date)) { const response = ws.createResponse(304); if (etag) response.setHeader('ETag', etag); response.setHeader('Last-Modified', lastModified); return response; } } } } /** * Match ETag against the "If-Match" or "If-None-Match" header value. * * @private * @param {string} val The conditional header value. * @param {string} [etag] The ETag. * @param {boolean} useWeak <code>true</code> to use weak comparison instead * of strong. */ _matchETag(val, etag, useWeak) { if (val === '*') return true; if (!etag) return false; for (let valEl of val.split(/\s*,\s*/g)) { if (valEl === etag) return true; if (useWeak && valEl.startsWith('W/') && (valEl.substring(2) === etag)) return true; } return false; } /** * Get record version information for the "ETag" and "Last-Modified" headers. * * @protected * @param {module:x2node-ws~ServiceCall} call The call. * @param {Object} rec The record. * @returns {Object} Record version information object. */ _getRecordVersionInfo(call, rec) { const videsc = this._recordVersionInfoDesc; return { etag: ( videsc.versionPropName && ( '"' + call.apiVersion + ':' + String(call.actor ? call.actor.id : '*') + ':' + rec[videsc.versionPropName] + '"' ) ), lastModified: ( videsc.lastModifiedPropName && new Date( rec[videsc.lastModifiedPropName] || '1970-01-01T00:00:00.000Z') ) }; } /** * Save values for "ETag" and "Last-Modified" headers for later use by the * [_addValidatorHeaders()]{@link module:x2node-ws-resources~AbstractResourceHandler#_addValidatorHeaders} * method. * * @protected * @param {module:x2node-ws-resources~TransactionContext} txCtx Transaction * context. * @param {string} [etag] "ETag" value to save. * @param {Date} [lastModified] "Last-Modified" value to save. */ _saveValidatorHeaders(txCtx, etag, lastModified) { if (etag) txCtx[RESPONSE_ETAG] = etag; if (lastModified) txCtx[RESPONSE_LASTMODIFIED] = lastModified; } /** * Add "ETag" and "Last-Modified" headers to the response if the values are * present on the transaction context. * * @protected * @param {module:x2node-ws-resources~TransactionContext} txCtx Transaction * context. * @param {module:x2node-ws~ServiceResponse} response The response. * @returns {module:x2node-ws~ServiceResponse} The response. */ _addValidatorHeaders(txCtx, response) { let val = txCtx[RESPONSE_ETAG]; if (val) response.setHeader('ETag', val); val = txCtx[RESPONSE_LASTMODIFIED]; if (val) response.setHeader('Last-Modified', val); return response; } /** * Build patch in the transaction context, if not there yet. * * @protected * @param {module:x2node-ws-resources~TransactionContext} txCtx Transaction * context. * @returns {(Promise|*)} Rejected promise if error, nothing if all good. */ _buildPatch(txCtx) { // check if patch already created by a hook if (txCtx.patch) return; // parse the patch depending on the format try { switch (txCtx.call.entityContentType) { case 'application/json-patch+json': txCtx.patch = patches.build( this._recordTypes, this._recordTypeName, txCtx.patchSpec); break; case 'application/merge-patch+json': txCtx.patch = patches.buildMerge( this._recordTypes, this._recordTypeName, txCtx.patchSpec); } } catch (err) { if (err instanceof common.X2SyntaxError) { return Promise.reject(ws.createResponse(400).setEntity({ errorCode: 'X2-RSRC-400-5', errorMessage: `Invalid patch document: ${err.message}` })); } throw err; } // check if did not understand the patch format if (!txCtx.patch) { return Promise.reject(ws.createResponse(415).setHeader( 'Accept-Patch', 'application/json-patch+json, application/merge-patch+json' ).setEntity({ errorCode: 'X2-RSRC-415-1', errorMessage: 'Unsupported patch document format.' })); } } } // export the class module.exports = AbstractResourceHandler;