mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 21:33:24 +03:00
Added basic CRUD admin API for snippets
no issue - standard browse/read/add/edit/destroy API endpoints for snippets resource - updates `@tryghost/admin-api-schema` dependency to version that includes snippet definition and schemas
This commit is contained in:
parent
e5a92be932
commit
16bfb3fa41
@ -135,6 +135,10 @@ module.exports = {
|
||||
return shared.pipeline(require('./site'), localUtils);
|
||||
},
|
||||
|
||||
get snippets() {
|
||||
return shared.pipeline(require('./snippets'), localUtils);
|
||||
},
|
||||
|
||||
get serializers() {
|
||||
return require('./utils/serializers');
|
||||
},
|
||||
|
109
core/server/api/canary/snippets.js
Normal file
109
core/server/api/canary/snippets.js
Normal file
@ -0,0 +1,109 @@
|
||||
const Promise = require('bluebird');
|
||||
const {i18n} = require('../../lib/common');
|
||||
const errors = require('@tryghost/errors');
|
||||
const models = require('../../models');
|
||||
|
||||
module.exports = {
|
||||
docName: 'snippets',
|
||||
|
||||
browse: {
|
||||
options: [
|
||||
'limit',
|
||||
'order',
|
||||
'page'
|
||||
],
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Snippet.findPage(frame.options);
|
||||
}
|
||||
},
|
||||
|
||||
read: {
|
||||
headers: {},
|
||||
data: [
|
||||
'id'
|
||||
],
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Snippet.findOne(frame.data, frame.options)
|
||||
.then((model) => {
|
||||
if (!model) {
|
||||
return Promise.reject(new errors.NotFoundError({
|
||||
message: i18n.t('errors.api.snippets.snippetNotFound')
|
||||
}));
|
||||
}
|
||||
|
||||
return model;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
add: {
|
||||
statusCode: 201,
|
||||
headers: {},
|
||||
permissions: true,
|
||||
async query(frame) {
|
||||
try {
|
||||
return await models.Snippet.add(frame.data.snippets[0], frame.options);
|
||||
} catch (error) {
|
||||
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
|
||||
throw new errors.ValidationError({message: i18n.t('errors.api.snippets.snippetAlreadyExists')});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
edit: {
|
||||
headers: {},
|
||||
options: [
|
||||
'id'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Snippet.edit(frame.data.snippets[0], frame.options)
|
||||
.then((model) => {
|
||||
if (!model) {
|
||||
return Promise.reject(new errors.NotFoundError({
|
||||
message: i18n.t('errors.api.snippets.snippetNotFound')
|
||||
}));
|
||||
}
|
||||
|
||||
return model;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroy: {
|
||||
statusCode: 204,
|
||||
headers: {},
|
||||
options: [
|
||||
'id'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
id: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
return models.Snippet.destroy(frame.options)
|
||||
.then(() => null)
|
||||
.catch(models.Snippet.NotFoundError, () => {
|
||||
return Promise.reject(new errors.NotFoundError({
|
||||
message: i18n.t('errors.api.snippets.snippetNotFound')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -121,5 +121,9 @@ module.exports = {
|
||||
|
||||
get labels() {
|
||||
return require('./labels');
|
||||
},
|
||||
|
||||
get snippets() {
|
||||
return require('./snippets');
|
||||
}
|
||||
};
|
||||
|
96
core/server/api/canary/utils/serializers/output/snippets.js
Normal file
96
core/server/api/canary/utils/serializers/output/snippets.js
Normal file
@ -0,0 +1,96 @@
|
||||
//@ts-check
|
||||
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:output:snippets');
|
||||
|
||||
module.exports = {
|
||||
browse: createSerializer('browse', paginatedSnippets),
|
||||
read: createSerializer('read', singleSnippet),
|
||||
edit: createSerializer('edit', singleSnippet),
|
||||
add: createSerializer('add', singleSnippet)
|
||||
};
|
||||
|
||||
/**
|
||||
* @template PageMeta
|
||||
*
|
||||
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
|
||||
* @param {APIConfig} _apiConfig
|
||||
* @param {Frame} frame
|
||||
*
|
||||
* @returns {{snippets: SerializedSnippet[], meta: PageMeta}}
|
||||
*/
|
||||
function paginatedSnippets(page, _apiConfig, frame) {
|
||||
return {
|
||||
snippets: page.data.map(model => serializeSnippet(model, frame.options)),
|
||||
meta: page.meta
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('bookshelf').Model} model
|
||||
* @param {APIConfig} _apiConfig
|
||||
* @param {Frame} frame
|
||||
*
|
||||
* @returns {{snippets: SerializedSnippet[]}}
|
||||
*/
|
||||
function singleSnippet(model, _apiConfig, frame) {
|
||||
return {
|
||||
snippets: [serializeSnippet(model, frame.options)]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template Data
|
||||
* @template Response
|
||||
* @param {string} debugString
|
||||
* @param {(data: Data, apiConfig: APIConfig, frame: Frame) => Response} serialize - A function to serialize the data into an object suitable for API response
|
||||
*
|
||||
* @returns {(data: Data, apiConfig: APIConfig, frame: Frame) => void}
|
||||
*/
|
||||
function createSerializer(debugString, serialize) {
|
||||
return function serializer(data, apiConfig, frame) {
|
||||
debug(debugString);
|
||||
const response = serialize(data, apiConfig, frame);
|
||||
frame.response = response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('bookshelf').Model} snippet
|
||||
* @param {object} options
|
||||
*
|
||||
* @returns {SerializedSnippet}
|
||||
*/
|
||||
function serializeSnippet(snippet, options) {
|
||||
const json = snippet.toJSON(options);
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
mobiledoc: json.mobiledoc,
|
||||
created_at: json.created_at,
|
||||
updated_at: json.updated_at,
|
||||
created_by: json.created_by,
|
||||
updated_by: json.updated_by
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SerializedSnippet
|
||||
* @prop {string} id
|
||||
* @prop {string=} name
|
||||
* @prop {string=} mobiledoc
|
||||
* @prop {string} created_at
|
||||
* @prop {string} updated_at
|
||||
* @prop {string} created_by
|
||||
* @prop {string} updated_by
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} APIConfig
|
||||
* @prop {string} docName
|
||||
* @prop {string} method
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object<string, any>} Frame
|
||||
* @prop {Object} options
|
||||
*/
|
@ -53,5 +53,9 @@ module.exports = {
|
||||
|
||||
get webhooks() {
|
||||
return require('./webhooks');
|
||||
},
|
||||
|
||||
get snippets() {
|
||||
return require('./snippets');
|
||||
}
|
||||
};
|
||||
|
@ -0,0 +1,6 @@
|
||||
const jsonSchema = require('../utils/json-schema');
|
||||
|
||||
module.exports = {
|
||||
add: jsonSchema.validate,
|
||||
edit: jsonSchema.validate
|
||||
};
|
@ -36,6 +36,7 @@ const models = [
|
||||
'email-recipient',
|
||||
'label',
|
||||
'single-use-token',
|
||||
'snippet',
|
||||
// Action model MUST be loaded last as it loops through all of the registered models
|
||||
// Please do not append items to this array.
|
||||
'action'
|
||||
|
14
core/server/models/snippet.js
Normal file
14
core/server/models/snippet.js
Normal file
@ -0,0 +1,14 @@
|
||||
const ghostBookshelf = require('./base');
|
||||
|
||||
const Snippet = ghostBookshelf.Model.extend({
|
||||
tableName: 'snippets'
|
||||
});
|
||||
|
||||
const Snippets = ghostBookshelf.Collection.extend({
|
||||
model: Snippet
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
Snippet: ghostBookshelf.model('Snippet', Snippet),
|
||||
Snippets: ghostBookshelf.collection('Snippets', Snippets)
|
||||
};
|
@ -520,6 +520,10 @@
|
||||
"HostLimitError": "Host Limit error, cannot {action}.",
|
||||
"DisabledFeatureError": "Theme validation error, the {{{helperName}}} helper is not available. Cannot {action}.",
|
||||
"UpdateCollisionError": "Saving failed! Someone else is editing this post."
|
||||
},
|
||||
"snippets": {
|
||||
"snippetNotFound": "Snippet not found.",
|
||||
"snippetAlreadyExists": "Snippet already exists"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
|
@ -247,5 +247,12 @@ module.exports = function apiRoutes() {
|
||||
router.get('/emails/:id', mw.authAdminApi, http(apiCanary.emails.read));
|
||||
router.put('/emails/:id/retry', mw.authAdminApi, http(apiCanary.emails.retry));
|
||||
|
||||
// ## Snippets
|
||||
router.get('/snippets', mw.authAdminApi, http(apiCanary.snippets.browse));
|
||||
router.get('/snippets/:id', mw.authAdminApi, http(apiCanary.snippets.read));
|
||||
router.post('/snippets', mw.authAdminApi, http(apiCanary.snippets.add));
|
||||
router.put('/snippets/:id', mw.authAdminApi, http(apiCanary.snippets.edit));
|
||||
router.del('/snippets/:id', mw.authAdminApi, http(apiCanary.snippets.destroy));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
@ -43,7 +43,7 @@
|
||||
"@nexes/nql": "0.4.0",
|
||||
"@sentry/node": "5.26.0",
|
||||
"@tryghost/adapter-manager": "0.1.11",
|
||||
"@tryghost/admin-api-schema": "1.1.0",
|
||||
"@tryghost/admin-api-schema": "1.2.0",
|
||||
"@tryghost/bootstrap-socket": "0.2.2",
|
||||
"@tryghost/constants": "0.1.1",
|
||||
"@tryghost/errors": "0.2.4",
|
||||
|
198
test/api-acceptance/admin/snippets_spec.js
Normal file
198
test/api-acceptance/admin/snippets_spec.js
Normal file
@ -0,0 +1,198 @@
|
||||
const path = require('path');
|
||||
const should = require('should');
|
||||
const supertest = require('supertest');
|
||||
const sinon = require('sinon');
|
||||
const testUtils = require('../../utils');
|
||||
const localUtils = require('./utils');
|
||||
const config = require('../../../core/shared/config');
|
||||
|
||||
const ghost = testUtils.startGhost;
|
||||
|
||||
let request;
|
||||
|
||||
describe.only('Snippets API', function () {
|
||||
after(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
before(function () {
|
||||
return ghost()
|
||||
.then(function () {
|
||||
request = supertest.agent(config.get('url'));
|
||||
})
|
||||
.then(function () {
|
||||
return localUtils.doAuth(request, 'snippets');
|
||||
});
|
||||
});
|
||||
|
||||
it('Can add', function () {
|
||||
const snippet = {
|
||||
name: 'test',
|
||||
// TODO: validate mobiledoc document
|
||||
mobiledoc: JSON.stringify({})
|
||||
};
|
||||
|
||||
return request
|
||||
.post(localUtils.API.getApiQuery(`snippets/`))
|
||||
.send({snippets: [snippet]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
|
||||
jsonResponse.snippets.should.have.length(1);
|
||||
jsonResponse.snippets[0].name.should.equal(snippet.name);
|
||||
jsonResponse.snippets[0].mobiledoc.should.equal(snippet.mobiledoc);
|
||||
|
||||
should.exist(res.headers.location);
|
||||
res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('snippets/')}${res.body.snippets[0].id}/`);
|
||||
});
|
||||
});
|
||||
|
||||
it('Can browse', function () {
|
||||
return request
|
||||
.get(localUtils.API.getApiQuery('snippets/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
// 1 from fixtures and 1 from "Can add" above
|
||||
jsonResponse.snippets.should.have.length(2);
|
||||
localUtils.API.checkResponse(jsonResponse.snippets[0], 'snippet');
|
||||
|
||||
// created in "Can add" above, individual tests are not idempotent
|
||||
jsonResponse.snippets[1].name.should.eql('test');
|
||||
|
||||
testUtils.API.isISO8601(jsonResponse.snippets[0].created_at).should.be.true();
|
||||
jsonResponse.snippets[0].created_at.should.be.an.instanceof(String);
|
||||
|
||||
jsonResponse.meta.pagination.should.have.property('page', 1);
|
||||
jsonResponse.meta.pagination.should.have.property('limit', 15);
|
||||
jsonResponse.meta.pagination.should.have.property('pages', 1);
|
||||
jsonResponse.meta.pagination.should.have.property('total', 2);
|
||||
jsonResponse.meta.pagination.should.have.property('next', null);
|
||||
jsonResponse.meta.pagination.should.have.property('prev', null);
|
||||
});
|
||||
});
|
||||
|
||||
it('Can read', function () {
|
||||
return request
|
||||
.get(localUtils.API.getApiQuery(`snippets/${testUtils.DataGenerator.Content.snippets[0].id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
jsonResponse.snippets.should.have.length(1);
|
||||
localUtils.API.checkResponse(jsonResponse.snippets[0], 'snippet');
|
||||
});
|
||||
});
|
||||
|
||||
it('Can edit', function () {
|
||||
const snippetToChange = {
|
||||
name: 'change me',
|
||||
mobiledoc: '{}'
|
||||
};
|
||||
|
||||
const snippetChanged = {
|
||||
name: 'changed',
|
||||
mobiledoc: '{}'
|
||||
};
|
||||
|
||||
return request
|
||||
.post(localUtils.API.getApiQuery(`snippets/`))
|
||||
.send({snippets: [snippetToChange]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
jsonResponse.snippets.should.have.length(1);
|
||||
|
||||
should.exist(res.headers.location);
|
||||
res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('snippets/')}${res.body.snippets[0].id}/`);
|
||||
|
||||
return jsonResponse.snippets[0];
|
||||
})
|
||||
.then((newsnippet) => {
|
||||
return request
|
||||
.put(localUtils.API.getApiQuery(`snippets/${newsnippet.id}/`))
|
||||
.send({snippets: [snippetChanged]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
jsonResponse.snippets.should.have.length(1);
|
||||
localUtils.API.checkResponse(jsonResponse.snippets[0], 'snippet');
|
||||
jsonResponse.snippets[0].name.should.equal(snippetChanged.name);
|
||||
jsonResponse.snippets[0].mobiledoc.should.equal(snippetChanged.mobiledoc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Can destroy', function () {
|
||||
const snippet = {
|
||||
name: 'destroy test',
|
||||
mobiledoc: '{}'
|
||||
};
|
||||
|
||||
return request
|
||||
.post(localUtils.API.getApiQuery(`snippets/`))
|
||||
.send({snippets: [snippet]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.snippets);
|
||||
|
||||
return jsonResponse.snippets[0];
|
||||
})
|
||||
.then((newSnippet) => {
|
||||
return request
|
||||
.delete(localUtils.API.getApiQuery(`snippets/${newSnippet.id}`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(204)
|
||||
.then(() => newSnippet);
|
||||
})
|
||||
.then((newSnippet) => {
|
||||
return request
|
||||
.get(localUtils.API.getApiQuery(`snippets/${newSnippet.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
@ -20,6 +20,7 @@ const expectedProperties = {
|
||||
themes: ['themes'],
|
||||
actions: ['actions', 'meta'],
|
||||
members: ['members', 'meta'],
|
||||
snippets: ['snippets', 'meta'],
|
||||
|
||||
action: ['id', 'resource_type', 'actor_type', 'event', 'created_at', 'actor'],
|
||||
|
||||
@ -112,7 +113,8 @@ const expectedProperties = {
|
||||
,
|
||||
email: _(schema.emails)
|
||||
.keys(),
|
||||
email_preview: ['html', 'subject', 'plaintext']
|
||||
email_preview: ['html', 'subject', 'plaintext'],
|
||||
snippet: _(schema.snippets).keys()
|
||||
};
|
||||
|
||||
_.each(expectedProperties, (value, key) => {
|
||||
|
@ -461,6 +461,14 @@ DataGenerator.Content = {
|
||||
plaintext: 'yes this is an email',
|
||||
submitted_at: moment().toDate()
|
||||
}
|
||||
],
|
||||
|
||||
snippets: [
|
||||
{
|
||||
id: ObjectId.generate(),
|
||||
name: 'Test snippet 1',
|
||||
mobiledoc: '{}'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@ -975,6 +983,10 @@ DataGenerator.forKnex = (function () {
|
||||
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[1])
|
||||
];
|
||||
|
||||
const snippets = [
|
||||
createBasic(DataGenerator.Content.snippets[0])
|
||||
];
|
||||
|
||||
return {
|
||||
createPost,
|
||||
createGenericPost,
|
||||
@ -1016,7 +1028,8 @@ DataGenerator.forKnex = (function () {
|
||||
members,
|
||||
members_labels,
|
||||
members_stripe_customers,
|
||||
stripe_customer_subscriptions
|
||||
stripe_customer_subscriptions,
|
||||
snippets
|
||||
};
|
||||
}());
|
||||
|
||||
|
@ -502,6 +502,12 @@ fixtures = {
|
||||
return models.StripeCustomerSubscription.add(subscription, module.exports.context.internal);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
insertSnippets: function insertSnippets() {
|
||||
return Promise.map(DataGenerator.forKnex.snippets, function (snippet) {
|
||||
return models.Snippet.add(snippet, module.exports.context.internal);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -636,6 +642,9 @@ toDoList = {
|
||||
},
|
||||
emails: function insertEmails() {
|
||||
return fixtures.insertEmails();
|
||||
},
|
||||
snippets: function insertSnippets() {
|
||||
return fixtures.insertSnippets();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -383,10 +383,10 @@
|
||||
dependencies:
|
||||
"@tryghost/errors" "^0.2.4"
|
||||
|
||||
"@tryghost/admin-api-schema@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-1.1.0.tgz#97224a9f5b74d08bdd93c1f89a85dfb2bfff9379"
|
||||
integrity sha512-VSn4LMtVmIAHJxXPeW380s3TKy2UbXaWwVsPaUmtYKCAtyO1DelVB20Lnh/UMuWpKOBLVdzMwI66E7V5xxj/ww==
|
||||
"@tryghost/admin-api-schema@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-1.2.0.tgz#6f266352e213e8fb60c42824b350673356346cf3"
|
||||
integrity sha512-gA9GxdhbkcTUBB5BOQ9R2ws9BVrqFkRhKIpxo71jhua9T1oJaLmvC85m1ju6p+ITQ1TH4WOio21Si2akPiKUUA==
|
||||
dependencies:
|
||||
"@tryghost/errors" "0.2.4"
|
||||
bluebird "^3.5.3"
|
||||
|
Loading…
Reference in New Issue
Block a user