diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 9a529ce07f78b..3000108df6ac8 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -6,6 +6,8 @@ import { mkdirp as mkdirpNode } from 'mkdirp'; import manageUuid from './server/lib/manage_uuid'; import search from './server/routes/api/search'; import settings from './server/routes/api/settings'; +import { importApi } from './server/routes/api/import'; +import { exportApi } from './server/routes/api/export'; import scripts from './server/routes/api/scripts'; import { registerSuggestionsApi } from './server/routes/api/suggestions'; import * as systemApi from './server/lib/system_api'; @@ -126,6 +128,8 @@ module.exports = function (kibana) { search(server); settings(server); scripts(server); + importApi(server); + exportApi(server); registerSuggestionsApi(server); server.expose('systemApi', systemApi); diff --git a/src/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js b/src/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js new file mode 100644 index 0000000000000..cbb27469cf736 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js @@ -0,0 +1,68 @@ +import sinon from 'sinon'; +import * as deps from '../collect_panels'; +import { collectDashboards } from '../collect_dashboards'; +import { expect } from 'chai'; + +describe('collectDashboards(req, ids)', () => { + + let collectPanelsStub; + const savedObjectsClient = { bulkGet: sinon.mock() }; + + const ids = ['dashboard-01', 'dashboard-02']; + + beforeEach(() => { + collectPanelsStub = sinon.stub(deps, 'collectPanels'); + collectPanelsStub.onFirstCall().returns(Promise.resolve([ + { id: 'dashboard-01' }, + { id: 'panel-01' }, + { id: 'index-*' } + ])); + collectPanelsStub.onSecondCall().returns(Promise.resolve([ + { id: 'dashboard-02' }, + { id: 'panel-01' }, + { id: 'index-*' } + ])); + + savedObjectsClient.bulkGet.returns(Promise.resolve([ + { id: 'dashboard-01' }, { id: 'dashboard-02' } + ])); + }); + + afterEach(() => { + collectPanelsStub.restore(); + savedObjectsClient.bulkGet.reset(); + }); + + it('should request all dashboards', async () => { + await collectDashboards(savedObjectsClient, ids); + + expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); + + const args = savedObjectsClient.bulkGet.getCall(0).args; + expect(args[0]).to.eql([{ + id: 'dashboard-01', + type: 'dashboard' + }, { + id: 'dashboard-02', + type: 'dashboard' + }]); + }); + + it('should call collectPanels with dashboard docs', async () => { + await collectDashboards(savedObjectsClient, ids); + + expect(collectPanelsStub.calledTwice).to.equal(true); + expect(collectPanelsStub.args[0][1]).to.eql({ id: 'dashboard-01' }); + expect(collectPanelsStub.args[1][1]).to.eql({ id: 'dashboard-02' }); + }); + + it('should return an unique list of objects', async () => { + const results = await collectDashboards(savedObjectsClient, ids); + expect(results).to.eql([ + { id: 'dashboard-01' }, + { id: 'panel-01' }, + { id: 'index-*' }, + { id: 'dashboard-02' }, + ]); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js b/src/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js new file mode 100644 index 0000000000000..2458ef9420b33 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js @@ -0,0 +1,85 @@ +import sinon from 'sinon'; +import { collectIndexPatterns } from '../collect_index_patterns'; +import { expect } from 'chai'; + +describe('collectIndexPatterns(req, panels)', () => { + const panels = [ + { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'index-*' }) + } + } + }, { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'logstash-*' }) + } + } + }, { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'logstash-*' }) + } + } + }, { + attributes: { + savedSearchId: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'bad-*' }) + } + } + } + ]; + + const savedObjectsClient = { bulkGet: sinon.mock() }; + + beforeEach(() => { + savedObjectsClient.bulkGet.returns(Promise.resolve([ + { id: 'index-*' }, { id: 'logstash-*' } + ])); + }); + + afterEach(() => { + savedObjectsClient.bulkGet.reset(); + }); + + it('should request all index patterns', async () => { + await collectIndexPatterns(savedObjectsClient, panels); + + expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); + expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{ + id: 'index-*', + type: 'index-pattern' + }, { + id: 'logstash-*', + type: 'index-pattern' + }]); + }); + + it('should return the index pattern docs', async () => { + const results = await collectIndexPatterns(savedObjectsClient, panels); + + expect(results).to.eql([ + { id: 'index-*' }, + { id: 'logstash-*' } + ]); + }); + + it('should return an empty array if nothing is requested', async () => { + const input = [ + { + attributes: { + savedSearchId: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'bad-*' }) + } + } + } + ]; + + const results = await collectIndexPatterns(savedObjectsClient, input); + expect(results).to.eql([]); + expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js b/src/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js new file mode 100644 index 0000000000000..67880fe6089dc --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js @@ -0,0 +1,84 @@ +import sinon from 'sinon'; +import * as collectIndexPatternsDep from '../collect_index_patterns'; +import * as collectSearchSourcesDep from '../collect_search_sources'; +import { collectPanels } from '../collect_panels'; +import { expect } from 'chai'; + +describe('collectPanels(req, dashboard)', () => { + let collectSearchSourcesStub; + let collectIndexPatternsStub; + let dashboard; + + const savedObjectsClient = { bulkGet: sinon.mock() }; + + beforeEach(() => { + dashboard = { + attributes: { + panelsJSON: JSON.stringify([ + { id: 'panel-01', type: 'search' }, + { id: 'panel-02', type: 'visualization' } + ]) + } + }; + + savedObjectsClient.bulkGet.returns(Promise.resolve([ + { id: 'panel-01' }, { id: 'panel-02' } + ])); + + collectIndexPatternsStub = sinon.stub(collectIndexPatternsDep, 'collectIndexPatterns'); + collectIndexPatternsStub.returns([{ id: 'logstash-*' }]); + collectSearchSourcesStub = sinon.stub(collectSearchSourcesDep, 'collectSearchSources'); + collectSearchSourcesStub.returns([ { id: 'search-01' }]); + }); + + afterEach(() => { + collectSearchSourcesStub.restore(); + collectIndexPatternsStub.restore(); + savedObjectsClient.bulkGet.reset(); + }); + + it('should request each panel in the panelJSON', async () => { + await collectPanels(savedObjectsClient, dashboard); + + expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); + expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{ + id: 'panel-01', + type: 'search' + }, { + id: 'panel-02', + type: 'visualization' + }]); + }); + + it('should call collectSearchSources()', async () => { + await collectPanels(savedObjectsClient, dashboard); + expect(collectSearchSourcesStub.calledOnce).to.equal(true); + expect(collectSearchSourcesStub.args[0][1]).to.eql([ + { id: 'panel-01' }, + { id: 'panel-02' } + ]); + }); + + it('should call collectIndexPatterns()', async () => { + await collectPanels(savedObjectsClient, dashboard); + + expect(collectIndexPatternsStub.calledOnce).to.equal(true); + expect(collectIndexPatternsStub.args[0][1]).to.eql([ + { id: 'panel-01' }, + { id: 'panel-02' } + ]); + }); + + it('should return panels, index patterns, search sources, and dashboard', async () => { + const results = await collectPanels(savedObjectsClient, dashboard); + + expect(results).to.eql([ + { id: 'panel-01' }, + { id: 'panel-02' }, + { id: 'logstash-*' }, + { id: 'search-01' }, + dashboard + ]); + }); + +}); diff --git a/src/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js b/src/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js new file mode 100644 index 0000000000000..6f75851042b91 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js @@ -0,0 +1,64 @@ +import sinon from 'sinon'; +import * as deps from '../collect_index_patterns'; +import { collectSearchSources } from '../collect_search_sources'; +import { expect } from 'chai'; +describe('collectSearchSources(req, panels)', () => { + const savedObjectsClient = { bulkGet: sinon.mock() }; + + let panels; + let collectIndexPatternsStub; + + beforeEach(() => { + panels = [ + { attributes: { savedSearchId: 1 } }, + { attributes: { savedSearchId: 2 } } + ]; + + collectIndexPatternsStub = sinon.stub(deps, 'collectIndexPatterns'); + collectIndexPatternsStub.returns(Promise.resolve([{ id: 'logstash-*' }])); + + savedObjectsClient.bulkGet.returns(Promise.resolve([ + { id: 1 }, { id: 2 } + ])); + }); + + afterEach(() => { + collectIndexPatternsStub.restore(); + savedObjectsClient.bulkGet.reset(); + }); + + it('should request all search sources', async () => { + await collectSearchSources(savedObjectsClient, panels); + + expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); + expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([ + { type: 'search', id: 1 }, { type: 'search', id: 2 } + ]); + }); + + it('should return the search source and index patterns', async () => { + const results = await collectSearchSources(savedObjectsClient, panels); + + expect(results).to.eql([ + { id: 1 }, + { id: 2 }, + { id: 'logstash-*' } + ]); + }); + + it('should return an empty array if nothing is requested', async () => { + const input = [ + { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ index: 'bad-*' }) + } + } + } + ]; + + const results = await collectSearchSources(savedObjectsClient, input); + expect(results).to.eql([]); + expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js b/src/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js new file mode 100644 index 0000000000000..b21acdfc79248 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js @@ -0,0 +1,52 @@ +import * as deps from '../collect_dashboards'; +import { exportDashboards } from '../export_dashboards'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +describe('exportDashboards(req)', () => { + + let req; + let collectDashboardsStub; + + beforeEach(() => { + req = { + query: { dashboard: 'dashboard-01' }, + server: { + config: () => ({ get: () => '6.0.0' }), + plugins: { + elasticsearch: { + getCluster: () => ({ callWithRequest: sinon.stub() }) + } + }, + } + }; + + collectDashboardsStub = sinon.stub(deps, 'collectDashboards'); + collectDashboardsStub.returns(Promise.resolve([ + { id: 'dasboard-01' }, + { id: 'logstash-*' }, + { id: 'panel-01' } + ])); + }); + + afterEach(() => { + collectDashboardsStub.restore(); + }); + + it('should return a response object with version', () => { + return exportDashboards(req).then((resp) => { + expect(resp).to.have.property('version', '6.0.0'); + }); + }); + + it('should return a response object with objects', () => { + return exportDashboards(req).then((resp) => { + expect(resp).to.have.property('objects'); + expect(resp.objects).to.eql([ + { id: 'dasboard-01' }, + { id: 'logstash-*' }, + { id: 'panel-01' } + ]); + }); + }); +}); diff --git a/src/core_plugins/kibana/server/lib/export/collect_dashboards.js b/src/core_plugins/kibana/server/lib/export/collect_dashboards.js new file mode 100644 index 0000000000000..a3a8e65fabd1d --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/collect_dashboards.js @@ -0,0 +1,24 @@ +import { collectPanels } from './collect_panels'; + +export async function collectDashboards(savedObjectsClient, ids) { + + if (ids.length === 0) return []; + + const objects = ids.map(id => { + return { + type: 'dashboard', + id: id + }; + }); + + const docs = await savedObjectsClient.bulkGet(objects); + const results = await Promise.all(docs.map(d => collectPanels(savedObjectsClient, d))); + + return results + .reduce((acc, result) => acc.concat(result), []) + .reduce((acc, obj) => { + if (!acc.find(o => o.id === obj.id)) acc.push(obj); + return acc; + }, []); + +} diff --git a/src/core_plugins/kibana/server/lib/export/collect_index_patterns.js b/src/core_plugins/kibana/server/lib/export/collect_index_patterns.js new file mode 100644 index 0000000000000..f134cab1cfa72 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/collect_index_patterns.js @@ -0,0 +1,25 @@ +export async function collectIndexPatterns(savedObjectsClient, panels) { + const docs = panels.reduce((acc, panel) => { + const { kibanaSavedObjectMeta, savedSearchId } = panel.attributes; + + if (kibanaSavedObjectMeta && kibanaSavedObjectMeta.searchSourceJSON && !savedSearchId) { + let searchSource; + try { + searchSource = JSON.parse(kibanaSavedObjectMeta.searchSourceJSON); + } catch (err) { + return acc; + } + + if (searchSource.index && !acc.find(s => s.id === searchSource.index)) { + acc.push({ type: 'index-pattern', id: searchSource.index }); + } + } + return acc; + }, []); + + if (docs.length === 0) return []; + + const response = await savedObjectsClient.bulkGet(docs); + return response; + +} diff --git a/src/core_plugins/kibana/server/lib/export/collect_panels.js b/src/core_plugins/kibana/server/lib/export/collect_panels.js new file mode 100644 index 0000000000000..21dbf12cdb7a1 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/collect_panels.js @@ -0,0 +1,23 @@ +import { get } from 'lodash'; + +import { collectIndexPatterns } from './collect_index_patterns'; +import { collectSearchSources } from './collect_search_sources'; + + +export async function collectPanels(savedObjectsClient, dashboard) { + let panels; + try { + panels = JSON.parse(get(dashboard, 'attributes.panelsJSON', '[]')); + } catch(err) { + panels = []; + } + + if (panels.length === 0) return [].concat([dashboard]); + + const docs = await savedObjectsClient.bulkGet(panels); + const [ indexPatterns, searchSources ] = await Promise.all([ + collectIndexPatterns(savedObjectsClient, docs), + collectSearchSources(savedObjectsClient, docs) + ]); + return docs.concat(indexPatterns).concat(searchSources).concat([dashboard]); +} diff --git a/src/core_plugins/kibana/server/lib/export/collect_search_sources.js b/src/core_plugins/kibana/server/lib/export/collect_search_sources.js new file mode 100644 index 0000000000000..b211f7e565870 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/collect_search_sources.js @@ -0,0 +1,20 @@ +import { collectIndexPatterns } from './collect_index_patterns'; + +export async function collectSearchSources(savedObjectsClient, panels) { + const docs = panels.reduce((acc, panel) => { + const { savedSearchId } = panel.attributes; + if (savedSearchId) { + if (!acc.find(s => s.id === savedSearchId) && !panels.find(p => p.id === savedSearchId)) { + acc.push({ type: 'search', id: savedSearchId }); + } + } + return acc; + }, []); + + if (docs.length === 0) return []; + + const savedSearches = await savedObjectsClient.bulkGet(docs); + const indexPatterns = await collectIndexPatterns(savedObjectsClient, savedSearches); + + return savedSearches.concat(indexPatterns); +} diff --git a/src/core_plugins/kibana/server/lib/export/export_dashboards.js b/src/core_plugins/kibana/server/lib/export/export_dashboards.js new file mode 100644 index 0000000000000..8a266ceb54ab5 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/export/export_dashboards.js @@ -0,0 +1,20 @@ +import _ from 'lodash'; +import { collectDashboards } from './collect_dashboards'; +import { SavedObjectsClient } from '../../../../../server/saved_objects'; + + +export async function exportDashboards(req) { + const ids = _.flatten([req.query.dashboard]); + const config = req.server.config(); + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); + const callAdminCluster = (...args) => callWithRequest(req, ...args); + const savedObjectsClient = new SavedObjectsClient(config.get('kibana.index'), callAdminCluster); + + const objects = await collectDashboards(savedObjectsClient, ids); + return { + version: config.get('pkg.version'), + objects + }; + +} diff --git a/src/core_plugins/kibana/server/lib/import/__tests__/import_dashboards.js b/src/core_plugins/kibana/server/lib/import/__tests__/import_dashboards.js new file mode 100644 index 0000000000000..720f820ef0d78 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/import/__tests__/import_dashboards.js @@ -0,0 +1,99 @@ +import { importDashboards } from '../import_dashboards'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +describe('importDashboards(req)', () => { + + let req; + let requestStub; + beforeEach(() => { + requestStub = sinon.stub().returns(Promise.resolve({ + responses: [] + })); + + req = { + query: {}, + payload: { + version: '6.0.0', + objects: [ + { id: 'dashboard-01', type: 'dashboard', attributes: { panelJSON: '{}' } }, + { id: 'panel-01', type: 'visualization', attributes: { visState: '{}' } } + ] + }, + server: { + config: () => ({ get: (id) => { + switch(id) { + case 'kibana.index': + return '.kibana'; + case 'pkg.version': + return '6.0.0'; + default: + throw new Error(`${id} is not available`); + } + } }), + plugins: { + elasticsearch: { + getCluster: () => ({ callWithRequest: requestStub }) + } + } + } + }; + + }); + + it('should throw an error if the version doesn\'t match', () => { + req.payload.version = '5.5.0'; + return importDashboards(req).catch((err) => { + expect(err).to.have.property('message', 'Version 5.5.0 does not match 6.0.0.'); + }); + }); + + it('should make a bulk request to create each asset', () => { + return importDashboards(req).then(() => { + expect(requestStub.calledOnce).to.equal(true); + expect(requestStub.args[0][1]).to.equal('bulk'); + expect(requestStub.args[0][2]).to.eql({ + body: [ + { create: { _type: 'dashboard', _id: 'dashboard-01' } }, + { panelJSON: '{}' }, + { create: { _type: 'visualization', _id: 'panel-01' } }, + { visState: '{}' } + ], + index: '.kibana' + }); + }); + }); + + it('should make a bulk request index each asset if force is truthy', () => { + req.query = { force: 'true' }; + return importDashboards(req).then(() => { + expect(requestStub.calledOnce).to.equal(true); + expect(requestStub.args[0][1]).to.equal('bulk'); + expect(requestStub.args[0][2]).to.eql({ + body: [ + { index: { _type: 'dashboard', _id: 'dashboard-01' } }, + { panelJSON: '{}' }, + { index: { _type: 'visualization', _id: 'panel-01' } }, + { visState: '{}' } + ], + index: '.kibana' + }); + }); + }); + + it('should exclude types based on exclude argument', () => { + req.query = { exclude: 'visualization' }; + return importDashboards(req).then(() => { + expect(requestStub.calledOnce).to.equal(true); + expect(requestStub.args[0][1]).to.equal('bulk'); + expect(requestStub.args[0][2]).to.eql({ + body: [ + { create: { _type: 'dashboard', _id: 'dashboard-01' } }, + { panelJSON: '{}' } + ], + index: '.kibana' + }); + }); + }); + +}); diff --git a/src/core_plugins/kibana/server/lib/import/import_dashboards.js b/src/core_plugins/kibana/server/lib/import/import_dashboards.js new file mode 100644 index 0000000000000..6fd4bc37782c1 --- /dev/null +++ b/src/core_plugins/kibana/server/lib/import/import_dashboards.js @@ -0,0 +1,24 @@ +import { flatten } from 'lodash'; +import { SavedObjectsClient } from '../../../../../server/saved_objects'; + +export async function importDashboards(req) { + const { payload } = req; + const config = req.server.config(); + const force = 'force' in req.query && req.query.force !== false; + const exclude = flatten([req.query.exclude]); + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); + const callAdminCluster = (...args) => callWithRequest(req, ...args); + const savedObjectsClient = new SavedObjectsClient(config.get('kibana.index'), callAdminCluster); + + + if (payload.version !== config.get('pkg.version')) { + throw new Error(`Version ${payload.version} does not match ${config.get('pkg.version')}.`); + } + + const docs = payload.objects + .filter(item => !exclude.includes(item.type)); + + const objects = await savedObjectsClient.bulkCreate(docs, { force }); + return { objects }; +} diff --git a/src/core_plugins/kibana/server/routes/api/export/index.js b/src/core_plugins/kibana/server/routes/api/export/index.js new file mode 100644 index 0000000000000..c9db81271d4ff --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/export/index.js @@ -0,0 +1,33 @@ +import { exportDashboards } from '../../../lib/export/export_dashboards'; +import Boom from 'boom'; +import Joi from 'joi'; +import moment from 'moment'; +export function exportApi(server) { + server.route({ + path: '/api/kibana/dashboards/export', + config: { + validate: { + query: Joi.object().keys({ + dashboard: Joi.alternatives().try( + Joi.string(), + Joi.array().items(Joi.string()) + ).required() + }) + }, + }, + method: ['GET'], + handler: (req, reply) => { + const currentDate = moment.utc(); + return exportDashboards(req) + .then(resp => { + const json = JSON.stringify(resp, null, ' '); + const filename = `kibana-dashboards.${currentDate.format('YYYY-MM-DD-HH-mm-ss')}.json`; + reply(json) + .header('Content-Disposition', `attachment; filename="${filename}"`) + .header('Content-Type', 'application/json') + .header('Content-Length', json.length); + }) + .catch(err => reply(Boom.wrap(err, 400))); + } + }); +} diff --git a/src/core_plugins/kibana/server/routes/api/import/index.js b/src/core_plugins/kibana/server/routes/api/import/index.js new file mode 100644 index 0000000000000..eb60f33508830 --- /dev/null +++ b/src/core_plugins/kibana/server/routes/api/import/index.js @@ -0,0 +1,28 @@ +import Boom from 'boom'; +import Joi from 'joi'; +import { importDashboards } from '../../../lib/import/import_dashboards'; + +export function importApi(server) { + server.route({ + path: '/api/kibana/dashboards/import', + method: ['POST'], + config: { + validate: { + payload: Joi.object().keys({ + objects: Joi.array(), + version: Joi.string() + }), + query: Joi.object().keys({ + force: Joi.boolean().default(false), + exclude: [Joi.string(), Joi.array().items(Joi.string())] + }) + }, + }, + + handler: (req, reply) => { + return importDashboards(req) + .then((resp) => reply(resp)) + .catch(err => reply(Boom.wrap(err, 400))); + } + }); +} diff --git a/src/server/saved_objects/client/__tests__/saved_objects_client.js b/src/server/saved_objects/client/__tests__/saved_objects_client.js index 2498327e1e401..e6dffde0157b3 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -84,6 +84,128 @@ describe('SavedObjectsClient', () => { }); }); + describe('#bulkCreate', () => { + it('formats Elasticsearch request', async () => { + await savedObjectsClient.bulkCreate([ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ]); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + + expect(args[0]).to.be('bulk'); + expect(args[1].body).to.eql([ + { create: { _type: 'config', _id: 'one' } }, + { title: 'Test One' }, + { create: { _type: 'index-pattern', _id: 'two' } }, + { title: 'Test Two' } + ]); + }); + + it('should overwrite objects if force is truthy', async () => { + await savedObjectsClient.bulkCreate([ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ], { force: true }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + + expect(args[0]).to.be('bulk'); + expect(args[1].body).to.eql([ + { index: { _type: 'config', _id: 'one' } }, + { title: 'Test One' }, + { index: { _type: 'index-pattern', _id: 'two' } }, + { title: 'Test Two' } + ]); + }); + + it('returns document errors', async () => { + callAdminCluster.returns(Promise.resolve({ + errors: false, + items: [{ + create: { + _type: 'foo', + _id: 'one', + error: { + reason: 'type[config] missing' + } + } + }, { + create: { + _type: 'config', + _id: 'one', + _version: 2 + } + }] + })); + + const response = await savedObjectsClient.bulkCreate([ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ]); + + expect(response).to.eql([ + { + id: 'one', + type: 'foo', + version: undefined, + attributes: { title: 'Test One' }, + error: { message: 'type[config] missing' } + }, { + id: 'one', + type: 'config', + version: 2, + attributes: { title: 'Test Two' }, + error: undefined + } + ]); + }); + + it('formats Elasticsearch response', async () => { + callAdminCluster.returns(Promise.resolve({ + errors: false, + items: [{ + create: { + _type: 'config', + _id: 'one', + _version: 2 + } + }, { + create: { + _type: 'config', + _id: 'one', + _version: 2 + } + }] + })); + + const response = await savedObjectsClient.bulkCreate([ + { type: 'config', id: 'one', attributes: { title: 'Test One' } }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + ]); + + expect(response).to.eql([ + { + id: 'one', + type: 'config', + version: 2, + attributes: { title: 'Test One' }, + error: undefined + }, { + id: 'one', + type: 'config', + version: 2, + attributes: { title: 'Test Two' }, + error: undefined + } + ]); + }); + }); + describe('#delete', () => { it('throws notFound when ES is unable to find the document', (done) => { callAdminCluster.returns(Promise.resolve({ found: false })); @@ -192,6 +314,57 @@ describe('SavedObjectsClient', () => { }); }); + describe('#bulkGet', () => { + it('accepts a array of mixed type and ids', async () => { + await savedObjectsClient.bulkGet([ + { id: 'one', type: 'config' }, + { id: 'two', type: 'index-pattern' }, + { id: 'three' } + ]); + + expect(callAdminCluster.calledOnce).to.be(true); + + const options = callAdminCluster.getCall(0).args[1]; + expect(options.body.docs).to.eql([ + { _type: 'config', _id: 'one' }, + { _type: 'index-pattern', _id: 'two' }, + { _type: undefined, _id: 'three' } + ]); + }); + + it('returns early for empty objects argument', async () => { + const response = await savedObjectsClient.bulkGet([]); + + expect(response).to.have.length(0); + expect(callAdminCluster.notCalled).to.be(true); + }); + + it('omits missed objects', async () => { + callAdminCluster.returns(Promise.resolve({ + docs:[{ + _type: 'config', + _id: 'bad', + found: false + }, { + _type: 'config', + _id: 'good', + found: true, + _version: 2, + _source: { title: 'Test' } + }] + })); + + const response = await savedObjectsClient.bulkGet(['good', 'bad', 'config']); + expect(response).to.have.length(1); + expect(response[0]).to.eql({ + id: 'good', + type: 'config', + version: 2, + attributes: { title: 'Test' } + }); + }); + }); + describe('#update', () => { it('returns current ES document version', async () => { const id = 'logstash-*'; diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 2d7f06efcb199..ad6eb9e023009 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -13,19 +13,46 @@ export class SavedObjectsClient { } async create(type, body = {}) { - const response = await this._withKibanaIndex('index', { - type, - body - }); + const response = await this._withKibanaIndex('index', { type, body }); return { - type: response._type, id: response._id, + type: response._type, version: response._version, attributes: body }; } + /** + * Creates multiple documents at once + * + * @param {array} objects + * @param {object} options + * @param {boolean} options.force - overrides existing documents + * @returns {promise} Returns promise containing array of documents + */ + async bulkCreate(objects, options = {}) { + const action = options.force === true ? 'index' : 'create'; + + const body = objects.reduce((acc, object) => { + acc.push({ [action]: { _type: object.type, _id: object.id } }); + acc.push(object.attributes); + + return acc; + }, []); + + return await this._withKibanaIndex('bulk', { body }) + .then(resp => get(resp, 'items', []).map((resp, i) => { + return { + id: resp[action]._id, + type: resp[action]._type, + version: resp[action]._version, + attributes: objects[i].attributes, + error: resp[action].error ? { message: get(resp[action], 'error.reason') } : undefined + }; + })); + } + async delete(type, id) { const response = await this._withKibanaIndex('delete', { type, @@ -74,6 +101,40 @@ export class SavedObjectsClient { }; } + /** + * Returns an array of objects by id + * + * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @returns {promise} Returns promise containing array of documents + * @example + * + * bulkGet([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' + * ]) + */ + async bulkGet(objects = []) { + if (objects.length === 0) { + return []; + } + + const docs = objects.map(doc => { + return { _type: get(doc, 'type'), _id: get(doc, 'id') }; + }); + + const response = await this._withKibanaIndex('mget', { body: { docs } }) + .then(resp => get(resp, 'docs', []).filter(resp => resp.found)); + + return response.map(r => { + return { + id: r._id, + type: r._type, + version: r._version, + attributes: r._source + }; + }); + } + async get(type, id) { const response = await this._withKibanaIndex('get', { type, diff --git a/src/server/saved_objects/index.js b/src/server/saved_objects/index.js index 13f15b7ddfcca..b920baa2c9c1a 100644 --- a/src/server/saved_objects/index.js +++ b/src/server/saved_objects/index.js @@ -1 +1,2 @@ export { savedObjectsMixin } from './saved_objects_mixin'; +export { SavedObjectsClient } from './client'; diff --git a/src/ui/public/vis_maps/__tests__/service_settings.js b/src/ui/public/vis_maps/__tests__/service_settings.js index bca96a6a36b58..a54fb6b1c5ec8 100644 --- a/src/ui/public/vis_maps/__tests__/service_settings.js +++ b/src/ui/public/vis_maps/__tests__/service_settings.js @@ -15,18 +15,19 @@ describe('service_settings (FKA tilemaptest)', function () { const manifestUrl2 = 'https://foobar/v1/manifest'; const manifest = { - 'services': [{ - 'id': 'tiles_v2', - 'name': 'Elastic Tile Service', - 'manifest': tmsManifestUrl, - 'type': 'tms' - }, - { - 'id': 'geo_layers', - 'name': 'Elastic Layer Service', - 'manifest': vectorManifestUrl, - 'type': 'file' - } + 'services': [ + { + 'id': 'tiles_v2', + 'name': 'Elastic Tile Service', + 'manifest': tmsManifestUrl, + 'type': 'tms' + }, + { + 'id': 'geo_layers', + 'name': 'Elastic Layer Service', + 'manifest': vectorManifestUrl, + 'type': 'file' + } ] };