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
This commit is contained in:
Katharina Irrgang 2018-10-05 00:50:45 +02:00 committed by GitHub
parent ebe0177b4f
commit 959912eca3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1895 additions and 2 deletions

View File

@ -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. 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 ## API Controller
A controller is no longer just a function, it's a set of configurations. 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);
}
}
```

View File

@ -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;

View File

@ -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;
}
};

View File

@ -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;

View File

@ -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');
}
};

View File

@ -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;

View File

@ -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);
};

View File

@ -0,0 +1,13 @@
module.exports = {
get handle() {
return require('./handle');
},
get input() {
return require('./input');
},
get output() {
return require('./output');
}
};

View File

@ -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);
};

View File

@ -0,0 +1,5 @@
module.exports = {
get all() {
return require('./all');
}
};

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -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);
};

View File

@ -0,0 +1,9 @@
module.exports = {
get handle() {
return require('./handle');
},
get input() {
return require('./input');
}
};

View File

@ -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();
};

View File

@ -0,0 +1,5 @@
module.exports = {
get all() {
return require('./all');
}
};

View File

@ -1,5 +1,17 @@
const shared = require('../shared');
const localUtils = require('./utils');
module.exports = { module.exports = {
get http() {
return shared.http;
},
// @TODO: transform
get session() { get session() {
return require('./session'); return require('./session');
},
get pages() {
return shared.pipeline(require('./pages'), localUtils);
} }
}; };

View File

@ -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);
}
}
};

View File

@ -0,0 +1,13 @@
module.exports = {
get permissions() {
return require('./permissions');
},
get serializers() {
return require('./serializers');
},
get validators() {
return require('./validators');
}
};

View File

@ -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);
}
};

View File

@ -0,0 +1,9 @@
module.exports = {
get input() {
return require('./input');
},
get output() {
return require('./output');
}
};

View File

@ -0,0 +1,5 @@
module.exports = {
get pages() {
return require('./pages');
}
};

View File

@ -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);
}
};

View File

@ -0,0 +1,5 @@
module.exports = {
get pages() {
return require('./pages');
}
};

View File

@ -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);
}
};

View File

@ -0,0 +1,9 @@
module.exports = {
get input() {
return require('./input');
},
get output() {
return require('./output');
}
};

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const api = require('../../../../api'); const api = require('../../../../api');
const apiv2 = require('../../../../api/v2');
const shared = require('../../../shared'); const shared = require('../../../shared');
const mw = require('./middleware'); 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/:id', mw.authenticatePublic, api.http(api.posts.read));
router.get('/posts/slug/:slug', 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 // ## Users
router.get('/users', mw.authenticatePublic, api.http(api.users.browse)); router.get('/users', mw.authenticatePublic, api.http(api.users.browse));
router.get('/users/:id', mw.authenticatePublic, api.http(api.users.read)); router.get('/users/:id', mw.authenticatePublic, api.http(api.users.read));

View File

@ -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) { 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')) request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=not_available&absolute_urls=true'))
.set('Origin', testUtils.API.getURL()) .set('Origin', testUtils.API.getURL())

View File

@ -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);
});
});
});

View File

@ -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'
});
});
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});
});
});

View File

@ -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();
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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();
});
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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');
});
});