diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index 6db292ee5930b..3e74a77eb6a68 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..2498327e1e401 --- /dev/null +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -0,0 +1,248 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { SavedObjectsClient } from '../saved_objects_client'; + +describe('SavedObjectsClient', () => { + let callAdminCluster; + 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(() => { + callAdminCluster = sinon.mock(); + savedObjectsClient = new SavedObjectsClient('.kibana-test', callAdminCluster); + }); + + afterEach(() => { + callAdminCluster.reset(); + }); + + + describe('#create', () => { + it('formats Elasticsearch response', async () => { + callAdminCluster.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); + + const response = await savedObjectsClient.create('index-pattern', { + title: 'Logstash' + }); + + expect(response).to.eql({ + type: 'index-pattern', + id: 'logstash-*', + version: 2, + attributes: { + title: 'Logstash', + } + }); + }); + + it('should use ES create action', async () => { + callAdminCluster.returns({ _type: 'index-pattern', _id: 'logstash-*', _version: 2 }); + + await savedObjectsClient.create('index-pattern', { + id: 'logstash-*', + title: 'Logstash' + }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + expect(args[0]).to.be('index'); + }); + }); + + describe('#delete', () => { + it('throws notFound when ES is unable to find the document', (done) => { + callAdminCluster.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 callAdminCluster', async () => { + await savedObjectsClient.delete('index-pattern', 'logstash-*'); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + expect(args[0]).to.be('delete'); + expect(args[1]).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; + + callAdminCluster.returns(Promise.resolve(docs)); + const response = await savedObjectsClient.find(); + + expect(response.total).to.be(count); + expect(response.saved_objects).to.have.length(count); + docs.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).to.eql({ + id: doc._id, + type: doc._type, + version: doc._version, + attributes: doc._source + }); + }); + }); + + it('accepts per_page/page', async () => { + await savedObjectsClient.find({ perPage: 10, page: 6 }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const options = callAdminCluster.getCall(0).args[1]; + expect(options.size).to.be(10); + expect(options.from).to.be(50); + }); + + it('accepts type', async () => { + await savedObjectsClient.find({ type: 'index-pattern' }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const options = callAdminCluster.getCall(0).args[1]; + const expectedQuery = { + bool: { + must: [{ match_all: {} }], + filter: [{ term: { _type: 'index-pattern' } }] + } + }; + + expect(options.body).to.eql({ + query: expectedQuery, version: true + }); + }); + + it('can filter by fields', async () => { + await savedObjectsClient.find({ fields: 'title' }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const options = callAdminCluster.getCall(0).args[1]; + expect(options._source).to.eql('title'); + }); + }); + + describe('#get', () => { + it('formats Elasticsearch response', async () => { + callAdminCluster.returns(Promise.resolve({ + _id: 'logstash-*', + _type: 'index-pattern', + _version: 2, + _source: { + title: 'Testing' + } + })); + + const response = await savedObjectsClient.get('index-pattern', 'logstash-*'); + expect(response).to.eql({ + id: 'logstash-*', + type: 'index-pattern', + version: 2, + attributes: { + title: 'Testing' + } + }); + }); + }); + + describe('#update', () => { + it('returns current ES document version', async () => { + const id = 'logstash-*'; + const type = 'index-pattern'; + const version = 2; + const attributes = { title: 'Testing' }; + + callAdminCluster.returns(Promise.resolve({ + _id: id, + _type: type, + _version: version, + result: 'updated' + })); + + const response = await savedObjectsClient.update('index-pattern', 'logstash-*', attributes); + expect(response).to.eql({ + id, + type, + version, + attributes + }); + }); + + it('accepts version', async () => { + await savedObjectsClient.update( + 'index-pattern', + 'logstash-*', + { title: 'Testing' }, + { version: 1 } + ); + + const esParams = callAdminCluster.getCall(0).args[1]; + expect(esParams.version).to.be(1); + }); + + it('passes the parameters to callAdminCluster', async () => { + await savedObjectsClient.update('index-pattern', 'logstash-*', { title: 'Testing' }); + + expect(callAdminCluster.calledOnce).to.be(true); + + const args = callAdminCluster.getCall(0).args; + + expect(args[0]).to.be('update'); + expect(args[1]).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/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_find_query.js b/src/server/saved_objects/client/lib/__tests__/create_find_query.js new file mode 100644 index 0000000000000..e682c5ddbda6f --- /dev/null +++ b/src/server/saved_objects/client/lib/__tests__/create_find_query.js @@ -0,0 +1,82 @@ +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: {} }, version: true }); + }); + + 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: {} + }] + } + }, + version: true + }); + }); + + 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 + } + }] + } + }, + version: 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'] + } + }] + } + }, + version: true + }); + }); + + 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'] + } + }] + } + }, + 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 new file mode 100644 index 0000000000000..fc5993e21b8a5 --- /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 { version: true, 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 { version: true, 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..a6a1b695a79db --- /dev/null +++ b/src/server/saved_objects/client/lib/handle_es_error.js @@ -0,0 +1,49 @@ +import elasticsearch from 'elasticsearch'; +import Boom from 'boom'; +import { get } from 'lodash'; + +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'); + } + + const reason = get(error, 'body.error.reason'); + + if ( + error instanceof ConnectionFault || + error instanceof ServiceUnavailable || + error instanceof NoConnections || + error instanceof RequestTimeout + ) { + throw Boom.serverTimeout(); + } + + if (error instanceof Conflict) { + throw Boom.conflict(reason); + } + + if (error instanceof Forbidden) { + throw Boom.forbidden(reason); + } + + if (error instanceof NotFound) { + throw Boom.notFound(reason); + } + + if (error instanceof BadRequest) { + throw Boom.badRequest(reason); + } + + 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..b0e3483b16b31 --- /dev/null +++ b/src/server/saved_objects/client/lib/index.js @@ -0,0 +1,2 @@ +export { createFindQuery } from './create_find_query'; +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..2d7f06efcb199 --- /dev/null +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -0,0 +1,120 @@ +import Boom from 'boom'; +import { get } from 'lodash'; + +import { + createFindQuery, + handleEsError, +} from './lib'; + +export class SavedObjectsClient { + constructor(kibanaIndex, callAdminCluster) { + this._kibanaIndex = kibanaIndex; + this._callAdminCluster = callAdminCluster; + } + + async create(type, body = {}) { + const response = await this._withKibanaIndex('index', { + type, + body + }); + + return { + type: response._type, + id: response._id, + version: response._version, + attributes: body + }; + } + + async delete(type, id) { + const response = await this._withKibanaIndex('delete', { + type, + id, + refresh: 'wait_for' + }); + + if (get(response, 'found') === false) { + throw Boom.notFound(); + } + } + + async find(options = {}) { + const { + search, + searchFields, + type, + fields, + perPage = 20, + page = 1, + } = options; + + const esOptions = { + type, + _source: fields, + size: perPage, + from: perPage * (page - 1), + body: createFindQuery({ search, searchFields, type }) + }; + + const response = await this._withKibanaIndex('search', esOptions); + + return { + saved_objects: get(response, 'hits.hits', []).map(r => { + return { + id: r._id, + type: r._type, + version: r._version, + attributes: 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 { + id: response._id, + type: response._type, + version: response._version, + attributes: response._source + }; + } + + async update(type, id, attributes, options = {}) { + const response = await this._withKibanaIndex('update', { + type, + id, + version: get(options, 'version'), + body: { + doc: attributes + }, + refresh: 'wait_for' + }); + + return { + id: id, + type: type, + version: get(response, '_version'), + attributes: attributes + }; + } + + async _withKibanaIndex(method, params) { + try { + return await this._callAdminCluster(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..384c4b32cdfce --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/create.js @@ -0,0 +1,87 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createCreateRoute } from '../create'; +import { MockServer } from './mock_server'; + +describe('POST /api/saved_objects/{type}', () => { + 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/saved_objects/index-pattern', + payload: { + attributes: { + 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('requires attributes', async () => { + const request = { + method: 'POST', + url: '/api/saved_objects/index-pattern', + payload: {} + }; + + const { statusCode, payload } = await server.inject(request); + const response = JSON.parse(payload); + + expect(response.validation.keys).to.contain('attributes'); + expect(response.message).to.match(/is required/); + expect(response.statusCode).to.be(400); + expect(statusCode).to.be(400); + }); + + it('calls upon savedObjectClient.create', async () => { + const request = { + method: 'POST', + url: '/api/saved_objects/index-pattern', + payload: { + attributes: { + 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' }]); + }); +}); 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..b020369210d73 --- /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/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/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/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..8b3e4808d10bf --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/find.js @@ -0,0 +1,162 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createFindRoute } from '../find'; +import { MockServer } from './mock_server'; + +describe('GET /api/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/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/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({ perPage: 20, page: 1 }); + }); + + it('accepts the query parameter page/per_page', async () => { + const request = { + method: 'GET', + url: '/api/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({ perPage: 10, page: 50 }); + }); + + it('accepts the query parameter search_fields', async() => { + const request = { + method: 'GET', + url: '/api/saved_objects?search_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({ perPage: 20, page: 1, searchFields: 'title' }); + }); + + it('accepts the query parameter fields as a string', async () => { + const request = { + method: 'GET', + url: '/api/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({ perPage: 20, page: 1, fields: 'title' }); + }); + + it('accepts the query parameter fields as an array', async () => { + const request = { + method: 'GET', + url: '/api/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({ + perPage: 20, page: 1, fields: ['title', 'description'] + }); + }); + + it('accepts the type as a query parameter', async () => { + const request = { + method: 'GET', + url: '/api/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({ perPage: 20, page: 1, type: 'index-pattern' }); + }); + + it('accepts the type as a URL parameter', async () => { + const request = { + method: 'GET', + url: '/api/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({ perPage: 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..ea1205fdf345b --- /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/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/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/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..92a7494aca339 --- /dev/null +++ b/src/server/saved_objects/routes/__tests__/update.js @@ -0,0 +1,67 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { createUpdateRoute } from '../update'; +import { MockServer } from './mock_server'; + +describe('PUT /api/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/saved_objects/index-pattern/logstash-*', + payload: { + attributes: { + 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 attributes = { title: 'Testing' }; + const options = { version: 2 }; + const request = { + method: 'PUT', + url: '/api/saved_objects/index-pattern/logstash-*', + payload: { + attributes, + version: options.version + } + }; + + 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-*', attributes, options]); + }); +}); diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js new file mode 100644 index 0000000000000..76e6e6d975b78 --- /dev/null +++ b/src/server/saved_objects/routes/create.js @@ -0,0 +1,26 @@ +import Joi from 'joi'; + +export const createCreateRoute = (prereqs) => { + return { + path: '/api/saved_objects/{type}', + method: 'POST', + config: { + pre: [prereqs.getSavedObjectsClient], + validate: { + params: Joi.object().keys({ + type: Joi.string().required() + }).required(), + payload: Joi.object({ + attributes: Joi.object().required() + }).required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type } = request.params; + const { attributes } = request.payload; + + reply(savedObjectsClient.create(type, attributes)); + } + } + }; +}; diff --git a/src/server/saved_objects/routes/delete.js b/src/server/saved_objects/routes/delete.js new file mode 100644 index 0000000000000..a394530a0f655 --- /dev/null +++ b/src/server/saved_objects/routes/delete.js @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +export const createDeleteRoute = (prereqs) => ({ + path: '/api/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..1d9720fb0302f --- /dev/null +++ b/src/server/saved_objects/routes/find.js @@ -0,0 +1,32 @@ +import Joi from 'joi'; +import { keysToCamelCaseShallow } from '../../../utils/case_conversion'; + +export const createFindRoute = (prereqs) => ({ + path: '/api/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(0).default(20), + page: Joi.number().min(0).default(1), + type: Joi.string(), + search: Joi.string().allow('').optional(), + search_fields: [Joi.string(), Joi.array().items(Joi.string())], + fields: [Joi.string(), Joi.array().items(Joi.string())] + }) + }, + handler(request, reply) { + const options = keysToCamelCaseShallow(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..f96b0cab51ad0 --- /dev/null +++ b/src/server/saved_objects/routes/read.js @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +export const createReadRoute = (prereqs) => ({ + path: '/api/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..771f3352d372b --- /dev/null +++ b/src/server/saved_objects/routes/update.js @@ -0,0 +1,29 @@ +import Joi from 'joi'; + +export const createUpdateRoute = (prereqs) => { + return { + path: '/api/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({ + attributes: Joi.object().required(), + version: Joi.number().min(1) + }).required() + }, + handler(request, reply) { + const { savedObjectsClient } = request.pre; + const { type, id } = request.params; + const { attributes, version } = request.payload; + const options = { version }; + + reply(savedObjectsClient.update(type, id, attributes, options)); + } + } + }; +}; 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..64046edc57bd3 --- /dev/null +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -0,0 +1,32 @@ +import { SavedObjectsClient } from './client'; + +import { + createCreateRoute, + createDeleteRoute, + createFindRoute, + createReadRoute, + createUpdateRoute +} from './routes'; + +export function savedObjectsMixin(kbnServer, server) { + const prereqs = { + getSavedObjectsClient: { + assign: 'savedObjectsClient', + method(req, reply) { + const adminCluster = req.server.plugins.elasticsearch.getCluster('admin'); + const callAdminCluster = (...args) => adminCluster.callWithRequest(req, ...args); + + reply(new SavedObjectsClient( + server.config().get('kibana.index'), + callAdminCluster + )); + } + }, + }; + + 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_object.js b/src/ui/public/saved_objects/__tests__/saved_object.js new file mode 100644 index 0000000000000..f519b3760de27 --- /dev/null +++ b/src/ui/public/saved_objects/__tests__/saved_object.js @@ -0,0 +1,34 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; +import { SavedObject } from '../saved_object'; + +describe('SavedObject', () => { + it('persists type and id', () => { + const id = 'logstash-*'; + const type = 'index-pattern'; + + const client = sinon.stub(); + const savedObject = new SavedObject(client, { id, type }); + + expect(savedObject.id).to.be(id); + expect(savedObject.type).to.be(type); + }); + + it('persists attributes', () => { + const attributes = { title: 'My title' }; + + const client = sinon.stub(); + const savedObject = new SavedObject(client, { attributes }); + + expect(savedObject._attributes).to.be(attributes); + }); + + it('persists version', () => { + const version = 2; + + const client = sinon.stub(); + const savedObject = new SavedObject(client, { version }); + + expect(savedObject._version).to.be(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 new file mode 100644 index 0000000000000..38d5fa5b5c59b --- /dev/null +++ b/src/ui/public/saved_objects/__tests__/saved_objects_client.js @@ -0,0 +1,317 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; +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(); + const doc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + attributes: { title: 'Example title' } + }; + + let savedObjectsClient; + let $http; + + beforeEach(() => { + $http = sandbox.stub().returns(Promise.resolve({})); + savedObjectsClient = new SavedObjectsClient($http, basePath); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#_getUrl', () => { + it('returns without arguments', () => { + const url = savedObjectsClient._getUrl(); + const expected = `${basePath}/api/saved_objects/`; + + expect(url).to.be(expected); + }); + + it('appends path', () => { + const url = savedObjectsClient._getUrl(['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/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', () => { + $http.withArgs({ + method: 'POST', + url: '/api/path', + data: params + }).returns(Promise.resolve({ data: '' })); + + savedObjectsClient._request('POST', '/api/path', params); + + expect($http.calledOnce).to.be(true); + }); + + it('throws error when body is provided for GET', async () => { + try { + await savedObjectsClient._request('GET', '/api/path', params); + expect().fail('should have error'); + } catch (e) { + expect(e.message).to.eql('body not permitted for GET requests'); + } + }); + + it('catches API error', async () => { + const message = 'Request failed'; + $http.returns(Promise.reject({ data: { error: message } })); + + try { + await savedObjectsClient._request('POST', '/api/path', params); + expect().fail('should have error'); + } catch (e) { + expect(e.message).to.eql(message); + } + }); + + it('catches API error status', async () => { + $http.returns(Promise.reject({ status: 404 })); + + try { + await savedObjectsClient._request('POST', '/api/path', params); + expect().fail('should have error'); + } catch (e) { + expect(e.message).to.eql('404 Response'); + } + }); + }); + + describe('#get', () => { + beforeEach(() => { + $http.withArgs({ + method: 'GET', + url: `${basePath}/api/saved_objects/index-pattern/logstash-*`, + data: undefined + }).returns(Promise.resolve({ data: doc })); + }); + + it('returns a promise', () => { + expect(savedObjectsClient.get('index-pattern', 'logstash-*')).to.be.a(Promise); + }); + + it('requires type', async () => { + try { + await savedObjectsClient.get(); + expect().fail('should have error'); + } catch (e) { + expect(e.message).to.be('requires type and id'); + } + }); + + it('requires id', async () => { + try { + await savedObjectsClient.get('index-pattern'); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be('requires type and id'); + } + }); + + it('resolves with instantiated SavedObject', async () => { + const response = await savedObjectsClient.get('index-pattern', 'logstash-*'); + 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-*'); + sinon.assert.calledOnce($http); + }); + }); + + describe('#delete', () => { + beforeEach(() => { + $http.withArgs({ + method: 'DELETE', + url: `${basePath}/api/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', async () => { + try { + await savedObjectsClient.delete(); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be('requires type and id'); + } + }); + + it('requires id', async () => { + try { + await savedObjectsClient.delete('index-pattern'); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be('requires type and id'); + } + }); + + it('makes HTTP call', () => { + savedObjectsClient.delete('index-pattern', 'logstash-*'); + sinon.assert.calledOnce($http); + }); + }); + + describe('#update', () => { + const requireMessage = 'requires type, id and attributes'; + + beforeEach(() => { + $http.withArgs({ + method: 'PUT', + url: `${basePath}/api/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', async () => { + try { + await savedObjectsClient.update(); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be(requireMessage); + } + }); + + it('requires id', async () => { + try { + await savedObjectsClient.update('index-pattern'); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be(requireMessage); + } + }); + + it('requires attributes', async () => { + try { + await savedObjectsClient.update('index-pattern', 'logstash-*'); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be(requireMessage); + } + }); + + it('makes HTTP call', () => { + const attributes = { foo: 'Foo', bar: 'Bar' }; + const body = { attributes, version: 2 }; + const options = { version: 2 }; + + savedObjectsClient.update('index-pattern', 'logstash-*', attributes, options); + sinon.assert.calledOnce($http); + + expect($http.getCall(0).args[0].data).to.eql(body); + }); + }); + + describe('#create', () => { + const requireMessage = 'requires type and body'; + + beforeEach(() => { + $http.withArgs({ + method: 'POST', + url: `${basePath}/api/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', async () => { + try { + await savedObjectsClient.create(); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be(requireMessage); + } + }); + + it('requires body', async () => { + try { + await savedObjectsClient.create('index-pattern'); + expect().throw('should have error'); + } catch (e) { + expect(e.message).to.be(requireMessage); + } + }); + + it('makes HTTP call', () => { + const body = { foo: 'Foo', bar: 'Bar', id: 'logstash-*' }; + savedObjectsClient.create('index-pattern', body); + + sinon.assert.calledOnce($http); + expect($http.getCall(0).args[0].data).to.eql(body); + }); + }); + + describe('#find', () => { + const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' }; + + beforeEach(() => { + $http.returns(Promise.resolve({ data: { saved_objects: [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($http.calledOnce).to.be(true); + + const options = $http.getCall(0).args[0]; + expect(options.url).to.eql(`${basePath}/api/saved_objects/index-pattern?type=index-pattern&invalid=true`); + }); + + it('accepts fields', () => { + const body = { fields: ['title', 'description'] }; + + savedObjectsClient.find(body); + expect($http.calledOnce).to.be(true); + + const options = $http.getCall(0).args[0]; + expect(options.url).to.eql(`${basePath}/api/saved_objects/?fields=title&fields=description`); + }); + + it('accepts from/size', () => { + const body = { from: 50, size: 10 }; + + savedObjectsClient.find(body); + expect($http.calledOnce).to.be(true); + + const options = $http.getCall(0).args[0]; + expect(options.url).to.eql(`${basePath}/api/saved_objects/?from=50&size=10`); + + }); + }); +}); 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..e9763c09affaf --- /dev/null +++ b/src/ui/public/saved_objects/saved_object.js @@ -0,0 +1,35 @@ +import _ from 'lodash'; + +export class SavedObject { + constructor(client, { id, type, version, attributes } = {}) { + this._client = client; + this.id = id; + this.type = type; + this._attributes = attributes || {}; + this._version = version; + } + + get(key) { + return _.get(this._attributes, key); + } + + set(key, value) { + return _.set(this._attributes, key, value); + } + + has(key) { + return _.has(this._attributes, key); + } + + 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._client.delete(this.type, this.id); + } +} 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..04aef92a0b678 --- /dev/null +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -0,0 +1,102 @@ +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'; + +const join = (...uriComponents) => ( + uriComponents.filter(Boolean).map(encodeURIComponent).join('/') +); + +export class SavedObjectsClient { + constructor($http, basePath, PromiseCtor = Promise) { + this._$http = $http; + this._apiBaseUrl = `${basePath}/api/saved_objects/`; + this._PromiseCtor = PromiseCtor; + } + + 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); + }); + } + + delete(type, id) { + if (!type || !id) { + return this._PromiseCtor.reject(new Error('requires type and id')); + } + + return this._request('DELETE', this._getUrl([type, id])); + } + + update(type, id, attributes, { version } = {}) { + if (!type || !id || !attributes) { + return this._PromiseCtor.reject(new Error('requires type, id and attributes')); + } + + const body = { + attributes, + version + }; + + return this._request('PUT', this._getUrl([type, id]), body); + } + + create(type, body) { + if (!type || !body) { + return this._PromiseCtor.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], keysToSnakeCaseShallow(options)); + + return this._request('GET', url).then(resp => { + resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); + return keysToCamelCaseShallow(resp); + }); + } + + createSavedObject(options) { + return new SavedObject(this, options); + } + + _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, data: body }; + + if (method === 'GET' && body) { + return this._PromiseCtor.reject(new Error('body not permitted for GET requests')); + } + + return this._$http(options) + .then(resp => get(resp, 'data')) + .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..2d335e5b2353d --- /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, $q) { + return new SavedObjectsClient($http, chrome.getBasePath(), $q); +}