From b250ad9297c47fb83ab39ec78e3fe9e45f2c0c99 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Sun, 14 May 2017 20:36:15 -0700 Subject: [PATCH 01/10] Adds saved object API Signed-off-by: Tyler Smalley --- package.json | 4 +- src/server/kbn_server.js | 8 + .../client/__tests__/saved_objects_client.js | 260 ++++++++++++++ src/server/saved_objects/client/index.js | 1 + .../lib/__tests__/create_filter_path.js | 25 ++ .../client/lib/__tests__/create_find_query.js | 78 +++++ .../client/lib/create_filter_path.js | 9 + .../client/lib/create_find_query.js | 39 +++ .../client/lib/handle_es_error.js | 46 +++ src/server/saved_objects/client/lib/index.js | 3 + .../client/saved_objects_client.js | 102 ++++++ src/server/saved_objects/index.js | 1 + .../saved_objects/routes/__tests__/create.js | 67 ++++ .../saved_objects/routes/__tests__/delete.js | 57 +++ .../saved_objects/routes/__tests__/find.js | 148 ++++++++ .../routes/__tests__/mock_server.js | 18 + .../saved_objects/routes/__tests__/read.js | 63 ++++ .../saved_objects/routes/__tests__/update.js | 62 ++++ src/server/saved_objects/routes/create.js | 30 ++ src/server/saved_objects/routes/delete.js | 21 ++ src/server/saved_objects/routes/find.js | 31 ++ src/server/saved_objects/routes/index.js | 5 + src/server/saved_objects/routes/read.js | 21 ++ src/server/saved_objects/routes/update.js | 24 ++ .../saved_objects/saved_objects_mixin.js | 30 ++ .../__tests__/saved_objects_client.js | 324 ++++++++++++++++++ src/ui/public/saved_objects/index.js | 4 + src/ui/public/saved_objects/saved_object.js | 36 ++ .../saved_objects/saved_objects_client.js | 95 +++++ .../saved_objects_client_provider.js | 7 + 30 files changed, 1617 insertions(+), 2 deletions(-) create mode 100644 src/server/saved_objects/client/__tests__/saved_objects_client.js create mode 100644 src/server/saved_objects/client/index.js create mode 100644 src/server/saved_objects/client/lib/__tests__/create_filter_path.js create mode 100644 src/server/saved_objects/client/lib/__tests__/create_find_query.js create mode 100644 src/server/saved_objects/client/lib/create_filter_path.js create mode 100644 src/server/saved_objects/client/lib/create_find_query.js create mode 100644 src/server/saved_objects/client/lib/handle_es_error.js create mode 100644 src/server/saved_objects/client/lib/index.js create mode 100644 src/server/saved_objects/client/saved_objects_client.js create mode 100644 src/server/saved_objects/index.js create mode 100644 src/server/saved_objects/routes/__tests__/create.js create mode 100644 src/server/saved_objects/routes/__tests__/delete.js create mode 100644 src/server/saved_objects/routes/__tests__/find.js create mode 100644 src/server/saved_objects/routes/__tests__/mock_server.js create mode 100644 src/server/saved_objects/routes/__tests__/read.js create mode 100644 src/server/saved_objects/routes/__tests__/update.js create mode 100644 src/server/saved_objects/routes/create.js create mode 100644 src/server/saved_objects/routes/delete.js create mode 100644 src/server/saved_objects/routes/find.js create mode 100644 src/server/saved_objects/routes/index.js create mode 100644 src/server/saved_objects/routes/read.js create mode 100644 src/server/saved_objects/routes/update.js create mode 100644 src/server/saved_objects/saved_objects_mixin.js create mode 100644 src/ui/public/saved_objects/__tests__/saved_objects_client.js create mode 100644 src/ui/public/saved_objects/index.js create mode 100644 src/ui/public/saved_objects/saved_object.js create mode 100644 src/ui/public/saved_objects/saved_objects_client.js create mode 100644 src/ui/public/saved_objects/saved_objects_client_provider.js diff --git a/package.json b/package.json index c5f63d1662861..13fdf8e6df5cb 100644 --- a/package.json +++ b/package.json @@ -115,8 +115,8 @@ "d3": "3.5.6", "d3-cloud": "1.2.1", "dragula": "3.7.0", - "elasticsearch": "13.0.0-beta2", - "elasticsearch-browser": "13.0.0-beta2", + "elasticsearch": "13.0.1", + "elasticsearch-browser": "13.0.1", "encode-uri-query": "1.0.0", "even-better": "7.0.2", "expiry-js": "0.1.7", diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index 28b16c0b9a15d..6ddd73aced884 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -19,6 +19,7 @@ import uiMixin from '../ui'; import uiSettingsMixin from '../ui/settings'; import optimizeMixin from '../optimize'; import pluginsInitializeMixin from './plugins/initialize'; +import { savedObjectsMixin } from './saved_objects'; const rootDir = fromRoot('.'); @@ -38,8 +39,10 @@ module.exports = class KbnServer { loggingMixin, warningsMixin, statusMixin, + // writes pid file pidMixin, + // find plugins and set this.plugins pluginsScanMixin, @@ -51,15 +54,20 @@ module.exports = class KbnServer { // tell the config we are done loading plugins configCompleteMixin, + // setup this.uiExports and this.bundles uiMixin, + // setup saved object routes + savedObjectsMixin, + // setup server.uiSettings uiSettingsMixin, // ensure that all bundles are built, or that the // lazy bundle server is running optimizeMixin, + // finally, initialize the plugins pluginsInitializeMixin, () => { diff --git a/src/server/saved_objects/client/__tests__/saved_objects_client.js b/src/server/saved_objects/client/__tests__/saved_objects_client.js new file mode 100644 index 0000000000000..4acc8be47fed7 --- /dev/null +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -0,0 +1,260 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { SavedObjectsClient } from '../saved_objects_client'; + +describe('SavedObjectsClient', () => { + let callWithRequest; + let savedObjectsClient; + const docs = { + hits: { + total: 3, + hits: [{ + _index: '.kibana', + _type: 'index-pattern', + _id: 'logstash-*', + _score: 1, + _source: { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true + } + }, { + _index: '.kibana', + _type: 'config', + _id: '6.0.0-alpha1', + _score: 1, + _source: { + buildNum: 8467, + defaultIndex: 'logstash-*' + } + }, { + _index: '.kibana', + _type: 'index-pattern', + _id: 'stocks-*', + _score: 1, + _source: { + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true + } + }] + } + }; + + beforeEach(() => { + callWithRequest = sinon.mock(); + savedObjectsClient = new SavedObjectsClient('.kibana-test', {}, callWithRequest); + }); + + afterEach(() => { + callWithRequest.reset(); + }); + + + describe('#create', () => { + it('formats Elasticsearch response', async () => { + callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*' }); + + const response = await savedObjectsClient.create('index-pattern', { + id: 'logstash-*', + title: 'Logstash' + }); + + expect(response).to.eql({ + type: 'index-pattern', + id: 'logstash-*', + title: 'Logstash' + }); + }); + + it('should use ES create action with specifying an id', async () => { + callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*' }); + + await savedObjectsClient.create('index-pattern', { + id: 'logstash-*', + title: 'Logstash' + }); + + expect(callWithRequest.calledOnce).to.be(true); + + const args = callWithRequest.getCall(0).args; + expect(args[1]).to.be('create'); + }); + + it('should use ES index action with specifying an id', async () => { + callWithRequest.returns({ _type: 'index-pattern', _id: 'abc123' }); + + await savedObjectsClient.create('index-pattern', { title: 'Logstash' }); + expect(callWithRequest.calledOnce).to.be(true); + + const args = callWithRequest.getCall(0).args; + expect(args[1]).to.be('index'); + }); + }); + + describe('#delete', () => { + it('returns based on ES success', async () => { + callWithRequest.returns(Promise.resolve({ deleted: 'testing' })); + const response = await savedObjectsClient.delete('index-pattern', 'logstash-*'); + + expect(response).to.be('testing'); + }); + + it('throws notFound when ES is unable to find the document', (done) => { + callWithRequest.returns(Promise.resolve({ found: false })); + + savedObjectsClient.delete('index-pattern', 'logstash-*').then(() => { + done('failed'); + }).catch(e => { + expect(e.output.statusCode).to.be(404); + done(); + }); + }); + + it('passes the parameters to callWithRequest', async () => { + await savedObjectsClient.delete('index-pattern', 'logstash-*'); + + expect(callWithRequest.calledOnce).to.be(true); + + const args = callWithRequest.getCall(0).args; + expect(args[1]).to.be('delete'); + expect(args[2]).to.eql({ + type: 'index-pattern', + id: 'logstash-*', + refresh: 'wait_for', + index: '.kibana-test' + }); + }); + }); + + describe('#find', () => { + it('formats Elasticsearch response', async () => { + const count = docs.hits.hits.length; + + callWithRequest.returns(Promise.resolve(docs)); + const response = await savedObjectsClient.find(); + + expect(response.total).to.be(count); + expect(response.data).to.have.length(count); + docs.hits.hits.forEach((doc, i) => { + expect(response.data[i]).to.eql(Object.assign( + { id: doc._id, type: doc._type }, + doc._source) + ); + }); + }); + + it('accepts per_page/page', async () => { + await savedObjectsClient.find({ per_page: 10, page: 6 }); + + expect(callWithRequest.calledOnce).to.be(true); + + const options = callWithRequest.getCall(0).args[2]; + expect(options).to.eql({ + index: '.kibana-test', + body: { query: { match_all: {} } }, + from: 50, + size: 10 + }); + }); + + it('accepts type', async () => { + await savedObjectsClient.find({ type: 'index-pattern' }); + + expect(callWithRequest.calledOnce).to.be(true); + + const options = callWithRequest.getCall(0).args[2]; + const expectedQuery = { + bool: { + must: [{ match_all: {} }], + filter: [{ term: { _type: 'index-pattern' } }] + } + }; + + expect(options).to.eql({ + from: 0, + index: '.kibana-test', + size: 20, + body: { query: expectedQuery }, + type: 'index-pattern', + }); + }); + + it('accepts fields as a string', async () => { + await savedObjectsClient.find({ fields: 'title' }); + + expect(callWithRequest.calledOnce).to.be(true); + + const options = callWithRequest.getCall(0).args[2]; + expect(options.filterPath).to.eql([ + 'hits.total', + 'hits.hits._id', + 'hits.hits._type', + 'hits.hits._source.title' + ]); + }); + + it('accepts fields as an array', async () => { + await savedObjectsClient.find({ fields: ['title', 'description'] }); + + expect(callWithRequest.calledOnce).to.be(true); + + const options = callWithRequest.getCall(0).args[2]; + expect(options.filterPath).to.eql([ + 'hits.hits._source.title', + 'hits.hits._source.description', + 'hits.total', + 'hits.hits._id', + 'hits.hits._type' + ]); + }); + }); + + describe('#get', () => { + it('formats Elasticsearch response', async () => { + callWithRequest.returns(Promise.resolve({ + _id: 'logstash-*', + _type: 'index-pattern', + _source: { + title: 'Testing' + } + })); + + const response = await savedObjectsClient.get('index-pattern', 'logstash-*'); + expect(response).to.eql({ + id: 'logstash-*', + type: 'index-pattern', + title: 'Testing' + }); + }); + }); + + describe('#update', () => { + it('returns based on ES success', async () => { + callWithRequest.returns(Promise.resolve({ + _id: 'logstash-*', + _type: 'index-pattern', + result: 'updated' + })); + + const response = await savedObjectsClient.update('index-pattern', 'logstash-*', { title: 'Testing' }); + expect(response).to.be(true); + }); + + it('passes the parameters to callWithRequest', async () => { + await savedObjectsClient.update('index-pattern', 'logstash-*', { title: 'Testing' }); + + expect(callWithRequest.calledOnce).to.be(true); + + const args = callWithRequest.getCall(0).args; + expect(args[1]).to.be('update'); + expect(args[2]).to.eql({ + type: 'index-pattern', + id: 'logstash-*', + body: { title: 'Testing' }, + refresh: true, + index: '.kibana-test' + }); + }); + }); +}); diff --git a/src/server/saved_objects/client/index.js b/src/server/saved_objects/client/index.js new file mode 100644 index 0000000000000..4b4ac9b5dcb17 --- /dev/null +++ b/src/server/saved_objects/client/index.js @@ -0,0 +1 @@ +export { SavedObjectsClient } from './saved_objects_client'; diff --git a/src/server/saved_objects/client/lib/__tests__/create_filter_path.js b/src/server/saved_objects/client/lib/__tests__/create_filter_path.js new file mode 100644 index 0000000000000..391ade7c9e2b8 --- /dev/null +++ b/src/server/saved_objects/client/lib/__tests__/create_filter_path.js @@ -0,0 +1,25 @@ +import expect from 'expect.js'; +import { createFilterPath } from '../create_filter_path'; + +describe('createFilterPath', () => { + it('handles a string', () => { + const fields = createFilterPath('foo'); + expect(fields).to.eql([ + 'hits.total', + 'hits.hits._id', + 'hits.hits._type', + 'hits.hits._source.foo' + ]); + }); + + it('handles an array', () => { + const fields = createFilterPath(['foo', 'bar']); + expect(fields).to.eql([ + 'hits.hits._source.foo', + 'hits.hits._source.bar', + 'hits.total', + 'hits.hits._id', + 'hits.hits._type' + ]); + }); +}); diff --git a/src/server/saved_objects/client/lib/__tests__/create_find_query.js b/src/server/saved_objects/client/lib/__tests__/create_find_query.js new file mode 100644 index 0000000000000..5e6dd84451093 --- /dev/null +++ b/src/server/saved_objects/client/lib/__tests__/create_find_query.js @@ -0,0 +1,78 @@ +import expect from 'expect.js'; +import { createFindQuery } from '../create_find_query'; + +describe('createFindQuery', () => { + it('matches all when there is no type or filter', () => { + const query = createFindQuery(); + expect(query).to.eql({ query: { match_all: {} } }); + }); + + it('adds bool filter for type', () => { + const query = createFindQuery({ type: 'index-pattern' }); + expect(query).to.eql({ + query: { + bool: { + filter: [{ + term: { + _type: 'index-pattern' + } + }], + must: [{ + match_all: {} + }] + } + } + }); + }); + + it('can search across all fields', () => { + const query = createFindQuery({ search: 'foo' }); + expect(query).to.eql({ + query: { + bool: { + filter: [], + must: [{ + simple_query_string: { + query: 'foo', + all_fields: true + } + }] + } + } + }); + }); + + it('can search a single field', () => { + const query = createFindQuery({ search: 'foo', searchFields: 'title' }); + expect(query).to.eql({ + query: { + bool: { + filter: [], + must: [{ + simple_query_string: { + query: 'foo', + fields: ['title'] + } + }] + } + } + }); + }); + + it('can search across multiple fields', () => { + const query = createFindQuery({ search: 'foo', searchFields: ['title', 'description'] }); + expect(query).to.eql({ + query: { + bool: { + filter: [], + must: [{ + simple_query_string: { + query: 'foo', + fields: ['title', 'description'] + } + }] + } + } + }); + }); +}); diff --git a/src/server/saved_objects/client/lib/create_filter_path.js b/src/server/saved_objects/client/lib/create_filter_path.js new file mode 100644 index 0000000000000..39c33b5717460 --- /dev/null +++ b/src/server/saved_objects/client/lib/create_filter_path.js @@ -0,0 +1,9 @@ +export function createFilterPath(fields) { + const baseKeys = ['hits.total', 'hits.hits._id', 'hits.hits._type']; + + if (Array.isArray(fields)) { + return fields.map(f => `hits.hits._source.${f}`).concat(baseKeys); + } else if (fields) { + return baseKeys.concat([`hits.hits._source.${fields}`]); + } +} diff --git a/src/server/saved_objects/client/lib/create_find_query.js b/src/server/saved_objects/client/lib/create_find_query.js new file mode 100644 index 0000000000000..67ee22a0ef36f --- /dev/null +++ b/src/server/saved_objects/client/lib/create_find_query.js @@ -0,0 +1,39 @@ +export function createFindQuery(options = {}) { + const { type, search, searchFields } = options; + + if (!type && !search) { + return { query: { match_all: {} } }; + } + + const bool = { must: [], filter: [] }; + + if (type) { + bool.filter.push({ + term: { + _type: type + } + }); + } + + if (search) { + const simpleQueryString = { + query: search + }; + + if (!searchFields) { + simpleQueryString.all_fields = true; + } else if (Array.isArray(searchFields)) { + simpleQueryString.fields = searchFields; + } else { + simpleQueryString.fields = [searchFields]; + } + + bool.must.push({ simple_query_string: simpleQueryString }); + } else { + bool.must.push({ + match_all: {} + }); + } + + return { query: { bool } }; +} diff --git a/src/server/saved_objects/client/lib/handle_es_error.js b/src/server/saved_objects/client/lib/handle_es_error.js new file mode 100644 index 0000000000000..0bea7eac7922d --- /dev/null +++ b/src/server/saved_objects/client/lib/handle_es_error.js @@ -0,0 +1,46 @@ +import elasticsearch from 'elasticsearch'; +import Boom from 'boom'; + +const { + ConnectionFault, + ServiceUnavailable, + NoConnections, + RequestTimeout, + Conflict, + 403: Forbidden, + NotFound, + BadRequest +} = elasticsearch.errors; + +export function handleEsError(error) { + if (!(error instanceof Error)) { + throw new Error('Expected an instance of Error'); + } + + if ( + error instanceof ConnectionFault || + error instanceof ServiceUnavailable || + error instanceof NoConnections || + error instanceof RequestTimeout + ) { + throw Boom.serverTimeout(error); + } + + if (error instanceof Conflict || error.message.includes('index_template_already_exists')) { + throw Boom.conflict(error); + } + + if (error instanceof Forbidden) { + throw Boom.forbidden(error); + } + + if (error instanceof NotFound) { + throw Boom.notFound(error); + } + + if (error instanceof BadRequest) { + throw Boom.badRequest(error); + } + + throw error; +} diff --git a/src/server/saved_objects/client/lib/index.js b/src/server/saved_objects/client/lib/index.js new file mode 100644 index 0000000000000..f7d3299bc0259 --- /dev/null +++ b/src/server/saved_objects/client/lib/index.js @@ -0,0 +1,3 @@ +export { createFindQuery } from './create_find_query'; +export { createFilterPath } from './create_filter_path'; +export { handleEsError } from './handle_es_error'; diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js new file mode 100644 index 0000000000000..2752967418266 --- /dev/null +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -0,0 +1,102 @@ +import Boom from 'boom'; +import { get, omit, pick } from 'lodash'; + +import { + createFindQuery, + createFilterPath, + handleEsError, +} from './lib'; + +export class SavedObjectsClient { + constructor(kibanaIndex, request, callWithRequest) { + this._kibanaIndex = kibanaIndex; + this._request = request; + this._callWithRequest = callWithRequest; + } + + async create(type, options = {}) { + const body = omit(options, 'id'); + const id = get(options, 'id'); + const method = id ? 'create' : 'index'; + + const response = await this._withKibanaIndex(method, { + type, + id, + body + }); + + return Object.assign({ type: response._type, id: response._id }, body); + } + + async delete(type, id) { + const response = await this._withKibanaIndex('delete', { + type, + id, + refresh: 'wait_for' + }); + + if (get(response, 'found') === false) { + throw Boom.notFound(); + } + + return get(response, 'deleted', false); + } + + async find(options = {}) { + const { search, searchFields, type, fields } = options; + const esOptions = pick(options, ['type']); + const perPage = get(options, 'per_page', 20); + const page = get(options, 'page', 1); + + if (fields) { + esOptions.filterPath = createFilterPath(fields); + } + + esOptions.size = perPage; + esOptions.from = esOptions.size * (page - 1); + esOptions.body = createFindQuery({ search, searchFields, type }); + + const response = await this._withKibanaIndex('search', esOptions); + + return { + data: get(response, 'hits.hits', []).map(r => { + return Object.assign({ id: r._id, type: r._type }, r._source); + }), + total: get(response, 'hits.total', 0), + per_page: perPage, + page + + }; + } + + async get(type, id) { + const response = await this._withKibanaIndex('get', { + type, + id, + }); + + return Object.assign({ id: response._id, type: response._type }, response._source); + } + + async update(type, id, body) { + const response = await this._withKibanaIndex('update', { + type, + id, + body, + refresh: true, + }); + + return get(response, 'result') === 'updated'; + } + + async _withKibanaIndex(method, params) { + try { + return await this._callWithRequest(this._request, method, { + ...params, + index: this._kibanaIndex, + }); + } catch (err) { + throw handleEsError(err); + } + } +} diff --git a/src/server/saved_objects/index.js b/src/server/saved_objects/index.js new file mode 100644 index 0000000000000..13f15b7ddfcca --- /dev/null +++ b/src/server/saved_objects/index.js @@ -0,0 +1 @@ +export { savedObjectsMixin } from './saved_objects_mixin'; diff --git a/src/server/saved_objects/routes/__tests__/create.js b/src/server/saved_objects/routes/__tests__/create.js new file mode 100644 index 0000000000000..40bfd24a4fad8 --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/create.js @@ -0,0 +1,67 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createCreateRoute } from '../create'; +import { MockServer } from './mock_server'; + +describe('POST /api/kibana/saved_objects/{type}/{id?}', () => { + const savedObjectsClient = { create: sinon.stub() }; + let server; + + beforeEach(() => { + server = new MockServer(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(savedObjectsClient); + } + }, + }; + + server.route(createCreateRoute(prereqs)); + }); + + afterEach(() => { + savedObjectsClient.create.reset(); + }); + + it('formats successful response', async () => { + const request = { + method: 'POST', + url: '/api/kibana/saved_objects/index-pattern', + payload: { + title: 'Testing' + } + }; + const clientResponse = { + type: 'index-pattern', + id: 'logstash-*', + title: 'Testing' + }; + + savedObjectsClient.create.returns(Promise.resolve(clientResponse)); + + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + + expect(statusCode).to.be(200); + expect(response).to.eql(clientResponse); + }); + + it('calls upon savedObjectClient.create', async () => { + const request = { + method: 'POST', + url: '/api/kibana/saved_objects/index-pattern/logstash-*', + payload: { + title: 'Testing' + } + }; + + await server.inject(request); + expect(savedObjectsClient.create.calledOnce).to.be(true); + + const args = savedObjectsClient.create.getCall(0).args; + expect(args).to.eql(['index-pattern', { title: 'Testing', id: 'logstash-*' }]); + }); +}); diff --git a/src/server/saved_objects/routes/__tests__/delete.js b/src/server/saved_objects/routes/__tests__/delete.js new file mode 100644 index 0000000000000..64bc301ff926a --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/delete.js @@ -0,0 +1,57 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createDeleteRoute } from '../delete'; +import { MockServer } from './mock_server'; + +describe('DELETE /api/kibana/saved_objects/{type}/{id}', () => { + const savedObjectsClient = { delete: sinon.stub() }; + let server; + + beforeEach(() => { + server = new MockServer(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(savedObjectsClient); + } + }, + }; + + server.route(createDeleteRoute(prereqs)); + }); + + afterEach(() => { + savedObjectsClient.delete.reset(); + }); + + it('formats successful response', async () => { + const request = { + method: 'DELETE', + url: '/api/kibana/saved_objects/index-pattern/logstash-*' + }; + const clientResponse = true; + + savedObjectsClient.delete.returns(Promise.resolve(clientResponse)); + + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + + expect(statusCode).to.be(200); + expect(response).to.eql(clientResponse); + }); + + it('calls upon savedObjectClient.delete', async () => { + const request = { + method: 'DELETE', + url: '/api/kibana/saved_objects/index-pattern/logstash-*' + }; + + await server.inject(request); + expect(savedObjectsClient.delete.calledOnce).to.be(true); + + const args = savedObjectsClient.delete.getCall(0).args; + expect(args).to.eql(['index-pattern', 'logstash-*']); + }); +}); diff --git a/src/server/saved_objects/routes/__tests__/find.js b/src/server/saved_objects/routes/__tests__/find.js new file mode 100644 index 0000000000000..eecaecb4ce669 --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/find.js @@ -0,0 +1,148 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createFindRoute } from '../find'; +import { MockServer } from './mock_server'; + +describe('GET /api/kibana/saved_objects/{type?}', () => { + const savedObjectsClient = { find: sinon.stub() }; + let server; + + beforeEach(() => { + server = new MockServer(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(savedObjectsClient); + } + }, + }; + + server.route(createFindRoute(prereqs)); + }); + + afterEach(() => { + savedObjectsClient.find.reset(); + }); + + it('formats successful response', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects' + }; + + const clientResponse = { + total: 2, + data: [ + { + type: 'index-pattern', + id: 'logstash-*', + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true + }, { + type: 'index-pattern', + id: 'stocks-*', + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true + } + ] + }; + + savedObjectsClient.find.returns(Promise.resolve(clientResponse)); + + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + + expect(statusCode).to.be(200); + expect(response).to.eql(clientResponse); + }); + + it('calls upon savedObjectClient.find with defaults', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ per_page: 20, page: 1 }); + }); + + it('accepts the query parameter page/per_page', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects?per_page=10&page=50' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ per_page: 10, page: 50 }); + }); + + it('accepts the query parameter fields as a string', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects?fields=title' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ per_page: 20, page: 1, fields: 'title' }); + }); + + it('accepts the query parameter fields as an array', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects?fields=title&fields=description' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ + per_page: 20, page: 1, fields: ['title', 'description'] + }); + }); + + it('accepts the type as a query parameter', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects?type=index-pattern' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ per_page: 20, page: 1, type: 'index-pattern' }); + }); + + it('accepts the type as a URL parameter', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects/index-pattern' + }; + + await server.inject(request); + + expect(savedObjectsClient.find.calledOnce).to.be(true); + + const options = savedObjectsClient.find.getCall(0).args[0]; + expect(options).to.eql({ per_page: 20, page: 1, type: 'index-pattern' }); + }); +}); diff --git a/src/server/saved_objects/routes/__tests__/mock_server.js b/src/server/saved_objects/routes/__tests__/mock_server.js new file mode 100644 index 0000000000000..f6bd1c8b92cf0 --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/mock_server.js @@ -0,0 +1,18 @@ +const Hapi = require('hapi'); +const defaultConfig = { + 'kibana.index': '.kibana' +}; + +export function MockServer(config = defaultConfig) { + const server = new Hapi.Server(); + server.connection({ port: 8080 }); + server.config = function () { + return { + get: (key) => { + return config[key]; + } + }; + }; + + return server; +} diff --git a/src/server/saved_objects/routes/__tests__/read.js b/src/server/saved_objects/routes/__tests__/read.js new file mode 100644 index 0000000000000..574185942ce46 --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/read.js @@ -0,0 +1,63 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createReadRoute } from '../read'; +import { MockServer } from './mock_server'; + +describe('GET /api/kibana/saved_objects/{type}/{id}', () => { + const savedObjectsClient = { get: sinon.stub() }; + let server; + + beforeEach(() => { + server = new MockServer(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(savedObjectsClient); + } + }, + }; + + server.route(createReadRoute(prereqs)); + }); + + afterEach(() => { + savedObjectsClient.get.reset(); + }); + + it('formats successful response', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects/index-pattern/logstash-*' + }; + const clientResponse = { + id: 'logstash-*', + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true + }; + + savedObjectsClient.get.returns(Promise.resolve(clientResponse)); + + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + + expect(statusCode).to.be(200); + expect(response).to.eql(clientResponse); + }); + + it('calls upon savedObjectClient.get', async () => { + const request = { + method: 'GET', + url: '/api/kibana/saved_objects/index-pattern/logstash-*' + }; + + await server.inject(request); + expect(savedObjectsClient.get.calledOnce).to.be(true); + + const args = savedObjectsClient.get.getCall(0).args; + expect(args).to.eql(['index-pattern', 'logstash-*']); + }); + +}); diff --git a/src/server/saved_objects/routes/__tests__/update.js b/src/server/saved_objects/routes/__tests__/update.js new file mode 100644 index 0000000000000..20f0952dc4bcd --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/update.js @@ -0,0 +1,62 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createUpdateRoute } from '../update'; +import { MockServer } from './mock_server'; + +describe('PUT /api/kibana/saved_objects/{type}/{id?}', () => { + const savedObjectsClient = { update: sinon.stub() }; + let server; + + beforeEach(() => { + server = new MockServer(); + + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(savedObjectsClient); + } + }, + }; + + server.route(createUpdateRoute(prereqs)); + }); + + afterEach(() => { + savedObjectsClient.update.reset(); + }); + + it('formats successful response', async () => { + const request = { + method: 'PUT', + url: '/api/kibana/saved_objects/index-pattern/logstash-*', + payload: { + title: 'Testing' + } + }; + + savedObjectsClient.update.returns(Promise.resolve(true)); + + const { payload, statusCode } = await server.inject(request); + const response = JSON.parse(payload); + + expect(statusCode).to.be(200); + expect(response).to.eql(true); + }); + + it('calls upon savedObjectClient.update', async () => { + const request = { + method: 'PUT', + url: '/api/kibana/saved_objects/index-pattern/logstash-*', + payload: { + title: 'Testing' + } + }; + + await server.inject(request); + expect(savedObjectsClient.update.calledOnce).to.be(true); + + const args = savedObjectsClient.update.getCall(0).args; + expect(args).to.eql(['index-pattern', 'logstash-*', { title: 'Testing' }]); + }); +}); diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js new file mode 100644 index 0000000000000..3c358045d8505 --- /dev/null +++ b/src/server/saved_objects/routes/create.js @@ -0,0 +1,30 @@ +import Joi from 'joi'; +import { has } from 'lodash'; + +export const createCreateRoute = (prereqs) => { + return { + path: '/api/kibana/saved_objects/{type}/{id?}', + method: 'POST', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string().required(), + id: Joi.string() + }).required(), + payload: Joi.object().required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type } = request.params; + const body = Object.assign({}, request.payload); + + if (has(request.params, 'id')) { + body.id = request.params.id; + } + + reply(savedObjectsClient.create(type, body)); + } + } + }; +}; diff --git a/src/server/saved_objects/routes/delete.js b/src/server/saved_objects/routes/delete.js new file mode 100644 index 0000000000000..a67cd5809cd44 --- /dev/null +++ b/src/server/saved_objects/routes/delete.js @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +export const createDeleteRoute = (prereqs) => ({ + path: '/api/kibana/saved_objects/{type}/{id}', + method: 'DELETE', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }).required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type, id } = request.params; + + reply(savedObjectsClient.delete(type, id)); + } + } +}); diff --git a/src/server/saved_objects/routes/find.js b/src/server/saved_objects/routes/find.js new file mode 100644 index 0000000000000..0581303592cfa --- /dev/null +++ b/src/server/saved_objects/routes/find.js @@ -0,0 +1,31 @@ +import Joi from 'joi'; + +export const createFindRoute = (prereqs) => ({ + path: '/api/kibana/saved_objects/{type?}', + method: 'GET', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string() + }), + query: Joi.object().keys({ + per_page: Joi.number().min(1).default(20), + page: Joi.number().min(0).default(1), + type: Joi.string(), + search: Joi.string().allow('').optional(), + searchFields: [Joi.string(), Joi.array().items(Joi.string())], + fields: [Joi.string(), Joi.array().items(Joi.string())] + }) + }, + handler(request, reply) { + const options = Object.assign({}, request.query); + + if (request.params.type) { + options.type = request.params.type; + } + + reply(request.pre.savedObjectsClient.find(options)); + } + } +}); diff --git a/src/server/saved_objects/routes/index.js b/src/server/saved_objects/routes/index.js new file mode 100644 index 0000000000000..c80378e63e4be --- /dev/null +++ b/src/server/saved_objects/routes/index.js @@ -0,0 +1,5 @@ +export { createCreateRoute } from './create'; +export { createDeleteRoute } from './delete'; +export { createFindRoute } from './find'; +export { createReadRoute } from './read'; +export { createUpdateRoute } from './update'; diff --git a/src/server/saved_objects/routes/read.js b/src/server/saved_objects/routes/read.js new file mode 100644 index 0000000000000..31ef0428ec9ec --- /dev/null +++ b/src/server/saved_objects/routes/read.js @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +export const createReadRoute = (prereqs) => ({ + path: '/api/kibana/saved_objects/{type}/{id}', + method: 'GET', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }).required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type, id } = request.params; + + reply(savedObjectsClient.get(type, id)); + } + } +}); diff --git a/src/server/saved_objects/routes/update.js b/src/server/saved_objects/routes/update.js new file mode 100644 index 0000000000000..0f9fe7e669971 --- /dev/null +++ b/src/server/saved_objects/routes/update.js @@ -0,0 +1,24 @@ +import Joi from 'joi'; + +export const createUpdateRoute = (prereqs) => { + return { + path: '/api/kibana/saved_objects/{type}/{id}', + method: 'PUT', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }).required(), + payload: Joi.object().required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type, id } = request.params; + + reply(savedObjectsClient.update(type, id, request.payload)); + } + } + }; +}; diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js new file mode 100644 index 0000000000000..151463493cb77 --- /dev/null +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -0,0 +1,30 @@ +import { SavedObjectsClient } from './client'; + +import { + createCreateRoute, + createDeleteRoute, + createFindRoute, + createReadRoute, + createUpdateRoute +} from './routes'; + +export function savedObjectsMixin(kbnServer, server) { + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(request, reply) { + reply(new SavedObjectsClient( + server.config().get('kibana.index'), + request, + server.plugins.elasticsearch.getCluster('admin').callWithRequest + )); + } + }, + }; + + server.route(createCreateRoute(prereqs)); + server.route(createDeleteRoute(prereqs)); + server.route(createFindRoute(prereqs)); + server.route(createReadRoute(prereqs)); + server.route(createUpdateRoute(prereqs)); +} diff --git a/src/ui/public/saved_objects/__tests__/saved_objects_client.js b/src/ui/public/saved_objects/__tests__/saved_objects_client.js new file mode 100644 index 0000000000000..65033e4ef544f --- /dev/null +++ b/src/ui/public/saved_objects/__tests__/saved_objects_client.js @@ -0,0 +1,324 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; +import { pick } from 'lodash'; +import { SavedObjectsClient } from '../saved_objects_client'; +import { SavedObject } from '../saved_object'; + + +describe('SavedObjectsClient', () => { + const basePath = Math.random().toString(36).substring(7); + const sandbox = sinon.sandbox.create(); + + let savedObjectsClient; + + beforeEach(() => { + savedObjectsClient = new SavedObjectsClient(sinon.stub, basePath); + sandbox.stub(savedObjectsClient, '_$http'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#_getUrl', () => { + it('returns without arguments', () => { + const url = savedObjectsClient._getUrl(); + const expected = `${basePath}/api/kibana/saved_objects/`; + + expect(url).to.be(expected); + }); + + it('appends path', () => { + const url = savedObjectsClient._getUrl(['some', 'path']); + const expected = `${basePath}/api/kibana/saved_objects/some/path`; + + expect(url).to.be(expected); + }); + + it('appends query', () => { + const url = savedObjectsClient._getUrl(['some', 'path'], { foo: 'Foo', bar: 'Bar' }); + const expected = `${basePath}/api/kibana/saved_objects/some/path?foo=Foo&bar=Bar`; + + expect(url).to.be(expected); + }); + }); + + describe('#_request', () => { + const params = { foo: 'Foo', bar: 'Bar' }; + + it('passes options to $http', () => { + savedObjectsClient._$http.withArgs({ + method: 'POST', + url: '/api/path', + data: params + }).returns(Promise.resolve({ data: '' })); + + savedObjectsClient._request('POST', '/api/path', params); + + expect(savedObjectsClient._$http.calledOnce).to.be(true); + }); + + it('sets params for GET request', () => { + savedObjectsClient._$http.withArgs({ + method: 'GET', + url: '/api/path', + params: params + }).returns(Promise.resolve({ data: '' })); + + savedObjectsClient._request('GET', '/api/path', params); + + expect(savedObjectsClient._$http.calledOnce).to.be(true); + }); + + it('catches API error', (done) => { + const message = 'Request failed'; + + savedObjectsClient._$http.returns(Promise.reject({ data: { error: message } })); + savedObjectsClient._request('GET', '/api/path', params).then(() => { + done('should have thrown'); + }).catch(e => { + expect(e.message).to.eql(message); + done(); + }); + }); + + it('catches API error message', (done) => { + const message = 'Request failed'; + + savedObjectsClient._$http.returns(Promise.reject({ data: { message: message } })); + savedObjectsClient._request('GET', '/api/path', params).then(() => { + done('should have thrown'); + }).catch(e => { + expect(e.message).to.eql(message); + done(); + }); + }); + + it('catches API error status', (done) => { + savedObjectsClient._$http.returns(Promise.reject({ status: 404 })); + savedObjectsClient._request('GET', '/api/path', params).then(() => { + done('should have thrown'); + }).catch(e => { + expect(e.message).to.eql('404 Response'); + done(); + }); + }); + }); + + describe('#get', () => { + const attributes = { type: 'index-pattern', foo: 'Foo' }; + + beforeEach(() => { + savedObjectsClient._$http.withArgs({ + method: 'GET', + url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*` + }).returns(Promise.resolve(attributes)); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.get('index-pattern', 'logstash-*')).to.be.a(Promise); + }); + + it('requires type', (done) => { + savedObjectsClient.get().then(() => { + done('should require type'); + }).catch((e) => { + expect(e.message).to.contain('requires type and id'); + done(); + }); + }); + + it('requires id', (done) => { + savedObjectsClient.get('index-pattern').then(() => { + done('should require id'); + }).catch((e) => { + expect(e.message).to.contain('requires type and id'); + done(); + }); + }); + + it('resolves with instantiated ObjectClass', async () => { + const response = await savedObjectsClient.get('index-pattern', 'logstash-*'); + expect(response).to.be.a(SavedObject); + expect(response.attributes).to.eql(attributes); + expect(response.client).to.be.a(SavedObjectsClient); + }); + + it('makes HTTP call', () => { + savedObjectsClient.get('index-pattern', 'logstash-*'); + sinon.assert.calledOnce(savedObjectsClient._$http); + }); + }); + + describe('#delete', () => { + beforeEach(() => { + savedObjectsClient._$http.withArgs({ + method: 'DELETE', + url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*` + }).returns(Promise.resolve({ data: 'api-response' })); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.delete('index-pattern', 'logstash-*')).to.be.a(Promise); + }); + + it('requires type', (done) => { + savedObjectsClient.delete().then(() => { + done('should require type'); + }).catch((e) => { + expect(e.message).to.contain('requires type and id'); + done(); + }); + }); + + it('requires id', (done) => { + savedObjectsClient.delete('index-pattern').then(() => { + done('should require id'); + }).catch((e) => { + expect(e.message).to.contain('requires type and id'); + done(); + }); + }); + + it('makes HTTP call', () => { + savedObjectsClient.delete('index-pattern', 'logstash-*'); + sinon.assert.calledOnce(savedObjectsClient._$http); + }); + }); + + describe('#update', () => { + const requireMessage = 'requires type, id and body'; + + beforeEach(() => { + savedObjectsClient._$http.withArgs({ + method: 'PUT', + url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*`, + data: sinon.match.any + }).returns(Promise.resolve({ data: 'api-response' })); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.update('index-pattern', 'logstash-*', {})).to.be.a(Promise); + }); + + it('requires type', (done) => { + savedObjectsClient.update().then(() => { + done('should require type'); + }).catch((e) => { + expect(e.message).to.contain(requireMessage); + done(); + }); + }); + + it('requires id', (done) => { + savedObjectsClient.update('index-pattern').then(() => { + done('should require id'); + }).catch((e) => { + expect(e.message).to.contain(requireMessage); + done(); + }); + }); + + it('requires body', (done) => { + savedObjectsClient.update('index-pattern', 'logstash-*').then(() => { + done('should require body'); + }).catch((e) => { + expect(e.message).to.contain(requireMessage); + done(); + }); + }); + + it('makes HTTP call', () => { + const body = { foo: 'Foo', bar: 'Bar' }; + + savedObjectsClient.update('index-pattern', 'logstash-*', body); + sinon.assert.calledOnce(savedObjectsClient._$http); + + expect(savedObjectsClient._$http.getCall(0).args[0].data).to.eql(body); + }); + }); + + describe('#create', () => { + const requireMessage = 'requires type and body'; + + beforeEach(() => { + savedObjectsClient._$http.withArgs({ + method: 'POST', + url: `${basePath}/api/kibana/saved_objects/index-pattern`, + data: sinon.match.any + }).returns(Promise.resolve({ data: 'api-response' })); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.create('index-pattern', {})).to.be.a(Promise); + }); + + it('requires type', (done) => { + savedObjectsClient.create().then(() => { + done('should require type'); + }).catch((e) => { + expect(e.message).to.contain(requireMessage); + done(); + }); + }); + + it('requires body', (done) => { + savedObjectsClient.create('index-pattern').then(() => { + done('should require body'); + }).catch((e) => { + expect(e.message).to.contain(requireMessage); + done(); + }); + }); + + it('makes HTTP call', () => { + const body = { foo: 'Foo', bar: 'Bar', id: 'logstash-*' }; + savedObjectsClient.create('index-pattern', body); + + sinon.assert.calledOnce(savedObjectsClient._$http); + expect(savedObjectsClient._$http.getCall(0).args[0].data).to.eql(body); + }); + }); + + describe('#find', () => { + const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; + + beforeEach(() => { + savedObjectsClient._$http.returns(Promise.resolve({ data: [object] })); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.find()).to.be.a(Promise); + }); + + it('accepts type', () => { + const body = { type: 'index-pattern', invalid: true }; + + savedObjectsClient.find(body); + expect(savedObjectsClient._$http.calledOnce).to.be(true); + + const options = savedObjectsClient._$http.getCall(0).args[0]; + expect(options.url).to.eql(`${basePath}/api/kibana/saved_objects/index-pattern`); + }); + + it('accepts fields', () => { + const body = { fields: ['title', 'description'], invalid: true }; + + savedObjectsClient.find(body); + expect(savedObjectsClient._$http.calledOnce).to.be(true); + + const options = savedObjectsClient._$http.getCall(0).args[0]; + expect(options.params).to.eql(pick(body, ['fields'])); + }); + + it('accepts from/size', () => { + const body = { from: 50, size: 10, invalid: true }; + + savedObjectsClient.find(body); + expect(savedObjectsClient._$http.calledOnce).to.be(true); + + const options = savedObjectsClient._$http.getCall(0).args[0]; + expect(options.params).to.eql(pick(body, ['from', 'size'])); + }); + }); +}); diff --git a/src/ui/public/saved_objects/index.js b/src/ui/public/saved_objects/index.js new file mode 100644 index 0000000000000..7b1d90d8452b0 --- /dev/null +++ b/src/ui/public/saved_objects/index.js @@ -0,0 +1,4 @@ +export { SavedObjectsClient } from './saved_objects_client'; +export { SavedObjectRegistryProvider } from './saved_object_registry'; +export { SavedObjectsClientProvider } from './saved_objects_client_provider'; +export { SavedObject } from './saved_object'; diff --git a/src/ui/public/saved_objects/saved_object.js b/src/ui/public/saved_objects/saved_object.js new file mode 100644 index 0000000000000..e6ec6e8bda596 --- /dev/null +++ b/src/ui/public/saved_objects/saved_object.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; + +export class SavedObject { + constructor(client, attributes) { + this.client = client; + this.attributes = attributes; + } + + get(key) { + return _.get(this.attributes, key); + } + + set(key, value) { + return _.set(this.attributes, key, value); + } + + save() { + if (this.id) { + return this.client.update(this.type, this.id, this.attributes); + } else { + return this.client.create(this.type, this.attributes); + } + } + + delete() { + return this.cient.delete(this.type, this.id); + } + + get id() { + return this.get('id'); + } + + get type() { + return this.get('type'); + } +} diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js new file mode 100644 index 0000000000000..060b37e799ce2 --- /dev/null +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -0,0 +1,95 @@ +import { resolve as resolveUrl, format as formatUrl } from 'url'; +import { pick, partial } from 'lodash'; + +import { SavedObject } from './saved_object'; + +const join = (...uriComponents) => ( + uriComponents.filter(Boolean).map(encodeURIComponent).join('/') +); + +export class SavedObjectsClient { + constructor($http, basePath) { + this._$http = $http; + this._apiBaseUrl = `${basePath}/api/kibana/saved_objects/`; + } + + get(type, id) { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + return this._request('GET', this._getUrl([type, id])).then(resp => { + return new this.ObjectClass(resp); + }); + } + + delete(type, id) { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + return this._request('DELETE', this._getUrl([type, id])); + } + + update(type, id, body) { + if (!type || !id || !body) { + return Promise.reject(new Error('requires type, id and body')); + } + + return this._request('PUT', this._getUrl([type, id]), body); + } + + create(type, body) { + if (!type || !body) { + return Promise.reject(new Error('requires type and body')); + } + + const url = this._getUrl([type]); + + return this._request('POST', url, body); + } + + find(options = {}) { + const url = this._getUrl([options.type]); + const validOptions = pick(options, ['from', 'size', 'fields', 'filter']); + + return this._request('GET', url, validOptions).then(resp => { + resp.data = resp.data.map(d => new this.ObjectClass(d)); + return resp; + }); + } + + get ObjectClass() { + return partial(SavedObject, this); + } + + _getUrl(path, query) { + if (!path && !query) { + return this._apiBaseUrl; + } + + return resolveUrl(this._apiBaseUrl, formatUrl({ + pathname: join(...path), + query: pick(query, value => value != null) + })); + } + + _request(method, url, body) { + const options = { method, url }; + + if (method === 'GET' && body) { + options.params = body; + } else if (body) { + options.data = body; + } + + return this._$http(options) + .catch(resp => { + const respBody = resp.data || {}; + const err = new Error(respBody.message || respBody.error || `${resp.status} Response`); + err.status = resp.status; + err.body = respBody; + throw err; + }); + } +} diff --git a/src/ui/public/saved_objects/saved_objects_client_provider.js b/src/ui/public/saved_objects/saved_objects_client_provider.js new file mode 100644 index 0000000000000..cb157bb480c97 --- /dev/null +++ b/src/ui/public/saved_objects/saved_objects_client_provider.js @@ -0,0 +1,7 @@ +import chrome from 'ui/chrome'; + +import { SavedObjectsClient } from './saved_objects_client'; + +export function SavedObjectsClientProvider($http) { + return new SavedObjectsClient($http, chrome.getBasePath()); +} From 621fd48f1170678cd4f030ec2f800415cce77739 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 09:30:06 -0700 Subject: [PATCH 02/10] Fixes typo Signed-off-by: Tyler Smalley --- src/ui/public/saved_objects/saved_object.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/public/saved_objects/saved_object.js b/src/ui/public/saved_objects/saved_object.js index e6ec6e8bda596..c48b5fb71a6e5 100644 --- a/src/ui/public/saved_objects/saved_object.js +++ b/src/ui/public/saved_objects/saved_object.js @@ -23,7 +23,7 @@ export class SavedObject { } delete() { - return this.cient.delete(this.type, this.id); + return this.client.delete(this.type, this.id); } get id() { From 197a5e4e39db700ecdd3e7d9b52293f64282d3be Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 09:38:18 -0700 Subject: [PATCH 03/10] Remove kibana from saved object API path Signed-off-by: Tyler Smalley --- .../saved_objects/routes/__tests__/create.js | 6 +++--- .../saved_objects/routes/__tests__/delete.js | 6 +++--- .../saved_objects/routes/__tests__/find.js | 16 ++++++++-------- .../saved_objects/routes/__tests__/read.js | 6 +++--- .../saved_objects/routes/__tests__/update.js | 6 +++--- src/server/saved_objects/routes/create.js | 2 +- src/server/saved_objects/routes/delete.js | 2 +- src/server/saved_objects/routes/find.js | 2 +- src/server/saved_objects/routes/read.js | 2 +- src/server/saved_objects/routes/update.js | 2 +- .../__tests__/saved_objects_client.js | 16 ++++++++-------- .../public/saved_objects/saved_objects_client.js | 2 +- 12 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/server/saved_objects/routes/__tests__/create.js b/src/server/saved_objects/routes/__tests__/create.js index 40bfd24a4fad8..356a636d1fcec 100644 --- a/src/server/saved_objects/routes/__tests__/create.js +++ b/src/server/saved_objects/routes/__tests__/create.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createCreateRoute } from '../create'; import { MockServer } from './mock_server'; -describe('POST /api/kibana/saved_objects/{type}/{id?}', () => { +describe('POST /api/saved_objects/{type}/{id?}', () => { const savedObjectsClient = { create: sinon.stub() }; let server; @@ -29,7 +29,7 @@ describe('POST /api/kibana/saved_objects/{type}/{id?}', () => { it('formats successful response', async () => { const request = { method: 'POST', - url: '/api/kibana/saved_objects/index-pattern', + url: '/api/saved_objects/index-pattern', payload: { title: 'Testing' } @@ -52,7 +52,7 @@ describe('POST /api/kibana/saved_objects/{type}/{id?}', () => { it('calls upon savedObjectClient.create', async () => { const request = { method: 'POST', - url: '/api/kibana/saved_objects/index-pattern/logstash-*', + url: '/api/saved_objects/index-pattern/logstash-*', payload: { title: 'Testing' } diff --git a/src/server/saved_objects/routes/__tests__/delete.js b/src/server/saved_objects/routes/__tests__/delete.js index 64bc301ff926a..b020369210d73 100644 --- a/src/server/saved_objects/routes/__tests__/delete.js +++ b/src/server/saved_objects/routes/__tests__/delete.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createDeleteRoute } from '../delete'; import { MockServer } from './mock_server'; -describe('DELETE /api/kibana/saved_objects/{type}/{id}', () => { +describe('DELETE /api/saved_objects/{type}/{id}', () => { const savedObjectsClient = { delete: sinon.stub() }; let server; @@ -29,7 +29,7 @@ describe('DELETE /api/kibana/saved_objects/{type}/{id}', () => { it('formats successful response', async () => { const request = { method: 'DELETE', - url: '/api/kibana/saved_objects/index-pattern/logstash-*' + url: '/api/saved_objects/index-pattern/logstash-*' }; const clientResponse = true; @@ -45,7 +45,7 @@ describe('DELETE /api/kibana/saved_objects/{type}/{id}', () => { it('calls upon savedObjectClient.delete', async () => { const request = { method: 'DELETE', - url: '/api/kibana/saved_objects/index-pattern/logstash-*' + url: '/api/saved_objects/index-pattern/logstash-*' }; await server.inject(request); diff --git a/src/server/saved_objects/routes/__tests__/find.js b/src/server/saved_objects/routes/__tests__/find.js index eecaecb4ce669..e93c5be51f78e 100644 --- a/src/server/saved_objects/routes/__tests__/find.js +++ b/src/server/saved_objects/routes/__tests__/find.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createFindRoute } from '../find'; import { MockServer } from './mock_server'; -describe('GET /api/kibana/saved_objects/{type?}', () => { +describe('GET /api/saved_objects/{type?}', () => { const savedObjectsClient = { find: sinon.stub() }; let server; @@ -29,7 +29,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('formats successful response', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects' + url: '/api/saved_objects' }; const clientResponse = { @@ -63,7 +63,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('calls upon savedObjectClient.find with defaults', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects' + url: '/api/saved_objects' }; await server.inject(request); @@ -77,7 +77,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('accepts the query parameter page/per_page', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects?per_page=10&page=50' + url: '/api/saved_objects?per_page=10&page=50' }; await server.inject(request); @@ -91,7 +91,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('accepts the query parameter fields as a string', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects?fields=title' + url: '/api/saved_objects?fields=title' }; await server.inject(request); @@ -105,7 +105,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('accepts the query parameter fields as an array', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects?fields=title&fields=description' + url: '/api/saved_objects?fields=title&fields=description' }; await server.inject(request); @@ -121,7 +121,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('accepts the type as a query parameter', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects?type=index-pattern' + url: '/api/saved_objects?type=index-pattern' }; await server.inject(request); @@ -135,7 +135,7 @@ describe('GET /api/kibana/saved_objects/{type?}', () => { it('accepts the type as a URL parameter', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects/index-pattern' + url: '/api/saved_objects/index-pattern' }; await server.inject(request); diff --git a/src/server/saved_objects/routes/__tests__/read.js b/src/server/saved_objects/routes/__tests__/read.js index 574185942ce46..ea1205fdf345b 100644 --- a/src/server/saved_objects/routes/__tests__/read.js +++ b/src/server/saved_objects/routes/__tests__/read.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createReadRoute } from '../read'; import { MockServer } from './mock_server'; -describe('GET /api/kibana/saved_objects/{type}/{id}', () => { +describe('GET /api/saved_objects/{type}/{id}', () => { const savedObjectsClient = { get: sinon.stub() }; let server; @@ -29,7 +29,7 @@ describe('GET /api/kibana/saved_objects/{type}/{id}', () => { it('formats successful response', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects/index-pattern/logstash-*' + url: '/api/saved_objects/index-pattern/logstash-*' }; const clientResponse = { id: 'logstash-*', @@ -50,7 +50,7 @@ describe('GET /api/kibana/saved_objects/{type}/{id}', () => { it('calls upon savedObjectClient.get', async () => { const request = { method: 'GET', - url: '/api/kibana/saved_objects/index-pattern/logstash-*' + url: '/api/saved_objects/index-pattern/logstash-*' }; await server.inject(request); diff --git a/src/server/saved_objects/routes/__tests__/update.js b/src/server/saved_objects/routes/__tests__/update.js index 20f0952dc4bcd..c55e90a7fc062 100644 --- a/src/server/saved_objects/routes/__tests__/update.js +++ b/src/server/saved_objects/routes/__tests__/update.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createUpdateRoute } from '../update'; import { MockServer } from './mock_server'; -describe('PUT /api/kibana/saved_objects/{type}/{id?}', () => { +describe('PUT /api/saved_objects/{type}/{id?}', () => { const savedObjectsClient = { update: sinon.stub() }; let server; @@ -29,7 +29,7 @@ describe('PUT /api/kibana/saved_objects/{type}/{id?}', () => { it('formats successful response', async () => { const request = { method: 'PUT', - url: '/api/kibana/saved_objects/index-pattern/logstash-*', + url: '/api/saved_objects/index-pattern/logstash-*', payload: { title: 'Testing' } @@ -47,7 +47,7 @@ describe('PUT /api/kibana/saved_objects/{type}/{id?}', () => { it('calls upon savedObjectClient.update', async () => { const request = { method: 'PUT', - url: '/api/kibana/saved_objects/index-pattern/logstash-*', + url: '/api/saved_objects/index-pattern/logstash-*', payload: { title: 'Testing' } diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js index 3c358045d8505..8e6436a4e549e 100644 --- a/src/server/saved_objects/routes/create.js +++ b/src/server/saved_objects/routes/create.js @@ -3,7 +3,7 @@ import { has } from 'lodash'; export const createCreateRoute = (prereqs) => { return { - path: '/api/kibana/saved_objects/{type}/{id?}', + path: '/api/saved_objects/{type}/{id?}', method: 'POST', config: { pre: [prereqs.getSavedObjectsClient], diff --git a/src/server/saved_objects/routes/delete.js b/src/server/saved_objects/routes/delete.js index a67cd5809cd44..a394530a0f655 100644 --- a/src/server/saved_objects/routes/delete.js +++ b/src/server/saved_objects/routes/delete.js @@ -1,7 +1,7 @@ import Joi from 'joi'; export const createDeleteRoute = (prereqs) => ({ - path: '/api/kibana/saved_objects/{type}/{id}', + path: '/api/saved_objects/{type}/{id}', method: 'DELETE', config: { pre: [prereqs.getSavedObjectsClient], diff --git a/src/server/saved_objects/routes/find.js b/src/server/saved_objects/routes/find.js index 0581303592cfa..7c1991019ae9e 100644 --- a/src/server/saved_objects/routes/find.js +++ b/src/server/saved_objects/routes/find.js @@ -1,7 +1,7 @@ import Joi from 'joi'; export const createFindRoute = (prereqs) => ({ - path: '/api/kibana/saved_objects/{type?}', + path: '/api/saved_objects/{type?}', method: 'GET', config: { pre: [prereqs.getSavedObjectsClient], diff --git a/src/server/saved_objects/routes/read.js b/src/server/saved_objects/routes/read.js index 31ef0428ec9ec..f96b0cab51ad0 100644 --- a/src/server/saved_objects/routes/read.js +++ b/src/server/saved_objects/routes/read.js @@ -1,7 +1,7 @@ import Joi from 'joi'; export const createReadRoute = (prereqs) => ({ - path: '/api/kibana/saved_objects/{type}/{id}', + path: '/api/saved_objects/{type}/{id}', method: 'GET', config: { pre: [prereqs.getSavedObjectsClient], diff --git a/src/server/saved_objects/routes/update.js b/src/server/saved_objects/routes/update.js index 0f9fe7e669971..6443b5308edce 100644 --- a/src/server/saved_objects/routes/update.js +++ b/src/server/saved_objects/routes/update.js @@ -2,7 +2,7 @@ import Joi from 'joi'; export const createUpdateRoute = (prereqs) => { return { - path: '/api/kibana/saved_objects/{type}/{id}', + path: '/api/saved_objects/{type}/{id}', method: 'PUT', config: { pre: [prereqs.getSavedObjectsClient], 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 65033e4ef544f..db2440c235242 100644 --- a/src/ui/public/saved_objects/__tests__/saved_objects_client.js +++ b/src/ui/public/saved_objects/__tests__/saved_objects_client.js @@ -23,21 +23,21 @@ describe('SavedObjectsClient', () => { describe('#_getUrl', () => { it('returns without arguments', () => { const url = savedObjectsClient._getUrl(); - const expected = `${basePath}/api/kibana/saved_objects/`; + const expected = `${basePath}/api/saved_objects/`; expect(url).to.be(expected); }); it('appends path', () => { const url = savedObjectsClient._getUrl(['some', 'path']); - const expected = `${basePath}/api/kibana/saved_objects/some/path`; + const expected = `${basePath}/api/saved_objects/some/path`; expect(url).to.be(expected); }); it('appends query', () => { const url = savedObjectsClient._getUrl(['some', 'path'], { foo: 'Foo', bar: 'Bar' }); - const expected = `${basePath}/api/kibana/saved_objects/some/path?foo=Foo&bar=Bar`; + const expected = `${basePath}/api/saved_objects/some/path?foo=Foo&bar=Bar`; expect(url).to.be(expected); }); @@ -111,7 +111,7 @@ describe('SavedObjectsClient', () => { beforeEach(() => { savedObjectsClient._$http.withArgs({ method: 'GET', - url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*` + url: `${basePath}/api/saved_objects/index-pattern/logstash-*` }).returns(Promise.resolve(attributes)); }); @@ -154,7 +154,7 @@ describe('SavedObjectsClient', () => { beforeEach(() => { savedObjectsClient._$http.withArgs({ method: 'DELETE', - url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*` + url: `${basePath}/api/saved_objects/index-pattern/logstash-*` }).returns(Promise.resolve({ data: 'api-response' })); }); @@ -192,7 +192,7 @@ describe('SavedObjectsClient', () => { beforeEach(() => { savedObjectsClient._$http.withArgs({ method: 'PUT', - url: `${basePath}/api/kibana/saved_objects/index-pattern/logstash-*`, + url: `${basePath}/api/saved_objects/index-pattern/logstash-*`, data: sinon.match.any }).returns(Promise.resolve({ data: 'api-response' })); }); @@ -244,7 +244,7 @@ describe('SavedObjectsClient', () => { beforeEach(() => { savedObjectsClient._$http.withArgs({ method: 'POST', - url: `${basePath}/api/kibana/saved_objects/index-pattern`, + url: `${basePath}/api/saved_objects/index-pattern`, data: sinon.match.any }).returns(Promise.resolve({ data: 'api-response' })); }); @@ -298,7 +298,7 @@ describe('SavedObjectsClient', () => { expect(savedObjectsClient._$http.calledOnce).to.be(true); const options = savedObjectsClient._$http.getCall(0).args[0]; - expect(options.url).to.eql(`${basePath}/api/kibana/saved_objects/index-pattern`); + expect(options.url).to.eql(`${basePath}/api/saved_objects/index-pattern`); }); it('accepts fields', () => { diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js index 060b37e799ce2..773ff5c7634c7 100644 --- a/src/ui/public/saved_objects/saved_objects_client.js +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -10,7 +10,7 @@ const join = (...uriComponents) => ( export class SavedObjectsClient { constructor($http, basePath) { this._$http = $http; - this._apiBaseUrl = `${basePath}/api/kibana/saved_objects/`; + this._apiBaseUrl = `${basePath}/api/saved_objects/`; } get(type, id) { From 005b029bdd467482f93602f5d2bbddc6aae43ac4 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 10:16:27 -0700 Subject: [PATCH 04/10] Nests document in doc element for ES Signed-off-by: Tyler Smalley --- src/server/saved_objects/client/saved_objects_client.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 2752967418266..c86ee47737115 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -39,7 +39,7 @@ export class SavedObjectsClient { throw Boom.notFound(); } - return get(response, 'deleted', false); + return get(response, 'result') === 'deleted'; } async find(options = {}) { @@ -82,8 +82,10 @@ export class SavedObjectsClient { const response = await this._withKibanaIndex('update', { type, id, - body, - refresh: true, + body: { + doc: body + }, + refresh: 'wait_for' }); return get(response, 'result') === 'updated'; From 8b0c32deef7f5dd29f6cdb3d1ab363590609b694 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 10:22:46 -0700 Subject: [PATCH 05/10] Resolves tests for update API Signed-off-by: Tyler Smalley --- .../client/__tests__/saved_objects_client.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 4acc8be47fed7..544c51c9918c9 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -94,10 +94,10 @@ describe('SavedObjectsClient', () => { describe('#delete', () => { it('returns based on ES success', async () => { - callWithRequest.returns(Promise.resolve({ deleted: 'testing' })); + callWithRequest.returns(Promise.resolve({ result: 'deleted' })); const response = await savedObjectsClient.delete('index-pattern', 'logstash-*'); - expect(response).to.be('testing'); + expect(response).to.be(true); }); it('throws notFound when ES is unable to find the document', (done) => { @@ -251,8 +251,8 @@ describe('SavedObjectsClient', () => { expect(args[2]).to.eql({ type: 'index-pattern', id: 'logstash-*', - body: { title: 'Testing' }, - refresh: true, + body: { doc: { title: 'Testing' } }, + refresh: 'wait_for', index: '.kibana-test' }); }); From f6dd70520170127d91170251e4fc6b259df866aa Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 12:45:03 -0700 Subject: [PATCH 06/10] Prevent leaking of ES query to API Signed-off-by: Tyler Smalley --- src/server/saved_objects/client/lib/handle_es_error.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/saved_objects/client/lib/handle_es_error.js b/src/server/saved_objects/client/lib/handle_es_error.js index 0bea7eac7922d..877c1a6804bf9 100644 --- a/src/server/saved_objects/client/lib/handle_es_error.js +++ b/src/server/saved_objects/client/lib/handle_es_error.js @@ -23,7 +23,7 @@ export function handleEsError(error) { error instanceof NoConnections || error instanceof RequestTimeout ) { - throw Boom.serverTimeout(error); + throw Boom.serverTimeout(); } if (error instanceof Conflict || error.message.includes('index_template_already_exists')) { @@ -31,15 +31,15 @@ export function handleEsError(error) { } if (error instanceof Forbidden) { - throw Boom.forbidden(error); + throw Boom.forbidden(); } if (error instanceof NotFound) { - throw Boom.notFound(error); + throw Boom.notFound(); } if (error instanceof BadRequest) { - throw Boom.badRequest(error); + throw Boom.badRequest(); } throw error; From af59a5c3d5de3c7c5189ccb5ebd7658af47ce1f4 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 15 May 2017 23:06:42 -0700 Subject: [PATCH 07/10] Adds version to saved objects API Signed-off-by: Tyler Smalley --- .../client/__tests__/saved_objects_client.js | 15 ++++++++++----- .../client/lib/__tests__/create_filter_path.js | 4 +++- .../client/lib/create_filter_path.js | 2 +- .../saved_objects/client/lib/handle_es_error.js | 13 ++++++++----- .../saved_objects/client/saved_objects_client.js | 14 +++++++++++--- 5 files changed, 33 insertions(+), 15 deletions(-) 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 544c51c9918c9..263a855b0ca83 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -53,7 +53,7 @@ describe('SavedObjectsClient', () => { describe('#create', () => { it('formats Elasticsearch response', async () => { - callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*' }); + callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); const response = await savedObjectsClient.create('index-pattern', { id: 'logstash-*', @@ -68,7 +68,7 @@ describe('SavedObjectsClient', () => { }); it('should use ES create action with specifying an id', async () => { - callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*' }); + callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); await savedObjectsClient.create('index-pattern', { id: 'logstash-*', @@ -138,7 +138,7 @@ describe('SavedObjectsClient', () => { expect(response.data).to.have.length(count); docs.hits.hits.forEach((doc, i) => { expect(response.data[i]).to.eql(Object.assign( - { id: doc._id, type: doc._type }, + { id: doc._id, type: doc._type, version: doc._version }, doc._source) ); }); @@ -190,6 +190,7 @@ describe('SavedObjectsClient', () => { 'hits.total', 'hits.hits._id', 'hits.hits._type', + 'hits.hits._version', 'hits.hits._source.title' ]); }); @@ -205,7 +206,8 @@ describe('SavedObjectsClient', () => { 'hits.hits._source.description', 'hits.total', 'hits.hits._id', - 'hits.hits._type' + 'hits.hits._type', + 'hits.hits._version' ]); }); }); @@ -215,6 +217,7 @@ describe('SavedObjectsClient', () => { callWithRequest.returns(Promise.resolve({ _id: 'logstash-*', _type: 'index-pattern', + _version: 2, _source: { title: 'Testing' } @@ -224,7 +227,8 @@ describe('SavedObjectsClient', () => { expect(response).to.eql({ id: 'logstash-*', type: 'index-pattern', - title: 'Testing' + title: 'Testing', + version: 2 }); }); }); @@ -251,6 +255,7 @@ describe('SavedObjectsClient', () => { expect(args[2]).to.eql({ type: 'index-pattern', id: 'logstash-*', + version: undefined, body: { doc: { title: 'Testing' } }, refresh: 'wait_for', index: '.kibana-test' diff --git a/src/server/saved_objects/client/lib/__tests__/create_filter_path.js b/src/server/saved_objects/client/lib/__tests__/create_filter_path.js index 391ade7c9e2b8..e115b242a28ea 100644 --- a/src/server/saved_objects/client/lib/__tests__/create_filter_path.js +++ b/src/server/saved_objects/client/lib/__tests__/create_filter_path.js @@ -8,6 +8,7 @@ describe('createFilterPath', () => { 'hits.total', 'hits.hits._id', 'hits.hits._type', + 'hits.hits._version', 'hits.hits._source.foo' ]); }); @@ -19,7 +20,8 @@ describe('createFilterPath', () => { 'hits.hits._source.bar', 'hits.total', 'hits.hits._id', - 'hits.hits._type' + 'hits.hits._type', + 'hits.hits._version', ]); }); }); diff --git a/src/server/saved_objects/client/lib/create_filter_path.js b/src/server/saved_objects/client/lib/create_filter_path.js index 39c33b5717460..2ffc1842ddbaa 100644 --- a/src/server/saved_objects/client/lib/create_filter_path.js +++ b/src/server/saved_objects/client/lib/create_filter_path.js @@ -1,5 +1,5 @@ export function createFilterPath(fields) { - const baseKeys = ['hits.total', 'hits.hits._id', 'hits.hits._type']; + const baseKeys = ['hits.total', 'hits.hits._id', 'hits.hits._type', 'hits.hits._version']; if (Array.isArray(fields)) { return fields.map(f => `hits.hits._source.${f}`).concat(baseKeys); diff --git a/src/server/saved_objects/client/lib/handle_es_error.js b/src/server/saved_objects/client/lib/handle_es_error.js index 877c1a6804bf9..a6a1b695a79db 100644 --- a/src/server/saved_objects/client/lib/handle_es_error.js +++ b/src/server/saved_objects/client/lib/handle_es_error.js @@ -1,5 +1,6 @@ import elasticsearch from 'elasticsearch'; import Boom from 'boom'; +import { get } from 'lodash'; const { ConnectionFault, @@ -17,6 +18,8 @@ export function handleEsError(error) { throw new Error('Expected an instance of Error'); } + const reason = get(error, 'body.error.reason'); + if ( error instanceof ConnectionFault || error instanceof ServiceUnavailable || @@ -26,20 +29,20 @@ export function handleEsError(error) { throw Boom.serverTimeout(); } - if (error instanceof Conflict || error.message.includes('index_template_already_exists')) { - throw Boom.conflict(error); + if (error instanceof Conflict) { + throw Boom.conflict(reason); } if (error instanceof Forbidden) { - throw Boom.forbidden(); + throw Boom.forbidden(reason); } if (error instanceof NotFound) { - throw Boom.notFound(); + throw Boom.notFound(reason); } if (error instanceof BadRequest) { - throw Boom.badRequest(); + throw Boom.badRequest(reason); } throw error; diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index c86ee47737115..57192117b93d5 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -60,7 +60,7 @@ export class SavedObjectsClient { return { data: get(response, 'hits.hits', []).map(r => { - return Object.assign({ id: r._id, type: r._type }, r._source); + return Object.assign({ id: r._id, type: r._type, version: r._version }, r._source); }), total: get(response, 'hits.total', 0), per_page: perPage, @@ -75,15 +75,23 @@ export class SavedObjectsClient { id, }); - return Object.assign({ id: response._id, type: response._type }, response._source); + return Object.assign({ + id: response._id, + type: response._type, + version: response._version + }, response._source); } async update(type, id, body) { + const version = get(body, 'version'); + const doc = omit(body, ['version']); + const response = await this._withKibanaIndex('update', { type, id, + version, body: { - doc: body + doc: doc }, refresh: 'wait_for' }); From a8e3279f159ce8ffac09b9ae7e6091b2e8df5043 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 16 May 2017 09:21:03 -0700 Subject: [PATCH 08/10] Return version for searches Signed-off-by: Tyler Smalley --- .../client/__tests__/saved_objects_client.js | 4 ++-- .../client/lib/__tests__/create_find_query.js | 14 +++++++++----- .../saved_objects/client/lib/create_find_query.js | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) 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 263a855b0ca83..7cb72005cb31b 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -152,7 +152,7 @@ describe('SavedObjectsClient', () => { const options = callWithRequest.getCall(0).args[2]; expect(options).to.eql({ index: '.kibana-test', - body: { query: { match_all: {} } }, + body: { query: { match_all: {} }, version: true }, from: 50, size: 10 }); @@ -175,7 +175,7 @@ describe('SavedObjectsClient', () => { from: 0, index: '.kibana-test', size: 20, - body: { query: expectedQuery }, + body: { query: expectedQuery, version: true }, type: 'index-pattern', }); }); diff --git a/src/server/saved_objects/client/lib/__tests__/create_find_query.js b/src/server/saved_objects/client/lib/__tests__/create_find_query.js index 5e6dd84451093..e682c5ddbda6f 100644 --- a/src/server/saved_objects/client/lib/__tests__/create_find_query.js +++ b/src/server/saved_objects/client/lib/__tests__/create_find_query.js @@ -4,7 +4,7 @@ import { createFindQuery } from '../create_find_query'; describe('createFindQuery', () => { it('matches all when there is no type or filter', () => { const query = createFindQuery(); - expect(query).to.eql({ query: { match_all: {} } }); + expect(query).to.eql({ query: { match_all: {} }, version: true }); }); it('adds bool filter for type', () => { @@ -21,7 +21,8 @@ describe('createFindQuery', () => { match_all: {} }] } - } + }, + version: true }); }); @@ -38,7 +39,8 @@ describe('createFindQuery', () => { } }] } - } + }, + version: true }); }); @@ -55,7 +57,8 @@ describe('createFindQuery', () => { } }] } - } + }, + version: true }); }); @@ -72,7 +75,8 @@ describe('createFindQuery', () => { } }] } - } + }, + version: true }); }); }); diff --git a/src/server/saved_objects/client/lib/create_find_query.js b/src/server/saved_objects/client/lib/create_find_query.js index 67ee22a0ef36f..fc5993e21b8a5 100644 --- a/src/server/saved_objects/client/lib/create_find_query.js +++ b/src/server/saved_objects/client/lib/create_find_query.js @@ -2,7 +2,7 @@ export function createFindQuery(options = {}) { const { type, search, searchFields } = options; if (!type && !search) { - return { query: { match_all: {} } }; + return { version: true, query: { match_all: {} } }; } const bool = { must: [], filter: [] }; @@ -35,5 +35,5 @@ export function createFindQuery(options = {}) { }); } - return { query: { bool } }; + return { version: true, query: { bool } }; } From 915c7dfa6143b6ce2a57a86bb690f1b6908bcfee Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 16 May 2017 10:50:53 -0700 Subject: [PATCH 09/10] Removes ability to specify id on object creation Signed-off-by: Tyler Smalley --- .../client/__tests__/saved_objects_client.js | 12 +----------- .../saved_objects/client/saved_objects_client.js | 5 +---- src/server/saved_objects/routes/__tests__/create.js | 6 +++--- src/server/saved_objects/routes/create.js | 13 +++---------- 4 files changed, 8 insertions(+), 28 deletions(-) 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 7cb72005cb31b..d6906d5176448 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -67,7 +67,7 @@ describe('SavedObjectsClient', () => { }); }); - it('should use ES create action with specifying an id', async () => { + it('should use ES create action', async () => { callWithRequest.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); await savedObjectsClient.create('index-pattern', { @@ -80,16 +80,6 @@ describe('SavedObjectsClient', () => { const args = callWithRequest.getCall(0).args; expect(args[1]).to.be('create'); }); - - it('should use ES index action with specifying an id', async () => { - callWithRequest.returns({ _type: 'index-pattern', _id: 'abc123' }); - - await savedObjectsClient.create('index-pattern', { title: 'Logstash' }); - expect(callWithRequest.calledOnce).to.be(true); - - const args = callWithRequest.getCall(0).args; - expect(args[1]).to.be('index'); - }); }); describe('#delete', () => { diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 57192117b93d5..ead6eeee61217 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -16,12 +16,9 @@ export class SavedObjectsClient { async create(type, options = {}) { const body = omit(options, 'id'); - const id = get(options, 'id'); - const method = id ? 'create' : 'index'; - const response = await this._withKibanaIndex(method, { + const response = await this._withKibanaIndex('create', { type, - id, body }); diff --git a/src/server/saved_objects/routes/__tests__/create.js b/src/server/saved_objects/routes/__tests__/create.js index 356a636d1fcec..174e787485209 100644 --- a/src/server/saved_objects/routes/__tests__/create.js +++ b/src/server/saved_objects/routes/__tests__/create.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { createCreateRoute } from '../create'; import { MockServer } from './mock_server'; -describe('POST /api/saved_objects/{type}/{id?}', () => { +describe('POST /api/saved_objects/{type}', () => { const savedObjectsClient = { create: sinon.stub() }; let server; @@ -52,7 +52,7 @@ describe('POST /api/saved_objects/{type}/{id?}', () => { it('calls upon savedObjectClient.create', async () => { const request = { method: 'POST', - url: '/api/saved_objects/index-pattern/logstash-*', + url: '/api/saved_objects/index-pattern', payload: { title: 'Testing' } @@ -62,6 +62,6 @@ describe('POST /api/saved_objects/{type}/{id?}', () => { expect(savedObjectsClient.create.calledOnce).to.be(true); const args = savedObjectsClient.create.getCall(0).args; - expect(args).to.eql(['index-pattern', { title: 'Testing', id: 'logstash-*' }]); + expect(args).to.eql(['index-pattern', { title: 'Testing' }]); }); }); diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js index 8e6436a4e549e..17abf78e1ef31 100644 --- a/src/server/saved_objects/routes/create.js +++ b/src/server/saved_objects/routes/create.js @@ -1,29 +1,22 @@ import Joi from 'joi'; -import { has } from 'lodash'; export const createCreateRoute = (prereqs) => { return { - path: '/api/saved_objects/{type}/{id?}', + path: '/api/saved_objects/{type}', method: 'POST', config: { pre: [prereqs.getSavedObjectsClient], validate: { params: Joi.object().keys({ - type: Joi.string().required(), - id: Joi.string() + type: Joi.string().required() }).required(), payload: Joi.object().required() }, handler(request, reply) { const { savedObjectsClient } = request.pre; const { type } = request.params; - const body = Object.assign({}, request.payload); - if (has(request.params, 'id')) { - body.id = request.params.id; - } - - reply(savedObjectsClient.create(type, body)); + reply(savedObjectsClient.create(type, request.payload)); } } }; From 89257bfa7df57635fa3a8f5c4ede3c644c646725 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Tue, 16 May 2017 16:08:37 -0400 Subject: [PATCH 10/10] Simple stats api POC --- src/server/status/index.js | 10 ++++++++-- src/server/status/stats.js | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 src/server/status/stats.js diff --git a/src/server/status/index.js b/src/server/status/index.js index 58031184e9bba..3156e472a598f 100644 --- a/src/server/status/index.js +++ b/src/server/status/index.js @@ -1,6 +1,7 @@ import ServerStatus from './server_status'; import wrapAuthConfig from './wrap_auth_config'; import { Metrics } from './metrics'; +import { getStats } from './stats'; export default function (kbnServer, server, config) { kbnServer.status = new ServerStatus(kbnServer.server); @@ -18,7 +19,11 @@ export default function (kbnServer, server, config) { server.route(wrapAuth({ method: 'GET', path: '/api/status', - handler: function (request, reply) { + handler: async function (request, reply) { + const stats = getStats( + server.config().get('kibana.index'), + request, + server.plugins.elasticsearch.getCluster('admin').callWithRequest); const status = { name: config.get('server.name'), uuid: config.get('server.uuid'), @@ -29,7 +34,8 @@ export default function (kbnServer, server, config) { build_snapshot: matchSnapshot.test(config.get('pkg.version')) }, status: kbnServer.status.toJSON(), - metrics: kbnServer.metrics + metrics: kbnServer.metrics, + stats, }; return reply(status); diff --git a/src/server/status/stats.js b/src/server/status/stats.js new file mode 100644 index 0000000000000..28958567b090c --- /dev/null +++ b/src/server/status/stats.js @@ -0,0 +1,12 @@ +import { SavedObjectsClient } from '../saved_objects/client'; + +export async function getStats(kibanaIndex, request, callWithRequest) { + const savedObjectsClient = new SavedObjectsClient(kibanaIndex, request, callWithRequest); + const dashResponse = await savedObjectsClient.find({ type: 'dashboard', perPage: 0 }); + const visResponse = await savedObjectsClient.find({ type: 'visualization', perPage: 0 }); + + return { + dashboardCount: dashResponse.total, + visualizationCount: visResponse.total, + }; +}