// # Get Helper // Usage: `{{#get "posts" limit="5"}}`, `{{#get "tags" limit="all"}}` // Fetches data from the API const {config, api, prepareContextResource} = require('../services/proxy'); const {hbs, SafeString} = require('../services/handlebars'); const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const Sentry = require('@sentry/node'); const _ = require('lodash'); const jsonpath = require('jsonpath'); const nqlLang = require('@tryghost/nql-lang'); const messages = { mustBeCalledAsBlock: 'The {\\{{helperName}}} helper must be called as a block. E.g. {{#{helperName}}}...{{/{helperName}}}', invalidResource: 'Invalid "{resource}" resource given to get helper' }; const createFrame = hbs.handlebars.createFrame; const RESOURCES = { posts: { alias: 'postsPublic' }, tags: { alias: 'tagsPublic' }, pages: { alias: 'pagesPublic' }, authors: { alias: 'authorsPublic' }, tiers: { alias: 'tiersPublic' }, newsletters: { alias: 'newslettersPublic' } }; // Short forms of paths which we should understand const pathAliases = { 'post.tags': 'post.tags[*].slug', 'post.author': 'post.author.slug' }; /** * ## Is Browse * Is this a Browse request or a Read request? * @param {Object} options * @returns {boolean} */ function isBrowse(options) { let browse = true; if (options.id || options.slug) { browse = false; } return browse; } /** * ## Resolve Paths * Find and resolve path strings * * @param {Object} data * @param {String} value * @returns {String} */ function resolvePaths(globals, data, value) { const regex = /\{\{(.*?)\}\}/g; value = value.replace(regex, function (match, path) { let result; // Handle aliases path = pathAliases[path] ? pathAliases[path] : path; // Handle Handlebars .[] style arrays path = path.replace(/\.\[/g, '['); if (path.charAt(0) === '@') { result = jsonpath.query(globals, path.slice(1)); } else { // Do the query, which always returns an array of matches result = jsonpath.query(data, path); } // Handle the case where the single data property we return is a Date // Data.toString() is not DB compatible, so use `toISOString()` instead if (_.isDate(result[0])) { result[0] = result[0].toISOString(); } // Concatenate the results with a comma, handles common case of multiple tag slugs return result.join(','); }); return value; } /** * ## Parse Options * Ensure options passed in make sense * * @param {Object} data * @param {Object} options * @returns {*} */ function parseOptions(globals, data, options) { if (_.isString(options.filter)) { options.filter = resolvePaths(globals, data, options.filter); } if (options.limit === 'all' && config.get('getHelperLimitAllMax')) { options.limit = config.get('getHelperLimitAllMax'); } return options; } function optimiseFilterCacheability(resource, options) { const noOptimisation = { options, parseResult(result) { return result; } }; if (resource !== 'posts') { return noOptimisation; } if (!options.filter) { return noOptimisation; } try { if (options.filter.split('id:-').length !== 2) { return noOptimisation; } const parsedFilter = nqlLang.parse(options.filter); // Support either `id:blah` or `id:blah+other:stuff` if (!parsedFilter.$and && !parsedFilter.id) { return noOptimisation; } const queries = parsedFilter.$and || [parsedFilter]; const query = queries.find((q) => { return q?.id?.$ne; }); if (!query) { return noOptimisation; } const idToFilter = query.id.$ne; let limit = options.limit; if (options.limit !== 'all') { limit = options.limit ? 1 + parseInt(options.limit, 10) : 16; } // We replace with id:-null so we don't have to deal with leading/trailing AND operators const filter = options.filter.replace(/id:-[a-f0-9A-F]{24}/, 'id:-null'); const parseResult = function parseResult(result) { const filteredPosts = result?.posts?.filter((post) => { return post.id !== idToFilter; }) || []; const modifiedResult = { ...result, posts: limit === 'all' ? filteredPosts : filteredPosts.slice(0, limit - 1) }; modifiedResult.meta = modifiedResult.meta || {}; modifiedResult.meta.cacheabilityOptimisation = true; if (typeof modifiedResult?.meta?.pagination?.limit === 'number') { modifiedResult.meta.pagination.limit = modifiedResult.meta.pagination.limit - 1; } return modifiedResult; }; return { options: { ...options, limit, filter }, parseResult }; } catch (err) { logging.warn(err); return noOptimisation; } } /** * * @param {String} resource * @param {String} controllerName * @param {String} action * @param {Object} apiOptions * @returns {Promise} */ async function makeAPICall(resource, controllerName, action, apiOptions) { const controller = api[controllerName]; let makeRequest = options => controller[action](options); const { options, parseResult } = optimiseFilterCacheability(resource, apiOptions); let timer; try { let response; if (config.get('optimization:getHelper:timeout:threshold')) { const logLevel = config.get('optimization:getHelper:timeout:level') || 'error'; const threshold = config.get('optimization:getHelper:timeout:threshold'); const apiResponse = makeRequest(options).then(parseResult); const timeout = new Promise((resolve) => { timer = setTimeout(() => { logging[logLevel](new errors.HelperWarning({ message: `{{#get}} took longer than ${threshold}ms and was aborted`, code: 'ABORTED_GET_HELPER', errorDetails: { api: `${controllerName}.${action}`, apiOptions } })); resolve({[resource]: [], '@@ABORTED_GET_HELPER@@': true}); }, threshold); }); response = await Promise.race([apiResponse, timeout]); clearTimeout(timer); } else { response = await makeRequest(options).then(parseResult); } return response; } catch (err) { clearTimeout(timer); throw err; } } /** * ## Get * @param {String} resource * @param {Object} options * @returns {Promise} */ module.exports = async function get(resource, options) { options = options || {}; options.hash = options.hash || {}; options.data = options.data || {}; const self = this; const start = Date.now(); const data = createFrame(options.data); const ghostGlobals = _.omit(data, ['_parent', 'root']); let apiOptions = options.hash; let returnedRowsCount; if (!options.fn) { data.error = tpl(messages.mustBeCalledAsBlock, {helperName: 'get'}); logging.warn(data.error); return; } if (!RESOURCES[resource]) { data.error = tpl(messages.invalidResource, {resource}); logging.warn(data.error); return options.inverse(self, {data: data}); } const controllerName = RESOURCES[resource].alias; const action = isBrowse(apiOptions) ? 'browse' : 'read'; // Parse the options we're going to pass to the API apiOptions = parseOptions(ghostGlobals, this, apiOptions); let apiOptionsString = Object.entries(apiOptions) .map(([key, value]) => ` ${key}="${value}"`) .join(''); apiOptions.context = {member: data.member}; try { const spanName = `{{#get "${resource}"${apiOptionsString}}} ${data.member ? 'member' : 'public'}`; const result = await Sentry.startSpan({ op: 'frontend.helpers.get', name: spanName, tags: { resource, ...apiOptions, context: data.member ? 'member' : 'public' } }, async (span) => { const response = await makeAPICall(resource, controllerName, action, apiOptions); // prepare data properties for use with handlebars if (response[resource] && response[resource].length) { response[resource].forEach(prepareContextResource); } // used for logging details of slow requests returnedRowsCount = response[resource] && response[resource].length; span?.setTag('returnedRows', returnedRowsCount); // block params allows the theme developer to name the data using something like // `{{#get "posts" as |result pageInfo|}}` const blockParams = [response[resource]]; if (response.meta && response.meta.pagination) { response.pagination = response.meta.pagination; blockParams.push(response.meta.pagination); } // Call the main template function const rendered = options.fn(response, { data: data, blockParams: blockParams }); if (response['@@ABORTED_GET_HELPER@@']) { return new SafeString(`Could not load content` + rendered); } else { return rendered; } }); return result; } catch (error) { logging.error(error); data.error = error.message; return options.inverse(self, {data: data}); } finally { if (config.get('optimization:getHelper:notify:threshold')) { const totalMs = Date.now() - start; const logLevel = config.get('optimization:getHelper:notify:level') || 'warn'; const threshold = config.get('optimization:getHelper:notify:threshold'); if (totalMs > threshold) { logging[logLevel](new errors.HelperWarning({ message: `{{#get}} helper took ${totalMs}ms to complete`, code: 'SLOW_GET_HELPER', errorDetails: { api: `${controllerName}.${action}`, apiOptions, returnedRows: returnedRowsCount } }), { time: totalMs }); } } } }; module.exports.async = true; module.exports.optimiseFilterCacheability = optimiseFilterCacheability;