diff --git a/core/server/api/canary/index.js b/core/server/api/canary/index.js index ceb4206e09..3cd4600532 100644 --- a/core/server/api/canary/index.js +++ b/core/server/api/canary/index.js @@ -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'); }, diff --git a/core/server/api/canary/snippets.js b/core/server/api/canary/snippets.js new file mode 100644 index 0000000000..dda54f7d99 --- /dev/null +++ b/core/server/api/canary/snippets.js @@ -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') + })); + }); + } + } +}; diff --git a/core/server/api/canary/utils/serializers/output/index.js b/core/server/api/canary/utils/serializers/output/index.js index 9b83bc7a14..1bb006516c 100644 --- a/core/server/api/canary/utils/serializers/output/index.js +++ b/core/server/api/canary/utils/serializers/output/index.js @@ -121,5 +121,9 @@ module.exports = { get labels() { return require('./labels'); + }, + + get snippets() { + return require('./snippets'); } }; diff --git a/core/server/api/canary/utils/serializers/output/snippets.js b/core/server/api/canary/utils/serializers/output/snippets.js new file mode 100644 index 0000000000..52e570cc88 --- /dev/null +++ b/core/server/api/canary/utils/serializers/output/snippets.js @@ -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} Frame + * @prop {Object} options + */ diff --git a/core/server/api/canary/utils/validators/input/index.js b/core/server/api/canary/utils/validators/input/index.js index 7a4cd511f2..2258786e62 100644 --- a/core/server/api/canary/utils/validators/input/index.js +++ b/core/server/api/canary/utils/validators/input/index.js @@ -53,5 +53,9 @@ module.exports = { get webhooks() { return require('./webhooks'); + }, + + get snippets() { + return require('./snippets'); } }; diff --git a/core/server/api/canary/utils/validators/input/snippets.js b/core/server/api/canary/utils/validators/input/snippets.js new file mode 100644 index 0000000000..b4d8cea580 --- /dev/null +++ b/core/server/api/canary/utils/validators/input/snippets.js @@ -0,0 +1,6 @@ +const jsonSchema = require('../utils/json-schema'); + +module.exports = { + add: jsonSchema.validate, + edit: jsonSchema.validate +}; diff --git a/core/server/models/index.js b/core/server/models/index.js index 69f378cb9b..2f41f7d138 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -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' diff --git a/core/server/models/snippet.js b/core/server/models/snippet.js new file mode 100644 index 0000000000..57a5d61409 --- /dev/null +++ b/core/server/models/snippet.js @@ -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) +}; diff --git a/core/server/translations/en.json b/core/server/translations/en.json index e4a4de995c..7fe9ae0707 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -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": { diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 04e346b047..f1f8a74fa4 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -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; }; diff --git a/package.json b/package.json index f7a1aa710e..24ef8e52b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/api-acceptance/admin/snippets_spec.js b/test/api-acceptance/admin/snippets_spec.js new file mode 100644 index 0000000000..77be20fcaa --- /dev/null +++ b/test/api-acceptance/admin/snippets_spec.js @@ -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); + }); + }); +}); diff --git a/test/api-acceptance/admin/utils.js b/test/api-acceptance/admin/utils.js index 08dfb81288..65b541a03f 100644 --- a/test/api-acceptance/admin/utils.js +++ b/test/api-acceptance/admin/utils.js @@ -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) => { diff --git a/test/utils/fixtures/data-generator.js b/test/utils/fixtures/data-generator.js index 52dc5d4ddb..9be43dc938 100644 --- a/test/utils/fixtures/data-generator.js +++ b/test/utils/fixtures/data-generator.js @@ -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 }; }()); diff --git a/test/utils/index.js b/test/utils/index.js index fc0046b197..09b905564f 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -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(); } }; diff --git a/yarn.lock b/yarn.lock index 02d6deeb34..eb9008ba60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"