@nzz/q-server
Version:
__Q__ is a system that lets journalists create visual elements for stories. It is developed by [NZZ Storytelling](https://www.nzz.ch/storytelling) and used in the [NZZ](https://www.nzz.ch) newsroom.
283 lines (251 loc) • 9.83 kB
JavaScript
const Boom = require('boom');
const Joi = require('joi');
const renderingInfoFetcher = require('../processing/rendering-info-fetcher.js');
const getDb = require('../db.js').getDb;
const server = require('../server.js').getServer();
const getCacheControlDirectivesFromConfig = require('../helper/cache.js').getCacheControlDirectivesFromConfig;
// size, width and height are optional
// if a width or height array is defined the following restrictions apply:
// the array consists of either one (equality or one-sided limitation) or two objects (for a range)
// the respective object has to have a value and a comparison sign,
// if no unit is defined the default value 'px' is assumed.
const sizeValidationObject = {
width: Joi.array().items(Joi.object({
value: Joi.number().required(),
comparison: Joi.string().regex(/^(<|>|=){1}$/).required(),
unit: Joi.string().regex(/^(px|mm)?$/).optional()
}).required()).max(2).optional(),
height: Joi.array().items(Joi.object({
value: Joi.number().required(),
comparison: Joi.string().regex(/^(<|>|=){1}$/).required(),
unit: Joi.string().optional()
})).max(2).optional()
};
function validateDimension(dimension) {
let error = "";
if (dimension.length === 2) {
let dimensionA = dimension[0];
let dimensionB = dimension[1];
if (dimensionA.unit === undefined) {
dimensionA.unit = 'px';
}
if (dimensionB.unit === undefined) {
dimensionB.unit = 'px'
}
if (dimensionA.unit !== dimensionB.unit) {
error = Boom.badData('Units are not the same for the given range.');
}
let comparisonA = dimensionA.comparison;
let comparisonB = dimensionB.comparison;
if (comparisonA === comparisonB || comparisonA === '=' || comparisonB === '='
|| (comparisonA === '<' && dimensionA.value < dimensionB.value)
|| (comparisonB === '>' && dimensionB.value > dimensionA.value)) {
error = Boom.badData('The combination of values and comparison signs does not result in a meaningful range.')
}
}
return error;
}
function getCompiledToolRuntimeConfig(item, target, requestToolRuntimeConfig = {}) {
// get overall tool runtime config
let overallToolRuntimeConfig = server.settings.app.misc.get('/toolRuntimeConfig');
const toolBaseUrlConfig = server.settings.app.misc.get('/toolBaseUrl');
if (toolBaseUrlConfig && toolBaseUrlConfig.host) {
// if we have a toolBaseUrl config object in misc, we take this to generate the toolBaseUrl
let protocol = 'https';
if (toolBaseUrlConfig.protocol) {
protocol = toolBaseUrlConfig.protocol;
}
let path = 'tools';
if (toolBaseUrlConfig.path) {
path = toolBaseUrlConfig.path;
}
overallToolRuntimeConfig.toolBaseUrl = `${protocol}://${toolBaseUrlConfig.host}/${path}`;
} else {
// otherwise we fall back to using the qServerBaseUrl with hardcoded `tools` path
overallToolRuntimeConfig.toolBaseUrl = server.settings.app.misc.get('/qServerBaseUrl') + `/tools`;
}
// add the tools name to the toolBaseUrl
overallToolRuntimeConfig.toolBaseUrl = `${overallToolRuntimeConfig.toolBaseUrl}/${item.tool}`;
// get the endpoint config for the tool and target combo
const toolEndpointConfig = server.settings.app.tools.get(`/${item.tool}/endpoint`, { target: target });
// default to the overall config
let toolRuntimeConfig = overallToolRuntimeConfig;
// add the item id if given or to a randomized id if not
if (item.hasOwnProperty('_id')) {
toolRuntimeConfig.id = item._id;
}
// if endpoint defines tool runtime config, apply it
if (toolEndpointConfig && toolEndpointConfig.toolRuntimeConfig) {
toolRuntimeConfig = Object.assign(toolRuntimeConfig, toolEndpointConfig.toolRuntimeConfig);
}
// apply to runtime config from the request
toolRuntimeConfig = Object.assign(toolRuntimeConfig, requestToolRuntimeConfig);
return toolRuntimeConfig;
}
// wrap getRenderingInfo as a server method to cache the response within Q-server
// as we do not want to load the tool services with caching logic.
const getRenderingInfoForId = function(id, target, requestToolRuntimeConfig, ignoreInactive, next) {
const itemDbBaseUrl = server.settings.app.misc.get('/itemDbBaseUrl');
let db = getDb();
db.get(id, (err, item) => {
if (err) {
return next(Boom.create(err.statusCode, err.description))
}
if (!ignoreInactive && item.active !== true) {
return next(Boom.forbidden('Item is not active'));
}
// transform legacy tool name with dashes to underscore
// we need to do this as the configuration framework 'confidence' we use
// has some problems with key names containing dashes
item.tool = item.tool.replace(new RegExp('-', 'g'), '_');
const toolRuntimeConfig = getCompiledToolRuntimeConfig(item, target, requestToolRuntimeConfig);
renderingInfoFetcher.getRenderingInfo(item, target, toolRuntimeConfig)
.then(renderingInfo => {
next(null, renderingInfo);
})
.catch(err => {
if (err.isBoom) {
next(err, null);
} else {
const error = Boom.badRequest(err.message);
next(error, null);
}
})
})
}
server.method('getRenderingInfoForId', getRenderingInfoForId, {
cache: {
expiresIn: server.settings.app.misc.get('/cache/serverCacheTime'),
generateTimeout: 10000
},
generateKey: (id, target, toolRuntimeConfig, ignoreInactive) => {
let toolRuntimeConfigKey = JSON
.stringify(toolRuntimeConfig)
.replace(new RegExp('{', 'g'), '')
.replace(new RegExp('}', 'g'), '')
.replace(new RegExp('"', 'g'), '')
.replace(new RegExp(':', 'g'), '-');
let key = `${id}-${target}-${toolRuntimeConfigKey}-${ignoreInactive}`;
return key;
}
});
const getRenderingInfoRoute = {
method: 'GET',
path: '/rendering-info/{id}/{target}',
config: {
validate: {
params: {
id: Joi.string().required(),
target: Joi.string().required()
},
query: {
toolRuntimeConfig: Joi.object({
size: Joi.object(sizeValidationObject).optional()
}),
noCache: Joi.boolean().optional(),
ignoreInactive: Joi.boolean().optional()
},
options: {
allowUnknown: true
}
},
description: 'Returns rendering information for the given graphic id and target (as configured in the environment).',
tags: ['api', 'reader-facing']
},
handler: function(request, reply) {
let requestToolRuntimeConfig = {};
if (request.query.toolRuntimeConfig) {
if (request.query.toolRuntimeConfig.size) {
if (request.query.toolRuntimeConfig.size.width) {
let error = validateDimension(request.query.toolRuntimeConfig.size.width);
if (error.isBoom) {
return reply(error);
}
}
if (request.query.toolRuntimeConfig.size.height) {
let error = validateDimension(request.query.toolRuntimeConfig.size.height);
if (error.isBoom) {
return reply(error);
}
}
}
requestToolRuntimeConfig = request.query.toolRuntimeConfig;
}
const configCacheControl = getCacheControlDirectivesFromConfig(server);
if (request.query.noCache) {
getRenderingInfoForId(request.params.id, request.params.target, requestToolRuntimeConfig, request.query.ignoreInactive, (err, result) => {
if (err) {
return reply(err);
}
return reply(result)
.header('cache-control', 'no-cache');
})
} else {
request.server.methods.getRenderingInfoForId(request.params.id, request.params.target, requestToolRuntimeConfig, request.query.ignoreInactive, (err, result) => {
if (err) {
return reply(err);
}
return reply(result)
.header('cache-control', configCacheControl.join(', '));
})
}
}
}
const postRenderingInfoRoute = {
method: 'POST',
path: '/rendering-info/{target}',
config: {
validate: {
params: {
target: Joi.string().required()
},
payload: {
item: Joi.object().required(),
toolRuntimeConfig: Joi.object({
size: Joi.object(sizeValidationObject).optional()
})
},
options: {
allowUnknown: true
}
},
description: 'Returns rendering information for the given data and target (as configured in the environment).',
tags: ['api', 'editor']
},
handler: function(request, reply) {
let requestToolRuntimeConfig = {};
if (request.payload.hasOwnProperty('toolRuntimeConfig')) {
if (request.payload.toolRuntimeConfig.size) {
if (request.payload.toolRuntimeConfig.size.width) {
let error = validateDimension(request.payload.toolRuntimeConfig.size.width);
if (error.isBoom) {
return reply(error);
}
}
if (request.payload.toolRuntimeConfig.size.height) {
let error = validateDimension(request.payload.toolRuntimeConfig.size.height);
if (error.isBoom) {
return reply(error);
}
}
}
requestToolRuntimeConfig = request.payload.toolRuntimeConfig;
}
const toolRuntimeConfig = getCompiledToolRuntimeConfig(request.payload.item, request.params.target, requestToolRuntimeConfig);
renderingInfoFetcher.getRenderingInfo(request.payload.item, request.params.target, toolRuntimeConfig)
.then(renderingInfo => {
reply(renderingInfo)
})
.catch(error => {
if (error.isBoom) {
reply(error)
} else {
reply(Boom.badRequest(error.message))
}
})
}
}
module.exports = {
getRenderingInfoRoute: getRenderingInfoRoute,
postRenderingInfoRoute: postRenderingInfoRoute
};