Ghost/ghost/api-framework/lib/pipeline.js
Fabien "egg" O'Carroll cfef41cd8f Added support for custom cache key generation
refs https://github.com/TryGhost/Arch/issues/83

This allows endpoints to implement their own key generation, with access to the
frame object they can be smart about key generation and use only options and
context values that are appropriate.
2023-09-19 12:39:50 +07:00

279 lines
9.6 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);
if (response) {
return Promise.resolve(response);
}
}
return Promise.resolve()
.then(() => {
return STAGES.validation.input(apiUtils, apiConfig, apiImpl, frame);
})
.then(() => {
return STAGES.serialisation.input(apiUtils, apiConfig, apiImpl, frame);
})
.then(() => {
return STAGES.permissions(apiUtils, apiConfig, apiImpl, frame);
})
.then(() => {
return STAGES.query(apiUtils, apiConfig, apiImpl, frame);
})
.then((response) => {
return STAGES.serialisation.output(response, apiUtils, apiConfig, apiImpl, frame);
})
.then(async () => {
if (apiImpl.cache) {
await apiImpl.cache.set(cacheKey, frame.response);
}
return frame.response;
});
};
Object.assign(obj[method], apiImpl);
return obj;
}, {});
};
module.exports = pipeline;
module.exports.STAGES = STAGES;