From 959912eca316e12715808b1cbaa7bc55ebfb81cb Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Fri, 5 Oct 2018 00:50:45 +0200 Subject: [PATCH] Added tiny framework to support multiple API versions (#9933) refs #9326, refs #9866 **ATTENTION: This is the first iteration. Bugs are expected.** Main Goals: - add support for multiple API versions. - do not touch v0.1 implementation - do not break v0.1 ## Problems with the existing v0.1 implementation 1. It tried to be generic and helpful, but it was a mixture of generic and explicit logic living in basically two files: utils.js and index.js. 2. Supporting multiple api versions means, you want to have as less as possible code per API version. With v0.1 it is impossible to reduce the API controller implementation. ---- This commit adds three things: 1. The tiny framework with well-defined API stages. 2. An example implementation of serving static pages via /pages for the content v2 API. 3. Unit tests to prove that the API framework works in general. ## API Stages - validation - input serialization - permissions - query - output serialization Each request should go through these stages. It is possible to disable stages, but it's not recommended. The code for each stage will either live in a shared folder or in the API version itself. It depends how API specific the validation or serialization is. Depends on the use case. We should add a specific API validator or serializer if the use case is API format specific. We should put everything else to shared. The goal is to add as much as possible into the shared API layer to reduce the logic per API version. --- Serializers and validators can be added: - for each request - for specific controllers - for specific actions --- There is room for improvements/extensions: 1. Remove http header configuration from the API controller, because the API controller should not know about http - decouple. 2. Put permissions helpers into shared. I've just extracted and capsulated the permissions helpers into a single file for now. It had no priority. The focus was on the framework itself. etc. --- You can find more information about it in the API README.md (api/README.md) - e.g. find more information about the structure - e.g. example controllers The docs are not perfect. We will improve the docs in the next two weeks. --- Upcoming tasks: - prepare test env to test multiple API versions - copy over the controllers from v0.1 to v2 - adapt the v2 express app to use the v2 controllers --- core/server/api/README.md | 105 +++++++- core/server/api/shared/frame.js | 74 +++++ core/server/api/shared/headers.js | 52 ++++ core/server/api/shared/http.js | 57 ++++ core/server/api/shared/index.js | 26 +- core/server/api/shared/pipeline.js | 158 +++++++++++ core/server/api/shared/serializers/handle.js | 89 ++++++ core/server/api/shared/serializers/index.js | 13 + .../api/shared/serializers/input/all.js | 46 ++++ .../api/shared/serializers/input/index.js | 5 + .../api/shared/serializers/output/index.js | 1 + core/server/api/shared/validators/handle.js | 56 ++++ core/server/api/shared/validators/index.js | 9 + .../server/api/shared/validators/input/all.js | 79 ++++++ .../api/shared/validators/input/index.js | 5 + core/server/api/v2/index.js | 12 + core/server/api/v2/pages.js | 33 +++ core/server/api/v2/utils/index.js | 13 + core/server/api/v2/utils/permissions.js | 76 ++++++ core/server/api/v2/utils/serializers/index.js | 9 + .../api/v2/utils/serializers/input/index.js | 5 + .../api/v2/utils/serializers/input/pages.js | 25 ++ .../api/v2/utils/serializers/output/index.js | 5 + .../api/v2/utils/serializers/output/pages.js | 22 ++ core/server/api/v2/utils/validators/index.js | 9 + .../api/v2/utils/validators/input/index.js | 1 + .../api/v2/utils/validators/output/index.js | 1 + core/server/web/api/v2/content/routes.js | 4 + .../functional/routes/api/public_api_spec.js | 23 ++ core/test/unit/api/shared/frame_spec.js | 100 +++++++ core/test/unit/api/shared/headers_spec.js | 47 ++++ core/test/unit/api/shared/http_spec.js | 83 ++++++ core/test/unit/api/shared/pipeline_spec.js | 255 ++++++++++++++++++ .../api/shared/serializers/handle_spec.js | 90 +++++++ .../api/shared/serializers/input/all_spec.js | 79 ++++++ .../unit/api/shared/validators/handle_spec.js | 55 ++++ .../api/shared/validators/input/all_spec.js | 125 +++++++++ .../v2/utils/serializers/input/pages_spec.js | 50 ++++ 38 files changed, 1895 insertions(+), 2 deletions(-) create mode 100644 core/server/api/shared/frame.js create mode 100644 core/server/api/shared/headers.js create mode 100644 core/server/api/shared/http.js create mode 100644 core/server/api/shared/pipeline.js create mode 100644 core/server/api/shared/serializers/handle.js create mode 100644 core/server/api/shared/serializers/index.js create mode 100644 core/server/api/shared/serializers/input/all.js create mode 100644 core/server/api/shared/serializers/input/index.js create mode 100644 core/server/api/shared/serializers/output/index.js create mode 100644 core/server/api/shared/validators/handle.js create mode 100644 core/server/api/shared/validators/index.js create mode 100644 core/server/api/shared/validators/input/all.js create mode 100644 core/server/api/shared/validators/input/index.js create mode 100644 core/server/api/v2/pages.js create mode 100644 core/server/api/v2/utils/index.js create mode 100644 core/server/api/v2/utils/permissions.js create mode 100644 core/server/api/v2/utils/serializers/index.js create mode 100644 core/server/api/v2/utils/serializers/input/index.js create mode 100644 core/server/api/v2/utils/serializers/input/pages.js create mode 100644 core/server/api/v2/utils/serializers/output/index.js create mode 100644 core/server/api/v2/utils/serializers/output/pages.js create mode 100644 core/server/api/v2/utils/validators/index.js create mode 100644 core/server/api/v2/utils/validators/input/index.js create mode 100644 core/server/api/v2/utils/validators/output/index.js create mode 100644 core/test/unit/api/shared/frame_spec.js create mode 100644 core/test/unit/api/shared/headers_spec.js create mode 100644 core/test/unit/api/shared/http_spec.js create mode 100644 core/test/unit/api/shared/pipeline_spec.js create mode 100644 core/test/unit/api/shared/serializers/handle_spec.js create mode 100644 core/test/unit/api/shared/serializers/input/all_spec.js create mode 100644 core/test/unit/api/shared/validators/handle_spec.js create mode 100644 core/test/unit/api/shared/validators/input/all_spec.js create mode 100644 core/test/unit/api/v2/utils/serializers/input/pages_spec.js diff --git a/core/server/api/README.md b/core/server/api/README.md index f6d5d2163e..f72715a46d 100644 --- a/core/server/api/README.md +++ b/core/server/api/README.md @@ -20,9 +20,112 @@ Each request goes through the following stages: The framework we are building pipes a request through these stages depending on the API controller implementation. +## Frame + +Is a class, which holds all the information for API processing. We pass this instance per reference. +The target function can modify the original instance. No need to return the class instance. + +### Structure + +``` +{ + original: Object, + options: Object, + data: Object, + user: Object, + file: Object, + files: Array +} +``` + +### Example + +``` +{ + original: { + include: 'tags' + }, + options: { + withRelated: ['tags'] + }, + data: { + posts: [] + } +} +``` + ## API Controller A controller is no longer just a function, it's a set of configurations. +### Structure -More is coming soon... +``` +edit: function || object +``` + +``` +edit: { + headers: object, + options: Array, + data: Array, + validation: object | function, + permissions: boolean | object | function, + query: function +} +``` + +### Examples + + +``` +edit: { + headers: { + cacheInvalidate: true + }, + options: ['include'] + validation: { + options: { + include: { + required: true, + values: ['tags'] + } + } + }, + permissions: true, + query(frame) { + return models.Post.edit(frame.data, frame.options); + } +} +``` + +``` +read: { + data: ['slug'] + validation: { + data: { + slug: { + values: ['eins'] + } + } + }, + permissions: true, + query(frame) { + return models.Post.findOne(frame.data, frame.options); + } +} +``` + +``` +edit: { + validation() { + // custom validation, skip framework + }, + permissions: { + unsafeAttrs: ['author'] + }, + query(frame) { + return models.Post.edit(frame.data, frame.options); + } +} +``` diff --git a/core/server/api/shared/frame.js b/core/server/api/shared/frame.js new file mode 100644 index 0000000000..9e02225536 --- /dev/null +++ b/core/server/api/shared/frame.js @@ -0,0 +1,74 @@ +const debug = require('ghost-ignition').debug('api:shared:frame'); +const _ = require('lodash'); + +class Frame { + constructor(obj) { + this.original = obj; + + this.options = {}; + this.data = {}; + this.user = {}; + this.file = {}; + this.files = []; + } + + /** + * If you instantiate a new frame, all the data you pass in, land in `this.original`. + * Based on the API ctrl implemented, this fn will pick allowed properties to either options or data. + */ + configure(apiConfig) { + debug('configure'); + + if (apiConfig.options) { + if (typeof apiConfig.options === 'function') { + apiConfig.options = apiConfig.options(this); + } + + if (this.original.hasOwnProperty('query')) { + Object.assign(this.options, _.pick(this.original.query, apiConfig.options)); + } + + if (this.original.hasOwnProperty('params')) { + Object.assign(this.options, _.pick(this.original.params, apiConfig.options)); + } + + if (this.original.hasOwnProperty('options')) { + Object.assign(this.options, _.pick(this.original.options, apiConfig.options)); + } + } + + this.options.context = this.original.context; + + if (this.original.body && Object.keys(this.original.body).length) { + this.data = this.original.body; + } else { + if (apiConfig.data) { + if (typeof apiConfig.data === 'function') { + apiConfig.data = apiConfig.data(this); + } + + if (this.original.hasOwnProperty('query')) { + Object.assign(this.data, _.pick(this.original.query, apiConfig.data)); + } + + if (this.original.hasOwnProperty('params')) { + Object.assign(this.data, _.pick(this.original.params, apiConfig.data)); + } + + if (this.original.hasOwnProperty('options')) { + Object.assign(this.data, _.pick(this.original.options, apiConfig.data)); + } + } + } + + this.user = this.original.user; + this.file = this.original.file; + this.files = this.original.files; + + debug('original', this.original); + debug('options', this.options); + debug('data', this.data); + } +} + +module.exports = Frame; diff --git a/core/server/api/shared/headers.js b/core/server/api/shared/headers.js new file mode 100644 index 0000000000..5935efca9d --- /dev/null +++ b/core/server/api/shared/headers.js @@ -0,0 +1,52 @@ +const debug = require('ghost-ignition').debug('api:shared:headers'); +const INVALIDATE_ALL = '/*'; + +const cacheInvalidate = (result, options = {}) => { + let value = options.value; + + return { + 'X-Cache-Invalidate': value || INVALIDATE_ALL + }; +}; + +const disposition = { + csv(result, options = {}) { + return { + 'Content-Disposition': options.value, + 'Content-Type': 'text/csv' + }; + }, + + json(result, options = {}) { + return { + 'Content-Disposition': options.value, + 'Content-Type': 'application/json', + 'Content-Length': JSON.stringify(result).length + }; + }, + + yaml(result, options = {}) { + return { + 'Content-Disposition': options.value, + 'Content-Type': 'application/yaml', + 'Content-Length': JSON.stringify(result).length + }; + } +}; + +module.exports = { + get(result, apiConfig = {}) { + let headers = {}; + + if (apiConfig.disposition) { + Object.assign(headers, disposition[apiConfig.disposition.type](result, apiConfig.disposition)); + } + + if (apiConfig.cacheInvalidate) { + Object.assign(headers, cacheInvalidate(result, apiConfig.cacheInvalidate)); + } + + debug(headers); + return headers; + } +}; diff --git a/core/server/api/shared/http.js b/core/server/api/shared/http.js new file mode 100644 index 0000000000..1d3552c2dc --- /dev/null +++ b/core/server/api/shared/http.js @@ -0,0 +1,57 @@ +const debug = require('ghost-ignition').debug('api:shared:http'); +const shared = require('../shared'); +const models = require('../../models'); + +const http = (apiImpl) => { + return (req, res, next) => { + debug('request'); + + const frame = new shared.Frame({ + body: req.body, + file: req.file, + files: req.files, + query: req.query, + params: req.params, + user: req.user, + context: { + user: ((req.user && req.user.id) || (req.user && models.User.isExternalUser(req.user.id))) ? req.user.id : null, + client: (req.client && req.client.slug) ? req.client.slug : null, + client_id: (req.client && req.client.id) ? req.client.id : null + } + }); + + frame.configure({ + options: apiImpl.options, + data: apiImpl.data + }); + + apiImpl(frame) + .then((result) => { + debug(result); + + // CASE: api ctrl wants to handle the express response (e.g. streams) + if (typeof result === 'function') { + debug('ctrl function call'); + return result(req, res, next); + } + + res.status(apiImpl.statusCode || 200); + + // CASE: generate headers based on the api ctrl configuration + res.set(shared.headers.get(result, apiImpl.headers)); + + if (apiImpl.response && apiImpl.response.format === 'plain') { + debug('plain text response'); + return res.send(result); + } + + debug('json response'); + res.json(result || {}); + }) + .catch((err) => { + next(err); + }); + }; +}; + +module.exports = http; diff --git a/core/server/api/shared/index.js b/core/server/api/shared/index.js index f053ebf797..33bc92fc70 100644 --- a/core/server/api/shared/index.js +++ b/core/server/api/shared/index.js @@ -1 +1,25 @@ -module.exports = {}; +module.exports = { + get headers() { + return require('./headers'); + }, + + get http() { + return require('./http'); + }, + + get Frame() { + return require('./frame'); + }, + + get pipeline() { + return require('./pipeline'); + }, + + get validators() { + return require('./validators'); + }, + + get serializers() { + return require('./serializers'); + } +}; diff --git a/core/server/api/shared/pipeline.js b/core/server/api/shared/pipeline.js new file mode 100644 index 0000000000..6f649ebc43 --- /dev/null +++ b/core/server/api/shared/pipeline.js @@ -0,0 +1,158 @@ +const debug = require('ghost-ignition').debug('api:shared:pipeline'); +const Promise = require('bluebird'); +const _ = require('lodash'); +const shared = require('../shared'); +const common = require('../../lib/common'); +const sequence = require('../../lib/promise/sequence'); + +const STAGES = { + validation: { + 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 shared.validators.handle.input( + Object.assign({}, apiConfig, apiImpl.validation), + apiUtils.validators.input, + frame + ); + }); + + return sequence(tasks); + } + }, + + serialisation: { + input(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: input serialisation'); + return shared.serializers.handle.input(apiConfig, apiUtils.serializers.input, frame); + }, + output(response, apiUtils, apiConfig, apiImpl, frame) { + debug('stages: output serialisation'); + return shared.serializers.handle.output(response, apiConfig, apiUtils.serializers.output, frame); + } + }, + + permissions(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: permissions'); + const tasks = []; + + // CASE: it's required to put the permission key to avoid security holes + if (!apiImpl.hasOwnProperty('permissions')) { + return Promise.reject(new common.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(); + } + + tasks.push(function doPermissions() { + return apiUtils.permissions.handle( + Object.assign({}, apiConfig, apiImpl.permissions), + frame + ); + }); + + return sequence(tasks); + }, + + query(apiUtils, apiConfig, apiImpl, frame) { + debug('stages: query'); + + if (!apiImpl.query) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + return apiImpl.query(frame); + } +}; + +const pipeline = (apiController, apiUtils) => { + const keys = Object.keys(apiController); + + // 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, key) => { + const docName = apiController.docName; + const method = key; + + const apiImpl = _.cloneDeep(apiController)[key]; + + obj[key] = function wrapper() { + const apiConfig = {docName, method}; + let options, data, 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 shared.Frame)) { + frame = new shared.Frame({ + body: data, + options: 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); + } + + 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(() => { + return frame.response; + }); + }; + + Object.assign(obj[key], apiImpl); + return obj; + }, {}); +}; + +module.exports = pipeline; +module.exports.STAGES = STAGES; diff --git a/core/server/api/shared/serializers/handle.js b/core/server/api/shared/serializers/handle.js new file mode 100644 index 0000000000..4932d19776 --- /dev/null +++ b/core/server/api/shared/serializers/handle.js @@ -0,0 +1,89 @@ +const debug = require('ghost-ignition').debug('api:shared:serializers:handle'); +const Promise = require('bluebird'); +const sequence = require('../../../lib/promise/sequence'); +const common = require('../../../lib/common'); + +/** + * The shared serialization handler runs the request through all the serialization steps. + * + * 1. shared serialization + * 2. api serialization + */ +module.exports.input = (apiConfig, apiSerializers, frame) => { + debug('input'); + + const tasks = []; + const sharedSerializers = require('./input'); + + if (!apiSerializers) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + if (!apiConfig) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + // ##### SHARED ALL SERIALIZATION + + tasks.push(function serializeAllShared() { + return sharedSerializers.all(apiConfig, frame); + }); + + // ##### API VERSION RESOURCE SERIALIZATION + + if (apiSerializers.all) { + tasks.push(function serializeOptionsShared() { + return apiSerializers.all(apiConfig, frame); + }); + } + + if (apiSerializers[apiConfig.docName]) { + if (apiSerializers[apiConfig.docName].all) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName].all(apiConfig, frame); + }); + } + + if (apiSerializers[apiConfig.docName][apiConfig.method]) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName][apiConfig.method](apiConfig, frame); + }); + } + } + + debug(tasks); + return sequence(tasks); +}; + +module.exports.output = (response = {}, apiConfig, apiSerializers, options) => { + debug('output'); + + const tasks = []; + + if (!apiConfig) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + if (!apiSerializers) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + // ##### API VERSION RESOURCE SERIALIZATION + + if (apiSerializers[apiConfig.docName]) { + if (apiSerializers[apiConfig.docName].all) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName].all(response, apiConfig, options); + }); + } + + if (apiSerializers[apiConfig.docName][apiConfig.method]) { + tasks.push(function serializeOptionsShared() { + return apiSerializers[apiConfig.docName][apiConfig.method](response, apiConfig, options); + }); + } + } + + debug(tasks); + return sequence(tasks); +}; diff --git a/core/server/api/shared/serializers/index.js b/core/server/api/shared/serializers/index.js new file mode 100644 index 0000000000..ba10b3cbee --- /dev/null +++ b/core/server/api/shared/serializers/index.js @@ -0,0 +1,13 @@ +module.exports = { + get handle() { + return require('./handle'); + }, + + get input() { + return require('./input'); + }, + + get output() { + return require('./output'); + } +}; diff --git a/core/server/api/shared/serializers/input/all.js b/core/server/api/shared/serializers/input/all.js new file mode 100644 index 0000000000..53089af8f1 --- /dev/null +++ b/core/server/api/shared/serializers/input/all.js @@ -0,0 +1,46 @@ +const debug = require('ghost-ignition').debug('api:shared:serializers:input:all'); +const _ = require('lodash'); +const INTERNAL_OPTIONS = ['transacting', 'forUpdate']; + +const trimAndLowerCase = (params) => { + params = params || ''; + if (_.isString(params)) { + params = params.split(','); + } + + return params.map((item) => { + return item.trim().toLowerCase(); + }); +}; + +/** + * Transform into model readable language. + */ +module.exports = function serializeAll(apiConfig, frame) { + debug('serialize all'); + + if (frame.options.include) { + frame.options.withRelated = trimAndLowerCase(frame.options.include); + delete frame.options.include; + } + + if (frame.options.fields) { + frame.options.columns = trimAndLowerCase(frame.options.fields); + delete frame.options.fields; + } + + if (frame.options.formats) { + frame.options.formats = trimAndLowerCase(frame.options.formats); + } + + if (frame.options.formats && frame.options.columns) { + frame.options.columns = frame.options.columns.concat(frame.options.formats); + } + + if (!frame.options.context.internal) { + debug('omit internal options'); + frame.options = _.omit(frame.options, INTERNAL_OPTIONS); + } + + debug(frame.options); +}; diff --git a/core/server/api/shared/serializers/input/index.js b/core/server/api/shared/serializers/input/index.js new file mode 100644 index 0000000000..7b25869f4f --- /dev/null +++ b/core/server/api/shared/serializers/input/index.js @@ -0,0 +1,5 @@ +module.exports = { + get all() { + return require('./all'); + } +}; diff --git a/core/server/api/shared/serializers/output/index.js b/core/server/api/shared/serializers/output/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/core/server/api/shared/serializers/output/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/core/server/api/shared/validators/handle.js b/core/server/api/shared/validators/handle.js new file mode 100644 index 0000000000..1d86963abb --- /dev/null +++ b/core/server/api/shared/validators/handle.js @@ -0,0 +1,56 @@ +const debug = require('ghost-ignition').debug('api:shared:validators:handle'); +const Promise = require('bluebird'); +const common = require('../../../lib/common'); +const sequence = require('../../../lib/promise/sequence'); + +/** + * The shared validation handler runs the request through all the validation steps. + * + * 1. shared validation + * 2. api validation + */ +module.exports.input = (apiConfig, apiValidators, frame) => { + debug('input'); + + const tasks = []; + const sharedValidators = require('./input'); + + if (!apiValidators) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + if (!apiConfig) { + return Promise.reject(new common.errors.IncorrectUsageError()); + } + + // ##### SHARED ALL VALIDATION + + tasks.push(function allShared() { + return sharedValidators.all(apiConfig, frame); + }); + + // ##### API VERSION VALIDATION + + if (apiValidators.all) { + tasks.push(function allAPIVersion() { + return apiValidators.all[apiConfig.method](apiConfig, frame); + }); + } + + if (apiValidators[apiConfig.docName]) { + if (apiValidators[apiConfig.docName].all) { + tasks.push(function docNameAll() { + return apiValidators[apiConfig.docName].all(apiConfig, frame); + }); + } + + if (apiValidators[apiConfig.docName][apiConfig.method]) { + tasks.push(function docNameMethod() { + return apiValidators[apiConfig.docName][apiConfig.method](apiConfig, frame); + }); + } + } + + debug(tasks); + return sequence(tasks); +}; diff --git a/core/server/api/shared/validators/index.js b/core/server/api/shared/validators/index.js new file mode 100644 index 0000000000..7830fcfee4 --- /dev/null +++ b/core/server/api/shared/validators/index.js @@ -0,0 +1,9 @@ +module.exports = { + get handle() { + return require('./handle'); + }, + + get input() { + return require('./input'); + } +}; diff --git a/core/server/api/shared/validators/input/all.js b/core/server/api/shared/validators/input/all.js new file mode 100644 index 0000000000..16d7e113b8 --- /dev/null +++ b/core/server/api/shared/validators/input/all.js @@ -0,0 +1,79 @@ +const debug = require('ghost-ignition').debug('api:shared:validators:input:all'); +const _ = require('lodash'); +const Promise = require('bluebird'); +const common = require('../../../../lib/common'); +const validation = require('../../../../data/validation'); + +const GLOBAL_VALIDATORS = { + id: {matches: /^[a-f\d]{24}$|^1$|me/i}, + page: {matches: /^\d+$/}, + limit: {matches: /^\d+|all$/}, + from: {isDate: true}, + to: {isDate: true}, + columns: {matches: /^[\w, ]+$/}, + order: {matches: /^[a-z0-9_,. ]+$/i}, + uuid: {isUUID: true}, + slug: {isSlug: true}, + name: {}, + email: {isEmail: true}, + filter: false, + context: false, + forUpdate: false, + transacting: false, + include: false, + formats: false +}; + +const validate = (config, attrs) => { + let errors = []; + + _.each(config, (value, key) => { + if (value.required && !attrs[key]) { + errors.push(new common.errors.ValidationError({ + message: `${key} is required.` + })); + } + }); + + _.each(attrs, (value, key) => { + debug(key, value); + + if (config && config[key] && config[key].values) { + debug('ctrl validation'); + + const valuesAsArray = value.trim().toLowerCase().split(','); + const unallowedValues = _.filter(valuesAsArray, (value) => { + return !config[key].values.includes(value); + }); + + if (unallowedValues.length) { + errors.push(new common.errors.ValidationError()); + } + } else if (GLOBAL_VALIDATORS[key]) { + debug('global validation'); + errors = errors.concat(validation.validate(value, key, GLOBAL_VALIDATORS[key])); + } + }); + + return errors; +}; + +module.exports = function validateAll(apiConfig, frame) { + debug('validate all'); + + let validationErrors = validate(apiConfig.options, frame.options); + + if (!_.isEmpty(validationErrors)) { + return Promise.reject(validationErrors[0]); + } + + if (frame.data) { + validationErrors = validate(apiConfig.data, frame.data); + } + + if (!_.isEmpty(validationErrors)) { + return Promise.reject(validationErrors[0]); + } + + return Promise.resolve(); +}; diff --git a/core/server/api/shared/validators/input/index.js b/core/server/api/shared/validators/input/index.js new file mode 100644 index 0000000000..7b25869f4f --- /dev/null +++ b/core/server/api/shared/validators/input/index.js @@ -0,0 +1,5 @@ +module.exports = { + get all() { + return require('./all'); + } +}; diff --git a/core/server/api/v2/index.js b/core/server/api/v2/index.js index e3b0ebcc66..86709c42bf 100644 --- a/core/server/api/v2/index.js +++ b/core/server/api/v2/index.js @@ -1,5 +1,17 @@ +const shared = require('../shared'); +const localUtils = require('./utils'); + module.exports = { + get http() { + return shared.http; + }, + + // @TODO: transform get session() { return require('./session'); + }, + + get pages() { + return shared.pipeline(require('./pages'), localUtils); } }; diff --git a/core/server/api/v2/pages.js b/core/server/api/v2/pages.js new file mode 100644 index 0000000000..e5a0e5f8ed --- /dev/null +++ b/core/server/api/v2/pages.js @@ -0,0 +1,33 @@ +const models = require('../../models'); + +module.exports = { + docName: 'pages', + browse: { + options: [ + 'include', + 'filter', + 'status', + 'fields', + 'formats', + 'absolute_urls', + 'page', + 'limit', + 'order', + 'debug' + ], + validation: { + options: { + include: { + values: ['created_by', 'updated_by', 'published_by', 'author', 'tags', 'authors', 'authors.roles'] + }, + formats: { + values: models.Post.allowedFormats + } + } + }, + permissions: true, + query(frame) { + return models.Post.findPage(frame.options); + } + } +}; diff --git a/core/server/api/v2/utils/index.js b/core/server/api/v2/utils/index.js new file mode 100644 index 0000000000..38cf7e1e57 --- /dev/null +++ b/core/server/api/v2/utils/index.js @@ -0,0 +1,13 @@ +module.exports = { + get permissions() { + return require('./permissions'); + }, + + get serializers() { + return require('./serializers'); + }, + + get validators() { + return require('./validators'); + } +}; diff --git a/core/server/api/v2/utils/permissions.js b/core/server/api/v2/utils/permissions.js new file mode 100644 index 0000000000..6832b24ada --- /dev/null +++ b/core/server/api/v2/utils/permissions.js @@ -0,0 +1,76 @@ +const debug = require('ghost-ignition').debug('api:v2:utils:permissions'); +const Promise = require('bluebird'); +const _ = require('lodash'); +const permissions = require('../../../services/permissions'); +const common = require('../../../lib/common'); + +const nonePublicAuth = (apiConfig, frame) => { + debug('check admin permissions'); + + const singular = apiConfig.docName.replace(/s$/, ''); + + let unsafeAttrObject = apiConfig.unsafeAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`) ? _.pick(frame.data[apiConfig.docName][0], apiConfig.unsafeAttrs) : {}, + permsPromise = permissions.canThis(frame.options.context)[apiConfig.method][singular](frame.options.id, unsafeAttrObject); + + return permsPromise.then((result) => { + /* + * Allow the permissions function to return a list of excluded attributes. + * If it does, omit those attrs from the data passed through + * + * NOTE: excludedAttrs differ from unsafeAttrs in that they're determined by the model's permissible function, + * and the attributes are simply excluded rather than throwing a NoPermission exception + * + * TODO: This is currently only needed because of the posts model and the contributor role. Once we extend the + * contributor role to be able to edit existing tags, this concept can be removed. + */ + if (result && result.excludedAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`)) { + frame.data[apiConfig.docName][0] = _.omit(frame.data[apiConfig.docName][0], result.excludedAttrs); + } + }).catch((err) => { + if (err instanceof common.errors.NoPermissionError) { + err.message = common.i18n.t('errors.api.utils.noPermissionToCall', { + method: apiConfig.method, + docName: apiConfig.docName + }); + return Promise.reject(err); + } + + if (common.errors.utils.isIgnitionError(err)) { + return Promise.reject(err); + } + + return Promise.reject(new common.errors.GhostError({ + err: err + })); + }); +}; + +module.exports = { + handle(apiConfig, frame) { + debug('handle'); + + frame.options.context = permissions.parseContext(frame.options.context); + + if (frame.options.context.public) { + debug('check content permissions'); + + // @TODO: The permission layer relies on the API format from v0.1. The permission layer should define + // it's own format and should not re-use or rely on the API format. For now we have to simulate the v0.1 + // structure. We should raise an issue asap. + return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, { + status: frame.options.status, + id: frame.options.id, + uuid: frame.options.uuid, + slug: frame.options.slug, + data: { + status: frame.data.status, + id: frame.data.id, + uuid: frame.data.uuid, + slug: frame.data.slug + } + }); + } + + return nonePublicAuth(apiConfig, frame); + } +}; diff --git a/core/server/api/v2/utils/serializers/index.js b/core/server/api/v2/utils/serializers/index.js new file mode 100644 index 0000000000..d3749c6bb9 --- /dev/null +++ b/core/server/api/v2/utils/serializers/index.js @@ -0,0 +1,9 @@ +module.exports = { + get input() { + return require('./input'); + }, + + get output() { + return require('./output'); + } +}; diff --git a/core/server/api/v2/utils/serializers/input/index.js b/core/server/api/v2/utils/serializers/input/index.js new file mode 100644 index 0000000000..2aaa0a2e1e --- /dev/null +++ b/core/server/api/v2/utils/serializers/input/index.js @@ -0,0 +1,5 @@ +module.exports = { + get pages() { + return require('./pages'); + } +}; diff --git a/core/server/api/v2/utils/serializers/input/pages.js b/core/server/api/v2/utils/serializers/input/pages.js new file mode 100644 index 0000000000..9c1ade620e --- /dev/null +++ b/core/server/api/v2/utils/serializers/input/pages.js @@ -0,0 +1,25 @@ +const debug = require('ghost-ignition').debug('api:v2:utils:serializers:input:pages'); + +module.exports = { + all(apiConfig, frame) { + debug('all'); + + // CASE: the content api endpoints for pages forces the model layer to return static pages only. + // we have to enforce the filter. + if (frame.options.filter) { + if (frame.options.filter.match(/page:\w+\+?/)) { + frame.options.filter = frame.options.filter.replace(/page:\w+\+?/, ''); + } + + if (frame.options.filter) { + frame.options.filter = frame.options.filter + '+page:true'; + } else { + frame.options.filter = 'page:true'; + } + } else { + frame.options.filter = 'page:true'; + } + + debug(frame.options); + } +}; diff --git a/core/server/api/v2/utils/serializers/output/index.js b/core/server/api/v2/utils/serializers/output/index.js new file mode 100644 index 0000000000..2aaa0a2e1e --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/index.js @@ -0,0 +1,5 @@ +module.exports = { + get pages() { + return require('./pages'); + } +}; diff --git a/core/server/api/v2/utils/serializers/output/pages.js b/core/server/api/v2/utils/serializers/output/pages.js new file mode 100644 index 0000000000..f7b39d987a --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/pages.js @@ -0,0 +1,22 @@ +const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:pages'); + +module.exports = { + all(models, apiConfig, frame) { + debug('all'); + + if (models.meta) { + frame.response = { + pages: models.data.map(model => model.toJSON(frame.options)), + meta: models.meta + }; + + return; + } + + frame.response = { + pages: [models.toJSON(frame.options)] + }; + + debug(frame.response); + } +}; diff --git a/core/server/api/v2/utils/validators/index.js b/core/server/api/v2/utils/validators/index.js new file mode 100644 index 0000000000..d3749c6bb9 --- /dev/null +++ b/core/server/api/v2/utils/validators/index.js @@ -0,0 +1,9 @@ +module.exports = { + get input() { + return require('./input'); + }, + + get output() { + return require('./output'); + } +}; diff --git a/core/server/api/v2/utils/validators/input/index.js b/core/server/api/v2/utils/validators/input/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/core/server/api/v2/utils/validators/output/index.js b/core/server/api/v2/utils/validators/output/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/core/server/api/v2/utils/validators/output/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/core/server/web/api/v2/content/routes.js b/core/server/web/api/v2/content/routes.js index bb9772235d..f0376b6e1f 100644 --- a/core/server/web/api/v2/content/routes.js +++ b/core/server/web/api/v2/content/routes.js @@ -1,5 +1,6 @@ const express = require('express'); const api = require('../../../../api'); +const apiv2 = require('../../../../api/v2'); const shared = require('../../../shared'); const mw = require('./middleware'); @@ -20,6 +21,9 @@ module.exports = function apiRoutes() { router.get('/posts/:id', mw.authenticatePublic, api.http(api.posts.read)); router.get('/posts/slug/:slug', mw.authenticatePublic, api.http(api.posts.read)); + // ## Pages + router.get('/pages', mw.authenticatePublic, apiv2.http(apiv2.pages.browse)); + // ## Users router.get('/users', mw.authenticatePublic, api.http(api.users.browse)); router.get('/users/:id', mw.authenticatePublic, api.http(api.users.read)); diff --git a/core/test/functional/routes/api/public_api_spec.js b/core/test/functional/routes/api/public_api_spec.js index 7bdc111ce0..6c1a45f486 100644 --- a/core/test/functional/routes/api/public_api_spec.js +++ b/core/test/functional/routes/api/public_api_spec.js @@ -55,6 +55,29 @@ describe('Public API', function () { }); }); + it('browse pages', function (done) { + request.get('/ghost/api/v2/content/pages/?client_id=ghost-admin&client_secret=not_available') + .set('Origin', testUtils.API.getURL()) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + res.headers.vary.should.eql('Origin, Accept-Encoding'); + should.exist(res.headers['access-control-allow-origin']); + should.not.exist(res.headers['x-cache-invalidate']); + + const jsonResponse = res.body; + should.exist(jsonResponse.pages); + should.exist(jsonResponse.meta); + jsonResponse.pages.should.have.length(1); + done(); + }); + }); + it('browse posts: request absolute urls', function (done) { request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=not_available&absolute_urls=true')) .set('Origin', testUtils.API.getURL()) diff --git a/core/test/unit/api/shared/frame_spec.js b/core/test/unit/api/shared/frame_spec.js new file mode 100644 index 0000000000..3ce8823057 --- /dev/null +++ b/core/test/unit/api/shared/frame_spec.js @@ -0,0 +1,100 @@ +const should = require('should'); +const shared = require('../../../../server/api/shared'); + +describe('Unit: api/shared/frame', function () { + it('constructor', function () { + const frame = new shared.Frame(); + Object.keys(frame).should.eql([ + 'original', + 'options', + 'data', + 'user', + 'file', + 'files' + ]); + }); + + describe('fn: configure', function () { + it('no transform', function () { + const original = { + context: {user: 'id'}, + body: {posts: []}, + params: {id: 'id'}, + query: {include: 'tags', filter: 'page:false', soup: 'yumyum'} + }; + + const frame = new shared.Frame(original); + + frame.configure({}); + + should.exist(frame.options.context.user); + should.not.exist(frame.options.include); + should.not.exist(frame.options.filter); + should.not.exist(frame.options.id); + should.not.exist(frame.options.soup); + + should.exist(frame.data.posts); + }); + + it('transform', function () { + const original = { + context: {user: 'id'}, + body: {posts: []}, + params: {id: 'id'}, + query: {include: 'tags', filter: 'page:false', soup: 'yumyum'} + }; + + const frame = new shared.Frame(original); + + frame.configure({ + options: ['include', 'filter', 'id'] + }); + + should.exist(frame.options.context.user); + should.exist(frame.options.include); + should.exist(frame.options.filter); + should.exist(frame.options.id); + should.not.exist(frame.options.soup); + + should.exist(frame.data.posts); + }); + + it('transform', function () { + const original = { + context: {user: 'id'}, + options: { + slug: 'slug' + } + }; + + const frame = new shared.Frame(original); + + frame.configure({ + options: ['include', 'filter', 'slug'] + }); + + should.exist(frame.options.context.user); + should.exist(frame.options.slug); + }); + + it('transform', function () { + const original = { + context: {user: 'id'}, + options: { + id: 'id' + }, + body: {} + }; + + const frame = new shared.Frame(original); + + frame.configure({ + data: ['id'] + }); + + should.exist(frame.options.context.user); + should.not.exist(frame.options.id); + should.exist(frame.data.id); + }); + }); +}); diff --git a/core/test/unit/api/shared/headers_spec.js b/core/test/unit/api/shared/headers_spec.js new file mode 100644 index 0000000000..9b78f638ba --- /dev/null +++ b/core/test/unit/api/shared/headers_spec.js @@ -0,0 +1,47 @@ +const should = require('should'); +const shared = require('../../../../server/api/shared'); + +describe('Unit: api/shared/headers', function () { + it('empty headers config', function () { + shared.headers.get().should.eql({}); + }); + + describe('config.disposition', function () { + it('json', function () { + shared.headers.get({}, {disposition: {type: 'json', value: 'value'}}).should.eql({ + 'Content-Disposition': 'value', + 'Content-Type': 'application/json', + 'Content-Length': 2 + }); + }); + + it('csv', function () { + shared.headers.get({}, {disposition: {type: 'csv', value: 'my.csv'}}).should.eql({ + 'Content-Disposition': 'my.csv', + 'Content-Type': 'text/csv' + }); + }); + + it('yaml', function () { + shared.headers.get('yaml file', {disposition: {type: 'yaml', value: 'my.yaml'}}).should.eql({ + 'Content-Disposition': 'my.yaml', + 'Content-Type': 'application/yaml', + 'Content-Length': 11 + }); + }); + }); + + describe('config.cacheInvalidate', function () { + it('default', function () { + shared.headers.get({}, {cacheInvalidate: true}).should.eql({ + 'X-Cache-Invalidate': '/*' + }); + }); + + it('custom value', function () { + shared.headers.get({}, {cacheInvalidate: {value: 'value'}}).should.eql({ + 'X-Cache-Invalidate': 'value' + }); + }); + }); +}); diff --git a/core/test/unit/api/shared/http_spec.js b/core/test/unit/api/shared/http_spec.js new file mode 100644 index 0000000000..97d25eff70 --- /dev/null +++ b/core/test/unit/api/shared/http_spec.js @@ -0,0 +1,83 @@ +const should = require('should'); +const sinon = require('sinon'); +const shared = require('../../../../server/api/shared'); +const sandbox = sinon.sandbox.create(); + +describe('Unit: api/shared/http', function () { + let req; + let res; + let next; + + beforeEach(function () { + req = sandbox.stub(); + res = sandbox.stub(); + next = sandbox.stub(); + + req.body = { + a: 'a' + }; + + res.status = sandbox.stub(); + res.json = sandbox.stub(); + res.set = sandbox.stub(); + res.send = sandbox.stub(); + + sandbox.stub(shared.headers, 'get'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('check options', function () { + const apiImpl = sandbox.stub().resolves(); + shared.http(apiImpl)(req, res, next); + + Object.keys(apiImpl.args[0][0]).should.eql([ + 'original', + 'options', + 'data', + 'user', + 'file', + 'files' + ]); + + apiImpl.args[0][0].data.should.eql({a: 'a'}); + apiImpl.args[0][0].options.should.eql({ + context: { + user: null, + client: null, + client_id: null + } + }); + }); + + it('api response is fn', function (done) { + const response = sandbox.stub().callsFake(function (req, res, next) { + should.exist(req); + should.exist(res); + should.exist(next); + apiImpl.calledOnce.should.be.true(); + res.json.called.should.be.false(); + done(); + }); + + const apiImpl = sandbox.stub().resolves(response); + shared.http(apiImpl)(req, res, next); + }); + + it('api response is fn', function (done) { + const apiImpl = sandbox.stub().resolves('data'); + + next.callsFake(done); + + res.json.callsFake(function () { + shared.headers.get.calledOnce.should.be.true(); + res.status.calledOnce.should.be.true(); + res.send.called.should.be.false(); + done(); + }); + + shared.http(apiImpl)(req, res, next); + }); +}); diff --git a/core/test/unit/api/shared/pipeline_spec.js b/core/test/unit/api/shared/pipeline_spec.js new file mode 100644 index 0000000000..5d0a0bf61e --- /dev/null +++ b/core/test/unit/api/shared/pipeline_spec.js @@ -0,0 +1,255 @@ +const should = require('should'); +const sinon = require('sinon'); +const Promise = require('bluebird'); +const sandbox = sinon.sandbox.create(); +const common = require('../../../../server/lib/common'); +const shared = require('../../../../server/api/shared'); + +describe('Unit: api/shared/pipeline', function () { + afterEach(function () { + sandbox.restore(); + }); + + describe('stages', function () { + describe('validation', function () { + describe('input', function () { + beforeEach(function () { + sandbox.stub(shared.validators.handle, 'input').resolves(); + }); + + it('do it yourself', function () { + const apiUtils = {}; + const apiConfig = {}; + const apiImpl = { + validation: sandbox.stub().resolves('response') + }; + const frame = {}; + + return shared.pipeline.STAGES.validation.input(apiUtils, apiConfig, apiImpl, frame) + .then((response) => { + response.should.eql('response'); + + apiImpl.validation.calledOnce.should.be.true(); + shared.validators.handle.input.called.should.be.false(); + }); + }); + + it('default', function () { + const apiUtils = { + validators: { + input: { + posts: {} + } + } + }; + const apiConfig = { + docName: 'posts' + }; + const apiImpl = { + options: ['include'], + validation: { + options: { + include: { + required: true + } + } + } + }; + const frame = { + options: {} + }; + + return shared.pipeline.STAGES.validation.input(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + shared.validators.handle.input.calledOnce.should.be.true(); + shared.validators.handle.input.calledWith( + { + docName: 'posts', + options: { + include: { + required: true + } + } + }, + { + posts: {} + }, + { + options: {} + }).should.be.true(); + }); + }); + }); + }); + + describe('permissions', function () { + let apiUtils; + + beforeEach(function () { + apiUtils = { + permissions: { + handle: sandbox.stub().resolves() + } + }; + }); + + it('key is missing', function () { + const apiConfig = {}; + const apiImpl = {}; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + apiUtils.permissions.handle.called.should.be.false(); + }); + }); + + it('do it yourself', function () { + const apiConfig = {}; + const apiImpl = { + permissions: sandbox.stub().resolves('lol') + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then((response) => { + response.should.eql('lol'); + apiImpl.permissions.calledOnce.should.be.true(); + apiUtils.permissions.handle.called.should.be.false(); + }); + }); + + it('skip stage', function () { + const apiConfig = {}; + const apiImpl = { + permissions: false + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + apiUtils.permissions.handle.called.should.be.false(); + }); + }); + + it('default', function () { + const apiConfig = {}; + const apiImpl = { + permissions: true + }; + const frame = {}; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + apiUtils.permissions.handle.calledOnce.should.be.true(); + }); + }); + + it('with permission config', function () { + const apiConfig = { + docName: 'posts' + }; + const apiImpl = { + permissions: { + unsafeAttrs: ['test'] + } + }; + const frame = { + options: {} + }; + + return shared.pipeline.STAGES.permissions(apiUtils, apiConfig, apiImpl, frame) + .then(() => { + apiUtils.permissions.handle.calledOnce.should.be.true(); + apiUtils.permissions.handle.calledWith( + { + docName: 'posts', + unsafeAttrs: ['test'] + }, + { + options: {} + }).should.be.true(); + }); + }); + }); + }); + + describe('pipeline', function () { + beforeEach(function () { + sandbox.stub(shared.pipeline.STAGES.validation, 'input'); + sandbox.stub(shared.pipeline.STAGES.serialisation, 'input'); + sandbox.stub(shared.pipeline.STAGES.serialisation, 'output'); + sandbox.stub(shared.pipeline.STAGES, 'permissions'); + sandbox.stub(shared.pipeline.STAGES, 'query'); + }); + + it('ensure we receive a callable api controller fn', function () { + const apiController = { + add: {}, + browse: {} + }; + + const apiUtils = {}; + + const result = shared.pipeline(apiController, apiUtils); + result.should.be.an.Object(); + + should.exist(result.add); + should.exist(result.browse); + result.add.should.be.a.Function(); + result.browse.should.be.a.Function(); + }); + + it('call api controller fn', function () { + const apiController = { + add: {} + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + shared.pipeline.STAGES.validation.input.resolves(); + shared.pipeline.STAGES.serialisation.input.resolves(); + shared.pipeline.STAGES.permissions.resolves(); + shared.pipeline.STAGES.query.resolves('response'); + shared.pipeline.STAGES.serialisation.output.callsFake(function (response, apiUtils, apiConfig, apiImpl, frame) { + frame.response = response; + }); + + return result.add() + .then((response) => { + response.should.eql('response'); + + shared.pipeline.STAGES.validation.input.calledOnce.should.be.true(); + shared.pipeline.STAGES.serialisation.input.calledOnce.should.be.true(); + shared.pipeline.STAGES.permissions.calledOnce.should.be.true(); + shared.pipeline.STAGES.query.calledOnce.should.be.true(); + shared.pipeline.STAGES.serialisation.output.calledOnce.should.be.true(); + }); + }); + + it('api controller is fn, not config', function () { + const apiController = { + add() { + return Promise.resolve('response'); + } + }; + + const apiUtils = {}; + const result = shared.pipeline(apiController, apiUtils); + + return result.add() + .then((response) => { + response.should.eql('response'); + + shared.pipeline.STAGES.validation.input.called.should.be.false(); + shared.pipeline.STAGES.serialisation.input.called.should.be.false(); + shared.pipeline.STAGES.permissions.called.should.be.false(); + shared.pipeline.STAGES.query.called.should.be.false(); + shared.pipeline.STAGES.serialisation.output.called.should.be.false(); + }); + }); + }); +}); diff --git a/core/test/unit/api/shared/serializers/handle_spec.js b/core/test/unit/api/shared/serializers/handle_spec.js new file mode 100644 index 0000000000..d705f9fd82 --- /dev/null +++ b/core/test/unit/api/shared/serializers/handle_spec.js @@ -0,0 +1,90 @@ +const should = require('should'); +const Promise = require('bluebird'); +const sinon = require('sinon'); +const common = require('../../../../../server/lib/common'); +const shared = require('../../../../../server/api/shared'); +const sandbox = sinon.sandbox.create(); + +describe('Unit: api/shared/serializers/handle', function () { + beforeEach(function () { + sandbox.restore(); + }); + + describe('input', function () { + it('no api config passed', function () { + return shared.serializers.handle.input() + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('no api serializers passed', function () { + return shared.serializers.handle.input({}) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('ensure serializers are called', function () { + const getStub = sandbox.stub(); + sandbox.stub(shared.serializers.input, 'all').get(() => getStub); + + const apiSerializers = { + all: sandbox.stub().resolves(), + posts: { + all: sandbox.stub().resolves(), + add: sandbox.stub().resolves() + } + }; + + return shared.serializers.handle.input({docName: 'posts', method: 'add'}, apiSerializers, {}) + .then(() => { + getStub.calledOnce.should.be.true(); + apiSerializers.all.calledOnce.should.be.true(); + apiSerializers.posts.all.calledOnce.should.be.true(); + apiSerializers.posts.add.calledOnce.should.be.true(); + }); + }); + }); + + describe('output', function () { + it('no models passed', function () { + return shared.serializers.handle.output(null, {}, {}, {}); + }); + + it('no api config passed', function () { + return shared.serializers.handle.output([]) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('no api serializers passed', function () { + return shared.serializers.handle.output([], {}) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('ensure serializers are called', function () { + const apiSerializers = { + posts: { + add: sandbox.stub().resolves() + }, + users: { + add: sandbox.stub().resolves() + } + }; + + return shared.serializers.handle.output([], {docName: 'posts', method: 'add'}, apiSerializers, {}) + .then(() => { + apiSerializers.posts.add.calledOnce.should.be.true(); + apiSerializers.users.add.called.should.be.false(); + }); + }); + }); +}); diff --git a/core/test/unit/api/shared/serializers/input/all_spec.js b/core/test/unit/api/shared/serializers/input/all_spec.js new file mode 100644 index 0000000000..1343824269 --- /dev/null +++ b/core/test/unit/api/shared/serializers/input/all_spec.js @@ -0,0 +1,79 @@ +const should = require('should'); +const shared = require('../../../../../../server/api/shared'); + +describe('Unit: v2/utils/serializers/input/all', function () { + it('transforms into model readable format', function () { + const apiConfig = {}; + const frame = { + original: { + include: 'tags', + fields: 'id,status', + formats: 'html' + }, + options: { + include: 'tags', + fields: 'id,status', + formats: 'html', + context: {} + } + }; + + shared.serializers.input.all(apiConfig, frame); + + should.exist(frame.original.include); + should.exist(frame.original.fields); + should.exist(frame.original.formats); + + should.not.exist(frame.options.include); + should.not.exist(frame.options.fields); + should.exist(frame.options.formats); + should.exist(frame.options.columns); + should.exist(frame.options.withRelated); + + frame.options.withRelated.should.eql(['tags']); + frame.options.columns.should.eql(['id','status','html']); + frame.options.formats.should.eql(['html']); + }); + + describe('extra allowed internal options', function () { + it('internal access', function () { + const frame = { + options: { + context: { + internal: true + }, + transacting: true, + forUpdate: true + } + }; + + const apiConfig = {}; + + shared.serializers.input.all(apiConfig, frame); + + should.exist(frame.options.transacting); + should.exist(frame.options.forUpdate); + should.exist(frame.options.context); + }); + + it('no internal access', function () { + const frame = { + options: { + context: { + user: true + }, + transacting: true, + forUpdate: true + } + }; + + const apiConfig = {}; + + shared.serializers.input.all(apiConfig, frame); + + should.not.exist(frame.options.transacting); + should.not.exist(frame.options.forUpdate); + should.exist(frame.options.context); + }); + }); +}); diff --git a/core/test/unit/api/shared/validators/handle_spec.js b/core/test/unit/api/shared/validators/handle_spec.js new file mode 100644 index 0000000000..20a2354b85 --- /dev/null +++ b/core/test/unit/api/shared/validators/handle_spec.js @@ -0,0 +1,55 @@ +const should = require('should'); +const Promise = require('bluebird'); +const sinon = require('sinon'); +const common = require('../../../../../server/lib/common'); +const shared = require('../../../../../server/api/shared'); +const sandbox = sinon.sandbox.create(); + +describe('Unit: api/shared/validators/handle', function () { + afterEach(function () { + sandbox.restore(); + }); + + describe('input', function () { + it('no api config passed', function () { + return shared.validators.handle.input() + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('no api validators passed', function () { + return shared.validators.handle.input({}) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.IncorrectUsageError).should.be.true(); + }); + }); + + it('ensure validators are called', function () { + const getStub = sandbox.stub(); + sandbox.stub(shared.validators.input, 'all').get(() => {return getStub;}); + + const apiValidators = { + all: { + add: sandbox.stub().resolves() + }, + posts: { + add: sandbox.stub().resolves() + }, + users: { + add: sandbox.stub().resolves() + } + }; + + return shared.validators.handle.input({docName: 'posts', method: 'add'}, apiValidators, {context: {}}) + .then(() => { + getStub.calledOnce.should.be.true(); + apiValidators.all.add.calledOnce.should.be.true(); + apiValidators.posts.add.calledOnce.should.be.true(); + apiValidators.users.add.called.should.be.false(); + }); + }); + }); +}); diff --git a/core/test/unit/api/shared/validators/input/all_spec.js b/core/test/unit/api/shared/validators/input/all_spec.js new file mode 100644 index 0000000000..59af068595 --- /dev/null +++ b/core/test/unit/api/shared/validators/input/all_spec.js @@ -0,0 +1,125 @@ +const should = require('should'); +const sinon = require('sinon'); +const Promise = require('bluebird'); +const shared = require('../../../../../../server/api/shared'); +const sandbox = sinon.sandbox.create(); + +describe('Unit: api/shared/validators/input/all', function () { + afterEach(function () { + sandbox.restore(); + }); + + describe('validate options', function () { + it('default', function () { + const frame = { + options: { + context: {}, + slug: 'slug', + include: 'tags,authors', + page: 2 + } + }; + + const apiConfig = { + options: { + include: { + values: ['tags', 'authors'], + required: true + } + } + }; + + return shared.validators.input.all(apiConfig, frame) + .then(() => { + should.exist(frame.options.page); + should.exist(frame.options.slug); + should.exist(frame.options.include); + should.exist(frame.options.context); + }); + }); + + it('fails', function () { + const frame = { + options: { + context: {}, + include: 'tags,authors' + } + }; + + const apiConfig = { + options: { + include: { + values: ['tags'] + } + } + }; + + return shared.validators.input.all(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + should.exist(err); + }); + }); + + it('fails', function () { + const frame = { + options: { + context: {} + } + }; + + const apiConfig = { + options: { + include: { + required: true + } + } + }; + + return shared.validators.input.all(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + should.exist(err); + }); + }); + }); + + describe('validate data', function () { + it('default', function () { + const frame = { + options: { + context: {} + }, + data: { + status: 'aus' + } + }; + + const apiConfig = {}; + + return shared.validators.input.all(apiConfig, frame) + .then(() => { + should.exist(frame.options.context); + should.exist(frame.data.status); + }); + }); + + it('fails', function () { + const frame = { + options: { + context: {} + }, + data: { + id: 'no-id' + } + }; + + const apiConfig = {}; + + return shared.validators.input.all(apiConfig, frame) + .catch((err) => { + should.exist(err); + }); + }); + }); +}); diff --git a/core/test/unit/api/v2/utils/serializers/input/pages_spec.js b/core/test/unit/api/v2/utils/serializers/input/pages_spec.js new file mode 100644 index 0000000000..ba4c19c8c7 --- /dev/null +++ b/core/test/unit/api/v2/utils/serializers/input/pages_spec.js @@ -0,0 +1,50 @@ +const should = require('should'); +const serializers = require('../../../../../../../server/api/v2/utils/serializers'); + +describe('Unit: v2/utils/serializers/input/pages', function () { + it('default', function () { + const apiConfig = {}; + const frame = { + options: {} + }; + + serializers.input.pages.all(apiConfig, frame); + frame.options.filter.should.eql('page:true'); + }); + + it('combine filters', function () { + const apiConfig = {}; + const frame = { + options: { + filter: 'status:published+tag:eins' + } + }; + + serializers.input.pages.all(apiConfig, frame); + frame.options.filter.should.eql('status:published+tag:eins+page:true'); + }); + + it('remove existing page filter', function () { + const apiConfig = {}; + const frame = { + options: { + filter: 'page:false+tag:eins' + } + }; + + serializers.input.pages.all(apiConfig, frame); + frame.options.filter.should.eql('tag:eins+page:true'); + }); + + it('remove existing page filter', function () { + const apiConfig = {}; + const frame = { + options: { + filter: 'page:false' + } + }; + + serializers.input.pages.all(apiConfig, frame); + frame.options.filter.should.eql('page:true'); + }); +});