Ghost/core/frontend/helpers/get.js
Hannah Wolfe 52b924638d
Removed core @tryghost pkg usage from f/e proxy
- The frontend proxy is meant to be a way to pass critical internal pieces of Ghost core into the frontend
- These fundamental @tryghost packages are shared and can be required directly, hence there's no need to pass them via the proxy
- Reducing the surface area of the proxy reduces the proxies API
- This makes it easier to see what's left in terms of decoupling the frontend, and what will always need to be passed (e.g. api)

Note on @tryghost/social-urls:
- this is a small utility that helps create URLs for social profiles, it's a util for working with data on the frontend aka part of the sdk
- I think there should be many of these small helpers and we'll probably want to bundle them for the frontend at some point
- for now, I'm leaving these as part of the proxy, as need to figure out where they belong
2021-09-28 12:19:02 +01:00

196 lines
5.6 KiB
JavaScript

// # Get Helper
// Usage: `{{#get "posts" limit="5"}}`, `{{#get "tags" limit="all"}}`
// Fetches data from the API
const {config, hbs, api, prepareContextResource} = require('../services/proxy');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const _ = require('lodash');
const Promise = require('bluebird');
const jsonpath = require('jsonpath');
const messages = {
mustBeCalledAsBlock: 'The {{{helperName}}} helper must be called as a block. E.g. {{#{helperName}}}...{{/{helperName}}}',
invalidResource: 'Invalid resource given to get helper'
};
const createFrame = hbs.handlebars.createFrame;
const RESOURCES = {
posts: {
alias: 'postsPublic'
},
tags: {
alias: 'tagsPublic'
},
pages: {
alias: 'pagesPublic'
},
authors: {
alias: 'authorsPublic'
}
};
// 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} resource
* @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.substr(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);
}
return options;
}
/**
* ## Get
* @param {Object} resource
* @param {Object} options
* @returns {Promise}
*/
module.exports = 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']);
const apiVersion = _.get(data, 'root._locals.apiVersion');
let apiOptions = options.hash;
let returnedRowsCount;
if (!options.fn) {
data.error = tpl(messages.mustBeCalledAsBlock, {helperName: 'get'});
logging.warn(data.error);
return Promise.resolve();
}
if (!RESOURCES[resource]) {
data.error = tpl(messages.invalidResource);
logging.warn(data.error);
return Promise.resolve(options.inverse(self, {data: data}));
}
const controllerName = RESOURCES[resource].alias;
const controller = api[apiVersion][controllerName];
const action = isBrowse(apiOptions) ? 'browse' : 'read';
// Parse the options we're going to pass to the API
apiOptions = parseOptions(ghostGlobals, this, apiOptions);
// @TODO: https://github.com/TryGhost/Ghost/issues/10548
return controller[action](apiOptions).then(function success(result) {
// prepare data properties for use with handlebars
if (result[resource] && result[resource].length) {
result[resource].forEach(prepareContextResource);
}
// used for logging details of slow requests
returnedRowsCount = result[resource] && result[resource].length;
// block params allows the theme developer to name the data using something like
// `{{#get "posts" as |result pageInfo|}}`
const blockParams = [result[resource]];
if (result.meta && result.meta.pagination) {
result.pagination = result.meta.pagination;
blockParams.push(result.meta.pagination);
}
// Call the main template function
return options.fn(result, {
data: data,
blockParams: blockParams
});
}).catch(function error(err) {
logging.error(err);
data.error = err.message;
return options.inverse(self, {data: data});
}).finally(function () {
const totalMs = Date.now() - start;
const logLevel = config.get('logging:slowHelper:level');
const threshold = config.get('logging:slowHelper:threshold');
if (totalMs > threshold) {
logging[logLevel](new errors.HelperWarning({
message: `{{#get}} helper took ${totalMs}ms to complete`,
code: 'SLOW_GET_HELPER',
errorDetails: {
api: `${apiVersion}.${controllerName}.${action}`,
apiOptions,
returnedRows: returnedRowsCount
}
}));
}
});
};