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:
Kevin Ansfield 2020-10-16 18:02:58 +01:00
parent e5a92be932
commit 16bfb3fa41
16 changed files with 478 additions and 7 deletions

View File

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

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

View File

@ -121,5 +121,9 @@ module.exports = {
get labels() {
return require('./labels');
},
get snippets() {
return require('./snippets');
}
};

View 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
*/

View File

@ -53,5 +53,9 @@ module.exports = {
get webhooks() {
return require('./webhooks');
},
get snippets() {
return require('./snippets');
}
};

View File

@ -0,0 +1,6 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
};

View File

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

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

View File

@ -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": {

View File

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

View File

@ -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",

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

View File

@ -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) => {

View File

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

View File

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

View File

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