@windingtree/wt-write-api
Version:
API to write data to the Winding Tree platform
489 lines (470 loc) • 17.9 kB
JavaScript
const _ = require('lodash');
const { errors: wtJsLibsErrors } = require('@windingtree/wt-js-libs');
const { logger, dataFormatVersions } = require('../config');
const { HttpValidationError, HttpBadRequestError,
HttpBadGatewayError, Http404Error,
HttpConflictError } = require('../errors');
const { ValidationError } = require('../services/validators');
const { normalizeHotelRequest } = require('../services/normalizers');
const { parseBoolean, QueryParserError } = require('../services/query-parsers');
const WT = require('../services/wt');
const { publishHotelCreated, publishHotelDeleted,
publishHotelUpdated } = require('../services/notifications');
/**
* Add the `updatedAt` timestamp to the following components (if
* present):
*
* - description
* - description.roomTypes.*
* - ratePlans.ratePlans.*
* - availability
*/
function _addTimestamps (body) {
const timestampedObjects = _([
body,
[_.get(body, ['hotel', 'description'])],
_.values(_.get(body, ['hotel', 'description', 'roomTypes'])),
_.values(_.get(body, ['hotel', 'ratePlans'])),
[_.get(body, ['hotel', 'availability'])],
])
.flatten()
.filter()
.value();
const updatedAt = (new Date()).toISOString();
for (const obj of timestampedObjects) {
if (!obj.updatedAt) {
obj.updatedAt = updatedAt;
}
}
}
/**
* Validate create/update request.
*
* @param {Object} body Request body
* @param {Boolean} enforceRequired
* @throw {ValidationError} when validation fails
*/
function _validateRequest (body, enforceRequired) {
function _validateData (dataset, dataFields, dataFieldNames) {
for (const field in dataset) {
if (dataFieldNames.indexOf(field) === -1) {
throw new ValidationError(`Unknown property: ${field}`);
}
}
for (const field of dataFields) {
const data = dataset[field.name];
if (enforceRequired && field.required && !data) {
throw new ValidationError(`Missing property: ${field.name}`);
}
if (data) {
field.validator(data);
}
}
}
let dataset, dataFields, dataFieldNames;
if (body.orgJson) {
dataFields = WT.NO_DATA_INDEX_FIELDS;
dataFieldNames = WT.NO_DATA_INDEX_FIELD_NAMES;
dataset = body;
_validateData(dataset, dataFields, dataFieldNames);
} else {
if (body.legalEntity) {
dataFields = WT.LEGAL_ENTITY_FIELDS;
dataFieldNames = WT.LEGAL_ENTITY_FIELD_NAMES;
dataset = body.legalEntity;
_validateData(dataset, dataFields, dataFieldNames);
}
if (body.hotel) {
dataFields = WT.DATA_INDEX_FIELDS;
dataFieldNames = WT.DATA_INDEX_FIELD_NAMES;
dataset = body.hotel;
_validateData(dataset, dataFields, dataFieldNames);
}
}
}
function _prepareOrgJsonContents (hotelData, dataIndexUri, versions, origOrgJsonContents) {
// in case of partial update use orig data
const legalEntity = hotelData.legalEntity || origOrgJsonContents.legalEntity;
const hotelName = _.get(hotelData, ['hotel', 'description', 'name']) || _.get(origOrgJsonContents, ['hotel', 'name']);
const hotelLocation = _.get(hotelData, ['hotel', 'description', 'location']) || _.get(origOrgJsonContents, ['hotel', 'location']);
const hotelWebsite = _.get(hotelData, ['hotel', 'description', 'website']) || _.get(origOrgJsonContents, ['hotel', 'website']);
const orgJsonContents = {
updatedAt: hotelData.updatedAt,
dataFormatVersion: versions.orgJson,
legalEntity: legalEntity,
hotel: {
name: hotelName,
location: hotelLocation,
website: hotelWebsite,
apis: [
{
entrypoint: dataIndexUri,
docs: 'https://developers.windingtree.com',
format: 'windingtree',
version: versions.hotels,
segment: 'hotels',
},
],
},
};
if (hotelData.guarantee) {
orgJsonContents.guarantee = hotelData.guarantee;
}
return orgJsonContents;
}
/**
* Add a new hotel to the WT index and store its data in an
* off-chain storage.
*
*/
module.exports.createHotel = async (req, res, next) => {
try {
const wt = WT.get();
const account = req.account;
// 0. Normalize data
const hotelData = normalizeHotelRequest(req.body);
// 1. Validate request payload.
_validateRequest(hotelData, true);
// 2. Add `updatedAt` timestamps.
_addTimestamps(hotelData);
// 3. Upload the actual data parts if necessary
let orgJsonUri;
let orgJsonHash;
let dataIndexUri;
let dataIndex = {};
if (hotelData.orgJson) {
orgJsonUri = hotelData.orgJson;
orgJsonHash = hotelData.hash;
const rawDataIndex = await wt.resolveUnregisteredDataIndex(hotelData.orgJson);
dataIndexUri = rawDataIndex.ref;
dataIndex = rawDataIndex && rawDataIndex.contents;
} else {
if (hotelData.hotel) {
// hotel data
const uploading = [];
for (const field of WT.DATA_INDEX_FIELDS) {
const data = hotelData.hotel[field.name];
if (!data) {
continue;
}
if (field.pointer) {
const uploader = account.uploaders.getUploader(field.name);
if (!uploader) {
throw new HttpConflictError('noUploaders', `Account does not have an appropriate uploader for ${field.name}`);
}
uploading.push((async () => {
dataIndex[field.dataIndexName] = await uploader.upload(data, field.name);
})());
} else {
dataIndex[field.dataIndexName] = data;
}
}
await Promise.all(uploading);
dataIndex.dataFormatVersion = dataFormatVersions.hotels;
}
// 4. Upload the data index.
const rootUploader = account.uploaders.getUploader('root');
if (!rootUploader) {
throw new HttpConflictError('noUploaders', 'Account does not have an appropriate uploader for root');
}
dataIndexUri = await rootUploader.upload(dataIndex, 'dataIndex');
// org json data
const contents = JSON.stringify(_prepareOrgJsonContents(hotelData, dataIndexUri, dataFormatVersions));
orgJsonUri = await rootUploader.upload(contents, 'orgJson');
orgJsonHash = wt.getContentsHash(contents);
}
// 5. Upload the resulting data to ethereum.
const address = await wt.upload(account.withWallet, orgJsonUri, orgJsonHash);
// 6. Publish create notification, if applicable.
if (dataIndex && dataIndex.notificationsUri) {
try {
await publishHotelCreated(dataIndex.notificationsUri, await wt.getDirectoryAddress(), address);
} catch (err) {
logger.info(`Could not publish notification to ${dataIndex.notificationsUri}: ${err}`);
}
}
res.status(201).json({
address: address,
});
} catch (err) {
if (err instanceof ValidationError) {
return next(new HttpValidationError('validationFailed', err.message));
}
next(err);
}
};
const updateHotelFactory = (ignoreOriginalData) => {
return async (req, res, next) => {
try {
const account = req.account,
wt = WT.get();
if (!wt.isValidAddress(req.params.address)) {
throw new Http404Error('notFound', 'Hotel not found.');
}
// 0. Normalize data
const hotelData = normalizeHotelRequest(req.body);
// 1. Validate request.
if (Object.keys(hotelData).length === 0) {
throw new HttpBadRequestError('badRequest', 'No data provided');
}
_validateRequest(hotelData, ignoreOriginalData);
// 2. Add `updatedAt` timestamps.
_addTimestamps(hotelData);
// 3. Upload the changed data parts.
let orgJsonUri, origOrgJsonHash, dataIndexUri,
dataIndex = {},
origDataIndex = { contents: {} },
origOrgJson = { contents: {} };
const notificationSubjects = [];
if (ignoreOriginalData) {
if ((!hotelData.orgJson || !hotelData.hash) && (!hotelData.hotel || !hotelData.legalEntity)) {
throw new HttpBadRequestError('badRequest', 'Provide either `orgJson` and `hash` or both `hotel` and `legalEntity` in PUT request.');
}
} else {
origOrgJson = await wt.getOrgJson(req.params.address);
origOrgJsonHash = await wt.getOrgJsonHash(req.params.address);
origDataIndex = await wt.getDataIndex(req.params.address);
dataIndexUri = origDataIndex.ref;
orgJsonUri = origOrgJson.ref;
}
if (hotelData.orgJson) {
if (!hotelData.hash) {
throw new ValidationError('Provide both `orgJson` and `hash` in update request.');
}
if (!origOrgJson || origOrgJson.ref !== hotelData.orgJson || origOrgJsonHash !== hotelData.hash) {
await wt.upload(account.withWallet, hotelData.orgJson, hotelData.hash, req.params.address);
const rawDataIndex = await wt.resolveUnregisteredDataIndex(hotelData.orgJson);
dataIndex = rawDataIndex && rawDataIndex.contents;
notificationSubjects.push('onChain');
}
} else {
if (hotelData.legalEntity) {
notificationSubjects.push('legalEntity');
}
if (hotelData.hotel) {
const uploading = [];
for (const field of WT.DATA_INDEX_FIELDS) {
const data = hotelData.hotel[field.name];
if (!data) {
continue;
}
if (field.pointer) {
const uploader = account.uploaders.getUploader(field.name);
if (!uploader) {
throw new HttpConflictError('noUploaders', `Account does not have an appropriate uploader for ${field.name}`);
}
uploading.push((async () => {
const docKey = field.dataIndexName;
const preferredUrl = origDataIndex.contents[docKey];
dataIndex[docKey] = await uploader.upload(data, field.name, preferredUrl);
notificationSubjects.push(field.name);
})());
} else {
dataIndex[field.dataIndexName] = data;
}
}
await Promise.all(uploading);
dataIndex.dataFormatVersion = dataFormatVersions.hotels;
// 4. Find out if the data index and wt index need to be reuploaded.
const newContents = Object.assign({}, origDataIndex.contents, dataIndex);
if (!_.isEqual(origDataIndex.contents, newContents)) {
const uploader = account.uploaders.getUploader('root');
dataIndexUri = await uploader.upload(newContents, 'dataIndex', origDataIndex.ref);
notificationSubjects.push('dataIndex');
}
}
const newOrgJsonContents = Object.assign({}, _prepareOrgJsonContents(hotelData, dataIndexUri, dataFormatVersions, origOrgJson.contents));
if (!_.isEqual(_.omit(origOrgJson.contents, 'updatedAt'), _.omit(newOrgJsonContents, 'updatedAt'))) {
const uploader = account.uploaders.getUploader('root');
const contents = JSON.stringify(newOrgJsonContents);
orgJsonUri = await uploader.upload(contents, 'orgJson', origOrgJson.ref);
const orgJsonHash = wt.getContentsHash(contents);
notificationSubjects.push('orgJson');
if (orgJsonUri !== origOrgJson.ref || orgJsonHash !== origOrgJsonHash) {
await wt.upload(account.withWallet, orgJsonUri, orgJsonHash, req.params.address);
notificationSubjects.push('onChain');
}
}
}
// 5. Publish update notifications, if applicable.
const notificationsUris = new Set([
(dataIndex && dataIndex.notificationsUri),
origDataIndex.contents.notificationsUri,
].filter(Boolean));
if (notificationSubjects.length) {
for (const notificationsUri of notificationsUris) {
try {
await publishHotelUpdated(notificationsUri, await wt.getDirectoryAddress(),
req.params.address, notificationSubjects);
} catch (err) {
logger.info(`Could not publish notification to ${notificationsUri}: ${err}`);
}
}
}
res.sendStatus(204);
} catch (err) {
if (err instanceof ValidationError) {
return next(new HttpValidationError('validationFailed', err.message));
}
next(err);
}
};
};
/**
* Update hotel information.
*/
module.exports.updateHotel = updateHotelFactory(false);
/**
* Update hotel information even if the original off-chain
* data is inaccessible.
*/
module.exports.forceUpdateHotel = updateHotelFactory(true);
/**
* Delete the hotel from WT index.
*
* If req.query.offChain is true, it also tries to delete the
* off-chain data if possible (which might or might not succeed,
* based on uploader configuration).
*
*/
module.exports.deleteHotel = async (req, res, next) => {
try {
const account = req.account,
wt = WT.get();
if (!wt.isValidAddress(req.params.address)) {
throw new Http404Error('notFound', 'Hotel not found.');
}
let dataIndex, orgJson;
try {
dataIndex = await wt.getDataIndex(req.params.address);
orgJson = await wt.getOrgJson(req.params.address);
} catch (err) {
// Ignore StoragePointerErrors as that simply means that
// off-chain data is not accessible (and thus doesn't have
// to be deleted).
if (!(err instanceof wtJsLibsErrors.StoragePointerError)) {
throw err;
}
}
await wt.remove(account.withWallet, req.params.address);
if ((dataIndex || orgJson) && req.query.offChain && parseBoolean(req.query.offChain)) {
const rootUploader = account.uploaders.getUploader('root');
if (!rootUploader) {
throw new HttpConflictError('noUploaders', 'Account does not have an appropriate uploader for root');
}
if (dataIndex) {
await rootUploader.remove(dataIndex.ref);
}
if (orgJson) {
await rootUploader.remove(orgJson.ref);
}
if (dataIndex.contents) {
const deleting = [];
for (const field of WT.DATA_INDEX_FIELDS) {
const documentUri = dataIndex.contents[`${field.name}Uri`];
if (!documentUri || !field.pointer) {
continue;
}
const uploader = account.uploaders.getUploader(field.name);
if (!uploader) {
throw new HttpConflictError('noUploaders', `Account does not have an appropriate uploader for ${field.name}`);
}
deleting.push((async () => {
await uploader.remove(documentUri);
})());
}
await Promise.all(deleting);
}
}
const notificationsUri = dataIndex && dataIndex.contents.notificationsUri;
if (notificationsUri) {
try {
await publishHotelDeleted(notificationsUri, await wt.getDirectoryAddress(), req.params.address);
} catch (err) {
logger.info(`Could not publish notification to ${notificationsUri}: ${err}`);
}
}
res.sendStatus(204);
} catch (err) {
if (err instanceof QueryParserError) {
return next(new HttpBadRequestError('badRequest', err.message));
}
next(err);
}
};
/**
* Get a hotel from the WT index.
*
* Accepts the "fields" parameter which can specify one or more
* comma-separated fields from WT.DATA_INDEX_FIELDS.
*
* Performs validation to avoid returning broken data.
*
* The main purpose of this endpoint is to offer a possibility
* to easily retrieve the current state in the correct format to
* prepare update requests.
*/
module.exports.getHotel = async (req, res, next) => {
try {
const fields = _.filter((req.query.fields || '').split(',')),
wt = WT.get();
if (!wt.isValidAddress(req.params.address)) {
throw new Http404Error('notFound', 'Hotel not found.');
}
for (const field of fields) {
if (WT.DATA_INDEX_FIELD_NAMES.indexOf(field) === -1) {
throw new HttpValidationError('validationFailed', `Unknown field: ${field}`);
}
}
const fieldNames = _(WT.DATA_INDEX_FIELDS)
.map('name')
.filter((name) => (fields.length === 0 || fields.indexOf(name) !== -1))
.value();
const data = await wt.getDocuments(req.params.address, fieldNames);
_validateRequest(data, fields.length === 0);
res.status(200).json(data);
} catch (err) {
if (err instanceof ValidationError) {
const msg = 'Invalid upstream response - hotel data is not valid.';
return next(new HttpBadGatewayError('badGateway', msg));
}
next(err);
}
};
/**
* Transfer hotel ownership to someone else.
*/
module.exports.transferHotel = async (req, res, next) => {
try {
const account = req.account,
wt = WT.get();
if (!wt.isValidAddress(req.params.address)) {
throw new Http404Error('notFound', 'Hotel not found.');
}
for (const key of Object.keys(req.body)) {
if (key !== 'to') {
const msg = `Unknown property in the transfer request: ${key}:`;
throw new HttpValidationError('validationFailed', msg);
}
}
if (!wt.isValidAddress(req.body.to)) {
throw new HttpValidationError('validationFailed', 'Invalid or missing new owner adress.');
}
await wt.transferHotel(account.withWallet, req.params.address, req.body.to);
const data = await wt.getDocuments(req.params.address, ['notifications']);
if (data.hotel.notifications) {
try {
await publishHotelUpdated(data.hotel.notifications, await wt.getDirectoryAddress(),
req.params.address, ['onChain']);
} catch (err) {
logger.info(`Could not publish notification to ${data.hotel.notifications}: ${err}`);
}
}
res.sendStatus(204);
} catch (err) {
if (err instanceof wtJsLibsErrors.InputDataError) {
return next(new HttpValidationError('validationFailed', err.message));
}
next(err);
}
};