diff --git a/core/server/api/v2/index.js b/core/server/api/v2/index.js index 5956d9b65f..46d2f07056 100644 --- a/core/server/api/v2/index.js +++ b/core/server/api/v2/index.js @@ -6,6 +6,10 @@ module.exports = { return shared.http; }, + get integrations() { + return shared.pipeline(require('./integrations'), localUtils); + }, + // @TODO: transform get session() { return require('./session'); diff --git a/core/server/api/v2/integrations.js b/core/server/api/v2/integrations.js new file mode 100644 index 0000000000..608f12e77f --- /dev/null +++ b/core/server/api/v2/integrations.js @@ -0,0 +1,145 @@ +const common = require('../../lib/common'); +const models = require('../../models'); + +module.exports = { + docName: 'integrations', + browse: { + permissions: true, + options: [ + 'include', + 'limit' + ], + validation: { + options: { + include: { + values: ['api_keys', 'webhooks'] + } + } + }, + query({options}) { + return models.Integration.findPage(options); + } + }, + read: { + permissions: true, + data: [ + 'id' + ], + options: [ + 'include' + ], + validation: { + data: { + id: { + required: true + } + }, + options: { + include: { + values: ['api_keys', 'webhooks'] + } + } + }, + query({data, options}) { + return models.Integration.findOne(data, Object.assign(options, {require: true})) + .catch(models.Integration.NotFoundError, () => { + throw new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.resource.resourceNotFound', { + resource: 'Integration' + }) + }); + }); + } + }, + edit: { + permissions: true, + data: [ + 'name', + 'icon_image', + 'description', + 'webhooks' + ], + options: [ + 'id', + 'include' + ], + validation: { + options: { + id: { + required: true + }, + include: { + values: ['api_keys', 'webhooks'] + } + } + }, + query({data, options}) { + return models.Integration.edit(data, Object.assign(options, {require: true})) + .catch(models.Integration.NotFoundError, () => { + throw new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.resource.resourceNotFound', { + resource: 'Integration' + }) + }); + }); + } + }, + add: { + statusCode: 201, + permissions: true, + data: [ + 'name', + 'icon_image', + 'description', + 'webhooks' + ], + options: [ + 'include' + ], + validation: { + data: { + name: { + required: true + } + }, + options: { + include: { + values: ['api_keys', 'webhooks'] + } + } + }, + query({data, options}) { + const dataWithApiKeys = Object.assign({ + api_keys: [ + {type: 'content'}, + {type: 'admin'} + ] + }, data); + return models.Integration.add(dataWithApiKeys, options); + } + }, + destroy: { + statusCode: 204, + permissions: true, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + query({options}) { + return models.Integration.destroy(Object.assign(options, {require: true})) + .catch(models.Integration.NotFoundError, () => { + throw new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.resource.resourceNotFound', { + resource: 'Integration' + }) + }); + }); + } + } +}; diff --git a/core/server/api/v2/utils/serializers/input/index.js b/core/server/api/v2/utils/serializers/input/index.js index 02bca0aaaa..ae9789057e 100644 --- a/core/server/api/v2/utils/serializers/input/index.js +++ b/core/server/api/v2/utils/serializers/input/index.js @@ -1,4 +1,7 @@ module.exports = { + get integrations() { + return require('./integrations'); + }, get pages() { return require('./pages'); }, diff --git a/core/server/api/v2/utils/serializers/input/integrations.js b/core/server/api/v2/utils/serializers/input/integrations.js new file mode 100644 index 0000000000..ba0be09417 --- /dev/null +++ b/core/server/api/v2/utils/serializers/input/integrations.js @@ -0,0 +1,15 @@ +const _ = require('lodash'); +const debug = require('ghost-ignition').debug('api:v2:utils:serializers:input:integrations'); + +module.exports = { + add(apiConfig, frame) { + debug('add'); + + frame.data = _.pick(frame.data.integrations[0], apiConfig.data); + }, + edit(apiConfig, frame) { + debug('edit'); + + frame.data = _.pick(frame.data.integrations[0], apiConfig.data); + } +}; diff --git a/core/server/api/v2/utils/serializers/output/index.js b/core/server/api/v2/utils/serializers/output/index.js index 538ccc3f4e..7c62ad2272 100644 --- a/core/server/api/v2/utils/serializers/output/index.js +++ b/core/server/api/v2/utils/serializers/output/index.js @@ -1,4 +1,8 @@ module.exports = { + get integrations() { + return require('./integrations'); + }, + get pages() { return require('./pages'); }, diff --git a/core/server/api/v2/utils/serializers/output/integrations.js b/core/server/api/v2/utils/serializers/output/integrations.js new file mode 100644 index 0000000000..0ebc3a76ad --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/integrations.js @@ -0,0 +1,34 @@ +const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:integrations'); + +module.exports = { + browse({data, meta}, apiConfig, frame) { + debug('browse'); + + frame.response = { + integrations: data.map(model => model.toJSON(frame.options)), + meta + }; + }, + read(model, apiConfig, frame) { + debug('read'); + + frame.response = { + integrations: [model.toJSON(frame.options)] + }; + }, + add(model, apiConfig, frame) { + debug('add'); + + frame.response = { + integrations: [model.toJSON(frame.options)] + }; + }, + edit(model, apiConfig, frame) { + debug('edit'); + + frame.response = { + integrations: [model.toJSON(frame.options)] + }; + } +}; + diff --git a/core/server/models/integration.js b/core/server/models/integration.js index aa3dcd1bb3..425e6322d6 100644 --- a/core/server/models/integration.js +++ b/core/server/models/integration.js @@ -10,6 +10,44 @@ const Integration = ghostBookshelf.Model.extend({ webhooks: 'webhooks' }, + add(data, options) { + const addIntegration = () => { + return ghostBookshelf.Model.add.call(this, data, options) + .then(({id}) => { + return this.findOne({id}, options); + }); + }; + + if (!options.transacting) { + return ghostBookshelf.transaction((transacting) => { + options.transacting = transacting; + + return addIntegration(); + }); + } + + return addIntegration(); + }, + + edit(data, options) { + const editIntegration = () => { + return ghostBookshelf.Model.edit.call(this, data, options) + .then(({id}) => { + return this.findOne({id}, options); + }); + }; + + if (!options.transacting) { + return ghostBookshelf.transaction((transacting) => { + options.transacting = transacting; + + return editIntegration(); + }); + } + + return editIntegration(); + }, + onSaving(newIntegration, attr, options) { if (this.hasChanged('slug') || !this.get('slug')) { // Pass the new slug through the generator to strip illegal characters, detect duplicates diff --git a/core/server/translations/en.json b/core/server/translations/en.json index cf818b37f7..1615880ce0 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -357,6 +357,9 @@ "missingFile": "Please select a JSON file.", "invalidFile": "Please select a valid JSON file to import." }, + "resource": { + "resourceNotFound": "{resource} not found." + }, "routes": { "missingFile": "Please select a YAML file.", "invalidFile": "Please select a valid YAML file to import." diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index f4b1b849bb..2e9c1d2c1d 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -33,6 +33,14 @@ module.exports = function apiRoutes() { router.put('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.edit)); router.del('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.destroy)); + // # Integrations + + router.get('/integrations', mw.authAdminAPI, apiv2.http(apiv2.integrations.browse)); + router.get('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.read)); + router.post('/integrations', mw.authAdminAPI, apiv2.http(apiv2.integrations.add)); + router.put('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.edit)); + router.del('/integrations/:id', mw.authAdminAPI, apiv2.http(apiv2.integrations.destroy)); + // ## Schedules router.put('/schedules/posts/:id', [ auth.authenticate.authenticateClient, diff --git a/core/test/functional/api/v2/admin/integrations_spec.js b/core/test/functional/api/v2/admin/integrations_spec.js new file mode 100644 index 0000000000..6ceba269a4 --- /dev/null +++ b/core/test/functional/api/v2/admin/integrations_spec.js @@ -0,0 +1,387 @@ +const should = require('should'); +const supertest = require('supertest'); +const config = require('../../../../../../core/server/config'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); + +const ghost = testUtils.startGhost; + +describe('Integrations API', function () { + let request; + + before(function () { + return ghost() + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request, 'integrations'); + }); + }); + + const findBy = (prop, val) => object => object[prop] === val; + + describe('POST /integrations/', function () { + it('Can successfully create a single integration with auto generated content and admin api key', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Dis-Integrate!!' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + should.equal(body.integrations.length, 1); + + const [integration] = body.integrations; + should.equal(integration.name, 'Dis-Integrate!!'); + + should.equal(integration.api_keys.length, 2); + + const contentApiKey = integration.api_keys.find(findBy('type', 'content')); + should.equal(contentApiKey.integration_id, integration.id); + + const adminApiKey = integration.api_keys.find(findBy('type', 'admin')); + should.equal(adminApiKey.integration_id, integration.id); + + done(); + }); + }); + + it('Can successfully create a single integration with a webhook', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Integratatron4000', + webhooks: [{ + event: 'something', + target_url: 'http://example.com', + }] + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + should.equal(body.integrations.length, 1); + + const [integration] = body.integrations; + should.equal(integration.name, 'Integratatron4000'); + + should.equal(integration.webhooks.length, 1); + + const webhook = integration.webhooks[0]; + should.equal(webhook.integration_id, integration.id); + + done(); + }); + }); + }); + + describe('GET /integrations/:id', function () { + it('Can successfully get a created integration', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Interrogation Integration' + }] + }) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + const [createdIntegration] = body.integrations; + + request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + should.equal(body.integrations.length, 1); + + const [integration] = body.integrations; + + should.equal(integration.id, createdIntegration.id); + should.equal(integration.name, createdIntegration.name); + should.equal(integration.slug, createdIntegration.slug); + should.equal(integration.description, createdIntegration.description); + should.equal(integration.icon_image, createdIntegration.icon_image); + done(); + }); + }); + }); + + it('Will 404 if the integration does not exist', function (done) { + request.get(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`)) + .set('Origin', config.get('url')) + .expect(404) + .end(done); + }); + }); + + describe('GET /integrations/', function () { + it('Can successfully get *all* created integrations with api_keys', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Integrate with this!' + }] + }) + .expect(201) + .end(function (err) { + if (err) { + return done(err); + } + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Winter-(is)-great' + }] + }) + .expect(201) + .end(function (err) { + if (err) { + return done(err); + } + + request.get(localUtils.API.getApiQuery(`integrations/?include=api_keys&limit=all`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + // This is the only page + should.equal(body.meta.pagination.page, 1); + should.equal(body.meta.pagination.pages, 1); + should.equal(body.meta.pagination.next, null); + should.equal(body.meta.pagination.prev, null); + + body.integrations.forEach(integration => { + should.exist(integration.api_keys); + }); + + done(); + }); + }); + }); + }); + }); + + describe('PUT /integrations/:id', function () { + it('Can successfully edit a created integration', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Rubbish Integration Name' + }] + }) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + const [createdIntegration] = body.integrations; + request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Awesome Integration Name', + description: 'Finally got round to writing this...' + }] + }) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + const [updatedIntegration] = body.integrations; + + should.equal(updatedIntegration.id, createdIntegration.id); + should.equal(updatedIntegration.name, 'Awesome Integration Name'); + should.equal(updatedIntegration.description, 'Finally got round to writing this...'); + done(); + }); + }); + }); + }); + + it('Can successfully add and delete a created integrations webhooks', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Webhook-less Integration', + }] + }) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + const [createdIntegration] = body.integrations; + request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + webhooks: [{ + event: 'somestuff', + target_url: 'http://example.com' + }] + }] + }) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/?include=webhooks`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + const [updatedIntegration] = body.integrations; + + should.equal(updatedIntegration.webhooks.length, 1); + + const webhook = updatedIntegration.webhooks[0]; + should.equal(webhook.integration_id, updatedIntegration.id); + + request.put(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + webhooks: [] + }] + }) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/?include=webhooks`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, {body}) { + if (err) { + return done(err); + } + + const [updatedIntegration] = body.integrations; + + should.equal(updatedIntegration.webhooks.length, 0); + done(); + }); + }); + }); + }); + }); + }); + + it('Will 404 if the integration does not exist', function (done) { + request.put(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`)) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'This better not work' + }] + }) + .expect(404) + .end(done); + }); + }); + + describe('DELETE /integrations/:id', function () { + it('Can succesfully delete a created integration', function (done) { + request.post(localUtils.API.getApiQuery('integrations/')) + .set('Origin', config.get('url')) + .send({ + integrations: [{ + name: 'Short Lived Integration' + }] + }) + .expect(201) + .end(function (err, {body}) { + if (err) { + return done(err); + } + const [createdIntegration] = body.integrations; + + request.del(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .expect(204) + .end(function (err) { + if (err) { + return done(err); + } + + request.get(localUtils.API.getApiQuery(`integrations/${createdIntegration.id}/`)) + .set('Origin', config.get('url')) + .expect(404) + .end(done); + }); + }); + }); + + it('Will 404 if the integration does not exist', function (done) { + request.del(localUtils.API.getApiQuery(`integrations/012345678901234567890123/`)) + .set('Origin', config.get('url')) + .expect(404) + .end(done); + }); + + it('Will delete the associated api_keys and webhooks', function () { + /** + * @TODO + * + * We do not have the /apikeys or /webhooks endpoints yet + * This will be manually tested by egg before merging + */ + }); + }); +});