diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js index 3870568079351..12ecb45c3e986 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js @@ -4,6 +4,7 @@ import Promise from 'bluebird'; import sinon from 'sinon'; import noDigestPromise from 'test_utils/no_digest_promises'; import mockUiState from 'fixtures/mock_ui_state'; +import { SavedObjectsClientProvider } from 'ui/saved_objects'; describe('dashboard panel', function () { let $scope; @@ -14,8 +15,13 @@ describe('dashboard panel', function () { function init(mockDocResponse) { ngMock.module('kibana'); - ngMock.inject(($rootScope, $compile, esAdmin) => { - sinon.stub(esAdmin, 'mget').returns(Promise.resolve({ docs: [ mockDocResponse ] })); + ngMock.inject(($rootScope, $compile, Private, esAdmin) => { + Private.swap(SavedObjectsClientProvider, () => { + return { + get: sinon.stub().returns(Promise.resolve(mockDocResponse)) + }; + }); + sinon.stub(esAdmin.indices, 'getFieldMapping').returns(Promise.resolve({ '.kibana': { mappings: { @@ -70,7 +76,7 @@ describe('dashboard panel', function () { }); it('should try to visualize the visualization if found', function () { - init({ found: true, _source: {} }); + init({ id: 'foo1', type: 'visualization', _version: 2, attributes: {} }); return $scope.loadedPanel.then(() => { expect($scope.error).not.to.be.ok(); parentScope.$digest(); diff --git a/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js b/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js index d9df46dbdcd37..b7f3bed8364d3 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js +++ b/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js @@ -10,6 +10,7 @@ export function loadSavedObject(loaders, panel) { if (!loader) { throw new Error(`No loader for object of type ${panel.type}`); } - return loader.get(panel.id) - .then(savedObj => ({ savedObj, editUrl: loader.urlFor(panel.id) })); + return loader.get(panel.id).then(savedObj => { + return { savedObj, editUrl: loader.urlFor(panel.id) }; + }); } diff --git a/src/core_plugins/kibana/public/management/sections/objects/_objects.js b/src/core_plugins/kibana/public/management/sections/objects/_objects.js index 1a4c83f012e3c..975a5b989fc31 100644 --- a/src/core_plugins/kibana/public/management/sections/objects/_objects.js +++ b/src/core_plugins/kibana/public/management/sections/objects/_objects.js @@ -1,10 +1,11 @@ import { saveAs } from '@spalger/filesaver'; -import { extend, find, flattenDeep, partialRight, pick, pluck, sortBy } from 'lodash'; +import { extend, find, flattenDeep, pluck, sortBy } from 'lodash'; import angular from 'angular'; import { savedObjectManagementRegistry } from 'plugins/kibana/management/saved_object_registry'; import objectIndexHTML from 'plugins/kibana/management/sections/objects/_objects.html'; import 'ui/directives/file_upload'; import uiRoutes from 'ui/routes'; +import { SavedObjectsClientProvider } from 'ui/saved_objects'; import { uiModules } from 'ui/modules'; uiRoutes @@ -19,10 +20,12 @@ uiRoutes uiModules.get('apps/management') .directive('kbnManagementObjects', function (kbnIndex, Notifier, Private, kbnUrl, Promise, confirmModal) { + const savedObjectsClient = Private(SavedObjectsClientProvider); + return { restrict: 'E', controllerAs: 'managementObjectsController', - controller: function ($scope, $injector, $q, AppState, esAdmin) { + controller: function ($scope, $injector, $q, AppState) { const notify = new Notifier({ location: 'Saved Objects' }); // TODO: Migrate all scope variables to the controller. @@ -123,7 +126,10 @@ uiModules.get('apps/management') // TODO: Migrate all scope methods to the controller. $scope.bulkExport = function () { - const objs = $scope.selectedItems.map(partialRight(extend, { type: $scope.currentTab.type })); + const objs = $scope.selectedItems.map(item => { + return { type: $scope.currentTab.type, id: item.id }; + }); + retrieveAndExportDocs(objs); }; @@ -138,18 +144,17 @@ uiModules.get('apps/management') function retrieveAndExportDocs(objs) { if (!objs.length) return notify.error('No saved objects to export.'); - esAdmin.mget({ - index: kbnIndex, - body: { docs: objs.map(transformToMget) } - }) - .then(function (response) { - saveToFile(response.docs.map(partialRight(pick, '_id', '_type', '_source'))); - }); - } - // Takes an object and returns the associated data needed for an mget API request - function transformToMget(obj) { - return { _id: obj.id, _type: obj.type }; + savedObjectsClient.bulkGet(objs) + .then(function (response) { + saveToFile(response.savedObjects.map(obj => { + return { + _id: obj.id, + _type: obj.type, + _source: obj.attributes + }; + })); + }); } function saveToFile(results) { @@ -235,18 +240,11 @@ uiModules.get('apps/management') return Promise.map(docTypes.searches, importDocument) .then(() => Promise.map(docTypes.other, importDocument)) - .then(refreshIndex) .then(refreshData) .catch(notify.error); }); }; - function refreshIndex() { - return esAdmin.indices.refresh({ - index: kbnIndex - }); - } - // TODO: Migrate all scope methods to the controller. $scope.changeTab = function (tab) { $scope.currentTab = tab; 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 index 992e54e83ff6e..70c5a86832814 100644 --- a/src/core_plugins/kibana/server/lib/import/__tests__/import_dashboards.js +++ b/src/core_plugins/kibana/server/lib/import/__tests__/import_dashboards.js @@ -52,7 +52,8 @@ describe('importDashboards(req)', () => { { create: { _type: 'visualization', _id: 'panel-01' } }, { visState: '{}' } ], - index: '.kibana' + index: '.kibana', + refresh: 'wait_for' }); }); }); @@ -69,7 +70,8 @@ describe('importDashboards(req)', () => { { index: { _type: 'visualization', _id: 'panel-01' } }, { visState: '{}' } ], - index: '.kibana' + index: '.kibana', + refresh: 'wait_for' }); }); }); @@ -84,7 +86,8 @@ describe('importDashboards(req)', () => { { create: { _type: 'dashboard', _id: 'dashboard-01' } }, { panelJSON: '{}' } ], - index: '.kibana' + index: '.kibana', + refresh: 'wait_for' }); }); }); diff --git a/src/core_plugins/kibana/server/lib/import/import_dashboards.js b/src/core_plugins/kibana/server/lib/import/import_dashboards.js index 52ad5f6cb174e..f57217cb52141 100644 --- a/src/core_plugins/kibana/server/lib/import/import_dashboards.js +++ b/src/core_plugins/kibana/server/lib/import/import_dashboards.js @@ -4,7 +4,7 @@ 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 overwrite = 'force' in req.query && req.query.force !== false; const exclude = flatten([req.query.exclude]); const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); @@ -15,6 +15,6 @@ export async function importDashboards(req) { const docs = payload.objects .filter(item => !exclude.includes(item.type)); - const objects = await savedObjectsClient.bulkCreate(docs, { force }); + const objects = await savedObjectsClient.bulkCreate(docs, { overwrite }); return { objects }; } 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 47a161090b8c7..37391fa8e48b3 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -69,7 +69,7 @@ describe('SavedObjectsClient', () => { }); }); - it('should use ES create action', async () => { + it('should use ES index action', async () => { callAdminCluster.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); await savedObjectsClient.create('index-pattern', { @@ -82,6 +82,35 @@ describe('SavedObjectsClient', () => { const args = callAdminCluster.getCall(0).args; expect(args[0]).to.be('index'); }); + + it('should use create action if ID defined and overwrite=false', async () => { + callAdminCluster.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); + + await savedObjectsClient.create('index-pattern', { + title: 'Logstash' + }, { + id: 'logstash-*', + }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + expect(args[0]).to.be('create'); + }); + + it('allows for id to be provided', async () => { + callAdminCluster.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); + + await savedObjectsClient.create('index-pattern', { + id: 'logstash-*', + title: 'Logstash' + }, { id: 'myId' }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + expect(args[1].id).to.be('myId'); + }); }); describe('#bulkCreate', () => { @@ -104,11 +133,11 @@ describe('SavedObjectsClient', () => { ]); }); - it('should overwrite objects if force is truthy', async () => { + it('should overwrite objects if overwrite 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 }); + ], { overwrite: true }); expect(callAdminCluster.calledOnce).to.be(true); diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 1ecbd1ebf9ec0..32faf98d0f86a 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -12,47 +12,72 @@ export class SavedObjectsClient { this._callAdminCluster = callAdminCluster; } - async create(type, body = {}) { - const response = await this._withKibanaIndex('index', { type, body }); + /** + * Persists an object + * + * @param {string} type + * @param {object} attributes + * @param {object} [options={}] + * @property {string} [options.id] - force id on creation, not recommended + * @property {boolean} [options.overwrite=false] + * @returns {promise} - { id, type, version, attributes } + */ + async create(type, attributes = {}, options = {}) { + const method = options.id && !options.overwrite ? 'create' : 'index'; + const response = await this._withKibanaIndex(method, { + type, + id: options.id, + body: attributes, + refresh: 'wait_for' + }); return { id: response._id, type: response._type, version: response._version, - attributes: body + attributes }; } /** * 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 + * @param {array} objects - [{ type, id, attributes }] + * @param {object} [options={}] + * @property {boolean} [options.overwrite=false] - overrides existing documents + * @returns {promise} - [{ id, type, version, attributes, error: { message } }] */ 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 } }); + const method = get(options, 'overwrite', false) === false && object.id ? 'create' : 'index'; + + acc.push({ [method]: { _type: object.type, _id: object.id } }); acc.push(object.attributes); return acc; }, []); - return await this._withKibanaIndex('bulk', { body }) + return await this._withKibanaIndex('bulk', { body, refresh: 'wait_for' }) .then(resp => get(resp, 'items', []).map((resp, i) => { + const method = Object.keys(resp)[0]; + return { - id: resp[action]._id, - type: resp[action]._type, - version: resp[action]._version, + id: resp[method]._id, + type: resp[method]._type, + version: resp[method]._version, attributes: objects[i].attributes, - error: resp[action].error ? { message: get(resp[action], 'error.reason') } : undefined + error: resp[method].error ? { message: get(resp[method], 'error.reason') } : undefined }; })); } + /** + * Deletes an object + * + * @param {string} type + * @param {string} id + * @returns {promise} + */ async delete(type, id) { const response = await this._withKibanaIndex('delete', { type, @@ -65,14 +90,25 @@ export class SavedObjectsClient { } } + /** + * @param {object} [options={}] + * @property {string} options.type + * @property {string} options.search + * @property {string} options.searchFields - see Elasticsearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } + */ async find(options = {}) { const { + type, search, searchFields, - type, - fields, - perPage = 20, page = 1, + perPage = 20, + fields } = options; const esOptions = { @@ -101,16 +137,37 @@ export class SavedObjectsClient { }; } + /** + * Gets a single object + * + * @param {string} type + * @param {string} id + * @returns {promise} - { id, type, version, attributes } + */ + async get(type, id) { + const response = await this._withKibanaIndex('get', { + type, + id, + }); + + return { + id: response._id, + type: response._type, + version: response._version, + attributes: response._source + }; + } + /** * 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 + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example * * bulkGet([ * { id: 'one', type: 'config' }, - * { id: 'foo', type: 'index-pattern' + * { id: 'foo', type: 'index-pattern' } * ]) */ async bulkGet(objects = []) { @@ -137,25 +194,20 @@ export class SavedObjectsClient { }; } - async get(type, id) { - const response = await this._withKibanaIndex('get', { - type, - id, - }); - - return { - id: response._id, - type: response._type, - version: response._version, - attributes: response._source - }; - } - + /** + * Updates an object + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {integer} options.version - ensures version matches that of persisted object + * @returns {promise} + */ async update(type, id, attributes, options = {}) { const response = await this._withKibanaIndex('update', { type, id, - version: get(options, 'version'), + version: options.version, body: { doc: attributes }, diff --git a/src/server/saved_objects/routes/__tests__/create.js b/src/server/saved_objects/routes/__tests__/create.js index 384c4b32cdfce..9c4e00d8fc1e1 100644 --- a/src/server/saved_objects/routes/__tests__/create.js +++ b/src/server/saved_objects/routes/__tests__/create.js @@ -82,6 +82,30 @@ describe('POST /api/saved_objects/{type}', () => { expect(savedObjectsClient.create.calledOnce).to.be(true); const args = savedObjectsClient.create.getCall(0).args; - expect(args).to.eql(['index-pattern', { title: 'Testing' }]); + const options = { overwrite: false, id: undefined }; + const attributes = { title: 'Testing' }; + + expect(args).to.eql(['index-pattern', attributes, options]); + }); + + it('can specify an id', async () => { + const request = { + method: 'POST', + url: '/api/saved_objects/index-pattern/logstash-*', + payload: { + attributes: { + title: 'Testing' + } + } + }; + + await server.inject(request); + expect(savedObjectsClient.create.calledOnce).to.be(true); + + const args = savedObjectsClient.create.getCall(0).args; + const options = { overwrite: false, id: 'logstash-*' }; + const attributes = { title: 'Testing' }; + + expect(args).to.eql(['index-pattern', attributes, options]); }); }); diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js index 76e6e6d975b78..3b46eb220c6a2 100644 --- a/src/server/saved_objects/routes/create.js +++ b/src/server/saved_objects/routes/create.js @@ -2,13 +2,17 @@ import Joi from 'joi'; export const createCreateRoute = (prereqs) => { return { - path: '/api/saved_objects/{type}', + path: '/api/saved_objects/{type}/{id?}', method: 'POST', config: { pre: [prereqs.getSavedObjectsClient], validate: { + query: Joi.object().keys({ + overwrite: Joi.boolean().default(false) + }), params: Joi.object().keys({ - type: Joi.string().required() + type: Joi.string().required(), + id: Joi.string() }).required(), payload: Joi.object({ attributes: Joi.object().required() @@ -16,10 +20,11 @@ export const createCreateRoute = (prereqs) => { }, handler(request, reply) { const { savedObjectsClient } = request.pre; - const { type } = request.params; - const { attributes } = request.payload; + const { type, id } = request.params; + const { overwrite } = request.query; + const options = { id, overwrite }; - reply(savedObjectsClient.create(type, attributes)); + reply(savedObjectsClient.create(type, request.payload.attributes, options)); } } }; diff --git a/src/ui/public/courier/__tests__/saved_object.js b/src/ui/public/courier/__tests__/saved_object.js index 0dbec7f85ea29..82d0e2a38e37f 100644 --- a/src/ui/public/courier/__tests__/saved_object.js +++ b/src/ui/public/courier/__tests__/saved_object.js @@ -5,7 +5,7 @@ import BluebirdPromise from 'bluebird'; import { SavedObjectProvider } from '../saved_object/saved_object'; import { IndexPatternProvider } from 'ui/index_patterns/_index_pattern'; -import { AdminDocSourceProvider } from '../data_source/admin_doc_source'; +import { SavedObjectsClientProvider } from 'ui/saved_objects'; import { StubIndexPatternsApiClientModule } from '../../index_patterns/__tests__/stub_index_patterns_api_client'; @@ -16,7 +16,7 @@ describe('Saved Object', function () { let IndexPattern; let esAdminStub; let esDataStub; - let DocSource; + let savedObjectsClientStub; let window; /** @@ -44,16 +44,15 @@ describe('Saved Object', function () { * that can be used to stub es calls. * @param indexPatternId * @param additionalOptions - object that will be assigned to the mocked doc response. - * @returns {{_source: {}, _index: *, _type: string, _id: *, found: boolean}} + * @returns {{attributes: {}, type: string, id: *, _version: integer}} */ function getMockedDocResponse(indexPatternId, additionalOptions = {}) { return Object.assign( { - _source: {}, - _index: indexPatternId, - _type: 'dashboard', - _id: indexPatternId, - found: true + type: 'dashboard', + id: indexPatternId, + _version: 2, + attributes: {} }, additionalOptions); } @@ -63,13 +62,14 @@ describe('Saved Object', function () { * @param {Object} mockDocResponse */ function stubESResponse(mockDocResponse) { - // Stub out search for duplicate title: - sinon.stub(esAdminStub, 'search').returns(BluebirdPromise.resolve({ hits: { total: 0 } })); - - sinon.stub(esDataStub, 'mget').returns(BluebirdPromise.resolve({ docs: [mockDocResponse] })); - sinon.stub(esDataStub, 'index').returns(BluebirdPromise.resolve(mockDocResponse)); sinon.stub(esAdminStub, 'mget').returns(BluebirdPromise.resolve({ docs: [mockDocResponse] })); sinon.stub(esAdminStub, 'index').returns(BluebirdPromise.resolve(mockDocResponse)); + + // Stub out search for duplicate title: + sinon.stub(savedObjectsClientStub, 'get').returns(BluebirdPromise.resolve(mockDocResponse)); + + sinon.stub(savedObjectsClientStub, 'find').returns(BluebirdPromise.resolve({ savedObjects: [], total: 0 })); + sinon.stub(savedObjectsClientStub, 'bulkGet').returns(BluebirdPromise.resolve({ savedObjects: [mockDocResponse] })); } /** @@ -102,7 +102,7 @@ describe('Saved Object', function () { IndexPattern = Private(IndexPatternProvider); esAdminStub = esAdmin; esDataStub = es; - DocSource = Private(AdminDocSourceProvider); + savedObjectsClientStub = Private(SavedObjectsClientProvider); window = $window; mockEsService(); @@ -110,9 +110,9 @@ describe('Saved Object', function () { describe('save', function () { describe('with confirmOverwrite', function () { - function stubConfirmOverwrite() { window.confirm = sinon.stub().returns(true); + sinon.stub(esAdminStub, 'create').returns(BluebirdPromise.reject({ status : 409 })); sinon.stub(esDataStub, 'create').returns(BluebirdPromise.reject({ status : 409 })); } @@ -121,6 +121,10 @@ describe('Saved Object', function () { it('requests confirmation and updates on yes response', function () { stubESResponse(getMockedDocResponse('myId')); return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => { + const createStub = sinon.stub(savedObjectsClientStub, 'create'); + createStub.onFirstCall().returns(BluebirdPromise.reject({ statusCode: 409 })); + createStub.onSecondCall().returns(BluebirdPromise.resolve({ id: 'myId' })); + stubConfirmOverwrite(); savedObject.lastSavedTitle = 'original title'; @@ -140,8 +144,8 @@ describe('Saved Object', function () { stubESResponse(getMockedDocResponse('HI')); return createInitializedSavedObject({ type: 'dashboard', id: 'HI' }).then(savedObject => { window.confirm = sinon.stub().returns(false); - sinon.stub(esAdminStub, 'create').returns(BluebirdPromise.reject({ status : 409 })); - sinon.stub(esDataStub, 'create').returns(BluebirdPromise.reject({ status : 409 })); + + sinon.stub(savedObjectsClientStub, 'create').returns(BluebirdPromise.reject({ statusCode: 409 })); savedObject.lastSavedTitle = 'original title'; savedObject.title = 'new title'; @@ -155,17 +159,14 @@ describe('Saved Object', function () { }); }); - it('handles doIndex failures', function () { + it('handles create failures', function () { stubESResponse(getMockedDocResponse('myId')); return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => { stubConfirmOverwrite(); - esAdminStub.index.restore(); - esDataStub.index.restore(); - sinon.stub(esAdminStub, 'index').returns(BluebirdPromise.reject()); - sinon.stub(esDataStub, 'index').returns(BluebirdPromise.reject()); + sinon.stub(savedObjectsClientStub, 'create').returns(BluebirdPromise.reject({ statusCode: 409 })); - return savedObject.save({ confirmOverwrite : true }) + return savedObject.save({ confirmOverwrite: true }) .then(() => { expect(true).to.be(false); // Force failure, the save should not succeed. }) @@ -179,46 +180,41 @@ describe('Saved Object', function () { it('when false does not request overwrite', function () { const mockDocResponse = getMockedDocResponse('myId'); stubESResponse(mockDocResponse); - return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doCreate', function () { - return BluebirdPromise.reject({ 'origError' : { 'status' : 409 } }); - }); + return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => { stubConfirmOverwrite(); - return savedObject.save({ confirmOverwrite : false }) - .then(() => { - expect(window.confirm.called).to.be(false); - }); + + sinon.stub(savedObjectsClientStub, 'create').returns(BluebirdPromise.resolve({ id: 'myId' })); + + return savedObject.save({ confirmOverwrite : false }).then(() => { + expect(window.confirm.called).to.be(false); + }); }); }); }); - describe(' with copyOnSave', function () { + describe('with copyOnSave', function () { it('as true creates a copy on save success', function () { const mockDocResponse = getMockedDocResponse('myId'); stubESResponse(mockDocResponse); - let newUniqueId; return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doIndex', function () { - newUniqueId = savedObject.id; - expect(newUniqueId).to.not.be('myId'); - mockDocResponse._id = newUniqueId; - return BluebirdPromise.resolve(newUniqueId); + sinon.stub(savedObjectsClientStub, 'create', function () { + return BluebirdPromise.resolve({ type: 'dashboard', id: 'newUniqueId' }); }); + savedObject.copyOnSave = true; - return savedObject.save() - .then((id) => { - expect(id).to.be(newUniqueId); - }); + return savedObject.save().then((id) => { + expect(id).to.be('newUniqueId'); + }); }); }); it('as true does not create a copy when save fails', function () { - const mockDocResponse = getMockedDocResponse('myId'); - stubESResponse(mockDocResponse); const originalId = 'id1'; + const mockDocResponse = getMockedDocResponse(originalId); + stubESResponse(mockDocResponse); return createInitializedSavedObject({ type: 'dashboard', id: originalId }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doIndex', function () { + sinon.stub(savedObjectsClientStub, 'create', function () { return BluebirdPromise.reject('simulated error'); }); savedObject.copyOnSave = true; @@ -231,19 +227,19 @@ describe('Saved Object', function () { }); it('as false does not create a copy', function () { - const mockDocResponse = getMockedDocResponse('myId'); - stubESResponse(mockDocResponse); const id = 'myId'; + const mockDocResponse = getMockedDocResponse(id); + stubESResponse(mockDocResponse); + return createInitializedSavedObject({ type: 'dashboard', id: id }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doIndex', function () { + sinon.stub(savedObjectsClientStub, 'create', function () { expect(savedObject.id).to.be(id); return BluebirdPromise.resolve(id); }); savedObject.copyOnSave = false; - return savedObject.save() - .then((id) => { - expect(id).to.be(id); - }); + return savedObject.save().then((id) => { + expect(id).to.be(id); + }); }); }); }); @@ -251,11 +247,14 @@ describe('Saved Object', function () { it('returns id from server on success', function () { return createInitializedSavedObject({ type: 'dashboard' }).then(savedObject => { const mockDocResponse = getMockedDocResponse('myId'); + sinon.stub(savedObjectsClientStub, 'create', function () { + return BluebirdPromise.resolve({ type: 'dashboard', id: 'myId', _version: 2 }); + }); + stubESResponse(mockDocResponse); - return savedObject.save() - .then((id) => { - expect(id).to.be('myId'); - }); + return savedObject.save().then(id => { + expect(id).to.be('myId'); + }); }); }); @@ -263,31 +262,32 @@ describe('Saved Object', function () { it('on success', function () { const id = 'id'; stubESResponse(getMockedDocResponse(id)); + return createInitializedSavedObject({ type: 'dashboard', id: id }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doIndex', () => { + sinon.stub(savedObjectsClientStub, 'create', () => { expect(savedObject.isSaving).to.be(true); - return BluebirdPromise.resolve(id); + return BluebirdPromise.resolve({ + type: 'dashboard', id, version: 2 + }); }); expect(savedObject.isSaving).to.be(false); - return savedObject.save() - .then(() => { - expect(savedObject.isSaving).to.be(false); - }); + return savedObject.save().then(() => { + expect(savedObject.isSaving).to.be(false); + }); }); }); it('on failure', function () { stubESResponse(getMockedDocResponse('id')); return createInitializedSavedObject({ type: 'dashboard' }).then(savedObject => { - sinon.stub(DocSource.prototype, 'doIndex', () => { + sinon.stub(savedObjectsClientStub, 'create', () => { expect(savedObject.isSaving).to.be(true); return BluebirdPromise.reject(); }); expect(savedObject.isSaving).to.be(false); - return savedObject.save() - .catch(() => { - expect(savedObject.isSaving).to.be(false); - }); + return savedObject.save().catch(() => { + expect(savedObject.isSaving).to.be(false); + }); }); }); }); @@ -296,7 +296,7 @@ describe('Saved Object', function () { describe('applyESResp', function () { it('throws error if not found', function () { return createInitializedSavedObject({ type: 'dashboard' }).then(savedObject => { - const response = { found: false }; + const response = {}; try { savedObject.applyESResp(response); expect(true).to.be(false); @@ -374,10 +374,9 @@ describe('Saved Object', function () { const mockDocResponse = getMockedDocResponse( id, - { _source: { dinosaurs: { tRex: 'is not so bad' }, } }); + { attributes: { dinosaurs: { tRex: 'is not so bad' }, } }); stubESResponse(mockDocResponse); - const savedObject = new SavedObject(config); return savedObject.init() .then(() => { @@ -434,7 +433,12 @@ describe('Saved Object', function () { indexPattern: indexPatternId }; - stubESResponse(getMockedDocResponse(indexPatternId)); + stubESResponse({ + _id: indexPatternId, + _type: 'dashboard', + _source: {}, + found: true + }); const savedObject = new SavedObject(config); expect(!!savedObject.searchSource.get('index')).to.be(false); @@ -553,7 +557,7 @@ describe('Saved Object', function () { const mockDocResponse = getMockedDocResponse( myId, - { _source: { overwriteMe: serverValue } }); + { attributes: { overwriteMe: serverValue } }); stubESResponse(mockDocResponse); diff --git a/src/ui/public/courier/saved_object/get_title_already_exists.js b/src/ui/public/courier/saved_object/get_title_already_exists.js index 667c2a52100c6..17a8d8afd6105 100644 --- a/src/ui/public/courier/saved_object/get_title_already_exists.js +++ b/src/ui/public/courier/saved_object/get_title_already_exists.js @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { find } from 'lodash'; /** * Returns true if the given saved object has a title that already exists, false otherwise. Search is case * insensitive. @@ -7,30 +7,27 @@ import _ from 'lodash'; * @returns {Promise} Returns the title that matches. Because this search is not case * sensitive, it may not exactly match the title of the object. */ -export function getTitleAlreadyExists(savedObject, esAdmin) { - const { index, title, id } = savedObject; - const esType = savedObject.getEsType(); +export function getTitleAlreadyExists(savedObject, savedObjectsClient) { + const { title, id } = savedObject; + const type = savedObject.getEsType(); if (!title) { throw new Error('Title must be supplied'); } - const body = { - query: { - bool: { - must: { match_phrase: { title } }, - must_not: { match: { id } } - } - } - }; - // Elastic search will return the most relevant results first, which means exact matches should come // first, and so we shouldn't need to request everything. Using 10 just to be on the safe side. - const size = 10; - return esAdmin.search({ index, type: esType, body, size }) - .then((response) => { - const match = _.find(response.hits.hits, function currentVersion(hit) { - return hit._source.title.toLowerCase() === title.toLowerCase(); - }); - return match ? match._source.title : undefined; + const perPage = 10; + return savedObjectsClient.find({ + type, + perPage, + search: title, + searchFields: 'title', + fields: ['title'] + }).then(response => { + const match = find(response.savedObjects, (obj) => { + return obj.id !== id && obj.get('title').toLowerCase() === title.toLowerCase(); }); + + return match ? match.get('title') : undefined; + }); } diff --git a/src/ui/public/courier/saved_object/saved_object.js b/src/ui/public/courier/saved_object/saved_object.js index a68f1f0d10dcb..c46074bce16be 100644 --- a/src/ui/public/courier/saved_object/saved_object.js +++ b/src/ui/public/courier/saved_object/saved_object.js @@ -13,18 +13,18 @@ import angular from 'angular'; import _ from 'lodash'; import { SavedObjectNotFound } from 'ui/errors'; -import uuid from 'uuid'; import MappingSetupProvider from 'ui/utils/mapping_setup'; -import { AdminDocSourceProvider } from '../data_source/admin_doc_source'; import { SearchSourceProvider } from '../data_source/search_source'; import { getTitleAlreadyExists } from './get_title_already_exists'; +import { SavedObjectsClientProvider } from 'ui/saved_objects'; /** * An error message to be used when the user rejects a confirm overwrite. * @type {string} */ const OVERWRITE_REJECTED = 'Overwrite confirmation was rejected'; + /** * An error message to be used when the user rejects a confirm save with duplicate title. * @type {string} @@ -41,8 +41,7 @@ function isErrorNonFatal(error) { } export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifier, confirmModalPromise, indexPatterns) { - - const DocSource = Private(AdminDocSourceProvider); + const savedObjectsClient = Private(SavedObjectsClientProvider); const SearchSource = Private(SearchSourceProvider); const mappingSetup = Private(MappingSetupProvider); @@ -52,8 +51,6 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie /************ * Initialize config vars ************/ - // the doc which is used to store this object - const docSource = new DocSource(); // type name for this object, used as the ES-type const esType = config.type; @@ -77,11 +74,6 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie this.isSaving = false; this.defaults = config.defaults || {}; - // Create a notifier for sending alerts - const notify = new Notifier({ - location: 'Saved ' + this.getDisplayName() - }); - // mapping definition for the fields that this object will expose const mapping = mappingSetup.expandShorthand(config.mapping); @@ -146,10 +138,9 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie // At this point index will either be an IndexPattern, if cached, or a promise that // will return an IndexPattern, if not cached. - return Promise.resolve(index) - .then((indexPattern) => { - this.searchSource.set('index', indexPattern); - }); + return Promise.resolve(index).then(indexPattern => { + this.searchSource.set('index', indexPattern); + }); }; /** @@ -163,12 +154,6 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie // ensure that the esType is defined if (!esType) throw new Error('You must define a type name to use SavedObject objects.'); - // tell the docSource where to find the doc - docSource - .index(kbnIndex) - .type(esType) - .id(this.id); - // check that the mapping for this esType is defined return mappingSetup.isDefined(esType) .then(function (defined) { @@ -198,7 +183,20 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie } // fetch the object from ES - return docSource.fetch().then(this.applyESResp); + return savedObjectsClient.get(esType, this.id) + .then(resp => { + // temporary compatability for savedObjectsClient + + return { + _id: resp.id, + _type: resp.type, + _source: _.cloneDeep(resp.attributes), + found: resp._version ? true : false + }; + }) + .then(this.applyESResp) + .catch(this.applyEsResp); + }) .then(() => { return customInit.call(this); @@ -239,14 +237,9 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie return Promise.try(() => { parseSearchSource(meta.searchSourceJSON); return hydrateIndexPattern(); - }) - .then(() => { - return Promise.cast(afterESResp.call(this, resp)); - }) - .then(() => { - // Any time obj is updated, re-call applyESResp - docSource.onUpdate().then(this.applyESResp, notify.fatal); - }); + }).then(() => { + return Promise.cast(afterESResp.call(this, resp)); + }); }; /** @@ -282,14 +275,6 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie return this._source && this._source.title !== this.title; }; - /** - * Queries es to refresh the index. - * @returns {Promise} - */ - function refreshIndex() { - return esAdmin.indices.refresh({ index: kbnIndex }); - } - /** * Attempts to create the current object using the serialized source. If an object already * exists, a warning message requests an overwrite confirmation. @@ -299,17 +284,17 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with * a confirmRejected = true parameter so that case can be handled differently than * a create or index error. - * @resolved {String} - The id of the doc + * @resolved {SavedObject} */ const createSource = (source) => { - return docSource.doCreate(source) - .catch((err) => { + return savedObjectsClient.create(esType, source, { id: this.id }) + .catch(err => { // record exists, confirm overwriting - if (_.get(err, 'origError.status') === 409) { + if (_.get(err, 'statusCode') === 409) { const confirmMessage = `Are you sure you want to overwrite ${this.title}?`; return confirmModalPromise(confirmMessage, { confirmButtonText: `Overwrite ${this.getDisplayName()}` }) - .then(() => docSource.doIndex(source)) + .then(() => savedObjectsClient.create(esType, source, { id: this.id, overwrite: true })) .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); } return Promise.reject(err); @@ -327,9 +312,10 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie return Promise.resolve(); } - return getTitleAlreadyExists(this, esAdmin) - .then((duplicateTitle) => { + return getTitleAlreadyExists(this, savedObjectsClient) + .then(duplicateTitle => { if (!duplicateTitle) return true; + const confirmMessage = `A ${this.getDisplayName()} with the title '${duplicateTitle}' already exists. Would you like to save anyway?`; @@ -338,20 +324,16 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie }); }; - /** - * @typedef {Object} SaveOptions - * @property {boolean} confirmOverwrite - If true, attempts to create the source so it - * can confirm an overwrite if a document with the id already exists. - */ - /** * Saves this object. * - * @param {SaveOptions} saveOptions? + * @param {object} [options={}] + * @property {boolean} [options.confirmOverwrite=false] - If true, attempts to create the source so it + * can confirm an overwrite if a document with the id already exists. * @return {Promise} * @resolved {String} - The id of the doc */ - this.save = (saveOptions = {}) => { + this.save = ({ confirmOverwrite } = {}) => { // Save the original id in case the save fails. const originalId = this.id; // Read https://github.com/elastic/kibana/issues/9056 and @@ -364,23 +346,21 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie this.id = null; } - // Create a unique id for this object if it doesn't have one already. - this.id = this.id || uuid.v1(); - // ensure that the docSource has the current id - docSource.id(this.id); - const source = this.serialize(); this.isSaving = true; return warnIfDuplicateTitle() .then(() => { - return saveOptions.confirmOverwrite ? createSource(source) : docSource.doIndex(source); + if (confirmOverwrite) { + return createSource(source); + } else { + return savedObjectsClient.create(esType, source, { id: this.id, overwrite: true }); + } }) - .then((id) => { - this.id = id; + .then((resp) => { + this.id = resp.id; }) - .then(refreshIndex) .then(() => { this.isSaving = false; this.lastSavedTitle = this.title; @@ -397,7 +377,6 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie }; this.destroy = () => { - docSource.cancelQueued(); if (this.searchSource) { this.searchSource.cancelQueued(); } @@ -408,15 +387,7 @@ export function SavedObjectProvider(esAdmin, kbnIndex, Promise, Private, Notifie * @return {promise} */ this.delete = () => { - return esAdmin.delete( - { - index: kbnIndex, - type: esType, - id: this.id - }) - .then(() => { - return refreshIndex(); - }); + return savedObjectsClient.delete(esType, this.id); }; } diff --git a/src/ui/public/courier/saved_object/saved_object_loader.js b/src/ui/public/courier/saved_object/saved_object_loader.js index df054eb2e9adf..310badc4c212d 100644 --- a/src/ui/public/courier/saved_object/saved_object_loader.js +++ b/src/ui/public/courier/saved_object/saved_object_loader.js @@ -82,13 +82,13 @@ export class SavedObjectLoader { } /** - * Updates hit._attributes to contain an id and url field, and returns the updated + * Updates hit.attributes to contain an id and url field, and returns the updated * attributes object. * @param hit - * @returns {hit._attributes} The modified hit._attributes object, with an id and url field. + * @returns {hit.attributes} The modified hit.attributes object, with an id and url field. */ mapSavedObjectApiHits(hit) { - return this.mapHitSource(hit._attributes, hit.id); + return this.mapHitSource(hit.attributes, hit.id); } /** diff --git a/src/ui/public/factories/__tests__/base_object.js b/src/ui/public/factories/__tests__/base_object.js index af695c317f1b9..bf47600b6c5c5 100644 --- a/src/ui/public/factories/__tests__/base_object.js +++ b/src/ui/public/factories/__tests__/base_object.js @@ -12,7 +12,7 @@ describe('Base Object', function () { expect(baseObject).to.have.property('message', 'test'); }); - it('should serialize _attributes to RISON', function () { + it('should serialize attributes to RISON', function () { const baseObject = new BaseObject(); baseObject.message = 'Testing... 1234'; const rison = baseObject.toRISON(); @@ -27,7 +27,7 @@ describe('Base Object', function () { expect(rison).to.equal('(message:\'Testing... 1234\')'); }); - it('should serialize _attributes for JSON', function () { + it('should serialize attributes for JSON', function () { const baseObject = new BaseObject(); baseObject.message = 'Testing... 1234'; baseObject._private = 'foo'; diff --git a/src/ui/public/index_patterns/_index_pattern.js b/src/ui/public/index_patterns/_index_pattern.js index f14b1e67ed800..21bb4eb9c6436 100644 --- a/src/ui/public/index_patterns/_index_pattern.js +++ b/src/ui/public/index_patterns/_index_pattern.js @@ -85,9 +85,7 @@ export function IndexPatternProvider(Private, $http, config, kbnIndex, Promise, if (!fieldMapping._deserialize) { return; } - response._source[name] = fieldMapping._deserialize( - response._source[name], response, name, fieldMapping - ); + response._source[name] = fieldMapping._deserialize(response._source[name]); }); // give index pattern all of the values in _source @@ -142,7 +140,10 @@ export function IndexPatternProvider(Private, $http, config, kbnIndex, Promise, if (isFieldRefreshRequired(indexPattern)) { promise = indexPattern.refreshFields(); } - return promise.then(() => {initFields(indexPattern);}); + + return promise.then(() => { + initFields(indexPattern); + }); } function setId(indexPattern, id) { @@ -224,8 +225,7 @@ export function IndexPatternProvider(Private, $http, config, kbnIndex, Promise, if (!this.id) { return; // no id === no elasticsearch document } - return docSources.get(this) - .fetch() + return docSources.get(this).fetch() .then(response => updateFromElasticSearch(this, response)); }) .then(() => this); diff --git a/src/ui/public/saved_objects/__tests__/saved_object.js b/src/ui/public/saved_objects/__tests__/saved_object.js index f519b3760de27..4d8cae8a41d25 100644 --- a/src/ui/public/saved_objects/__tests__/saved_object.js +++ b/src/ui/public/saved_objects/__tests__/saved_object.js @@ -20,7 +20,7 @@ describe('SavedObject', () => { const client = sinon.stub(); const savedObject = new SavedObject(client, { attributes }); - expect(savedObject._attributes).to.be(attributes); + expect(savedObject.attributes).to.be(attributes); }); it('persists version', () => { diff --git a/src/ui/public/saved_objects/__tests__/saved_objects_client.js b/src/ui/public/saved_objects/__tests__/saved_objects_client.js index 4d72df583b2ed..855dbabfa4669 100644 --- a/src/ui/public/saved_objects/__tests__/saved_objects_client.js +++ b/src/ui/public/saved_objects/__tests__/saved_objects_client.js @@ -9,14 +9,15 @@ describe('SavedObjectsClient', () => { const doc = { id: 'AVwSwFxtcMV38qjDZoQg', type: 'config', - attributes: { title: 'Example title' } + attributes: { title: 'Example title' }, + version: 2 }; let savedObjectsClient; let $http; beforeEach(() => { - $http = sandbox.stub().returns(Promise.resolve({})); + $http = sandbox.stub(); savedObjectsClient = new SavedObjectsClient($http, basePath); }); @@ -98,10 +99,10 @@ describe('SavedObjectsClient', () => { describe('#get', () => { beforeEach(() => { $http.withArgs({ - method: 'GET', - url: `${basePath}/api/saved_objects/index-pattern/logstash-*`, - data: undefined - }).returns(Promise.resolve({ data: doc })); + method: 'POST', + url: `${basePath}/api/saved_objects/bulk_get`, + data: sinon.match.any + }).returns(Promise.resolve({ data: { saved_objects: [doc] } })); }); it('returns a promise', () => { @@ -127,15 +128,15 @@ describe('SavedObjectsClient', () => { }); it('resolves with instantiated SavedObject', async () => { - const response = await savedObjectsClient.get('index-pattern', 'logstash-*'); + const response = await savedObjectsClient.get(doc.type, doc.id); expect(response).to.be.a(SavedObject); expect(response.type).to.eql('config'); expect(response.get('title')).to.eql('Example title'); expect(response._client).to.be.a(SavedObjectsClient); }); - it('makes HTTP call', () => { - savedObjectsClient.get('index-pattern', 'logstash-*'); + it('makes HTTP call', async () => { + await savedObjectsClient.get(doc.type, doc.id); sinon.assert.calledOnce($http); }); }); @@ -144,7 +145,8 @@ describe('SavedObjectsClient', () => { beforeEach(() => { $http.withArgs({ method: 'DELETE', - url: `${basePath}/api/saved_objects/index-pattern/logstash-*` + url: `${basePath}/api/saved_objects/index-pattern/logstash-*`, + data: undefined }).returns(Promise.resolve({ data: 'api-response' })); }); @@ -254,17 +256,23 @@ describe('SavedObjectsClient', () => { } }); - it('requires body', async () => { - try { - await savedObjectsClient.create('index-pattern'); - expect().throw('should have error'); - } catch (e) { - expect(e.message).to.be(requireMessage); - } + it('allows for id to be provided', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + const url = `${basePath}/api/saved_objects/index-pattern/myId`; + $http.withArgs({ + method: 'POST', + url, + data: sinon.match.any + }).returns(Promise.resolve({ data: 'api-response' })); + + savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); + + sinon.assert.calledOnce($http); + expect($http.getCall(0).args[0].url).to.eql(url); }); it('makes HTTP call', () => { - const attributes = { foo: 'Foo', bar: 'Bar', id: 'logstash-*' }; + const attributes = { foo: 'Foo', bar: 'Bar' }; savedObjectsClient.create('index-pattern', attributes); sinon.assert.calledOnce($http); diff --git a/src/ui/public/saved_objects/saved_object.js b/src/ui/public/saved_objects/saved_object.js index e9763c09affaf..c397140a794f9 100644 --- a/src/ui/public/saved_objects/saved_object.js +++ b/src/ui/public/saved_objects/saved_object.js @@ -5,27 +5,27 @@ export class SavedObject { this._client = client; this.id = id; this.type = type; - this._attributes = attributes || {}; + this.attributes = attributes || {}; this._version = version; } get(key) { - return _.get(this._attributes, key); + return _.get(this.attributes, key); } set(key, value) { - return _.set(this._attributes, key, value); + return _.set(this.attributes, key, value); } has(key) { - return _.has(this._attributes, key); + return _.has(this.attributes, key); } save() { if (this.id) { - return this._client.update(this.type, this.id, this._attributes); + return this._client.update(this.type, this.id, this.attributes); } else { - return this._client.create(this.type, this._attributes); + return this._client.create(this.type, this.attributes); } } diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js index e216818910b05..71180bc3175d4 100644 --- a/src/ui/public/saved_objects/saved_objects_client.js +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -1,28 +1,102 @@ +import _ from 'lodash'; +import chrome from 'ui/chrome'; + import { resolve as resolveUrl, format as formatUrl } from 'url'; -import { pick, get } from 'lodash'; import { keysToSnakeCaseShallow, keysToCamelCaseShallow } from '../../../utils/case_conversion'; - import { SavedObject } from './saved_object'; -import chrome from 'ui/chrome'; const join = (...uriComponents) => ( uriComponents.filter(Boolean).map(encodeURIComponent).join('/') ); +/** + * Interval that requests are batched for + * @type {integer} + */ +const BATCH_INTERVAL = 100; + export class SavedObjectsClient { constructor($http, basePath = chrome.getBasePath(), PromiseCtor = Promise) { this._$http = $http; this._apiBaseUrl = `${basePath}/api/saved_objects/`; this._PromiseCtor = PromiseCtor; + this.batchQueue = []; + } + + /** + * Persists an object + * + * @param {string} type + * @param {object} [attributes={}] + * @param {object} [options={}] + * @property {string} [options.id] - force id on creation, not recommended + * @property {boolean} [options.overwrite=false] + * @returns {promise} - SavedObject({ id, type, version, attributes }) + */ + create(type, attributes = {}, options = {}) { + if (!type || !attributes) { + return this._PromiseCtor.reject(new Error('requires type and attributes')); + } + + const url = this._getUrl([type, options.id], _.pick(options, ['overwrite'])); + + return this._request('POST', url, { attributes }).then(resp => { + return this.createSavedObject(resp); + }); + } + + /** + * Deletes an object + * + * @param {string} type + * @param {string} id + * @returns {promise} + */ + delete(type, id) { + if (!type || !id) { + return this._PromiseCtor.reject(new Error('requires type and id')); + } + + return this._request('DELETE', this._getUrl([type, id])); } + /** + * Search for objects + * + * @param {object} [options={}] + * @property {string} options.type + * @property {string} options.search + * @property {string} options.searchFields - see Elasticsearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ]} + */ + find(options = {}) { + const url = this._getUrl([options.type], keysToSnakeCaseShallow(options)); + + return this._request('GET', url).then(resp => { + resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); + return keysToCamelCaseShallow(resp); + }); + } + + /** + * Fetches a single object + * + * @param {string} type + * @param {string} id + * @returns {promise} - SavedObject({ id, type, version, attributes }) + */ get(type, id) { if (!type || !id) { return this._PromiseCtor.reject(new Error('requires type and id')); } - return this._request('GET', this._getUrl([type, id])).then(resp => { - return this.createSavedObject(resp); + return new this._PromiseCtor((resolve, reject) => { + this.batchQueue.push({ type, id, resolve, reject }); + this.processBatchQueue(); }); } @@ -30,7 +104,7 @@ 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 + * @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ] } * @example * * bulkGet([ @@ -40,21 +114,23 @@ export class SavedObjectsClient { */ bulkGet(objects = []) { const url = this._getUrl(['bulk_get']); + const filteredObjects = objects.map(obj => _.pick(obj, ['id', 'type'])); - return this._request('POST', url, objects).then(resp => { + return this._request('POST', url, filteredObjects).then(resp => { resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); return keysToCamelCaseShallow(resp); }); } - delete(type, id) { - if (!type || !id) { - return this._PromiseCtor.reject(new Error('requires type and id')); - } - - return this._request('DELETE', this._getUrl([type, id])); - } - + /** + * Updates an object + * + * @param {string} type + * @param {string} id + * @param {object} options + * @param {integer} options.version - ensures version matches that of persisted object + * @returns {promise} + */ update(type, id, attributes, { version } = {}) { if (!type || !id || !attributes) { return this._PromiseCtor.reject(new Error('requires type, id and attributes')); @@ -65,41 +141,33 @@ export class SavedObjectsClient { version }; - return this._request('PUT', this._getUrl([type, id]), body); + return this._request('PUT', this._getUrl([type, id]), body).then(resp => { + return this.createSavedObject(resp); + }); } /** - * @param {string} type - * @param {object} attributes - * @returns {promise} - */ - create(type, attributes) { - if (!type || !attributes) { - return this._PromiseCtor.reject(new Error('requires type and attributes')); - } - - const url = this._getUrl([type]); + * Throttled processing of get requests into bulk requests at 100ms interval + */ + processBatchQueue = _.throttle(() => { + const queue = _.cloneDeep(this.batchQueue); + this.batchQueue = []; - return this._request('POST', url, { attributes }); - } + this.bulkGet(queue).then(({ savedObjects }) => { + queue.forEach((queueItem) => { + const foundObject = savedObjects.find(savedObject => { + return savedObject.id === queueItem.id & savedObject.type === queueItem.type; + }); - /** - * @param {object} options - * @param {string} options.type - * @param {string} options.search - * @param {integer} options.page - * @param {integer} options.perPage - * @param {array} option.fields - * @returns {promise} - */ - find(options = {}) { - const url = this._getUrl([options.type], keysToSnakeCaseShallow(options)); + if (!foundObject) { + return queueItem.resolve(this.createSavedObject(_.pick(queueItem, ['id', 'type']))); + } - return this._request('GET', url).then(resp => { - resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); - return keysToCamelCaseShallow(resp); + queueItem.resolve(foundObject); + }); }); - } + + }, BATCH_INTERVAL, { leading: false }); createSavedObject(options) { return new SavedObject(this, options); @@ -112,7 +180,7 @@ export class SavedObjectsClient { return resolveUrl(this._apiBaseUrl, formatUrl({ pathname: join(...path), - query: pick(query, value => value != null) + query: _.pick(query, value => value != null) })); } @@ -124,12 +192,12 @@ export class SavedObjectsClient { } return this._$http(options) - .then(resp => get(resp, 'data')) + .then(resp => _.get(resp, 'data')) .catch(resp => { - const respBody = resp.data || {}; + const respBody = _.get(resp, 'data', {}); const err = new Error(respBody.message || respBody.error || `${resp.status} Response`); - err.status = resp.status; + err.statusCode = respBody.statusCode || resp.status; err.body = respBody; throw err; diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 278a10724aa0f..7038f97d65c25 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -174,7 +174,7 @@ export function StateProvider(Private, $rootScope, $location, config, kbnUrl) { */ State.prototype.reset = function () { kbnUrl.removeParam(this.getQueryParamName()); - // apply diff to _attributes from defaults, this is side effecting so + // apply diff to attributes from defaults, this is side effecting so // it will change the state in place. const diffResults = applyDiff(this, this._defaults); if (diffResults.keys.length) {