mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
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:
parent
ebe0177b4f
commit
959912eca3
@ -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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
74
core/server/api/shared/frame.js
Normal file
74
core/server/api/shared/frame.js
Normal 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;
|
52
core/server/api/shared/headers.js
Normal file
52
core/server/api/shared/headers.js
Normal 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;
|
||||
}
|
||||
};
|
57
core/server/api/shared/http.js
Normal file
57
core/server/api/shared/http.js
Normal 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;
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
158
core/server/api/shared/pipeline.js
Normal file
158
core/server/api/shared/pipeline.js
Normal 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;
|
89
core/server/api/shared/serializers/handle.js
Normal file
89
core/server/api/shared/serializers/handle.js
Normal 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);
|
||||
};
|
13
core/server/api/shared/serializers/index.js
Normal file
13
core/server/api/shared/serializers/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
get handle() {
|
||||
return require('./handle');
|
||||
},
|
||||
|
||||
get input() {
|
||||
return require('./input');
|
||||
},
|
||||
|
||||
get output() {
|
||||
return require('./output');
|
||||
}
|
||||
};
|
46
core/server/api/shared/serializers/input/all.js
Normal file
46
core/server/api/shared/serializers/input/all.js
Normal 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);
|
||||
};
|
5
core/server/api/shared/serializers/input/index.js
Normal file
5
core/server/api/shared/serializers/input/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
get all() {
|
||||
return require('./all');
|
||||
}
|
||||
};
|
1
core/server/api/shared/serializers/output/index.js
Normal file
1
core/server/api/shared/serializers/output/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
56
core/server/api/shared/validators/handle.js
Normal file
56
core/server/api/shared/validators/handle.js
Normal 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);
|
||||
};
|
9
core/server/api/shared/validators/index.js
Normal file
9
core/server/api/shared/validators/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
get handle() {
|
||||
return require('./handle');
|
||||
},
|
||||
|
||||
get input() {
|
||||
return require('./input');
|
||||
}
|
||||
};
|
79
core/server/api/shared/validators/input/all.js
Normal file
79
core/server/api/shared/validators/input/all.js
Normal 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();
|
||||
};
|
5
core/server/api/shared/validators/input/index.js
Normal file
5
core/server/api/shared/validators/input/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
get all() {
|
||||
return require('./all');
|
||||
}
|
||||
};
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
33
core/server/api/v2/pages.js
Normal file
33
core/server/api/v2/pages.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
13
core/server/api/v2/utils/index.js
Normal file
13
core/server/api/v2/utils/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
get permissions() {
|
||||
return require('./permissions');
|
||||
},
|
||||
|
||||
get serializers() {
|
||||
return require('./serializers');
|
||||
},
|
||||
|
||||
get validators() {
|
||||
return require('./validators');
|
||||
}
|
||||
};
|
76
core/server/api/v2/utils/permissions.js
Normal file
76
core/server/api/v2/utils/permissions.js
Normal 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);
|
||||
}
|
||||
};
|
9
core/server/api/v2/utils/serializers/index.js
Normal file
9
core/server/api/v2/utils/serializers/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
get input() {
|
||||
return require('./input');
|
||||
},
|
||||
|
||||
get output() {
|
||||
return require('./output');
|
||||
}
|
||||
};
|
5
core/server/api/v2/utils/serializers/input/index.js
Normal file
5
core/server/api/v2/utils/serializers/input/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
get pages() {
|
||||
return require('./pages');
|
||||
}
|
||||
};
|
25
core/server/api/v2/utils/serializers/input/pages.js
Normal file
25
core/server/api/v2/utils/serializers/input/pages.js
Normal 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);
|
||||
}
|
||||
};
|
5
core/server/api/v2/utils/serializers/output/index.js
Normal file
5
core/server/api/v2/utils/serializers/output/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
get pages() {
|
||||
return require('./pages');
|
||||
}
|
||||
};
|
22
core/server/api/v2/utils/serializers/output/pages.js
Normal file
22
core/server/api/v2/utils/serializers/output/pages.js
Normal 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);
|
||||
}
|
||||
};
|
9
core/server/api/v2/utils/validators/index.js
Normal file
9
core/server/api/v2/utils/validators/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
get input() {
|
||||
return require('./input');
|
||||
},
|
||||
|
||||
get output() {
|
||||
return require('./output');
|
||||
}
|
||||
};
|
1
core/server/api/v2/utils/validators/input/index.js
Normal file
1
core/server/api/v2/utils/validators/input/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
1
core/server/api/v2/utils/validators/output/index.js
Normal file
1
core/server/api/v2/utils/validators/output/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
@ -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));
|
||||
|
@ -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())
|
||||
|
100
core/test/unit/api/shared/frame_spec.js
Normal file
100
core/test/unit/api/shared/frame_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
47
core/test/unit/api/shared/headers_spec.js
Normal file
47
core/test/unit/api/shared/headers_spec.js
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
83
core/test/unit/api/shared/http_spec.js
Normal file
83
core/test/unit/api/shared/http_spec.js
Normal 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);
|
||||
});
|
||||
});
|
255
core/test/unit/api/shared/pipeline_spec.js
Normal file
255
core/test/unit/api/shared/pipeline_spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
90
core/test/unit/api/shared/serializers/handle_spec.js
Normal file
90
core/test/unit/api/shared/serializers/handle_spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
79
core/test/unit/api/shared/serializers/input/all_spec.js
Normal file
79
core/test/unit/api/shared/serializers/input/all_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
55
core/test/unit/api/shared/validators/handle_spec.js
Normal file
55
core/test/unit/api/shared/validators/handle_spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
125
core/test/unit/api/shared/validators/input/all_spec.js
Normal file
125
core/test/unit/api/shared/validators/input/all_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
50
core/test/unit/api/v2/utils/serializers/input/pages_spec.js
Normal file
50
core/test/unit/api/v2/utils/serializers/input/pages_spec.js
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user