UNPKG

@windingtree/wt-write-api

Version:

API to write data to the Winding Tree platform

489 lines (470 loc) 17.9 kB
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); } };