x2node-ws-resources
Version:
Persistent resources for web services module.
753 lines (638 loc) • 21.7 kB
JavaScript
'use strict';
const common = require('x2node-common');
const ws = require('x2node-ws');
const dbos = require('x2node-dbos');
const validators = require('x2node-validators');
const patches = require('x2node-patches');
const AbstractResourceHandler = require('./abstract-resource-handler.js');
/**
* Create a deep copy of a value.
*
* @private
* @param {*} val The value.
* @returns {*} Deep copy of the value, or the value itself if simple value.
*/
function deepCopy(val) {
let res;
if (Array.isArray(val)) {
res = [];
for (let v of val)
res.push(deepCopy(v));
} else if (((typeof val) === 'object') && (val !== null)) {
res = {};
for (let k of Object.keys(val))
res[k] = deepCopy(val[k]);
} else {
res = val;
}
return res;
}
// TODO: support PUT for assigned id records
/**
* Standard individual resource web wervice endpoint handler.
*
* @memberof module:x2node-ws-resources
* @extends module:x2node-ws-resources~AbstractResourceHandler
* @implements module:x2node-ws.Handler
*/
class IndividualResourceHandler extends AbstractResourceHandler {
/**
* Create new handler.
*
* @param {module:x2node-dbos.DataSource} ds Data source.
* @param {module:x2node-dbos~DBOFactory} dboFactory DBO factory.
* @param {string} rsrcPath Resource path.
* @param {Object} [options] Options.
*/
constructor(ds, dboFactory, rsrcPath, options) {
super(ds, dboFactory, rsrcPath, options);
// build record fetch by id DBO
this._recordFetchByIdDBO = this._dboFactory.buildFetch(
this._recordTypeName, {
filter: [
[ this._recordTypeDesc.idPropertyName, dbos.param('id') ]
]
});
}
/**
* Default implementation for the <code>isAllowed()</code> method that calls
* handler's <code>isAllowedAction()</code> method.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {boolean} The <code>isAllowedAction()</code> method call result.
*/
_defaultIsAllowed(call) {
let action;
switch (call.method) {
case 'GET':
action = 'read';
break;
case 'PATCH':
action = 'update';
break;
case 'DELETE':
action = 'delete';
break;
}
return this.isAllowedAction(action, call.actor, call);
}
/////////////////////////////////////////////////////////////////////////////
// process OPTIONS call
/////////////////////////////////////////////////////////////////////////////
OPTIONS(call, response) {
response.setHeader(
'Accept-Patch',
'application/json-patch+json, application/merge-patch+json');
}
/////////////////////////////////////////////////////////////////////////////
// process GET call
/////////////////////////////////////////////////////////////////////////////
GET(call) {
// transaction context
const txCtx = this._createTransactionContext(call);
// determine selected properties
const selectedProps = new Set();
const propsParam = call.requestUrl.query.p;
if (propsParam) {
for (let p of propsParam.split(','))
if (!p.startsWith('.'))
selectedProps.add(p);
for (let propName of this._recordVersionInfoDesc.versionProps)
selectedProps.add(propName);
} else {
selectedProps.add('*');
}
// create query specification
txCtx.queryParams = new Object();
txCtx.querySpec = {
props: Array.from(selectedProps),
filter: this._createFilter(call, txCtx.queryParams),
lock: 'shared'
};
// pre-resolve response promise
let responsePromise = Promise.resolve();
// custom preparation logic
if ((typeof this.prepareRead) === 'function')
responsePromise = responsePromise.then(
() => Promise.resolve(this.prepareRead(txCtx)));
// proceed to the transaction
responsePromise = responsePromise.then(() => {
// build read DBO
// TODO: cache read DBO
let readDBO;
try {
readDBO = this._dboFactory.buildFetch(
this._recordTypeName, txCtx.querySpec);
} catch (err) {
if (err instanceof common.X2SyntaxError) {
return Promise.reject(ws.createResponse(400).setEntity({
errorCode: 'X2-RSRC-400-6',
errorMessage: 'Invalid query string: ' + err.message
}));
}
return Promise.reject(err);
}
// assemble transaction phases
const txPhases = new Array();
// pre-fetch the record version info if complex query
const versionInfoPrefetch = (
this._isConditionalRequest(call) && (readDBO.complexity > 0));
if (versionInfoPrefetch) {
const response = this._addProcessConditionalRequestPhase(
txPhases, call);
if (response)
return response;
}
// custom "before" hook
if ((typeof this.beforeRead) === 'function')
txPhases.push((_, txCtx) => this.beforeRead(txCtx));
// main action
txPhases.push(
(tx, txCtx) => readDBO.execute(
tx, call.actor, txCtx.queryParams
).then(result => {
// check if record does not exist
if (result.records.length === 0)
return Promise.reject(
ws.createResponse(404).setEntity({
errorCode: 'X2-RSRC-404-1',
errorMessage: 'Record not found.'
})
);
// get the record
const rec = result.records[0];
// save fetched referred records on the context
txCtx.referredRecords = result.referredRecords;
// get record version info
const recVI = this._getRecordVersionInfo(call, rec);
// check pre-conditions if not checked yet
if (!versionInfoPrefetch) {
const response = this._evaluatePreconditions(
call, recVI.etag, recVI.lastModified);
if (response) {
txCtx.makeComplete();
return response;
}
}
// save the version info on the context
this._saveValidatorHeaders(
txCtx, recVI.etag, recVI.lastModified);
// return the record
return rec;
})
);
// custom "after" hook
if ((typeof this.afterRead) === 'function')
txPhases.push((_, txCtx, record) => this.afterRead(
txCtx, record));
// execute the transaction
return this._executeTransaction(txCtx, txPhases);
});
// custom completion logic
if ((typeof this.completeRead) === 'function')
responsePromise = responsePromise.then(
record => Promise.resolve(
this.completeRead(undefined, txCtx, record)),
err => Promise.reject(
this.completeRecord(err, txCtx, undefined))
);
// build and return the response promise
return responsePromise.then(result => {
// check if already a response
if (ws.isResponse(result))
return result;
// create and return respose
return this._addValidatorHeaders(txCtx, ws.createResponse(200))
.setEntity(result);
});
}
/**
* Add transaction phase for pre-fetching record version information with a
* simple query and checking conditional request's pre-conditions.
*
* @private
* @param {Array.<module:x2node-ws-resources~AbstractResourceHandler~transactionPhase>} txPhases
* Transaction phases list, to which to add the phase.
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {module:x2node-ws.ServiceResponse} Service response if execution
* of the call can/must be stoped (record type has no version info and
* pre-conditions failed), or nothing if it needs to continue.
*/
_addProcessConditionalRequestPhase(txPhases, call) {
// check pre-conditions right away if no record version info
const videsc = this._recordVersionInfoDesc;
if (videsc.versionProps.length === 0)
return this._evaluatePreconditions(call, null, null);
// pre-fetch the record version info and evaluate the pre-conditions
txPhases.push(
(tx, txCtx) => videsc.versionInfoFetchDBO.execute(
tx, txCtx.call.actor, {
id: this.getRecordId(txCtx.call)
}
).then(result => {
// check if record does not exist
if (result.records.length === 0)
return Promise.reject(
ws.createResponse(404).setEntity({
errorCode: 'X2-RSRC-404-1',
errorMessage: 'Record not found.'
}));
// evaluate pre-conditions
const recVI = this._getRecordVersionInfo(
txCtx.call, result.records[0]);
const response = this._evaluatePreconditions(
txCtx.call, recVI.etag, recVI.lastModified);
if (response) {
txCtx.makeComplete();
return response;
}
})
);
}
/////////////////////////////////////////////////////////////////////////////
// process PATCH call
/////////////////////////////////////////////////////////////////////////////
PATCH(call) {
// transaction context
const txCtx = this._createTransactionContext(call);
txCtx.patchSpec = call.entity;
// make sure that we have the entity
if (!txCtx.patchSpec)
return ws.createResponse(400).setEntity({
errorCode: 'X2-RSRC-400-4',
errorMessage: 'Expected patch document in the request body.'
});
// pre-resolve response promise
let responsePromise = Promise.resolve();
// custom patch specification modification logic
if ((typeof this.prepareUpdateSpec) === 'function')
responsePromise = responsePromise.then(() => Promise.resolve(
this.prepareUpdateSpec(txCtx, txCtx.patchSpec)));
// build the patch
responsePromise = responsePromise.then(() => this._buildPatch(txCtx));
// build specification for the record pre-fetch DBO
txCtx.prefetchQueryParams = new Object();
txCtx.prefetchQuerySpec = {
props: [ '*' ],
filter: this._createFilter(call, txCtx.prefetchQueryParams),
lock: 'exclusive'
};
// lock the main records collection by default
txCtx.lockCollections = [ this._recordTypeName ];
// custom preparation logic
if ((typeof this.prepareUpdate) === 'function')
responsePromise = responsePromise.then(() => Promise.resolve(
this.prepareUpdate(txCtx)));
// proceed to the transaction
const responseType = this._options.patch.response;
responsePromise = responsePromise.then(() => {
// build the DBOs
const prefetchDBO = this._dboFactory.buildFetch(
this._recordTypeName, txCtx.prefetchQuerySpec);
// assemble transaction phases
const txPhases = new Array();
// lock collections for update
const rcMonitor = this._dboFactory.recordCollectionsMonitor;
if (rcMonitor)
txPhases.push(
(_, txCtx) => rcMonitor.getCollectionsVersion(
txCtx.transaction,
new Set(txCtx.lockCollections),
'exclusive'
)
);
// record fetch and pre-conditions check
txPhases.push((tx, txCtx) => prefetchDBO.execute(
tx, call.actor, txCtx.prefetchQueryParams).then(result => {
// check if got the record
if (result.records.length === 0)
return Promise.reject(
ws.createResponse(404).setEntity({
errorCode: 'X2-RSRC-404-1',
errorMessage: 'Record not found.'
}));
// check pre-conditions
const record = result.records[0];
const recVI = this._getRecordVersionInfo(txCtx.call, record);
const response = this._evaluatePreconditions(
txCtx.call, recVI.etag, recVI.lastModified);
if (response)
return Promise.reject(response);
// save the original record
txCtx.originalRecord = deepCopy(record);
txCtx.referredRecords = result.referredRecords;
// return the record
return record;
})
);
// custom "before" hook
if ((typeof this.beforeUpdate) === 'function')
txPhases.push((_, txCtx, record) => {
const hookResult = this.beforeUpdate(txCtx, record);
if ((hookResult !== undefined) &&
(hookResult !== null) &&
((typeof hookResult.then) === 'function'))
return hookResult.then(() => record);
return record;
});
// apply patch and normalize the resulting record
txPhases.push((_, txCtx, record) => {
// apply the requested patch
try {
if (!txCtx.patch.apply(record))
return Promise.reject(
ws.createResponse(422).setEntity({
errorCode: 'X2-RSRC-422-2',
errorMessage: 'Patch "test" operation failed.'
}));
} catch (err) {
if (err instanceof common.X2DataError) {
return Promise.reject(
ws.createResponse(400).setEntity({
errorCode: 'X2-RSRC-400-8',
errorMessage: 'Unable to apply' +
` the patch: ${err.message}`
}));
}
return Promise.reject(err);
}
// validate and normalize the record after patch
const errors = validators.normalizeRecord(
this._recordTypes, this._recordTypeName,
record,
call.httpRequest.headers['Accept-Language'],
'onUpdate'
);
if (errors)
return Promise.reject(
ws.createResponse(422).setEntity({
errorCode: 'X2-RSRC-422-1',
errorMessage: 'Patch results in' +
' invalid record data.',
validationErrors: errors
}));
// requested patch applied, continue
return record;
});
// custom "before save" hook
if ((typeof this.beforeUpdateSave) === 'function')
txPhases.push((_, txCtx, record) => {
const hookResult = this.beforeUpdateSave(txCtx, record);
if ((hookResult !== undefined) &&
(hookResult !== null) &&
((typeof hookResult.then) === 'function'))
return hookResult.then(() => record);
return record;
});
// prepare final patch and execute the update
txPhases.push((tx, txCtx, record) => {
// build the final patch and the update DBO
const updateDBO = this._dboFactory.buildUpdate(
this._recordTypeName,
patches.build(
this._recordTypes, this._recordTypeName,
patches.fromDiff(
this._recordTypes, this._recordTypeName,
txCtx.originalRecord, record
)
),
() => [ txCtx.originalRecord ]
);
// execute the DBO
return updateDBO.execute(tx, call.actor).then(result => {
txCtx.updateResult = result;
delete txCtx.originalRecord;
return result.records[0];
});
});
// re-read updated record from the database if configured
if (responseType === 'reread')
txPhases.push(
(tx, txCtx, record) => this._recordFetchByIdDBO.execute(
tx, call.actor, {
id: record[this._recordTypeDesc.idPropertyName]
}).then(result => result.records[0]));
// custom "after" hook
if ((typeof this.afterUpdate) === 'function')
txPhases.push(
(_, txCtx, record) => this.afterUpdate(txCtx, record));
// execute the transaction
return this._executeTransaction(txCtx, txPhases);
});
// custom completion logic
if ((typeof this.completeUpdate) === 'function')
responsePromise = responsePromise.then(
record => Promise.resolve(
this.completeUpdate(undefined, txCtx, record)),
err => Promise.reject(
this.completeUpdate(err, txCtx, undefined))
);
// build and return the response promise
return responsePromise.then(record => {
// save updated record version information
const recVI = this._getRecordVersionInfo(call, record);
this._saveValidatorHeaders(txCtx, recVI.etag, recVI.lastModified);
// create response
let response;
switch (responseType) {
case 'nocontent':
response = ws.createResponse(204);
break;
default: // updated record in the response
response = ws.createResponse(200)
.setHeader('Content-Location', call.requestUrl.pathname)
.setEntity(record);
}
// add record version informtation to the response and return it
return this._addValidatorHeaders(txCtx, response);
});
}
/////////////////////////////////////////////////////////////////////////////
// process DELETE call
/////////////////////////////////////////////////////////////////////////////
DELETE(call) {
// TODO: check existence of weakly dependent records
// transaction context
const txCtx = this._createTransactionContext(call);
// create record selection filter specification
txCtx.queryParams = new Object();
txCtx.selectionFilter = this._createFilter(call, txCtx.queryParams);
// lock the main records collection by default
txCtx.lockCollections = [ this._recordTypeName ];
// pre-resolve response promise
let responsePromise = Promise.resolve();
// custom preparation logic
if ((typeof this.prepareDelete) === 'function')
responsePromise = responsePromise.then(() => Promise.resolve(
this.prepareDelete(txCtx)));
// proceed to the transaction
responsePromise = responsePromise.then(() => {
// build delete DBO
// TODO: cache delete DBO
const deleteDBO = this._dboFactory.buildDelete(
this._recordTypeName, txCtx.selectionFilter);
// assemble transaction phases
const txPhases = new Array();
// lock collections for update
const rcMonitor = this._dboFactory.recordCollectionsMonitor;
if (rcMonitor)
txPhases.push(
(_, txCtx) => rcMonitor.getCollectionsVersion(
txCtx.transaction,
new Set(txCtx.lockCollections),
'exclusive'
)
);
// properties to fetch before deleting
const fetchProps = (txCtx.fetchProps || []);
const conditionalRequest = this._isConditionalRequest(call);
if (conditionalRequest) {
const versionPropsToAdd = new Set(
this._recordVersionInfoDesc.versionProps);
for (let prop of fetchProps) {
if (prop === '*') {
versionPropsToAdd.clear();
break;
}
versionPropsToAdd.delete(prop);
}
for (let prop of versionPropsToAdd)
fetchProps.push(prop);
}
// fetch the record
if (fetchProps.length > 0)
txPhases.push(
(tx, txCtx) => this._dboFactory.buildFetch(
this._recordTypeName, {
props: fetchProps,
filter: txCtx.selectionFilter,
lock: 'exclusive'
}
).execute(
tx, call.actor, txCtx.queryParams
).then(result => {
// check if invalid filter
if (result.records.length > 1)
throw new common.X2DataError(
'More than one record matched selection' +
' filter.');
// check if record exists
if (result.records.length === 0)
return Promise.reject(
ws.createResponse(404).setEntity({
errorCode: 'X2-RSRC-404-1',
errorMessage: 'Record not found.'
}));
// save fetch data in the context
txCtx.record = result.records[0];
txCtx.referredRecords = result.referredRecords;
})
);
// add conditional request processing phase
if (conditionalRequest)
txPhases.push((_, txCtx) => {
const record = txCtx.record;
let response;
if (record) {
const recVI = this._getRecordVersionInfo(
call, record);
response = this._evaluatePreconditions(
call, recVI.etag, recVI.lastModified);
} else {
response = this._evaluatePreconditions(
call, null, null);
}
if (response) {
txCtx.makeComplete();
return response;
}
});
// custom "before" hook
if ((typeof this.beforeDelete) === 'function')
txPhases.push((_, txCtx) => this.beforeDelete(
txCtx, txCtx.record));
// main action
txPhases.push(
(tx, txCtx) => deleteDBO.execute(
tx, call.actor, txCtx.queryParams
).then(result => {
txCtx.deleteResult = result;
if (!result[this._recordTypeName])
return Promise.reject(
ws.createResponse(404).setEntity({
errorCode: 'X2-RSRC-404-1',
errorMessage: 'Record not found.'
}));
return null;
})
);
// custom "after" hook
if ((typeof this.afterDelete) === 'function')
txPhases.push((_, txCtx) => this.afterDelete(
txCtx, txCtx.record));
// execute the transaction
return this._executeTransaction(txCtx, txPhases);
});
// custom completion logic
if ((typeof this.completeDelete) === 'function')
responsePromise = responsePromise.then(
() => Promise.resolve(
this.completeDelete(undefined, txCtx, txCtx.record)),
err => Promise.reject(
this.completeDelete(err, txCtx, undefined))
);
// return the response promise
return responsePromise;
}
/////////////////////////////////////////////////////////////////////////////
// common methods
/////////////////////////////////////////////////////////////////////////////
/**
* Create full filter specification for the addressed record including its id
* and the uplink records.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {Object.<string,*>} queryParams DBO query parameters to populate.
* @returns {Array} Filter specification for the DBO.
*/
_createFilter(call, queryParams) {
return this._addUplinkFilters(
call, -2, this._createIdFilter(call, queryParams), queryParams);
}
/**
* Create filter specification for the addressed record using only its id.
*
* @private
* @param {module:x2node-ws~ServiceCall} call The call.
* @param {Object.<string,*>} queryParams DBO query parameters to populate.
* @returns {Array} Filter specification for the DBO.
*/
_createIdFilter(call, queryParams) {
const filter = new Array();
filter.push([ this._recordTypeDesc.idPropertyName, dbos.param('id') ]);
queryParams['id'] = this.getRecordId(call);
return filter;
}
/**
* Get addressed record id from the call URI. Can be overridden in the
* handler extension if the addressed record id needs to be determined in a
* different way.
*
* @param {module:x2node-ws~ServiceCall} call The call.
* @returns {(number|string)} The record id (the type must match the record
* type).
*/
getRecordId(call) {
const idPropDesc = this._recordTypeDesc.getPropertyDesc(
this._recordTypeDesc.idPropertyName);
return (
idPropDesc.scalarValueType === 'number' ?
Number(call.uriParams[call.uriParams.length - 1]) :
call.uriParams[call.uriParams.length - 1]
);
}
}
// export the class
module.exports = IndividualResourceHandler;