Ghost/ghost/api-framework/lib/pipeline.js
Fabien "egg" O'Carroll 104f84f252 Added eslint rule for file naming convention
As discussed with the product team we want to enforce kebab-case file names for
all files, with the exception of files which export a single class, in which
case they should be PascalCase and reflect the class which they export.

This will help find classes faster, and should push better naming for them too.

Some files and packages have been excluded from this linting, specifically when
a library or framework depends on the naming of a file for the functionality
e.g. Ember, knex-migrator, adapter-manager
2023-05-09 12:34:34 -04:00

272 lines
9.4 KiB
JavaScript

const debug = require('@tryghost/debug')('pipeline');
const _ = require('lodash');
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 cacheKey = JSON.stringify(frame.options);
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;