diff --git a/package.json b/package.json index b4d55ac39c8d3..961246e98c42f 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "babel-register": "6.18.0", "bluebird": "2.9.34", "body-parser": "1.12.0", - "boom": "2.8.0", + "boom": "5.2.0", "brace": "0.5.1", "bunyan": "1.7.1", "check-hash": "1.0.1", diff --git a/src/core_plugins/elasticsearch/lib/__tests__/cluster.js b/src/core_plugins/elasticsearch/lib/__tests__/cluster.js index e0a729c70b65e..e4afa75d7477c 100644 --- a/src/core_plugins/elasticsearch/lib/__tests__/cluster.js +++ b/src/core_plugins/elasticsearch/lib/__tests__/cluster.js @@ -104,10 +104,12 @@ describe('plugins/elasticsearch', function () { describe('wrap401Errors', () => { let handler; - const error = new Error('Authentication required'); - error.statusCode = 401; + let error; beforeEach(() => { + error = new Error('Authentication required'); + error.statusCode = 401; + handler = sinon.stub(); }); diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 1632d389a7a8f..19cacb67cbbd4 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -5,7 +5,6 @@ import { mkdirp as mkdirpNode } from 'mkdirp'; import manageUuid from './server/lib/manage_uuid'; import search from './server/routes/api/search'; -import settings from './server/routes/api/settings'; import { importApi } from './server/routes/api/import'; import { exportApi } from './server/routes/api/export'; import scripts from './server/routes/api/scripts'; @@ -153,7 +152,6 @@ module.exports = function (kibana) { manageUuid(server); // routes search(server); - settings(server); scripts(server); importApi(server); exportApi(server); diff --git a/src/core_plugins/kibana/server/lib/handle_es_error.js b/src/core_plugins/kibana/server/lib/handle_es_error.js index 9154d3d441bb4..d641a8fb491af 100644 --- a/src/core_plugins/kibana/server/lib/handle_es_error.js +++ b/src/core_plugins/kibana/server/lib/handle_es_error.js @@ -11,7 +11,7 @@ module.exports = function handleESError(error) { error instanceof esErrors.ServiceUnavailable || error instanceof esErrors.NoConnections || error instanceof esErrors.RequestTimeout) { - return Boom.serverTimeout(error); + return Boom.serverUnavailable(error); } else if (error instanceof esErrors.Conflict || _.contains(error.message, 'index_template_already_exists')) { return Boom.conflict(error); } else if (error instanceof esErrors[403]) { diff --git a/src/core_plugins/kibana/server/routes/api/settings/index.js b/src/core_plugins/kibana/server/routes/api/settings/index.js deleted file mode 100644 index d6c0df42ff44f..0000000000000 --- a/src/core_plugins/kibana/server/routes/api/settings/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export default function (server) { - require('./register_get')(server); - require('./register_set')(server); - require('./register_set_many')(server); - require('./register_delete')(server); -} diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_delete.js b/src/core_plugins/kibana/server/routes/api/settings/register_delete.js deleted file mode 100644 index 1b78e83b5eac6..0000000000000 --- a/src/core_plugins/kibana/server/routes/api/settings/register_delete.js +++ /dev/null @@ -1,20 +0,0 @@ -import Boom from 'boom'; - -export default function registerDelete(server) { - server.route({ - path: '/api/kibana/settings/{key}', - method: 'DELETE', - handler: function (req, reply) { - const { key } = req.params; - const uiSettings = req.getUiSettingsService(); - - uiSettings - .remove(key) - .then(() => uiSettings - .getUserProvided() - .then(settings => reply({ settings }).type('application/json')) - ) - .catch(err => reply(Boom.wrap(err, err.statusCode))); - } - }); -} diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_get.js b/src/core_plugins/kibana/server/routes/api/settings/register_get.js deleted file mode 100644 index 123f869ef0672..0000000000000 --- a/src/core_plugins/kibana/server/routes/api/settings/register_get.js +++ /dev/null @@ -1,15 +0,0 @@ -import Boom from 'boom'; - -export default function registerGet(server) { - server.route({ - path: '/api/kibana/settings', - method: 'GET', - handler: function (req, reply) { - req - .getUiSettingsService() - .getUserProvided() - .then(settings => reply({ settings }).type('application/json')) - .catch(err => reply(Boom.wrap(err, err.statusCode))); - } - }); -} diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_set.js b/src/core_plugins/kibana/server/routes/api/settings/register_set.js deleted file mode 100644 index f82cafef43324..0000000000000 --- a/src/core_plugins/kibana/server/routes/api/settings/register_set.js +++ /dev/null @@ -1,21 +0,0 @@ -import Boom from 'boom'; - -export default function registerSet(server) { - server.route({ - path: '/api/kibana/settings/{key}', - method: 'POST', - handler: function (req, reply) { - const { key } = req.params; - const { value } = req.payload; - const uiSettings = req.getUiSettingsService(); - - uiSettings - .set(key, value) - .then(() => uiSettings - .getUserProvided() - .then(settings => reply({ settings }).type('application/json')) - ) - .catch(err => reply(Boom.wrap(err, err.statusCode))); - } - }); -} diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js b/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js deleted file mode 100644 index 71e873bd80d79..0000000000000 --- a/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js +++ /dev/null @@ -1,20 +0,0 @@ -import Boom from 'boom'; - -export default function registerSet(server) { - server.route({ - path: '/api/kibana/settings', - method: 'POST', - handler: function (req, reply) { - const { changes } = req.payload; - const uiSettings = req.getUiSettingsService(); - - uiSettings - .setMany(changes) - .then(() => uiSettings - .getUserProvided() - .then(settings => reply({ settings }).type('application/json')) - ) - .catch(err => reply(Boom.wrap(err, err.statusCode))); - } - }); -} diff --git a/src/server/http/__tests__/index.js b/src/server/http/__tests__/index.js index f6909114698e4..a48fc128a5c84 100644 --- a/src/server/http/__tests__/index.js +++ b/src/server/http/__tests__/index.js @@ -70,6 +70,8 @@ describe('routes', () => { it('redirects shortened urls', (done) => { kbnTestServer.makeRequest(kbnServer, shortenOptions, (res) => { + expect(res).to.have.property('statusCode', 200); + const gotoOptions = { method: 'GET', url: '/goto/' + res.payload diff --git a/src/server/http/__tests__/short_url_lookup.js b/src/server/http/__tests__/short_url_lookup.js index eac9dee0f43a8..fa9ecb0357a0a 100644 --- a/src/server/http/__tests__/short_url_lookup.js +++ b/src/server/http/__tests__/short_url_lookup.js @@ -1,6 +1,7 @@ import expect from 'expect.js'; import sinon from 'sinon'; import shortUrlLookupProvider from '../short_url_lookup'; +import { SavedObjectsClient } from '../../saved_objects/client'; describe('shortUrlLookupProvider', () => { const ID = 'bf00ad16941fc51420f91a93428b27a0'; @@ -17,7 +18,8 @@ describe('shortUrlLookupProvider', () => { savedObjectsClient = { get: sandbox.stub(), create: sandbox.stub().returns(Promise.resolve({ id: ID })), - update: sandbox.stub() + update: sandbox.stub(), + errors: SavedObjectsClient.errors }; req = { getSavedObjectsClient: () => savedObjectsClient }; @@ -58,10 +60,8 @@ describe('shortUrlLookupProvider', () => { }); it('gracefully handles version conflict', async () => { - const error = new Error('version conflict'); - error.data = { type: 'version_conflict_engine_exception' }; + const error = savedObjectsClient.errors.decorateConflictError(new Error()); savedObjectsClient.create.throws(error); - const id = await shortUrl.generateUrlId(URL, req); expect(id).to.eql(ID); }); diff --git a/src/server/http/short_url_lookup.js b/src/server/http/short_url_lookup.js index 60d7cd5b1cc69..b79b7063afeba 100644 --- a/src/server/http/short_url_lookup.js +++ b/src/server/http/short_url_lookup.js @@ -17,9 +17,11 @@ export default function (server) { return { async generateUrlId(url, req) { const id = crypto.createHash('md5').update(url).digest('hex'); + const savedObjectsClient = req.getSavedObjectsClient(); + const { isConflictError } = savedObjectsClient.errors; try { - const doc = await req.getSavedObjectsClient().create('url', { + const doc = await savedObjectsClient.create('url', { url, accessCount: 0, createDate: new Date(), @@ -27,12 +29,12 @@ export default function (server) { }, { id }); return doc.id; - } catch(e) { - if (get(e, 'data.type') === 'version_conflict_engine_exception') { + } catch (error) { + if (isConflictError(error)) { return id; } - throw e; + throw error; } }, diff --git a/src/server/saved_objects/client/__tests__/saved_objects_client_mappings.js b/src/server/saved_objects/client/__tests__/saved_objects_client_mappings.js index 05b555ec1b346..e0d9419029a6b 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client_mappings.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client_mappings.js @@ -3,6 +3,7 @@ import expect from 'expect.js'; import sinon from 'sinon'; import { SavedObjectsClient } from '../saved_objects_client'; +import { decorateEsError } from '../lib'; const { BadRequest } = elasticsearch.errors; describe('SavedObjectsClient', () => { @@ -23,11 +24,11 @@ describe('SavedObjectsClient', () => { describe('#create', () => { it('falls back to single-type mapping', async () => { - const error = new BadRequest('[illegal_argument_exception] Rejecting mapping update to [.kibana-v6]', { + const error = decorateEsError(new BadRequest('[illegal_argument_exception] Rejecting mapping update to [.kibana-v6]', { body: { error: illegalArgumentException } - }); + })); callAdminCluster .onFirstCall().throws(error) @@ -49,11 +50,11 @@ describe('SavedObjectsClient', () => { it('prepends id for single-type', async () => { const id = 'foo'; - const error = new BadRequest('[illegal_argument_exception] Rejecting mapping update to [.kibana-v6]', { + const error = decorateEsError(new BadRequest('[illegal_argument_exception] Rejecting mapping update to [.kibana-v6]', { body: { error: illegalArgumentException } - }); + })); callAdminCluster .onFirstCall().throws(error) @@ -154,13 +155,13 @@ describe('SavedObjectsClient', () => { const type = 'index-pattern'; const version = 2; const attributes = { title: 'Testing' }; - const error = new BadRequest('[document_missing_exception] [config][logstash-*]: document missing', { + const error = decorateEsError(new BadRequest('[document_missing_exception] [config][logstash-*]: document missing', { body: { error: { type: 'document_missing_exception' } } - }); + })); beforeEach(() => { callAdminCluster diff --git a/src/server/saved_objects/client/lib/__tests__/decorate_es_error.js b/src/server/saved_objects/client/lib/__tests__/decorate_es_error.js new file mode 100644 index 0000000000000..4a6c09495c331 --- /dev/null +++ b/src/server/saved_objects/client/lib/__tests__/decorate_es_error.js @@ -0,0 +1,89 @@ +import expect from 'expect.js'; +import { errors as esErrors } from 'elasticsearch'; + +import { decorateEsError } from '../decorate_es_error'; +import { + isEsUnavailableError, + isConflictError, + isNotAuthorizedError, + isForbiddenError, + isNotFoundError, + isBadRequestError, +} from '../errors'; + +describe('savedObjectsClient/decorateEsError', () => { + it('always returns the same error it receives', () => { + const error = new Error(); + expect(decorateEsError(error)).to.be(error); + }); + + it('makes es.ConnectionFault a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ConnectionFault(); + expect(isEsUnavailableError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isEsUnavailableError(error)).to.be(true); + }); + + it('makes es.ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ServiceUnavailable(); + expect(isEsUnavailableError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isEsUnavailableError(error)).to.be(true); + }); + + it('makes es.NoConnections a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.NoConnections(); + expect(isEsUnavailableError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isEsUnavailableError(error)).to.be(true); + }); + + it('makes es.RequestTimeout a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.RequestTimeout(); + expect(isEsUnavailableError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isEsUnavailableError(error)).to.be(true); + }); + + it('makes es.Conflict a SavedObjectsClient/Conflict error', () => { + const error = new esErrors.Conflict(); + expect(isConflictError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isConflictError(error)).to.be(true); + }); + + it('makes es.AuthenticationException a SavedObjectsClient/NotAuthorized error', () => { + const error = new esErrors.AuthenticationException(); + expect(isNotAuthorizedError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isNotAuthorizedError(error)).to.be(true); + }); + + it('makes es.Forbidden a SavedObjectsClient/Forbidden error', () => { + const error = new esErrors.Forbidden(); + expect(isForbiddenError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isForbiddenError(error)).to.be(true); + }); + + it('makes es.NotFound a SavedObjectsClient/NotFound error', () => { + const error = new esErrors.NotFound(); + expect(isNotFoundError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isNotFoundError(error)).to.be(true); + }); + + it('makes es.BadRequest a SavedObjectsClient/BadRequest error', () => { + const error = new esErrors.BadRequest(); + expect(isBadRequestError(error)).to.be(false); + expect(decorateEsError(error)).to.be(error); + expect(isBadRequestError(error)).to.be(true); + }); + + it('returns other errors as Boom errors', () => { + const error = new Error(); + expect(error).to.not.have.property('isBoom'); + expect(decorateEsError(error)).to.be(error); + expect(error).to.have.property('isBoom'); + }); +}); diff --git a/src/server/saved_objects/client/lib/__tests__/errors.js b/src/server/saved_objects/client/lib/__tests__/errors.js new file mode 100644 index 0000000000000..f912d20944850 --- /dev/null +++ b/src/server/saved_objects/client/lib/__tests__/errors.js @@ -0,0 +1,304 @@ +import expect from 'expect.js'; +import Boom from 'boom'; + +import { + decorateBadRequestError, + isBadRequestError, + decorateNotAuthorizedError, + isNotAuthorizedError, + decorateForbiddenError, + isForbiddenError, + decorateNotFoundError, + isNotFoundError, + decorateConflictError, + isConflictError, + decorateEsUnavailableError, + isEsUnavailableError, + decorateGeneralError, +} from '../errors'; + +describe('savedObjectsClient/errorTypes', () => { + describe('BadRequest error', () => { + describe('decorateBadRequestError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateBadRequestError(error)).to.be(error); + }); + + it('makes the error identifiable as a BadRequest error', () => { + const error = new Error(); + expect(isBadRequestError(error)).to.be(false); + decorateBadRequestError(error); + expect(isBadRequestError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateBadRequestError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(400); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateBadRequestError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateBadRequestError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateBadRequestError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 400', () => { + const error = decorateBadRequestError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 400); + }); + }); + }); + }); + describe('NotAuthorized error', () => { + describe('decorateNotAuthorizedError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateNotAuthorizedError(error)).to.be(error); + }); + + it('makes the error identifiable as a NotAuthorized error', () => { + const error = new Error(); + expect(isNotAuthorizedError(error)).to.be(false); + decorateNotAuthorizedError(error); + expect(isNotAuthorizedError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateNotAuthorizedError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(401); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateNotAuthorizedError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateNotAuthorizedError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateNotAuthorizedError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 401', () => { + const error = decorateNotAuthorizedError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 401); + }); + }); + }); + }); + describe('Forbidden error', () => { + describe('decorateForbiddenError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateForbiddenError(error)).to.be(error); + }); + + it('makes the error identifiable as a Forbidden error', () => { + const error = new Error(); + expect(isForbiddenError(error)).to.be(false); + decorateForbiddenError(error); + expect(isForbiddenError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateForbiddenError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(403); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateForbiddenError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateForbiddenError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateForbiddenError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 403', () => { + const error = decorateForbiddenError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 403); + }); + }); + }); + }); + describe('NotFound error', () => { + describe('decorateNotFoundError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateNotFoundError(error)).to.be(error); + }); + + it('makes the error identifiable as a NotFound error', () => { + const error = new Error(); + expect(isNotFoundError(error)).to.be(false); + decorateNotFoundError(error); + expect(isNotFoundError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateNotFoundError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(404); + }); + + it('preserves boom properties of input', () => { + const error = Boom.forbidden(); + decorateNotFoundError(error); + expect(error.output.statusCode).to.be(403); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateNotFoundError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateNotFoundError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 404', () => { + const error = decorateNotFoundError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 404); + }); + }); + }); + }); + describe('Conflict error', () => { + describe('decorateConflictError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateConflictError(error)).to.be(error); + }); + + it('makes the error identifiable as a Conflict error', () => { + const error = new Error(); + expect(isConflictError(error)).to.be(false); + decorateConflictError(error); + expect(isConflictError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateConflictError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(409); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateConflictError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateConflictError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateConflictError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 409', () => { + const error = decorateConflictError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 409); + }); + }); + }); + }); + describe('EsUnavailable error', () => { + describe('decorateEsUnavailableError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateEsUnavailableError(error)).to.be(error); + }); + + it('makes the error identifiable as a EsUnavailable error', () => { + const error = new Error(); + expect(isEsUnavailableError(error)).to.be(false); + decorateEsUnavailableError(error); + expect(isEsUnavailableError(error)).to.be(true); + }); + + it('adds boom properties', () => { + const error = decorateEsUnavailableError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(503); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateEsUnavailableError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('defaults to message of erorr', () => { + const error = decorateEsUnavailableError(new Error('foobar')); + expect(error.output.payload).to.have.property('message', 'foobar'); + }); + it('prefixes message with passed reason', () => { + const error = decorateEsUnavailableError(new Error('foobar'), 'biz'); + expect(error.output.payload).to.have.property('message', 'biz: foobar'); + }); + it('sets statusCode to 503', () => { + const error = decorateEsUnavailableError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 503); + }); + }); + }); + }); + describe('General error', () => { + describe('decorateGeneralError', () => { + it('returns original object', () => { + const error = new Error(); + expect(decorateGeneralError(error)).to.be(error); + }); + + it('adds boom properties', () => { + const error = decorateGeneralError(new Error()); + expect(error.output).to.be.an('object'); + expect(error.output.statusCode).to.be(500); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + decorateGeneralError(error); + expect(error.output.statusCode).to.be(404); + }); + + describe('error.output', () => { + it('ignores error message', () => { + const error = decorateGeneralError(new Error('foobar')); + expect(error.output.payload).to.have.property('message').match(/internal server error/i); + }); + it('sets statusCode to 500', () => { + const error = decorateGeneralError(new Error('foo')); + expect(error.output).to.have.property('statusCode', 500); + }); + }); + }); + }); +}); diff --git a/src/server/saved_objects/client/lib/decorate_es_error.js b/src/server/saved_objects/client/lib/decorate_es_error.js new file mode 100644 index 0000000000000..9f721e25eb6ba --- /dev/null +++ b/src/server/saved_objects/client/lib/decorate_es_error.js @@ -0,0 +1,62 @@ +import elasticsearch from 'elasticsearch'; +import { get } from 'lodash'; + +const { + ConnectionFault, + ServiceUnavailable, + NoConnections, + RequestTimeout, + Conflict, + 401: NotAuthorized, + 403: Forbidden, + NotFound, + BadRequest +} = elasticsearch.errors; + +import { + decorateBadRequestError, + decorateNotAuthorizedError, + decorateForbiddenError, + decorateNotFoundError, + decorateConflictError, + decorateEsUnavailableError, + decorateGeneralError, +} from './errors'; + +export function decorateEsError(error) { + if (!(error instanceof Error)) { + throw new Error('Expected an instance of Error'); + } + + const { reason } = get(error, 'body.error', {}); + if ( + error instanceof ConnectionFault || + error instanceof ServiceUnavailable || + error instanceof NoConnections || + error instanceof RequestTimeout + ) { + return decorateEsUnavailableError(error, reason); + } + + if (error instanceof Conflict) { + return decorateConflictError(error, reason); + } + + if (error instanceof NotAuthorized) { + return decorateNotAuthorizedError(error, reason); + } + + if (error instanceof Forbidden) { + return decorateForbiddenError(error, reason); + } + + if (error instanceof NotFound) { + return decorateNotFoundError(error, reason); + } + + if (error instanceof BadRequest) { + return decorateBadRequestError(error, reason); + } + + return decorateGeneralError(error, reason); +} diff --git a/src/server/saved_objects/client/lib/errors.js b/src/server/saved_objects/client/lib/errors.js new file mode 100644 index 0000000000000..cbeb4921ffe70 --- /dev/null +++ b/src/server/saved_objects/client/lib/errors.js @@ -0,0 +1,81 @@ +import Boom from 'boom'; + +const code = Symbol('SavedObjectsClientErrorCode'); + +function decorate(error, errorCode, statusCode, message) { + const boom = Boom.boomify(error, { + statusCode, + message, + override: false, + }); + + boom[code] = errorCode; + + return boom; +} + +// 400 - badRequest +const CODE_BAD_REQUEST = 'SavedObjectsClient/badRequest'; +export function decorateBadRequestError(error, reason) { + return decorate(error, CODE_BAD_REQUEST, 400, reason); +} +export function isBadRequestError(error) { + return error && error[code] === CODE_BAD_REQUEST; +} + + +// 401 - Not Authorized +const CODE_NOT_AUTHORIZED = 'SavedObjectsClient/notAuthorized'; +export function decorateNotAuthorizedError(error, reason) { + return decorate(error, CODE_NOT_AUTHORIZED, 401, reason); +} +export function isNotAuthorizedError(error) { + return error && error[code] === CODE_NOT_AUTHORIZED; +} + + +// 403 - Forbidden +const CODE_FORBIDDEN = 'SavedObjectsClient/forbidden'; +export function decorateForbiddenError(error, reason) { + return decorate(error, CODE_FORBIDDEN, 403, reason); +} +export function isForbiddenError(error) { + return error && error[code] === CODE_FORBIDDEN; +} + + +// 404 - Not Found +const CODE_NOT_FOUND = 'SavedObjectsClient/notFound'; +export function decorateNotFoundError(error, reason) { + return decorate(error, CODE_NOT_FOUND, 404, reason); +} +export function isNotFoundError(error) { + return error && error[code] === CODE_NOT_FOUND; +} + + +// 409 - Conflict +const CODE_CONFLICT = 'SavedObjectsClient/conflict'; +export function decorateConflictError(error, reason) { + return decorate(error, CODE_CONFLICT, 409, reason); +} +export function isConflictError(error) { + return error && error[code] === CODE_CONFLICT; +} + + +// 500 - Es Unavailable +const CODE_ES_UNAVAILABLE = 'SavedObjectsClient/esUnavailable'; +export function decorateEsUnavailableError(error, reason) { + return decorate(error, CODE_ES_UNAVAILABLE, 503, reason); +} +export function isEsUnavailableError(error) { + return error && error[code] === CODE_ES_UNAVAILABLE; +} + + +// 500 - General Error +const CODE_GENERAL_ERROR = 'SavedObjectsClient/generalError'; +export function decorateGeneralError(error, reason) { + return decorate(error, CODE_GENERAL_ERROR, 500, reason); +} diff --git a/src/server/saved_objects/client/lib/handle_es_error.js b/src/server/saved_objects/client/lib/handle_es_error.js deleted file mode 100644 index 87bdcf44841b0..0000000000000 --- a/src/server/saved_objects/client/lib/handle_es_error.js +++ /dev/null @@ -1,50 +0,0 @@ -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, type } = get(error, 'body.error', {}); - const details = { type }; - - if ( - error instanceof ConnectionFault || - error instanceof ServiceUnavailable || - error instanceof NoConnections || - error instanceof RequestTimeout - ) { - throw Boom.serverTimeout(); - } - - if (error instanceof Conflict) { - throw Boom.conflict(reason, details); - } - - if (error instanceof Forbidden) { - throw Boom.forbidden(reason, details); - } - - if (error instanceof NotFound) { - throw Boom.notFound(reason, details); - } - - if (error instanceof BadRequest) { - throw Boom.badRequest(reason, details); - } - - throw error; -} diff --git a/src/server/saved_objects/client/lib/index.js b/src/server/saved_objects/client/lib/index.js index 2db84b11d49ec..d232b9dc30ebf 100644 --- a/src/server/saved_objects/client/lib/index.js +++ b/src/server/saved_objects/client/lib/index.js @@ -1,6 +1,9 @@ export { createFindQuery } from './create_find_query'; export { createIdQuery } from './create_id_query'; -export { handleEsError } from './handle_es_error'; export { v5BulkCreate, v6BulkCreate } from './compatibility'; export { normalizeEsDoc } from './normalize_es_doc'; export { includedFields } from './included_fields'; +export { decorateEsError } from './decorate_es_error'; + +import * as errors from './errors'; +export { errors }; diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index cd69c34fd6d1b..6325e115c5af0 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -5,11 +5,12 @@ import { get } from 'lodash'; import { createFindQuery, createIdQuery, - handleEsError, v5BulkCreate, v6BulkCreate, normalizeEsDoc, - includedFields + includedFields, + decorateEsError, + errors, } from './lib'; export const V6_TYPE = 'doc'; @@ -21,6 +22,9 @@ export class SavedObjectsClient { this._callAdminCluster = callAdminCluster; } + static errors = errors + errors = errors + /** * Persists an object * @@ -107,7 +111,7 @@ export class SavedObjectsClient { }); if (get(response, 'deleted') === 0) { - throw Boom.notFound(); + throw errors.decorateNotFoundError(Boom.notFound()); } } @@ -207,7 +211,7 @@ export class SavedObjectsClient { const [hit] = get(response, 'hits.hits', []); if (!hit) { - throw Boom.notFound(); + throw errors.decorateNotFoundError(Boom.notFound()); } return normalizeEsDoc(hit); @@ -252,8 +256,14 @@ export class SavedObjectsClient { }; return this._withKibanaIndex(method, params).catch(err => { - if (get(fallbacks, method, []).includes(get(err, 'data.type'))) { - return this._withKibanaIndex(method, Object.assign({}, params, fallbackParams)); + const fallbackWhen = get(fallbacks, method, []); + const type = get(err, 'body.error.type'); + + if (type && fallbackWhen.includes(type)) { + return this._withKibanaIndex(method, { + ...params, + ...fallbackParams + }); } throw err; @@ -267,7 +277,7 @@ export class SavedObjectsClient { index: this._kibanaIndex, }); } catch (err) { - throw handleEsError(err); + throw decorateEsError(err); } } } diff --git a/src/test_utils/es/create_call_cluster.js b/src/test_utils/es/create_call_cluster.js new file mode 100644 index 0000000000000..2801b466d3fa8 --- /dev/null +++ b/src/test_utils/es/create_call_cluster.js @@ -0,0 +1,21 @@ +import { get } from 'lodash'; +import toPath from 'lodash/internal/toPath'; + +/** + * Create a callCluster function that properly executes methods on an + * elasticsearch-js client + * + * @param {elasticsearch.Client} esClient + * @return {Function} + */ +export function createCallCluster(esClient) { + return function callCluster(method, params) { + const path = toPath(method); + const contextPath = path.slice(0, -1); + + const action = get(esClient, path); + const context = contextPath.length ? get(esClient, contextPath) : esClient; + + return action.call(context, params); + }; +} diff --git a/src/test_utils/es/es_test_cluster.js b/src/test_utils/es/es_test_cluster.js index 97ec418e1d831..0f08ff994cd90 100644 --- a/src/test_utils/es/es_test_cluster.js +++ b/src/test_utils/es/es_test_cluster.js @@ -1,8 +1,10 @@ import { resolve } from 'path'; import libesvm from 'libesvm'; +import elasticsearch from 'elasticsearch'; import { esTestConfig } from './es_test_config'; +import { createCallCluster } from './create_call_cluster'; const ESVM_DIR = resolve(__dirname, '../../../esvm/test_utils/es_test_cluster'); const BRANCHES_DOWNLOADED = []; @@ -33,12 +35,27 @@ export function createEsTestCluster(options = {}) { // assigned in use.start(), reassigned in use.stop() let cluster; + let client; return new class EsTestCluster { getStartTimeout() { return esTestConfig.getLibesvmStartTimeout(); } + getClient() { + if (!client) { + client = new elasticsearch.Client({ + host: esTestConfig.getUrl() + }); + } + + return client; + } + + getCallCluster() { + return createCallCluster(this.getClient()); + } + async start() { const download = isDownloadNeeded(branch); @@ -89,6 +106,12 @@ export function createEsTestCluster(options = {}) { } async stop() { + if (client) { + const c = client; + client = null; + await c.close(); + } + if (cluster) { const c = cluster; cluster = null; diff --git a/src/test_utils/es/index.js b/src/test_utils/es/index.js index 0409e204608eb..0b4aa85c802a0 100644 --- a/src/test_utils/es/index.js +++ b/src/test_utils/es/index.js @@ -1,2 +1,3 @@ export { esTestConfig } from './es_test_config'; export { createEsTestCluster } from './es_test_cluster'; +export { createCallCluster } from './create_call_cluster'; diff --git a/src/ui/ui_settings/__tests__/lib/create_objects_client_stub.js b/src/ui/ui_settings/__tests__/lib/create_objects_client_stub.js index 5b6c38b8a5cf5..63ff8b5363b64 100644 --- a/src/ui/ui_settings/__tests__/lib/create_objects_client_stub.js +++ b/src/ui/ui_settings/__tests__/lib/create_objects_client_stub.js @@ -1,10 +1,14 @@ import sinon from 'sinon'; import expect from 'expect.js'; +import { SavedObjectsClient } from '../../../../server/saved_objects/client'; + +export const savedObjectsClientErrors = SavedObjectsClient.errors; export function createObjectsClientStub(type, id, esDocSource = {}) { const savedObjectsClient = { update: sinon.stub().returns(Promise.resolve()), - get: sinon.stub().returns({ attributes: esDocSource }) + get: sinon.stub().returns({ attributes: esDocSource }), + errors: savedObjectsClientErrors }; savedObjectsClient.assertGetQuery = () => { diff --git a/src/ui/ui_settings/__tests__/lib/index.js b/src/ui/ui_settings/__tests__/lib/index.js index 14c9b5af0c610..0ca35ea49fb7d 100644 --- a/src/ui/ui_settings/__tests__/lib/index.js +++ b/src/ui/ui_settings/__tests__/lib/index.js @@ -1 +1,4 @@ -export { createObjectsClientStub } from './create_objects_client_stub'; +export { + createObjectsClientStub, + savedObjectsClientErrors, +} from './create_objects_client_stub'; diff --git a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js index a81d7d6d8d3a3..90839c4f083cc 100644 --- a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js +++ b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js @@ -37,6 +37,7 @@ describe('uiSettingsMixin()', () => { // mock hapi server const server = { log: sinon.stub(), + route: sinon.stub(), config: () => config, decorate: sinon.spy((type, name, value) => { decorations[type][name] = value; diff --git a/src/ui/ui_settings/__tests__/ui_settings_service.js b/src/ui/ui_settings/__tests__/ui_settings_service.js index aad976cb98d81..c7ab164c5945e 100644 --- a/src/ui/ui_settings/__tests__/ui_settings_service.js +++ b/src/ui/ui_settings/__tests__/ui_settings_service.js @@ -5,7 +5,10 @@ import Chance from 'chance'; import { UiSettingsService } from '../ui_settings_service'; -import { createObjectsClientStub } from './lib'; +import { + createObjectsClientStub, + savedObjectsClientErrors, +} from './lib'; const TYPE = 'config'; const ID = 'kibana-version'; @@ -197,9 +200,12 @@ describe('ui settings', () => { it('throws 401 errors', async () => { const { uiSettings } = setup({ - savedObjectsClient: { async get() { - throw new esErrors[401](); - } } + savedObjectsClient: { + errors: savedObjectsClientErrors, + async get() { + throw new esErrors[401](); + } + } }); try { @@ -214,9 +220,12 @@ describe('ui settings', () => { const expectedUnexpectedError = new Error('unexpected'); const { uiSettings } = setup({ - savedObjectsClient: { async get() { - throw expectedUnexpectedError; - } } + savedObjectsClient: { + errors: savedObjectsClientErrors, + async get() { + throw expectedUnexpectedError; + } + } }); try { diff --git a/src/ui/ui_settings/routes/__tests__/doc_exists.js b/src/ui/ui_settings/routes/__tests__/doc_exists.js new file mode 100644 index 0000000000000..13b445089a1d2 --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/doc_exists.js @@ -0,0 +1,134 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; + +import { + getServices, + chance, + assertSinonMatch, +} from './lib'; + +export function docExistsSuite() { + async function setup(options = {}) { + const { + initialSettings + } = options; + + const { kbnServer, uiSettings } = getServices(); + + if (initialSettings) { + await uiSettings.setMany(initialSettings); + } + + return { kbnServer, uiSettings }; + } + + describe('get route', () => { + it('returns a 200 and includes userValues', async () => { + const defaultIndex = chance.word({ length: 10 }); + const { kbnServer } = await setup({ + initialSettings: { + defaultIndex + } + }); + + const { statusCode, result } = await kbnServer.inject({ + method: 'GET', + url: '/api/kibana/settings' + }); + + expect(statusCode).to.be(200); + assertSinonMatch(result, { + settings: { + buildNum: { + userValue: sinon.match.number + }, + defaultIndex: { + userValue: defaultIndex + } + } + }); + }); + }); + + describe('set route', () => { + it('returns a 200 and all values including update', async () => { + const { kbnServer } = await setup(); + + const defaultIndex = chance.word(); + const { statusCode, result } = await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings/defaultIndex', + payload: { + value: defaultIndex + } + }); + + expect(statusCode).to.be(200); + assertSinonMatch(result, { + settings: { + buildNum: { + userValue: sinon.match.number + }, + defaultIndex: { + userValue: defaultIndex + } + } + }); + }); + }); + + describe('setMany route', () => { + it('returns a 200 and all values including updates', async () => { + const { kbnServer } = await setup(); + + const defaultIndex = chance.word(); + const { statusCode, result } = await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings', + payload: { + changes: { + defaultIndex + } + } + }); + + expect(statusCode).to.be(200); + assertSinonMatch(result, { + settings: { + buildNum: { + userValue: sinon.match.number + }, + defaultIndex: { + userValue: defaultIndex + } + } + }); + }); + }); + + describe('delete route', () => { + it('returns a 200 and deletes the setting', async () => { + const defaultIndex = chance.word({ length: 10 }); + + const { kbnServer, uiSettings } = await setup({ + initialSettings: { defaultIndex } + }); + + expect(await uiSettings.get('defaultIndex')).to.be(defaultIndex); + + const { statusCode, result } = await kbnServer.inject({ + method: 'DELETE', + url: '/api/kibana/settings/defaultIndex' + }); + + expect(statusCode).to.be(200); + assertSinonMatch(result, { + settings: { + buildNum: { + userValue: sinon.match.number + } + } + }); + }); + }); +} diff --git a/src/ui/ui_settings/routes/__tests__/doc_missing.js b/src/ui/ui_settings/routes/__tests__/doc_missing.js new file mode 100644 index 0000000000000..8c46aaa113dcf --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/doc_missing.js @@ -0,0 +1,77 @@ +import expect from 'expect.js'; + +import { + getServices, + chance, + assertDocMissingResponse +} from './lib'; + +export function docMissingSuite() { + async function setup() { + const { kbnServer, savedObjectsClient } = getServices(); + + // delete all config docs + const { saved_objects: objs } = await savedObjectsClient.find({ type: 'config' }); + + for (const obj of objs) { + await savedObjectsClient.delete(obj.type, obj.id); + } + + return { kbnServer }; + } + + describe('get route', () => { + it('returns a 200 with empty values', async () => { + const { kbnServer } = await setup(); + + const { statusCode, result } = await kbnServer.inject({ + method: 'GET', + url: '/api/kibana/settings' + }); + + expect(statusCode).to.be(200); + expect(result).to.eql({ settings: {} }); + }); + }); + + describe('set route', () => { + it('returns a 404', async () => { + const { kbnServer } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings/defaultIndex', + payload: { + value: chance.word() + } + })); + }); + }); + + describe('setMany route', () => { + it('returns a 404', async () => { + const { kbnServer } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings', + payload: { + changes: { + defaultIndex: chance.word() + } + } + })); + }); + }); + + describe('delete route', () => { + it('returns a 404', async () => { + const { kbnServer } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'DELETE', + url: '/api/kibana/settings/defaultIndex' + })); + }); + }); +} diff --git a/src/ui/ui_settings/routes/__tests__/index.js b/src/ui/ui_settings/routes/__tests__/index.js new file mode 100644 index 0000000000000..d2936052a94e4 --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/index.js @@ -0,0 +1,23 @@ +import { + startServers, + stopServers, +} from './lib'; + +import { docExistsSuite } from './doc_exists'; +import { docMissingSuite } from './doc_missing'; +import { indexMissingSuite } from './index_missing'; + +describe('uiSettings/routes', function () { + this.slow(2000); + this.timeout(10000); + + // these tests rely on getting sort of lucky with + // the healthcheck, so we retry if they fail + this.retries(3); + + before(startServers); + describe('doc exists', docExistsSuite); + describe('doc missing', docMissingSuite); + describe('index missing', indexMissingSuite); + after(stopServers); +}); diff --git a/src/ui/ui_settings/routes/__tests__/index_missing.js b/src/ui/ui_settings/routes/__tests__/index_missing.js new file mode 100644 index 0000000000000..790ba02207f4f --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/index_missing.js @@ -0,0 +1,116 @@ +import expect from 'expect.js'; + +import { + getServices, + chance, + assertDocMissingResponse +} from './lib'; + +export function indexMissingSuite() { + beforeEach(async function () { + const { kbnServer } = getServices(); + await kbnServer.server.plugins.elasticsearch.waitUntilReady(); + }); + + function getNumberOfShards(index) { + return parseInt(Object.values(index)[0].settings.index.number_of_shards, 10); + } + + async function getIndex(callCluster, indexName) { + return await callCluster('indices.get', { + index: indexName, + }); + } + + async function setup() { + const { callCluster, kbnServer } = getServices(); + const indexName = kbnServer.config.get('kibana.index'); + const initialIndex = await getIndex(callCluster, indexName); + + await callCluster('indices.delete', { + index: indexName, + }); + + return { + kbnServer, + + // an incorrect number of shards is how we determine when the index was not created by Kibana, + // but automatically by writing to es when index didn't exist + async assertInvalidKibanaIndex() { + const index = await getIndex(callCluster, indexName); + + expect(getNumberOfShards(index)) + .to.not.be(getNumberOfShards(initialIndex)); + } + }; + } + + afterEach(async () => { + const { kbnServer, callCluster } = getServices(); + await callCluster('indices.delete', { + index: kbnServer.config.get('kibana.index'), + ignore: 404 + }); + }); + + describe('get route', () => { + it('returns a 200 and with empty values', async () => { + const { kbnServer } = await setup(); + + const { statusCode, result } = await kbnServer.inject({ + method: 'GET', + url: '/api/kibana/settings' + }); + + expect(statusCode).to.be(200); + expect(result).to.eql({ settings: {} }); + }); + }); + + describe('set route', () => { + it('creates an invalid Kibana index and returns a 404 document missing error', async () => { + const { kbnServer, assertInvalidKibanaIndex } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings/defaultIndex', + payload: { + value: chance.word() + } + })); + + await assertInvalidKibanaIndex(); + }); + }); + + describe('setMany route', () => { + it('creates an invalid Kibana index and returns a 404 document missing error', async () => { + const { kbnServer, assertInvalidKibanaIndex } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'POST', + url: '/api/kibana/settings', + payload: { + changes: { + defaultIndex: chance.word() + } + } + })); + + await assertInvalidKibanaIndex(); + }); + }); + + describe('delete route', () => { + it('creates an invalid Kibana index and returns a 404 document missing error', async () => { + const { kbnServer, assertInvalidKibanaIndex } = await setup(); + + assertDocMissingResponse(await kbnServer.inject({ + method: 'DELETE', + url: '/api/kibana/settings/defaultIndex' + })); + + await assertInvalidKibanaIndex(); + }); + }); +} diff --git a/src/ui/ui_settings/routes/__tests__/lib/assert.js b/src/ui/ui_settings/routes/__tests__/lib/assert.js new file mode 100644 index 0000000000000..36de9a0e1e090 --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/lib/assert.js @@ -0,0 +1,16 @@ +import sinon from 'sinon'; + +export function assertSinonMatch(value, match) { + const stub = sinon.stub(); + stub(value); + sinon.assert.calledWithExactly(stub, match); +} + +export function assertDocMissingResponse({ result }) { + assertSinonMatch(result, { + statusCode: 404, + error: 'Not Found', + message: sinon.match('document_missing_exception') + .and(sinon.match('document missing')) + }); +} diff --git a/src/ui/ui_settings/routes/__tests__/lib/chance.js b/src/ui/ui_settings/routes/__tests__/lib/chance.js new file mode 100644 index 0000000000000..f3b9840f57537 --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/lib/chance.js @@ -0,0 +1,3 @@ +import Chance from 'chance'; + +export const chance = new Chance(); diff --git a/src/ui/ui_settings/routes/__tests__/lib/index.js b/src/ui/ui_settings/routes/__tests__/lib/index.js new file mode 100644 index 0000000000000..3bb32ca01ce8f --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/lib/index.js @@ -0,0 +1,14 @@ +export { + startServers, + getServices, + stopServers +} from './servers'; + +export { + chance +} from './chance'; + +export { + assertSinonMatch, + assertDocMissingResponse +} from './assert'; diff --git a/src/ui/ui_settings/routes/__tests__/lib/servers.js b/src/ui/ui_settings/routes/__tests__/lib/servers.js new file mode 100644 index 0000000000000..2cc5c7a29c78d --- /dev/null +++ b/src/ui/ui_settings/routes/__tests__/lib/servers.js @@ -0,0 +1,53 @@ +import { createEsTestCluster } from '../../../../../test_utils/es'; +import * as kbnTestServer from '../../../../../test_utils/kbn_server'; + +let kbnServer; +let services; +const es = createEsTestCluster({ + name: 'ui_settings/routes' +}); + +export async function startServers() { + this.timeout(es.getStartTimeout()); + await es.start(); + + kbnServer = kbnTestServer.createServerWithCorePlugins(); + await kbnServer.ready(); + await kbnServer.server.plugins.elasticsearch.waitUntilReady(); +} + +export function getServices() { + if (services) { + return services; + } + + const callCluster = es.getCallCluster(); + + const savedObjectsClient = kbnServer.server.savedObjectsClientFactory({ + callCluster, + }); + + const uiSettings = kbnServer.server.uiSettingsServiceFactory({ + savedObjectsClient, + }); + + services = { + kbnServer, + callCluster, + savedObjectsClient, + uiSettings + }; + + return services; +} + +export async function stopServers() { + services = null; + + if (kbnServer) { + await kbnServer.close(); + kbnServer = null; + } + + await es.stop(); +} diff --git a/src/ui/ui_settings/routes/delete.js b/src/ui/ui_settings/routes/delete.js new file mode 100644 index 0000000000000..ccd3418101b52 --- /dev/null +++ b/src/ui/ui_settings/routes/delete.js @@ -0,0 +1,17 @@ +async function handleRequest(request) { + const { key } = request.params; + const uiSettings = request.getUiSettingsService(); + + await uiSettings.remove(key); + return { + settings: await uiSettings.getUserProvided() + }; +} + +export const deleteRoute = { + path: '/api/kibana/settings/{key}', + method: 'DELETE', + handler(request, reply) { + reply(handleRequest(request)); + } +}; diff --git a/src/ui/ui_settings/routes/get.js b/src/ui/ui_settings/routes/get.js new file mode 100644 index 0000000000000..813c6b8a1fe2d --- /dev/null +++ b/src/ui/ui_settings/routes/get.js @@ -0,0 +1,14 @@ +async function handleRequest(request) { + const uiSettings = request.getUiSettingsService(); + return { + settings: await uiSettings.getUserProvided() + }; +} + +export const getRoute = { + path: '/api/kibana/settings', + method: 'GET', + handler: function (request, reply) { + reply(handleRequest(request)); + } +}; diff --git a/src/ui/ui_settings/routes/index.js b/src/ui/ui_settings/routes/index.js new file mode 100644 index 0000000000000..9100e0107ac35 --- /dev/null +++ b/src/ui/ui_settings/routes/index.js @@ -0,0 +1,4 @@ +export { deleteRoute } from './delete'; +export { getRoute } from './get'; +export { setManyRoute } from './set_many'; +export { setRoute } from './set'; diff --git a/src/ui/ui_settings/routes/set.js b/src/ui/ui_settings/routes/set.js new file mode 100644 index 0000000000000..5070304d4eb00 --- /dev/null +++ b/src/ui/ui_settings/routes/set.js @@ -0,0 +1,31 @@ +import Joi from 'joi'; + +async function handleRequest(request) { + const { key } = request.params; + const { value } = request.payload; + const uiSettings = request.getUiSettingsService(); + + await uiSettings.set(key, value); + return { + settings: await uiSettings.getUserProvided() + }; +} + +export const setRoute = { + path: '/api/kibana/settings/{key}', + method: 'POST', + config: { + validate: { + params: Joi.object().keys({ + key: Joi.string().required(), + }).default(), + + payload: Joi.object().keys({ + value: Joi.any().required() + }).required() + }, + handler(request, reply) { + reply(handleRequest(request)); + } + } +}; diff --git a/src/ui/ui_settings/routes/set_many.js b/src/ui/ui_settings/routes/set_many.js new file mode 100644 index 0000000000000..c43aa6f72ba5c --- /dev/null +++ b/src/ui/ui_settings/routes/set_many.js @@ -0,0 +1,26 @@ +import Joi from 'joi'; + +async function handleRequest(request) { + const { changes } = request.payload; + const uiSettings = request.getUiSettingsService(); + + await uiSettings.setMany(changes); + return { + settings: await uiSettings.getUserProvided() + }; +} + +export const setManyRoute = { + path: '/api/kibana/settings', + method: 'POST', + config: { + validate: { + payload: Joi.object().keys({ + changes: Joi.object().unknown(true).required() + }).required() + }, + handler(request, reply) { + reply(handleRequest(request)); + } + } +}; diff --git a/src/ui/ui_settings/ui_settings_mixin.js b/src/ui/ui_settings/ui_settings_mixin.js index 62b59db991542..ae9c6ad14cfa3 100644 --- a/src/ui/ui_settings/ui_settings_mixin.js +++ b/src/ui/ui_settings/ui_settings_mixin.js @@ -2,6 +2,12 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory'; import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request'; import { mirrorStatus } from './mirror_status'; import { UiExportsConsumer } from './ui_exports_consumer'; +import { + deleteRoute, + getRoute, + setManyRoute, + setRoute, +} from './routes'; export function uiSettingsMixin(kbnServer, server, config) { const status = kbnServer.status.create('ui settings'); @@ -56,4 +62,9 @@ export function uiSettingsMixin(kbnServer, server, config) { server.uiSettings has been removed, see https://github.com/elastic/kibana/pull/12243. `); }); + + server.route(deleteRoute); + server.route(getRoute); + server.route(setManyRoute); + server.route(setRoute); } diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js index d966d4a0e9683..559134f90191a 100644 --- a/src/ui/ui_settings/ui_settings_service.js +++ b/src/ui/ui_settings/ui_settings_service.js @@ -1,5 +1,4 @@ import { defaultsDeep, noop } from 'lodash'; -import { errors as esErrors } from 'elasticsearch'; function hydrateUserSettings(userSettings) { return Object.keys(userSettings) @@ -106,11 +105,18 @@ export class UiSettingsService { ignore401Errors = false } = options; + const { + isNotFoundError, + isForbiddenError, + isEsUnavailableError, + isNotAuthorizedError + } = this._savedObjectsClient.errors; + const isIgnorableError = error => ( - error instanceof esErrors[404] || - error instanceof esErrors[403] || - error instanceof esErrors.NoConnections || - (ignore401Errors && error instanceof esErrors[401]) + isNotFoundError(error) || + isForbiddenError(error) || + isEsUnavailableError(error) || + (ignore401Errors && isNotAuthorizedError(error)) ); try {