[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
This commit is contained in:
Spencer 2017-06-12 14:16:10 -07:00 committed by GitHub
parent dd072c8596
commit 65d6b5d309
19 changed files with 879 additions and 639 deletions

View file

@ -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)));

View file

@ -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)));
}

View file

@ -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)));

View file

@ -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)));

View file

@ -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,

View file

@ -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'];

View file

@ -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;

View file

@ -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 };
});
});

View file

@ -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,

View file

@ -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 };
}

View file

@ -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;
}

View file

@ -0,0 +1 @@
export { createCallClusterStub } from './call_cluster_stub';

View file

@ -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');
});
});
});

View file

@ -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',
});
});
});
});
});

View file

@ -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 };
}
}

View file

@ -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.
`);
});
}

View file

@ -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;
}
}
}

View file

@ -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,
});
}

View file

@ -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;
}