0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Added comments for Ghost API

no issue

- jsdoc
- added more information & context
This commit is contained in:
kirrg001 2019-05-06 14:24:12 +02:00
parent 79345f9030
commit a31ed7c71d
11 changed files with 241 additions and 23 deletions

View file

@ -2,28 +2,28 @@
Ghost supports multiple API versions. Ghost supports multiple API versions.
Each version lives in a separate folder e.g. api/v0.1, api/v2. 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.** contains the API layer which we have used since Ghost was born.**
## Stages ## Stages
Each request goes through the following stages: Each request goes through the following stages:
- validation - input validation
- input serialisation - input serialisation
- permissions - permissions
- query - query
- output serialisation - 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 ## Frame
Is a class, which holds all the information for API processing. We pass this instance per reference. Is a class, which holds all the information for request processing. We pass this instance by reference.
The target function can modify the original instance. No need to return the class instance. Each function can modify the original instance. No need to return the class instance.
### Structure ### Structure
@ -83,7 +83,9 @@ edit: {
headers: { headers: {
cacheInvalidate: true cacheInvalidate: true
}, },
// Allowed url/query params
options: ['include'] options: ['include']
// Url/query param validation configuration
validation: { validation: {
options: { options: {
include: { include: {
@ -93,6 +95,7 @@ edit: {
} }
}, },
permissions: true, permissions: true,
// Returns a model response!
query(frame) { query(frame) {
return models.Post.edit(frame.data, frame.options); return models.Post.edit(frame.data, frame.options);
} }
@ -101,6 +104,9 @@ edit: {
``` ```
read: { 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'] data: ['slug']
validation: { validation: {
data: { data: {

View file

@ -1,10 +1,25 @@
const debug = require('ghost-ignition').debug('api:shared:frame'); const debug = require('ghost-ignition').debug('api:shared:frame');
const _ = require('lodash'); 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 { class Frame {
constructor(obj = {}) { constructor(obj = {}) {
this.original = 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.options = {};
this.data = {}; this.data = {};
this.user = {}; 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. * Based on the API ctrl implemented, this fn will pick allowed properties to either options or data.
*/ */
configure(apiConfig) { configure(apiConfig) {

View file

@ -11,6 +11,13 @@ const cacheInvalidate = (result, options = {}) => {
}; };
const disposition = { const disposition = {
/**
* @description Generate CSV header.
*
* @param {Object} result - API response
* @param {Object} options
* @return {Object}
*/
csv(result, options = {}) { csv(result, options = {}) {
let value = options.value; 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 = {}) { json(result, options = {}) {
return { return {
'Content-Disposition': `Attachment; filename="${options.value}"`, '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 = {}) { yaml(result, options = {}) {
return { return {
'Content-Disposition': `Attachment; filename="${options.value}"`, 'Content-Disposition': `Attachment; filename="${options.value}"`,
@ -41,8 +62,9 @@ const disposition = {
}, },
/** /**
* ### Content Disposition Header * @description Content Disposition Header
* create a header that invokes the 'Save As' dialog in the browser when exporting the database to file. The 'filename' *
* 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). * 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=". * 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 = { module.exports = {
/**
* @description Get header based on ctrl configuration.
*
* @param {Object} result - API response
* @param {Object} apiConfig
* @return {Promise}
*/
get(result, apiConfig = {}) { get(result, apiConfig = {}) {
let headers = {}; let headers = {};

View file

@ -2,6 +2,15 @@ const debug = require('ghost-ignition').debug('api:shared:http');
const shared = require('../shared'); const shared = require('../shared');
const models = require('../../models'); 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) => { const http = (apiImpl) => {
return (req, res, next) => { return (req, res, next) => {
debug('request'); 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))) { if ((req.user && req.user.id) || (req.user && models.User.isExternalUser(req.user.id))) {
user = req.user.id; user = req.user.id;
} }

View file

@ -7,6 +7,20 @@ const sequence = require('../../lib/promise/sequence');
const STAGES = { const STAGES = {
validation: { 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) { input(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: validation'); debug('stages: validation');
const tasks = []; const tasks = [];
@ -30,6 +44,20 @@ const STAGES = {
}, },
serialisation: { 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) { input(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: input serialisation'); debug('stages: input serialisation');
return shared.serializers.handle.input( return shared.serializers.handle.input(
@ -38,12 +66,40 @@ const STAGES = {
frame 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) { output(response, apiUtils, apiConfig, apiImpl, frame) {
debug('stages: output serialisation'); debug('stages: output serialisation');
return shared.serializers.handle.output(response, apiConfig, apiUtils.serializers.output, frame); 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) { permissions(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: permissions'); debug('stages: permissions');
const tasks = []; const tasks = [];
@ -81,6 +137,15 @@ const STAGES = {
return sequence(tasks); 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) { query(apiUtils, apiConfig, apiImpl, frame) {
debug('stages: query'); 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 pipeline = (apiController, apiUtils, apiType) => {
const keys = Object.keys(apiController); const keys = Object.keys(apiController);

View file

@ -4,10 +4,16 @@ const sequence = require('../../../lib/promise/sequence');
const common = require('../../../lib/common'); 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 * The shared input handler runs the request through all the validation steps.
* 2. api serialization *
* 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) => { module.exports.input = (apiConfig, apiSerializers, frame) => {
debug('input'); debug('input');
@ -61,7 +67,20 @@ module.exports.input = (apiConfig, apiSerializers, frame) => {
return sequence(tasks); 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'); debug('output');
const tasks = []; const tasks = [];
@ -78,27 +97,27 @@ module.exports.output = (response = {}, apiConfig, apiSerializers, options) => {
if (apiSerializers.all && apiSerializers.all.before) { if (apiSerializers.all && apiSerializers.all.before) {
tasks.push(function allSerializeBefore() { 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]) {
if (apiSerializers[apiConfig.docName].all) { if (apiSerializers[apiConfig.docName].all) {
tasks.push(function serializeOptionsShared() { 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]) { if (apiSerializers[apiConfig.docName][apiConfig.method]) {
tasks.push(function serializeOptionsShared() { 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) { if (apiSerializers.all && apiSerializers.all.after) {
tasks.push(function allSerializeAfter() { tasks.push(function allSerializeAfter() {
return apiSerializers.all.after(apiConfig, options); return apiSerializers.all.after(apiConfig, frame);
}); });
} }

View file

@ -5,7 +5,11 @@ const utils = require('../../utils');
const INTERNAL_OPTIONS = ['transacting', 'forUpdate']; 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 = { module.exports = {
all(apiConfig, frame) { all(apiConfig, frame) {

View file

@ -1,5 +1,13 @@
const _ = require('lodash'); 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) => { const trimAndLowerCase = (params) => {
params = params || ''; params = params || '';

View file

@ -4,10 +4,16 @@ const common = require('../../../lib/common');
const sequence = require('../../../lib/promise/sequence'); const sequence = require('../../../lib/promise/sequence');
/** /**
* @description Shared input validation handler.
*
* The shared validation handler runs the request through all the validation steps. * The shared validation handler runs the request through all the validation steps.
* *
* 1. shared validation * 1. Shared validation
* 2. api 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) => { module.exports.input = (apiConfig, apiValidators, frame) => {
debug('input'); debug('input');

View file

@ -11,10 +11,22 @@ module.exports = {
return require('./validators'); 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) => { isContentAPI: (frame) => {
return frame.apiType === 'content'; return frame.apiType === 'content';
}, },
// @TODO: Remove, not used.
isAdminAPIKey: (frame) => { isAdminAPIKey: (frame) => {
return frame.options.context && Object.keys(frame.options.context).length !== 0 && frame.options.context.api_key && return frame.options.context && Object.keys(frame.options.context).length !== 0 && frame.options.context.api_key &&
frame.options.context.api_key.type === 'admin'; frame.options.context.api_key.type === 'admin';

View file

@ -4,6 +4,13 @@ const _ = require('lodash');
const permissions = require('../../../services/permissions'); const permissions = require('../../../services/permissions');
const common = require('../../../lib/common'); 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) => { const nonePublicAuth = (apiConfig, frame) => {
debug('check admin permissions'); debug('check admin permissions');
@ -11,6 +18,10 @@ const nonePublicAuth = (apiConfig, frame) => {
let permissionIdentifier = frame.options.id; 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) { if (apiConfig.identifier) {
permissionIdentifier = apiConfig.identifier(frame); permissionIdentifier = apiConfig.identifier(frame);
} }
@ -51,18 +62,27 @@ const nonePublicAuth = (apiConfig, frame) => {
}); });
}; };
// @TODO: https://github.com/TryGhost/Ghost/issues/10735
module.exports = { 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) { handle(apiConfig, frame) {
debug('handle'); debug('handle');
// @TODO: https://github.com/TryGhost/Ghost/issues/10099
frame.options.context = permissions.parseContext(frame.options.context); frame.options.context = permissions.parseContext(frame.options.context);
// CASE: Content API access
if (frame.options.context.public) { if (frame.options.context.public) {
debug('check content permissions'); debug('check content permissions');
// @TODO: The permission layer relies on the API format from v0.1. The permission layer should define // @TODO: Remove when we drop v0.1
// it's own format and should not re-use or rely on the API format. For now we have to simulate the v0.1 // @TODO: https://github.com/TryGhost/Ghost/issues/10733
// structure. We should raise an issue asap.
return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, { return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, {
status: frame.options.status, status: frame.options.status,
id: frame.options.id, id: frame.options.id,