From 65d6b5d3094e1faefbd126db6551e59a08690f90 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 12 Jun 2017 14:16:10 -0700 Subject: [PATCH] [uiSettings] make service request based (#12243) * [server/uiSettings] make uiSettings service request based * [server/uiSettings] disambiguate UiSettings/Service * [server/uiSettings] link to PR in removal error * [server/uiSettings] await _read before hydrating * [server/uiSettings] focus tests, remove server integration * [server/uiSettings] add tests for readInterceptor() arg * [server/uiSettings] add server integration tests * [server/uiExports] fix replaceInjectedVars tests * [server/uiSettings] convert all methods to use async/await * [uiSettings/serviceFactory] fix doc block * [uiSettings/service] fix doc block * [uiSettings/tests/callClusterStub] stop tracking state needlessly * [uiSettings/tests] remove invalid tests and pointless promise helpers * [uiSettings/forRequest] fix typo * [uiSettings/tests] remove mixture of arrow and function expressions * [uiSettings/tests/callClusterStub] leverage sinon.calledWithExactly * [uiSettings/mixin/tests] add exception for eslint import/no-duplicates * [uiSettings/mixin/tests] wrap single args in parens --- .../routes/api/settings/register_delete.js | 7 +- .../routes/api/settings/register_get.js | 6 +- .../routes/api/settings/register_set.js | 7 +- .../routes/api/settings/register_set_many.js | 7 +- .../timelion/server/routes/run.js | 2 +- .../timelion/server/routes/validate_es.js | 2 +- src/server/http/index.js | 4 +- .../ui_exports_replace_injected_vars.js | 2 +- src/ui/index.js | 4 +- src/ui/ui_settings/__tests__/index.js | 497 ------------------ .../__tests__/lib/call_cluster_stub.js | 41 ++ src/ui/ui_settings/__tests__/lib/index.js | 1 + .../ui_settings_mixin_integration.js | 197 +++++++ .../__tests__/ui_settings_service.js | 393 ++++++++++++++ src/ui/ui_settings/ui_settings.js | 120 ----- src/ui/ui_settings/ui_settings_mixin.js | 33 +- src/ui/ui_settings/ui_settings_service.js | 144 +++++ .../ui_settings_service_factory.js | 31 ++ .../ui_settings_service_for_request.js | 20 + 19 files changed, 879 insertions(+), 639 deletions(-) delete mode 100644 src/ui/ui_settings/__tests__/index.js create mode 100644 src/ui/ui_settings/__tests__/lib/call_cluster_stub.js create mode 100644 src/ui/ui_settings/__tests__/lib/index.js create mode 100644 src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js create mode 100644 src/ui/ui_settings/__tests__/ui_settings_service.js delete mode 100644 src/ui/ui_settings/ui_settings.js create mode 100644 src/ui/ui_settings/ui_settings_service.js create mode 100644 src/ui/ui_settings/ui_settings_service_factory.js create mode 100644 src/ui/ui_settings/ui_settings_service_for_request.js 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 index ea68620298d3d..1b78e83b5eac6 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_delete.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_delete.js @@ -6,11 +6,12 @@ export default function registerDelete(server) { method: 'DELETE', handler: function (req, reply) { const { key } = req.params; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .remove(req, key) + .remove(key) .then(() => uiSettings - .getUserProvided(req) + .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 index 85fc8c9ca5f27..123f869ef0672 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_get.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_get.js @@ -5,9 +5,9 @@ export default function registerGet(server) { path: '/api/kibana/settings', method: 'GET', handler: function (req, reply) { - server - .uiSettings() - .getUserProvided(req) + 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 index 8d572bbd1ab25..f82cafef43324 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_set.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_set.js @@ -7,11 +7,12 @@ export default function registerSet(server) { handler: function (req, reply) { const { key } = req.params; const { value } = req.payload; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .set(req, key, value) + .set(key, value) .then(() => uiSettings - .getUserProvided(req) + .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 index 57ce8357fb1d2..71e873bd80d79 100644 --- 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 @@ -6,11 +6,12 @@ export default function registerSet(server) { method: 'POST', handler: function (req, reply) { const { changes } = req.payload; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .setMany(req, changes) + .setMany(changes) .then(() => uiSettings - .getUserProvided(req) + .getUserProvided() .then(settings => reply({ settings }).type('application/json')) ) .catch(err => reply(Boom.wrap(err, err.statusCode))); diff --git a/src/core_plugins/timelion/server/routes/run.js b/src/core_plugins/timelion/server/routes/run.js index 6a5cffcd1754b..136d5e08673dd 100644 --- a/src/core_plugins/timelion/server/routes/run.js +++ b/src/core_plugins/timelion/server/routes/run.js @@ -14,7 +14,7 @@ export default function (server) { path: '/api/timelion/run', handler: async (request, reply) => { try { - const uiSettings = await server.uiSettings().getAll(request); + const uiSettings = await request.getUiSettingsService().getAll(); const tlConfig = require('../handlers/lib/tl_config.js')({ server, diff --git a/src/core_plugins/timelion/server/routes/validate_es.js b/src/core_plugins/timelion/server/routes/validate_es.js index 0bac26f414af3..7802c09a6196e 100644 --- a/src/core_plugins/timelion/server/routes/validate_es.js +++ b/src/core_plugins/timelion/server/routes/validate_es.js @@ -3,7 +3,7 @@ export default function (server) { method: 'GET', path: '/api/timelion/validate/es', handler: function (request, reply) { - return server.uiSettings().getAll(request).then((uiSettings) => { + return request.getUiSettingsService().getAll().then((uiSettings) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); const timefield = uiSettings['timelion:es.timefield']; diff --git a/src/server/http/index.js b/src/server/http/index.js index 6380c0beef33d..ef1fec8264f08 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -120,8 +120,8 @@ export default async function (kbnServer, server, config) { const url = await shortUrlLookup.getUrl(request.params.urlId, request); shortUrlAssertValid(url); - const uiSettings = server.uiSettings(); - const stateStoreInSessionStorage = await uiSettings.get(request, 'state:storeInSessionStorage'); + const uiSettings = request.getUiSettingsService(); + const stateStoreInSessionStorage = await uiSettings.get('state:storeInSessionStorage'); if (!stateStoreInSessionStorage) { reply().redirect(config.get('server.basePath') + url); return; diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js index 6ebd06df03237..662236464c90f 100644 --- a/src/ui/__tests__/ui_exports_replace_injected_vars.js +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -40,7 +40,7 @@ describe('UiExports', function () { await kbnServer.ready(); kbnServer.status.get('ui settings').state = 'green'; - kbnServer.server.decorate('server', 'uiSettings', () => { + kbnServer.server.decorate('request', 'getUiSettingsService', () => { return { getDefaults: noop, getUserProvided: noop }; }); }); diff --git a/src/ui/index.js b/src/ui/index.js index 130f8357c053e..8d4eb73c85117 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -66,7 +66,7 @@ export default async (kbnServer, server, config) => { }); async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) { - const uiSettings = server.uiSettings(); + const uiSettings = request.getUiSettingsService(); const translations = await uiI18n.getTranslationsForRequest(request); return { @@ -82,7 +82,7 @@ export default async (kbnServer, server, config) => { translations: translations, uiSettings: await props({ defaults: uiSettings.getDefaults(), - user: includeUserProvidedConfig && uiSettings.getUserProvided(request) + user: includeUserProvidedConfig && uiSettings.getUserProvided() }), vars: await reduceAsync( uiExports.injectedVarsReplacers, diff --git a/src/ui/ui_settings/__tests__/index.js b/src/ui/ui_settings/__tests__/index.js deleted file mode 100644 index 40c681f2b9765..0000000000000 --- a/src/ui/ui_settings/__tests__/index.js +++ /dev/null @@ -1,497 +0,0 @@ -import { isEqual } from 'lodash'; -import sinon from 'sinon'; -import expect from 'expect.js'; -import { uiSettingsMixin } from '../ui_settings_mixin'; -import { getDefaultSettings } from '../defaults'; -import { errors as esErrors } from 'elasticsearch'; - -async function expectRejection(promise, errorMessageContain) { - if (!promise || typeof promise.then !== 'function') { - throw new Error('Expected function to return a promise'); - } - - try { - await promise; - } catch (err) { - expect(err.message).to.contain(errorMessageContain); - } -} - -describe('ui settings', function () { - describe('overview', function () { - it('has expected api surface', function () { - const { uiSettings } = instantiate(); - expect(typeof uiSettings.get).to.equal('function'); - expect(typeof uiSettings.getAll).to.equal('function'); - expect(typeof uiSettings.getDefaults).to.equal('function'); - expect(typeof uiSettings.getRaw).to.equal('function'); - expect(typeof uiSettings.getUserProvided).to.equal('function'); - expect(typeof uiSettings.remove).to.equal('function'); - expect(typeof uiSettings.removeMany).to.equal('function'); - expect(typeof uiSettings.set).to.equal('function'); - expect(typeof uiSettings.setMany).to.equal('function'); - }); - - it('throws if the first error is not a request', async () => { - const { uiSettings } = instantiate(); - await expectRejection(uiSettings.get(null), 'hapi.Request'); - await expectRejection(uiSettings.get(false), 'hapi.Request'); - await expectRejection(uiSettings.get('key'), 'hapi.Request'); - await expectRejection(uiSettings.get(/regex/), 'hapi.Request'); - await expectRejection(uiSettings.get(new Date()), 'hapi.Request'); - await expectRejection(uiSettings.get({}), 'hapi.Request'); - await expectRejection(uiSettings.get({ path:'' }), 'hapi.Request'); - await expectRejection(uiSettings.get({ path:'', headers:null }), 'hapi.Request'); - await expectRejection(uiSettings.get({ headers:{} }), 'hapi.Request'); - }); - }); - - describe('#setMany()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.setMany(req, { a: 'b' }); - expect(result).to.be.a(Promise); - }); - - it('updates a single value in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.setMany(req, { one: 'value' }); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value' - }); - }); - - it('updates several values in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.setMany(req, { one: 'value', another: 'val' }); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value', another: 'val' - }); - }); - }); - - describe('#set()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.set(req, 'a', 'b'); - expect(result).to.be.a(Promise); - }); - - it('updates single values by (key, value)', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.set(req, 'one', 'value'); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value' - }); - }); - }); - - describe('#remove()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.remove(req, 'one'); - expect(result).to.be.a(Promise); - }); - - it('removes single values by key', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.remove(req, 'one'); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null - }); - }); - }); - - describe('#removeMany()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.removeMany(req, ['one']); - expect(result).to.be.a(Promise); - }); - - it('removes a single value', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.removeMany(req, ['one']); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null - }); - }); - - it('updates several values in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.removeMany(req, ['one', 'two', 'three']); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null, two: null, three: null - }); - }); - }); - - describe('#getDefaults()', function () { - it('is promised the default values', async function () { - const { - uiSettings - } = instantiate(); - const defaults = await uiSettings.getDefaults(); - expect(isEqual(defaults, getDefaultSettings())).to.equal(true); - }); - - - describe('defaults for formatters', async function () { - - const defaults = getDefaultSettings(); - const mapping = JSON.parse(defaults['format:defaultTypeMap'].value); - const expected = { - ip: { id: 'ip', params: {} }, - date: { id: 'date', params: {} }, - number: { id: 'number', params: {} }, - boolean: { id: 'boolean', params: {} }, - _source: { id: '_source', params: {} }, - _default_: { id: 'string', params: {} } - }; - - Object.keys(mapping).forEach(function (dataType) { - it(`should configure ${dataType}`, function () { - expect(expected.hasOwnProperty(dataType)).to.equal(true); - expect(mapping[dataType].id).to.equal(expected[dataType].id); - expect(JSON.stringify(mapping[dataType].params)).to.equal(JSON.stringify(expected[dataType].params)); - }); - }); - }); - }); - - describe('#getUserProvided()', function () { - it('pulls user configuration from ES', async function () { - const getResult = { user: 'customized' }; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getUserProvided(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it('returns user configuration', async function () { - const getResult = { user: 'customized' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getUserProvided(req); - expect(isEqual(result, { - user: { userValue: 'customized' } - })).to.equal(true); - }); - - it('ignores null user configuration (because default values)', async function () { - const getResult = { user: 'customized', usingDefault: null, something: 'else' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getUserProvided(req); - expect(isEqual(result, { - user: { userValue: 'customized' }, something: { userValue: 'else' } - })).to.equal(true); - }); - - it('returns an empty object when status is not green', async function () { - const { uiSettings, req } = instantiate({ - settingsStatusOverrides: { state: 'yellow' } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on 404 responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[404](); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on 403 responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[403](); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on NoConnections responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors.NoConnections(); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('throws 401 errors', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[401](); - } - }); - - try { - await uiSettings.getUserProvided(req); - throw new Error('expect getUserProvided() to throw'); - } catch (err) { - expect(err).to.be.a(esErrors[401]); - } - }); - - it('throw when callWithRequest fails in some unexpected way', async function () { - const expectedUnexpectedError = new Error('unexpected'); - - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw expectedUnexpectedError; - } - }); - - try { - await uiSettings.getUserProvided(req); - throw new Error('expect getUserProvided() to throw'); - } catch (err) { - expect(err).to.be(expectedUnexpectedError); - } - }); - }); - - describe('#getRaw()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getRaw(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`without user configuration it's equal to the defaults`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - expect(isEqual(result, getDefaultSettings())).to.equal(true); - }); - - it(`user configuration gets merged with defaults`, async function () { - const getResult = { foo: 'bar' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - const merged = getDefaultSettings(); - merged.foo = { userValue: 'bar' }; - expect(isEqual(result, merged)).to.equal(true); - }); - - it(`user configuration gets merged into defaults`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - const merged = getDefaultSettings(); - merged.dateFormat.userValue = 'YYYY-MM-DD'; - expect(isEqual(result, merged)).to.equal(true); - }); - }); - - describe('#getAll()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getAll(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`returns key value pairs`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expect(isEqual(result, expectation)).to.equal(true); - }); - - it(`returns key value pairs including user configuration`, async function () { - const getResult = { something: 'user-provided' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expectation.something = 'user-provided'; - expect(isEqual(result, expectation)).to.equal(true); - }); - - it(`returns key value pairs including user configuration for existing settings`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expectation.dateFormat = 'YYYY-MM-DD'; - expect(isEqual(result, expectation)).to.equal(true); - }); - }); - - describe('#get()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.get(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`returns the promised value for a key`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'dateFormat'); - const defaults = getDefaultSettings(); - expect(result).to.equal(defaults.dateFormat.value); - }); - - it(`returns the user-configured value for a custom key`, async function () { - const getResult = { custom: 'value' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'custom'); - expect(result).to.equal('value'); - }); - - it(`returns the user-configured value for a modified key`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'dateFormat'); - expect(result).to.equal('YYYY-MM-DD'); - }); - }); -}); - -function expectElasticsearchGetQuery(server, req, configGet) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - sinon.assert.calledOnce(callWithRequest); - const [reqPassed, method, params] = callWithRequest.args[0]; - expect(reqPassed).to.be(req); - expect(method).to.be('get'); - expect(params).to.eql({ - index: configGet('kibana.index'), - id: configGet('pkg.version'), - type: 'config' - }); -} - -function expectElasticsearchUpdateQuery(server, req, configGet, doc) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - sinon.assert.calledOnce(callWithRequest); - const [reqPassed, method, params] = callWithRequest.args[0]; - expect(reqPassed).to.be(req); - expect(method).to.be('update'); - expect(params).to.eql({ - index: configGet('kibana.index'), - id: configGet('pkg.version'), - type: 'config', - body: { doc } - }); -} - -function instantiate({ getResult, callWithRequest, settingsStatusOverrides } = {}) { - const esStatus = { - state: 'green', - on: sinon.spy() - }; - const settingsStatus = { - state: 'green', - red: sinon.spy(), - yellow: sinon.spy(), - green: sinon.spy(), - ...settingsStatusOverrides - }; - const kbnServer = { - status: { - create: sinon.stub().withArgs('ui settings').returns(settingsStatus), - getForPluginId: sinon.stub().withArgs('elasticsearch').returns(esStatus) - }, - ready: sinon.stub().returns(Promise.resolve()) - }; - - const req = { __stubHapiRequest: true, path: '', headers: {} }; - - const adminCluster = { - errors: esErrors, - callWithInternalUser: sinon.stub(), - callWithRequest: sinon.spy((withReq, method, params) => { - if (callWithRequest) { - return callWithRequest(withReq, method, params); - } - - expect(withReq).to.be(req); - switch (method) { - case 'get': - return Promise.resolve({ _source: getResult }); - case 'update': - return Promise.resolve(); - default: - throw new Error(`callWithRequest() is using unexpected method "${method}"`); - } - }) - }; - - adminCluster.callWithInternalUser.withArgs('get', sinon.match.any).returns(Promise.resolve({ _source: getResult })); - adminCluster.callWithInternalUser.withArgs('update', sinon.match.any).returns(Promise.resolve()); - - const configGet = sinon.stub(); - configGet.withArgs('kibana.index').returns('.kibana'); - configGet.withArgs('pkg.version').returns('1.2.3-test'); - configGet.withArgs('uiSettings.enabled').returns(true); - const config = { - get: configGet - }; - - const server = { - config: () => config, - decorate: (_, key, value) => server[key] = value, - plugins: { - elasticsearch: { - getCluster: sinon.stub().withArgs('admin').returns(adminCluster) - } - } - }; - uiSettingsMixin(kbnServer, server, config); - const uiSettings = server.uiSettings(); - return { server, uiSettings, configGet, req }; -} diff --git a/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js b/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js new file mode 100644 index 0000000000000..568c18388912d --- /dev/null +++ b/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js @@ -0,0 +1,41 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; + +export function createCallClusterStub(index, type, id, esDocSource) { + const callCluster = sinon.spy(async (method, params) => { + expect(params) + .to.have.property('index', index) + .and.to.have.property('type', type) + .and.to.have.property('id', id); + + switch (method) { + case 'get': + return { _source: { ...esDocSource } }; + + case 'update': + expect(params).to.have.property('body'); + expect(params.body).to.have.property('doc'); + return {}; + + default: + throw new Error(`unexpected es method ${method}`); + } + }); + + callCluster.assertGetQuery = () => { + sinon.assert.calledOnce(callCluster); + sinon.assert.calledWith(callCluster, 'get'); + }; + + callCluster.assertUpdateQuery = doc => { + sinon.assert.calledOnce(callCluster); + sinon.assert.calledWithExactly(callCluster, 'update', { + index, + type, + id, + body: { doc } + }); + }; + + return callCluster; +} diff --git a/src/ui/ui_settings/__tests__/lib/index.js b/src/ui/ui_settings/__tests__/lib/index.js new file mode 100644 index 0000000000000..f3cd36fad2691 --- /dev/null +++ b/src/ui/ui_settings/__tests__/lib/index.js @@ -0,0 +1 @@ +export { createCallClusterStub } from './call_cluster_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 new file mode 100644 index 0000000000000..11159061ef74f --- /dev/null +++ b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js @@ -0,0 +1,197 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; +import Chance from 'chance'; + +import ServerStatus from '../../../server/status/server_status'; +import Config from '../../../server/config/config'; + +/* eslint-disable import/no-duplicates */ +import * as uiSettingsServiceFactoryNS from '../ui_settings_service_factory'; +import { uiSettingsServiceFactory } from '../ui_settings_service_factory'; +import * as getUiSettingsServiceForRequestNS from '../ui_settings_service_for_request'; +import { getUiSettingsServiceForRequest } from '../ui_settings_service_for_request'; +/* eslint-enable import/no-duplicates */ + +import { uiSettingsMixin } from '../ui_settings_mixin'; + +const chance = new Chance(); + +describe('uiSettingsMixin()', () => { + const sandbox = sinon.sandbox.create(); + + function setup(options = {}) { + const { + enabled = true + } = options; + + const config = Config.withDefaultSchema({ + uiSettings: { enabled } + }); + + // maps of decorations passed to `server.decorate()` + const decorations = { + server: {}, + request: {} + }; + + // mock hapi server + const server = { + log: sinon.stub(), + config: () => config, + decorate: sinon.spy((type, name, value) => { + decorations[type][name] = value; + }), + }; + + // "promise" returned from kbnServer.ready() + const readyPromise = { + then: sinon.stub(), + }; + + const kbnServer = { + server, + config, + status: new ServerStatus(server), + ready: sinon.stub().returns(readyPromise), + }; + + uiSettingsMixin(kbnServer, server, config); + + return { + kbnServer, + server, + decorations, + readyPromise, + status: kbnServer.status.get('ui settings'), + }; + } + + afterEach(() => sandbox.restore()); + + describe('status', () => { + it('creates a "ui settings" status', () => { + const { status } = setup(); + expect(status).to.have.property('state', 'uninitialized'); + }); + + describe('disabled', () => { + it('disables if uiSettings.enabled config is false', () => { + const { status } = setup({ enabled: false }); + expect(status).to.have.property('state', 'disabled'); + }); + + it('does not register a handler for kbnServer.ready()', () => { + const { readyPromise } = setup({ enabled: false }); + sinon.assert.notCalled(readyPromise.then); + }); + }); + + describe('enabled', () => { + it('registers a handler for kbnServer.ready()', () => { + const { readyPromise } = setup(); + sinon.assert.calledOnce(readyPromise.then); + }); + + it('mirrors the elasticsearch plugin status once kibanaServer.ready() resolves', () => { + const { kbnServer, readyPromise, status } = setup(); + const esStatus = kbnServer.status.createForPlugin({ + id: 'elasticsearch', + version: 'kibana', + }); + + esStatus.green(); + expect(status).to.have.property('state', 'uninitialized'); + const readyPromiseHandler = readyPromise.then.firstCall.args[0]; + readyPromiseHandler(); + expect(status).to.have.property('state', 'green'); + + + const states = chance.shuffle(['red', 'green', 'yellow']); + states.forEach((state) => { + esStatus[state](); + expect(esStatus).to.have.property('state', state); + expect(status).to.have.property('state', state); + }); + }); + }); + }); + + describe('server.uiSettingsServiceFactory()', () => { + it('decorates server with "uiSettingsServiceFactory"', () => { + const { decorations } = setup(); + expect(decorations.server).to.have.property('uiSettingsServiceFactory').a('function'); + + sandbox.stub(uiSettingsServiceFactoryNS, 'uiSettingsServiceFactory'); + sinon.assert.notCalled(uiSettingsServiceFactory); + decorations.server.uiSettingsServiceFactory(); + sinon.assert.calledOnce(uiSettingsServiceFactory); + }); + + it('passes `server` and `options` argument to factory', () => { + const { decorations, server } = setup(); + expect(decorations.server).to.have.property('uiSettingsServiceFactory').a('function'); + + sandbox.stub(uiSettingsServiceFactoryNS, 'uiSettingsServiceFactory'); + sinon.assert.notCalled(uiSettingsServiceFactory); + const football = {}; + decorations.server.uiSettingsServiceFactory(football); + sinon.assert.calledWith(uiSettingsServiceFactory, server, football); + }); + }); + + describe('request.getUiSettingsService()', () => { + it('exposes "getUiSettingsService" on requests', () => { + const { decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + sinon.assert.notCalled(getUiSettingsServiceForRequest); + decorations.request.getUiSettingsService(); + sinon.assert.calledOnce(getUiSettingsServiceForRequest); + }); + + it('passes request to getUiSettingsServiceForRequest', () => { + const { server, decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + sinon.assert.notCalled(getUiSettingsServiceForRequest); + const request = {}; + decorations.request.getUiSettingsService.call(request); + sinon.assert.calledWith(getUiSettingsServiceForRequest, server, request); + }); + + it('defines read interceptor that intercepts when status is not green', () => { + const { status, decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + decorations.request.getUiSettingsService(); + + const readInterceptor = getUiSettingsServiceForRequest.firstCall.args[2]; + expect(readInterceptor).to.be.a('function'); + + status.green(); + expect(readInterceptor()).to.be(undefined); + + status.yellow(); + expect(readInterceptor()).to.eql({}); + + status.red(); + expect(readInterceptor()).to.eql({}); + + status.green(); + expect(readInterceptor()).to.eql(undefined); + }); + }); + + describe('server.uiSettings()', () => { + it('throws an error, links to pr', () => { + const { decorations } = setup(); + expect(decorations.server).to.have.property('uiSettings').a('function'); + expect(() => { + decorations.server.uiSettings(); + }).to.throwError('http://github.com'); + }); + }); +}); diff --git a/src/ui/ui_settings/__tests__/ui_settings_service.js b/src/ui/ui_settings/__tests__/ui_settings_service.js new file mode 100644 index 0000000000000..c7706414b0a0c --- /dev/null +++ b/src/ui/ui_settings/__tests__/ui_settings_service.js @@ -0,0 +1,393 @@ +import { isEqual } from 'lodash'; +import expect from 'expect.js'; +import { errors as esErrors } from 'elasticsearch'; + +import { getDefaultSettings } from '../defaults'; +import { UiSettingsService } from '../ui_settings_service'; + +import { createCallClusterStub } from './lib'; + +const INDEX = '.kibana'; +const TYPE = 'config'; +const ID = 'kibana-version'; + +function setup(options = {}) { + const { + readInterceptor, + esDocSource = {}, + callCluster = createCallClusterStub(INDEX, TYPE, ID, esDocSource) + } = options; + + const uiSettings = new UiSettingsService({ + index: INDEX, + type: TYPE, + id: ID, + readInterceptor, + callCluster, + }); + + return { + uiSettings, + assertGetQuery: callCluster.assertGetQuery, + assertUpdateQuery: callCluster.assertUpdateQuery, + }; +} + +describe('ui settings', () => { + describe('overview', () => { + it('has expected api surface', () => { + const { uiSettings } = setup(); + expect(uiSettings).to.have.property('get').a('function'); + expect(uiSettings).to.have.property('getAll').a('function'); + expect(uiSettings).to.have.property('getDefaults').a('function'); + expect(uiSettings).to.have.property('getRaw').a('function'); + expect(uiSettings).to.have.property('getUserProvided').a('function'); + expect(uiSettings).to.have.property('remove').a('function'); + expect(uiSettings).to.have.property('removeMany').a('function'); + expect(uiSettings).to.have.property('set').a('function'); + expect(uiSettings).to.have.property('setMany').a('function'); + }); + }); + + describe('#setMany()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.setMany({ a: 'b' })).to.be.a(Promise); + }); + + it('updates a single value in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.setMany({ one: 'value' }); + assertUpdateQuery({ one: 'value' }); + }); + + it('updates several values in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.setMany({ one: 'value', another: 'val' }); + assertUpdateQuery({ one: 'value', another: 'val' }); + }); + }); + + describe('#set()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.set('a', 'b')).to.be.a(Promise); + }); + + it('updates single values by (key, value)', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.set('one', 'value'); + assertUpdateQuery({ one: 'value' }); + }); + }); + + describe('#remove()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.remove('one')).to.be.a(Promise); + }); + + it('removes single values by key', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.remove('one'); + assertUpdateQuery({ one: null }); + }); + }); + + describe('#removeMany()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.removeMany(['one'])).to.be.a(Promise); + }); + + it('removes a single value', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.removeMany(['one']); + assertUpdateQuery({ one: null }); + }); + + it('updates several values in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.removeMany(['one', 'two', 'three']); + assertUpdateQuery({ one: null, two: null, three: null }); + }); + }); + + describe('#getDefaults()', () => { + it('is promised the default values', async () => { + const { + uiSettings + } = setup(); + const defaults = await uiSettings.getDefaults(); + expect(isEqual(defaults, getDefaultSettings())).to.equal(true); + }); + + + describe('defaults for formatters', async () => { + + const defaults = getDefaultSettings(); + const mapping = JSON.parse(defaults['format:defaultTypeMap'].value); + const expected = { + ip: { id: 'ip', params: {} }, + date: { id: 'date', params: {} }, + number: { id: 'number', params: {} }, + boolean: { id: 'boolean', params: {} }, + _source: { id: '_source', params: {} }, + _default_: { id: 'string', params: {} } + }; + + Object.keys(mapping).forEach((dataType) => { + it(`should configure ${dataType}`, () => { + expect(expected.hasOwnProperty(dataType)).to.equal(true); + expect(mapping[dataType].id).to.equal(expected[dataType].id); + expect(JSON.stringify(mapping[dataType].params)).to.equal(JSON.stringify(expected[dataType].params)); + }); + }); + }); + }); + + describe('#getUserProvided()', () => { + it('pulls user configuration from ES', async () => { + const { uiSettings, assertGetQuery } = setup(); + await uiSettings.getUserProvided(); + assertGetQuery(); + }); + + it('returns user configuration', async () => { + const esDocSource = { user: 'customized' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getUserProvided(); + expect(isEqual(result, { + user: { userValue: 'customized' } + })).to.equal(true); + }); + + it('ignores null user configuration (because default values)', async () => { + const esDocSource = { user: 'customized', usingDefault: null, something: 'else' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getUserProvided(); + expect(isEqual(result, { + user: { userValue: 'customized' }, something: { userValue: 'else' } + })).to.equal(true); + }); + + it('returns an empty object on 404 responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[404](); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('returns an empty object on 403 responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[403](); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('returns an empty object on NoConnections responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors.NoConnections(); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('throws 401 errors', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[401](); + } + }); + + try { + await uiSettings.getUserProvided(); + throw new Error('expect getUserProvided() to throw'); + } catch (err) { + expect(err).to.be.a(esErrors[401]); + } + }); + + it('throw when callCluster fails in some unexpected way', async () => { + const expectedUnexpectedError = new Error('unexpected'); + + const { uiSettings } = setup({ + async callCluster() { + throw expectedUnexpectedError; + } + }); + + try { + await uiSettings.getUserProvided(); + throw new Error('expect getUserProvided() to throw'); + } catch (err) { + expect(err).to.be(expectedUnexpectedError); + } + }); + }); + + describe('#getRaw()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.getRaw(); + assertGetQuery(); + }); + + it(`without user configuration it's equal to the defaults`, async () => { + const esDocSource = {}; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getRaw(); + expect(isEqual(result, getDefaultSettings())).to.equal(true); + }); + + it(`user configuration gets merged with defaults`, async () => { + const esDocSource = { foo: 'bar' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getRaw(); + const merged = getDefaultSettings(); + merged.foo = { userValue: 'bar' }; + expect(isEqual(result, merged)).to.equal(true); + }); + + it(`user configuration gets merged into defaults`, async () => { + const esDocSource = { dateFormat: 'YYYY-MM-DD' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getRaw(); + const merged = getDefaultSettings(); + merged.dateFormat.userValue = 'YYYY-MM-DD'; + expect(isEqual(result, merged)).to.equal(true); + }); + }); + + describe('#getAll()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.getAll(); + assertGetQuery(); + }); + + it(`returns key value pairs`, async () => { + const esDocSource = {}; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getAll(); + const defaults = getDefaultSettings(); + const expectation = {}; + Object.keys(defaults).forEach((key) => { + expectation[key] = defaults[key].value; + }); + expect(isEqual(result, expectation)).to.equal(true); + }); + + it(`returns key value pairs including user configuration`, async () => { + const esDocSource = { something: 'user-provided' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getAll(); + const defaults = getDefaultSettings(); + const expectation = {}; + Object.keys(defaults).forEach((key) => { + expectation[key] = defaults[key].value; + }); + expectation.something = 'user-provided'; + expect(isEqual(result, expectation)).to.equal(true); + }); + + it(`returns key value pairs including user configuration for existing settings`, async () => { + const esDocSource = { dateFormat: 'YYYY-MM-DD' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getAll(); + const defaults = getDefaultSettings(); + const expectation = {}; + Object.keys(defaults).forEach((key) => { + expectation[key] = defaults[key].value; + }); + expectation.dateFormat = 'YYYY-MM-DD'; + expect(isEqual(result, expectation)).to.equal(true); + }); + }); + + describe('#get()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.get(); + assertGetQuery(); + }); + + it(`returns the promised value for a key`, async () => { + const esDocSource = {}; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.get('dateFormat'); + const defaults = getDefaultSettings(); + expect(result).to.equal(defaults.dateFormat.value); + }); + + it(`returns the user-configured value for a custom key`, async () => { + const esDocSource = { custom: 'value' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.get('custom'); + expect(result).to.equal('value'); + }); + + it(`returns the user-configured value for a modified key`, async () => { + const esDocSource = { dateFormat: 'YYYY-MM-DD' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.get('dateFormat'); + expect(result).to.equal('YYYY-MM-DD'); + }); + }); + + describe('readInterceptor() argument', () => { + describe('#getUserProvided()', () => { + it('returns a promise when interceptValue doesn\'t', () => { + const { uiSettings } = setup({ readInterceptor: () => ({}) }); + expect(uiSettings.getUserProvided()).to.be.a(Promise); + }); + + it('returns intercept values', async () => { + const { uiSettings } = setup({ + readInterceptor: () => ({ + foo: 'bar' + }) + }); + + expect(await uiSettings.getUserProvided()).to.eql({ + foo: { + userValue: 'bar' + } + }); + }); + }); + + describe('#getAll()', () => { + it('merges intercept value with defaults', async () => { + const { uiSettings } = setup({ + readInterceptor: () => ({ + foo: 'not foo' + }), + }); + + const defaults = getDefaultSettings(); + const defaultValues = Object.keys(defaults).reduce((acc, key) => ({ + ...acc, + [key]: defaults[key].value, + }), {}); + + expect(await uiSettings.getAll()).to.eql({ + ...defaultValues, + foo: 'not foo', + }); + }); + }); + }); + +}); diff --git a/src/ui/ui_settings/ui_settings.js b/src/ui/ui_settings/ui_settings.js deleted file mode 100644 index e645cee68edc4..0000000000000 --- a/src/ui/ui_settings/ui_settings.js +++ /dev/null @@ -1,120 +0,0 @@ -import { defaultsDeep } from 'lodash'; -import Bluebird from 'bluebird'; - -import { getDefaultSettings } from './defaults'; - -function hydrateUserSettings(user) { - return Object.keys(user) - .map(key => ({ key, userValue: user[key] })) - .filter(({ userValue }) => userValue !== null) - .reduce((acc, { key, userValue }) => ({ ...acc, [key]: { userValue } }), {}); -} - -function assertRequest(req) { - if ( - !req || - typeof req !== 'object' || - typeof req.path !== 'string' || - !req.headers || - typeof req.headers !== 'object' - ) { - throw new TypeError('all uiSettings methods must be passed a hapi.Request object'); - } -} - -export class UiSettings { - constructor(server, status) { - this._server = server; - this._status = status; - } - - getDefaults() { - return getDefaultSettings(); - } - - // returns a Promise for the value of the requested setting - async get(req, key) { - assertRequest(req); - return this.getAll(req) - .then(all => all[key]); - } - - async getAll(req) { - assertRequest(req); - return this.getRaw(req) - .then(raw => Object.keys(raw) - .reduce((all, key) => { - const item = raw[key]; - const hasUserValue = 'userValue' in item; - all[key] = hasUserValue ? item.userValue : item.value; - return all; - }, {}) - ); - } - - async getRaw(req) { - assertRequest(req); - return this.getUserProvided(req) - .then(user => defaultsDeep(user, this.getDefaults())); - } - - async getUserProvided(req, { ignore401Errors = false } = {}) { - assertRequest(req); - const { callWithRequest, errors } = this._server.plugins.elasticsearch.getCluster('admin'); - - // If the ui settings status isn't green, we shouldn't be attempting to get - // user settings, since we can't be sure that all the necessary conditions - // (e.g. elasticsearch being available) are met. - if (this._status.state !== 'green') { - return hydrateUserSettings({}); - } - - const params = this._getClientSettings(); - const allowedErrors = [errors[404], errors[403], errors.NoConnections]; - if (ignore401Errors) allowedErrors.push(errors[401]); - - return Bluebird - .resolve(callWithRequest(req, 'get', params, { wrap401Errors: !ignore401Errors })) - .catch(...allowedErrors, () => ({})) - .then(resp => resp._source || {}) - .then(source => hydrateUserSettings(source)); - } - - async setMany(req, changes) { - assertRequest(req); - const { callWithRequest } = this._server.plugins.elasticsearch.getCluster('admin'); - const clientParams = { - ...this._getClientSettings(), - body: { doc: changes } - }; - return callWithRequest(req, 'update', clientParams) - .then(() => ({})); - } - - async set(req, key, value) { - assertRequest(req); - return this.setMany(req, { [key]: value }); - } - - async remove(req, key) { - assertRequest(req); - return this.set(req, key, null); - } - - async removeMany(req, keys) { - assertRequest(req); - const changes = {}; - keys.forEach(key => { - changes[key] = null; - }); - return this.setMany(req, changes); - } - - _getClientSettings() { - const config = this._server.config(); - const index = config.get('kibana.index'); - const id = config.get('pkg.version'); - const type = 'config'; - return { index, type, id }; - } -} diff --git a/src/ui/ui_settings/ui_settings_mixin.js b/src/ui/ui_settings/ui_settings_mixin.js index c8d37abbafc32..92651ef59e7e5 100644 --- a/src/ui/ui_settings/ui_settings_mixin.js +++ b/src/ui/ui_settings/ui_settings_mixin.js @@ -1,4 +1,5 @@ -import { UiSettings } from './ui_settings'; +import { uiSettingsServiceFactory } from './ui_settings_service_factory'; +import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request'; import { mirrorStatus } from './mirror_status'; export function uiSettingsMixin(kbnServer, server, config) { @@ -9,9 +10,35 @@ export function uiSettingsMixin(kbnServer, server, config) { return; } - const uiSettings = new UiSettings(server, status); - server.decorate('server', 'uiSettings', () => uiSettings); + // Passed to the UiSettingsService. + // UiSettingsService calls the function before trying to read data from + // elasticsearch, giving us a chance to prevent it from happening. + // + // If the ui settings status isn't green we shouldn't be attempting to get + // user settings, since we can't be sure that all the necessary conditions + // (e.g. elasticsearch being available) are met. + const readUiSettingsInterceptor = () => { + if (status.state !== 'green') { + return {}; + } + }; + + // don't return, just let it happen when the plugins are ready kbnServer.ready().then(() => { mirrorStatus(status, kbnServer.status.getForPluginId('elasticsearch')); }); + + server.decorate('server', 'uiSettingsServiceFactory', function (options) { + return uiSettingsServiceFactory(server, options); + }); + + server.decorate('request', 'getUiSettingsService', function () { + return getUiSettingsServiceForRequest(server, this, readUiSettingsInterceptor); + }); + + server.decorate('server', 'uiSettings', () => { + throw new Error(` + server.uiSettings has been removed, see https://github.com/elastic/kibana/pull/12243. + `); + }); } diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js new file mode 100644 index 0000000000000..f929d79d91437 --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service.js @@ -0,0 +1,144 @@ +import { defaultsDeep, noop } from 'lodash'; +import { errors as esErrors } from 'elasticsearch'; + +import { getDefaultSettings } from './defaults'; + +function hydrateUserSettings(userSettings) { + return Object.keys(userSettings) + .map(key => ({ key, userValue: userSettings[key] })) + .filter(({ userValue }) => userValue !== null) + .reduce((acc, { key, userValue }) => ({ ...acc, [key]: { userValue } }), {}); +} + +/** + * Service that provides access to the UiSettings stored in elasticsearch. + * + * @class UiSettingsService + * @param {Object} options + * @property {string} options.index Elasticsearch index name where settings are stored + * @property {string} options.type type of ui settings Elasticsearch doc + * @property {string} options.id id of ui settings Elasticsearch doc + * @property {AsyncFunction} options.callCluster function that accepts a method name and + * param object which causes a request via some elasticsearch client + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() an has an oportunity to intercept the + * request and return an alternate `_source` value to use. + */ +export class UiSettingsService { + constructor(options) { + const { + index, + type, + id, + callCluster, + readInterceptor = noop, + } = options; + + this._callCluster = callCluster; + this._readInterceptor = readInterceptor; + this._index = index; + this._type = type; + this._id = id; + } + + getDefaults() { + return getDefaultSettings(); + } + + // returns a Promise for the value of the requested setting + async get(key) { + const all = await this.getAll(); + return all[key]; + } + + async getAll() { + const raw = await this.getRaw(); + + return Object.keys(raw) + .reduce((all, key) => { + const item = raw[key]; + const hasUserValue = 'userValue' in item; + all[key] = hasUserValue ? item.userValue : item.value; + return all; + }, {}); + } + + async getRaw() { + const userProvided = await this.getUserProvided(); + return defaultsDeep(userProvided, this.getDefaults()); + } + + async getUserProvided(options) { + return hydrateUserSettings(await this._read(options)); + } + + async setMany(changes) { + await this._write(changes); + } + + async set(key, value) { + await this.setMany({ [key]: value }); + } + + async remove(key) { + await this.set(key, null); + } + + async removeMany(keys) { + const changes = {}; + keys.forEach(key => { + changes[key] = null; + }); + await this.setMany(changes); + } + + async _write(changes) { + await this._callCluster('update', { + index: this._index, + type: this._type, + id: this._id, + body: { + doc: changes + } + }); + } + + async _read(options = {}) { + const interceptValue = await this._readInterceptor(options); + if (interceptValue != null) { + return interceptValue; + } + + const { + ignore401Errors = false + } = options; + + const isIgnorableError = error => ( + error instanceof esErrors[404] || + error instanceof esErrors[403] || + error instanceof esErrors.NoConnections || + (ignore401Errors && error instanceof esErrors[401]) + ); + + try { + const clientParams = { + index: this._index, + type: this._type, + id: this._id, + }; + + const callOptions = { + wrap401Errors: !ignore401Errors + }; + + const resp = await this._callCluster('get', clientParams, callOptions); + return resp._source; + } catch (error) { + if (isIgnorableError(error)) { + return {}; + } + + throw error; + } + } +} diff --git a/src/ui/ui_settings/ui_settings_service_factory.js b/src/ui/ui_settings/ui_settings_service_factory.js new file mode 100644 index 0000000000000..c300bc9bc4bf9 --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service_factory.js @@ -0,0 +1,31 @@ +import { UiSettingsService } from './ui_settings_service'; + +/** + * Create an instance of UiSettingsService that will use the + * passed `callCluster` function to communicate with elasticsearch + * + * @param {Hapi.Server} server + * @param {Object} options + * @property {AsyncFunction} options.callCluster function that accepts a method name and + * param object which causes a request via some elasticsearch client + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() an has an oportunity to intercept the + * request and return an alternate `_source` value to use. + * @return {UiSettingsService} + */ +export function uiSettingsServiceFactory(server, options) { + const config = server.config(); + + const { + callCluster, + readInterceptor + } = options; + + return new UiSettingsService({ + index: config.get('kibana.index'), + type: 'config', + id: config.get('pkg.version'), + callCluster, + readInterceptor, + }); +} diff --git a/src/ui/ui_settings/ui_settings_service_for_request.js b/src/ui/ui_settings/ui_settings_service_for_request.js new file mode 100644 index 0000000000000..650bc98a53781 --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service_for_request.js @@ -0,0 +1,20 @@ +import { uiSettingsServiceFactory } from './ui_settings_service_factory'; + +const BY_REQUEST_CACHE = new WeakMap(); + +export function getUiSettingsServiceForRequest(server, request, readInterceptor) { + if (BY_REQUEST_CACHE.has(request)) { + return BY_REQUEST_CACHE.get(request); + } + + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + const uiSettingsService = uiSettingsServiceFactory(server, { + readInterceptor, + callCluster(...args) { + return adminCluster.callWithRequest(request, ...args); + } + }); + + BY_REQUEST_CACHE.set(request, uiSettingsService); + return uiSettingsService; +}