TryGhost/Ghost

View on GitHub
ghost/api-framework/lib/pipeline.js

Summary

Maintainability
C
1 day
Test Coverage
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 {import('@tryghost/api-framework').ControllerMethod} apiImpl -  Controller configuration.
         * @param {import('@tryghost/api-framework').Frame} 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 {import('@tryghost/api-framework').ControllerMethod} apiImpl -  Controller configuration.
         * @param {import('@tryghost/api-framework').Frame} 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 {import('@tryghost/api-framework').ControllerMethod} apiImpl -  Controller configuration.
         * @param {import('@tryghost/api-framework').Frame} 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 {import('@tryghost/api-framework').ControllerMethod} apiImpl -  Controller configuration.
     * @param {import('@tryghost/api-framework').Frame} 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 {import('@tryghost/api-framework').ControllerMethod} apiImpl -  Controller configuration.
     * @param {import('@tryghost/api-framework').Frame} frame
     * @return {Promise}
     */
    query(apiUtils, apiConfig, apiImpl, frame) {
        debug('stages: query');

        if (!apiImpl.query) {
            return Promise.reject(new errors.IncorrectUsageError());
        }

        return apiImpl.query(frame);
    }
};

const controllerMap = new Map();

/**
 * @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 {import('@tryghost/api-framework').Controller} 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) => {
    if (controllerMap.has(apiController)) {
        return controllerMap.get(apiController);
    }

    const keys = Object.keys(apiController).filter(key => key !== 'docName');
    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.
    const result = keys.reduce((obj, method) => {
        const apiImpl = _.cloneDeep(apiController)[method];

        Object.freeze(apiImpl.headers);

        obj[method] = async function ImplWrapper() {
            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;
    }, {});

    controllerMap.set(apiController, result);

    return result;
};

module.exports = pipeline;
module.exports.STAGES = STAGES;