Ghost/ghost/api-framework/lib/pipeline.js
Fabien "egg" O'Carroll f34999a51f Implemented Refresh-Ahead caching for Redis
This updates the AdapterCacheRedis instance to be able to handle updating
itself when reading from the cache. For this to work we need to pass a
`fetchData` function to the `get` method.

In the case of a cache miss, we will read the data via the `fetchData`
function, and store it in the cache, before returning the value to the caller.

When coupled with a `refreshAheadFactor` config, we will go a step further and
implement the "Refresh Ahead" caching strategy. What this means is that we will
refresh the contents of the cache in the background, this happens on a cache
read and only once the data in the cache has only a certain percentage of the
TTL left, which is set as a decimal value between 0 and 1.

e.g.

ttl = 100s
refreshAheadFactor = 0.2;

Any read from the cache that happens _after_ 80s will do a background refresh
2024-01-18 20:16:36 +07:00

272 lines
9.4 KiB
JavaScript

const debug = require('@tryghost/debug')('pipeline');
const _ = require('lodash');
const stringify = require('json-stable-stringify');
const errors = require('@tryghost/errors');
const {sequence} = require('@tryghost/promise');
const Frame = require('./Frame');
const serializers = require('./serializers');
const validators = require('./validators');
const STAGES = {
validation: {
/**
* @description Input validation.
*
* We call the shared validator which runs the request through:
*
* 1. Shared validator
* 2. Custom API validators
*
* @param {Object} apiUtils - Local utils of target API version.
* @param {Object} apiConfig - Docname & Method of ctrl.
* @param {Object} apiImpl - Controller configuration.
* @param {Object} frame
* @return {Promise}
*/
input(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: validation');
const tasks = [];
// CASE: do validation completely yourself
if (typeof apiImpl.validation === 'function') {
debug('validation function call');
return apiImpl.validation(frame);
}
tasks.push(function doValidation() {
return validators.handle.input(
Object.assign({}, apiConfig, apiImpl.validation),
apiUtils.validators.input,
frame
);
});
return sequence(tasks);
}
},
serialisation: {
/**
* @description Input Serialisation.
*
* We call the shared serializer which runs the request through:
*
* 1. Shared serializers
* 2. Custom API serializers
*
* @param {Object} apiUtils - Local utils of target API version.
* @param {Object} apiConfig - Docname & Method of ctrl.
* @param {Object} apiImpl - Controller configuration.
* @param {Object} frame
* @return {Promise}
*/
input(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: input serialisation');
return serializers.handle.input(
Object.assign({data: apiImpl.data}, apiConfig),
apiUtils.serializers.input,
frame
);
},
/**
* @description Output Serialisation.
*
* We call the shared serializer which runs the request through:
*
* 1. Shared serializers
* 2. Custom API serializers
*
* @param {Object} apiUtils - Local utils of target API version.
* @param {Object} apiConfig - Docname & Method of ctrl.
* @param {Object} apiImpl - Controller configuration.
* @param {Object} frame
* @return {Promise}
*/
output(response, apiUtils, apiConfig, apiImpl, frame) {
debug('stages: output serialisation');
return serializers.handle.output(response, apiConfig, apiUtils.serializers.output, frame);
}
},
/**
* @description Permissions stage.
*
* We call the target API implementation of permissions.
* Permissions implementation can change across API versions.
* There is no shared implementation right now.
*
* @param {Object} apiUtils - Local utils of target API version.
* @param {Object} apiConfig - Docname & Method of ctrl.
* @param {Object} apiImpl - Controller configuration.
* @param {Object} frame
* @return {Promise}
*/
permissions(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: permissions');
const tasks = [];
// CASE: it's required to put the permission key to avoid security holes
if (!Object.prototype.hasOwnProperty.call(apiImpl, 'permissions')) {
return Promise.reject(new errors.IncorrectUsageError());
}
// CASE: handle permissions completely yourself
if (typeof apiImpl.permissions === 'function') {
debug('permissions function call');
return apiImpl.permissions(frame);
}
// CASE: skip stage completely
if (apiImpl.permissions === false) {
debug('disabled permissions');
return Promise.resolve();
}
if (typeof apiImpl.permissions === 'object' && apiImpl.permissions.before) {
tasks.push(function beforePermissions() {
return apiImpl.permissions.before(frame);
});
}
tasks.push(function doPermissions() {
return apiUtils.permissions.handle(
Object.assign({}, apiConfig, apiImpl.permissions),
frame
);
});
return sequence(tasks);
},
/**
* @description Execute controller & receive model response.
*
* @param {Object} apiUtils - Local utils of target API version.
* @param {Object} apiConfig - Docname & Method of ctrl.
* @param {Object} apiImpl - Controller configuration.
* @param {Object} frame
* @return {Promise}
*/
query(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: query');
if (!apiImpl.query) {
return Promise.reject(new errors.IncorrectUsageError());
}
return apiImpl.query(frame);
}
};
/**
* @description The pipeline runs the request through all stages (validation, serialisation, permissions).
*
* The target API version calls the pipeline and wraps the actual ctrl implementation to be able to
* run the request through various stages before hitting the controller.
*
* The stages are executed in the following order:
*
* 1. Input validation - General & schema validation
* 2. Input serialisation - Modification of incoming data e.g. force filters, auto includes, url transformation etc.
* 3. Permissions - Runs after validation & serialisation because the body structure must be valid (see unsafeAttrs)
* 4. Controller - Execute the controller implementation & receive model response.
* 5. Output Serialisation - Output formatting, Deprecations, Extra attributes etc...
*
* @param {Object} apiController
* @param {Object} apiUtils - Local utils (validation & serialisation) from target API version
* @param {String} [apiType] - Content or Admin API access
* @return {Object}
*/
const pipeline = (apiController, apiUtils, apiType) => {
const keys = Object.keys(apiController);
const docName = apiController.docName;
// CASE: api controllers are objects with configuration.
// We have to ensure that we expose a functional interface e.g. `api.posts.add` has to be available.
return keys.reduce((obj, method) => {
const apiImpl = _.cloneDeep(apiController)[method];
obj[method] = async function wrapper() {
const apiConfig = {docName, method};
let options;
let data;
let frame;
if (arguments.length === 2) {
data = arguments[0];
options = arguments[1];
} else if (arguments.length === 1) {
options = arguments[0] || {};
} else {
options = {};
}
// CASE: http helper already creates it's own frame.
if (!(options instanceof Frame)) {
debug(`Internal API request for ${docName}.${method}`);
frame = new Frame({
body: data,
options: _.omit(options, 'context'),
context: options.context || {}
});
frame.configure({
options: apiImpl.options,
data: apiImpl.data
});
} else {
frame = options;
}
// CASE: api controller *can* be a single function, but it's not recommended to disable the framework.
if (typeof apiImpl === 'function') {
debug('ctrl function call');
return apiImpl(frame);
}
frame.apiType = apiType;
frame.docName = docName;
frame.method = method;
let cacheKeyData = frame.options;
if (apiImpl.generateCacheKeyData) {
cacheKeyData = await apiImpl.generateCacheKeyData(frame);
}
const cacheKey = stringify(cacheKeyData);
if (apiImpl.cache) {
const response = await apiImpl.cache.get(cacheKey, getResponse);
if (response) {
return Promise.resolve(response);
}
}
async function getResponse() {
await STAGES.validation.input(apiUtils, apiConfig, apiImpl, frame);
await STAGES.serialisation.input(apiUtils, apiConfig, apiImpl, frame);
await STAGES.permissions(apiUtils, apiConfig, apiImpl, frame);
const response = await STAGES.query(apiUtils, apiConfig, apiImpl, frame);
await STAGES.serialisation.output(response, apiUtils, apiConfig, apiImpl, frame);
return frame.response;
}
const response = await getResponse();
if (apiImpl.cache) {
await apiImpl.cache.set(cacheKey, response);
}
return response;
};
Object.assign(obj[method], apiImpl);
return obj;
}, {});
};
module.exports = pipeline;
module.exports.STAGES = STAGES;