From a31ed7c71d99d565e628a7defc472c79fb888339 Mon Sep 17 00:00:00 2001 From: kirrg001 Date: Mon, 6 May 2019 14:24:12 +0200 Subject: [PATCH] Added comments for Ghost API no issue - jsdoc - added more information & context --- core/server/api/README.md | 18 ++-- core/server/api/shared/frame.js | 22 ++++- core/server/api/shared/headers.js | 33 +++++++- core/server/api/shared/http.js | 10 +++ core/server/api/shared/pipeline.js | 84 +++++++++++++++++++ core/server/api/shared/serializers/handle.js | 35 ++++++-- .../api/shared/serializers/input/all.js | 6 +- core/server/api/shared/utils/options.js | 8 ++ core/server/api/shared/validators/handle.js | 10 ++- core/server/api/v2/utils/index.js | 12 +++ core/server/api/v2/utils/permissions.js | 26 +++++- 11 files changed, 241 insertions(+), 23 deletions(-) diff --git a/core/server/api/README.md b/core/server/api/README.md index f72715a46d..510e51a231 100644 --- a/core/server/api/README.md +++ b/core/server/api/README.md @@ -2,28 +2,28 @@ Ghost supports multiple API versions. Each version lives in a separate folder e.g. api/v0.1, api/v2. -Next to the API folders there is a shared folder, which the API versions use. +Next to the API folders there is a shared folder, which contains shared code, which all API versions use. -**NOTE: v0.1 is deprecated and we won't touch this folder at all. The v0.1 folder +**NOTE: v0.1 is deprecated and we won't touch the shared folder at all. The v0.1 folder contains the API layer which we have used since Ghost was born.** ## Stages Each request goes through the following stages: -- validation +- input validation - input serialisation - permissions - query - output serialisation -The framework we are building pipes a request through these stages depending on the API controller implementation. +The framework we are building pipes a request through these stages in respect of the API controller configuration. ## 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. +Is a class, which holds all the information for request processing. We pass this instance by reference. +Each function can modify the original instance. No need to return the class instance. ### Structure @@ -83,7 +83,9 @@ edit: { headers: { cacheInvalidate: true }, + // Allowed url/query params options: ['include'] + // Url/query param validation configuration validation: { options: { include: { @@ -93,6 +95,7 @@ edit: { } }, permissions: true, + // Returns a model response! query(frame) { return models.Post.edit(frame.data, frame.options); } @@ -101,6 +104,9 @@ edit: { ``` read: { + // Allowed url/query params, which will be remembered inside `frame.data` + // This is helpful for READ requests e.g. `model.findOne(frame.data, frame.options)`. + // Our model layer requires sending the where clauses as first parameter. data: ['slug'] validation: { data: { diff --git a/core/server/api/shared/frame.js b/core/server/api/shared/frame.js index 709fa47781..5991b6b0e5 100644 --- a/core/server/api/shared/frame.js +++ b/core/server/api/shared/frame.js @@ -1,10 +1,25 @@ const debug = require('ghost-ignition').debug('api:shared:frame'); const _ = require('lodash'); +/** + * @description The "frame" holds all information of a request. + * + * Each party can modify the frame by reference. + * A request hits a lot of stages in the API implementation and that's why modification by reference was the + * easiest to use. We always have access to the original input, we never loose track of it. + */ class Frame { constructor(obj = {}) { this.original = obj; + /** + * options: Query params, url params, context and custom options + * data: Body or if the ctrl wants query/url params inside body + * user: Logged in user + * file: Uploaded file + * files: Uploaded files + * apiType: Content or admin api access + */ this.options = {}; this.data = {}; this.user = {}; @@ -14,7 +29,12 @@ class Frame { } /** - * If you instantiate a new frame, all the data you pass in, land in `this.original`. + * @description Configure the frame. + * + * If you instantiate a new frame, all the data you pass in, land in `this.original`. This is helpful + * for debugging to see what the original input was. + * + * This function will prepare the incoming data for further processing. * Based on the API ctrl implemented, this fn will pick allowed properties to either options or data. */ configure(apiConfig) { diff --git a/core/server/api/shared/headers.js b/core/server/api/shared/headers.js index 35d7618794..a4f1e93c32 100644 --- a/core/server/api/shared/headers.js +++ b/core/server/api/shared/headers.js @@ -11,6 +11,13 @@ const cacheInvalidate = (result, options = {}) => { }; const disposition = { + /** + * @description Generate CSV header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ csv(result, options = {}) { let value = options.value; @@ -24,6 +31,13 @@ const disposition = { }; }, + /** + * @description Generate JSON header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ json(result, options = {}) { return { 'Content-Disposition': `Attachment; filename="${options.value}"`, @@ -32,6 +46,13 @@ const disposition = { }; }, + /** + * @description Generate YAML header. + * + * @param {Object} result - API response + * @param {Object} options + * @return {Object} + */ yaml(result, options = {}) { return { 'Content-Disposition': `Attachment; filename="${options.value}"`, @@ -41,8 +62,9 @@ const disposition = { }, /** - * ### Content Disposition Header - * create a header that invokes the 'Save As' dialog in the browser when exporting the database to file. The 'filename' + * @description Content Disposition Header + * + * Create a header that invokes the 'Save As' dialog in the browser when exporting the database to file. The 'filename' * parameter is governed by [RFC6266](http://tools.ietf.org/html/rfc6266#section-4.3). * * For encoding whitespace and non-ISO-8859-1 characters, you MUST use the "filename*=" attribute, NOT "filename=". @@ -72,6 +94,13 @@ const disposition = { }; module.exports = { + /** + * @description Get header based on ctrl configuration. + * + * @param {Object} result - API response + * @param {Object} apiConfig + * @return {Promise} + */ get(result, apiConfig = {}) { let headers = {}; diff --git a/core/server/api/shared/http.js b/core/server/api/shared/http.js index 39884b6f84..2b685b7f2d 100644 --- a/core/server/api/shared/http.js +++ b/core/server/api/shared/http.js @@ -2,6 +2,15 @@ const debug = require('ghost-ignition').debug('api:shared:http'); const shared = require('../shared'); const models = require('../../models'); +/** + * @description HTTP wrapper. + * + * This wrapper is used in the routes definition (see web/). + * The wrapper receives the express request, prepares the frame and forwards the request to the pipeline. + * + * @param {Function} apiImpl - Pipeline wrapper, which executes the target ctrl function. + * @return {Function} + */ const http = (apiImpl) => { return (req, res, next) => { debug('request'); @@ -20,6 +29,7 @@ const http = (apiImpl) => { }; } + // NOTE: "external user" is only used in the subscriber app. External user is ID "0". if ((req.user && req.user.id) || (req.user && models.User.isExternalUser(req.user.id))) { user = req.user.id; } diff --git a/core/server/api/shared/pipeline.js b/core/server/api/shared/pipeline.js index b22c77ff33..3853612035 100644 --- a/core/server/api/shared/pipeline.js +++ b/core/server/api/shared/pipeline.js @@ -7,6 +7,20 @@ const sequence = require('../../lib/promise/sequence'); const STAGES = { validation: { + /** + * @description Input validation. + * + * We call the shared validator 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: validation'); const tasks = []; @@ -30,6 +44,20 @@ const STAGES = { }, 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 shared.serializers.handle.input( @@ -38,12 +66,40 @@ const STAGES = { 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 shared.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 = []; @@ -81,6 +137,15 @@ const STAGES = { 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'); @@ -92,6 +157,25 @@ const STAGES = { } }; +/** + * @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 {Function} apiController + * @param {Object} apiUtils - Local utils (validation & serialisation) from target API version + * @param {String} apiType - Content or Admin API access + * @return {Function} + */ const pipeline = (apiController, apiUtils, apiType) => { const keys = Object.keys(apiController); diff --git a/core/server/api/shared/serializers/handle.js b/core/server/api/shared/serializers/handle.js index e77cc74a9e..fe91850b04 100644 --- a/core/server/api/shared/serializers/handle.js +++ b/core/server/api/shared/serializers/handle.js @@ -4,10 +4,16 @@ const sequence = require('../../../lib/promise/sequence'); const common = require('../../../lib/common'); /** - * The shared serialization handler runs the request through all the serialization steps. + * @description Shared input serialization handler. * - * 1. shared serialization - * 2. api serialization + * The shared input handler runs the request through all the validation steps. + * + * 1. Shared serialization + * 2. API serialization + * + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiSerializers - Target API serializers + * @param {Object} frame */ module.exports.input = (apiConfig, apiSerializers, frame) => { debug('input'); @@ -61,7 +67,20 @@ module.exports.input = (apiConfig, apiSerializers, frame) => { return sequence(tasks); }; -module.exports.output = (response = {}, apiConfig, apiSerializers, options) => { +/** + * @description Shared output serialization handler. + * + * The shared output handler runs the request through all the validation steps. + * + * 1. Shared serialization + * 2. API serialization + * + * @param {Object} response - API response + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiSerializers - Target API serializers + * @param {Object} frame + */ +module.exports.output = (response = {}, apiConfig, apiSerializers, frame) => { debug('output'); const tasks = []; @@ -78,27 +97,27 @@ module.exports.output = (response = {}, apiConfig, apiSerializers, options) => { if (apiSerializers.all && apiSerializers.all.before) { tasks.push(function allSerializeBefore() { - return apiSerializers.all.before(response, apiConfig, options); + return apiSerializers.all.before(response, apiConfig, frame); }); } if (apiSerializers[apiConfig.docName]) { if (apiSerializers[apiConfig.docName].all) { tasks.push(function serializeOptionsShared() { - return apiSerializers[apiConfig.docName].all(response, apiConfig, options); + return apiSerializers[apiConfig.docName].all(response, apiConfig, frame); }); } if (apiSerializers[apiConfig.docName][apiConfig.method]) { tasks.push(function serializeOptionsShared() { - return apiSerializers[apiConfig.docName][apiConfig.method](response, apiConfig, options); + return apiSerializers[apiConfig.docName][apiConfig.method](response, apiConfig, frame); }); } } if (apiSerializers.all && apiSerializers.all.after) { tasks.push(function allSerializeAfter() { - return apiSerializers.all.after(apiConfig, options); + return apiSerializers.all.after(apiConfig, frame); }); } diff --git a/core/server/api/shared/serializers/input/all.js b/core/server/api/shared/serializers/input/all.js index 1e2927bdd6..0a4d330a9f 100644 --- a/core/server/api/shared/serializers/input/all.js +++ b/core/server/api/shared/serializers/input/all.js @@ -5,7 +5,11 @@ const utils = require('../../utils'); const INTERNAL_OPTIONS = ['transacting', 'forUpdate']; /** - * Transform into model readable language. + * @description Shared serializer for all requests. + * + * Transforms certain options from API notation into model readable language/notation. + * + * e.g. API uses "include", but model layer uses "withRelated". */ module.exports = { all(apiConfig, frame) { diff --git a/core/server/api/shared/utils/options.js b/core/server/api/shared/utils/options.js index ef9e56a50b..22dd575925 100644 --- a/core/server/api/shared/utils/options.js +++ b/core/server/api/shared/utils/options.js @@ -1,5 +1,13 @@ const _ = require('lodash'); +/** + * @description Helper function to prepare params for internal usages. + * + * e.g. "a,B,c" -> ["a", "b", "c"] + * + * @param {String} params + * @return {Array} + */ const trimAndLowerCase = (params) => { params = params || ''; diff --git a/core/server/api/shared/validators/handle.js b/core/server/api/shared/validators/handle.js index a21a3fe960..1042c7059c 100644 --- a/core/server/api/shared/validators/handle.js +++ b/core/server/api/shared/validators/handle.js @@ -4,10 +4,16 @@ const common = require('../../../lib/common'); const sequence = require('../../../lib/promise/sequence'); /** + * @description Shared input validation handler. + * * The shared validation handler runs the request through all the validation steps. * - * 1. shared validation - * 2. api validation + * 1. Shared validation + * 2. API validation + * + * @param {Object} apiConfig - Docname + method of the ctrl + * @param {Object} apiValidators - Target API validators + * @param {Object} frame */ module.exports.input = (apiConfig, apiValidators, frame) => { debug('input'); diff --git a/core/server/api/v2/utils/index.js b/core/server/api/v2/utils/index.js index 940f466ed8..9ec0033b76 100644 --- a/core/server/api/v2/utils/index.js +++ b/core/server/api/v2/utils/index.js @@ -11,10 +11,22 @@ module.exports = { return require('./validators'); }, + /** + * @description Does the request access the Content API? + * + * Each controller is either for the Content or for the Admin API. + * When Ghost registers each controller, it currently passes a String "content" if the controller + * is a Content API implementation - see index.js file. + * + * @TODO: Move this helper function into a utils.js file. + * @param {Object} frame + * @return {boolean} + */ isContentAPI: (frame) => { return frame.apiType === 'content'; }, + // @TODO: Remove, not used. isAdminAPIKey: (frame) => { return frame.options.context && Object.keys(frame.options.context).length !== 0 && frame.options.context.api_key && frame.options.context.api_key.type === 'admin'; diff --git a/core/server/api/v2/utils/permissions.js b/core/server/api/v2/utils/permissions.js index 71d1bf5721..214b5abacc 100644 --- a/core/server/api/v2/utils/permissions.js +++ b/core/server/api/v2/utils/permissions.js @@ -4,6 +4,13 @@ const _ = require('lodash'); const permissions = require('../../../services/permissions'); const common = require('../../../lib/common'); +/** + * @description Handle requests, which need authentication. + * + * @param {Object} apiConfig - Docname & method of API ctrl + * @param {Object} frame + * @return {Promise} + */ const nonePublicAuth = (apiConfig, frame) => { debug('check admin permissions'); @@ -11,6 +18,10 @@ const nonePublicAuth = (apiConfig, frame) => { let permissionIdentifier = frame.options.id; + // CASE: Target ctrl can override the identifier. The identifier is the unique identifier of the target resource + // e.g. edit a setting -> the key of the setting + // e.g. edit a post -> post id from url param + // e.g. change user password -> user id inside of the body structure if (apiConfig.identifier) { permissionIdentifier = apiConfig.identifier(frame); } @@ -51,18 +62,27 @@ const nonePublicAuth = (apiConfig, frame) => { }); }; +// @TODO: https://github.com/TryGhost/Ghost/issues/10735 module.exports = { + /** + * @description Handle permission stage for API version v2. + * + * @param {Object} apiConfig - Docname & method of target ctrl. + * @param {Object} frame + * @return {Promise} + */ handle(apiConfig, frame) { debug('handle'); + // @TODO: https://github.com/TryGhost/Ghost/issues/10099 frame.options.context = permissions.parseContext(frame.options.context); + // CASE: Content API access 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. + // @TODO: Remove when we drop v0.1 + // @TODO: https://github.com/TryGhost/Ghost/issues/10733 return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, { status: frame.options.status, id: frame.options.id,