[GlobalExperience] Global UI settings: API & service changes (#147069)

## Summary

This is a follow-up on: https://github.com/elastic/kibana/pull/146270
This PR:
1. adds server-side service changes
2. registers new routes for global settings
3. add a new `UiSettingsGlobalClient` on the browser side, similarly to
what was done on the server side

There are no browser-side service changes yet, as this will be bigger
change worthy of its own PR.


### Checklist

Delete any items that are not applicable to this PR.

~- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~
~- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials~
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
~- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard
accessibility](https://webaim.org/techniques/keyboard/))~
~- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~
~- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~
~- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~
~- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Maja Grubic 2022-12-08 13:20:46 +01:00 committed by GitHub
parent 431c32b894
commit 8e3fbe22ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1082 additions and 245 deletions

View file

@ -45,6 +45,7 @@ export function createCoreSetupMock({
const uiSettingsMock = {
register: uiSettingsServiceMock.createSetupContract().register,
registerGlobal: uiSettingsServiceMock.createSetupContract().registerGlobal,
};
const mock: CoreSetupMockType = {

View file

@ -250,6 +250,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
},
uiSettings: {
register: deps.uiSettings.register,
registerGlobal: deps.uiSettings.registerGlobal,
},
getStartServices: () => plugin.startDependencies,
deprecations: deps.deprecations.getRegistry(plugin.name),
@ -311,6 +312,7 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>(
},
uiSettings: {
asScopedToClient: deps.uiSettings.asScopedToClient,
globalAsScopedToClient: deps.uiSettings.globalAsScopedToClient,
},
coreUsageData: deps.coreUsageData,
};

View file

@ -119,3 +119,123 @@ Array [
],
]
`;
exports[`#batchSetGlobal Buffers are always clear of previously buffered changes: two requests, second only sends bar, not foo 1`] = `
Array [
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
]
`;
exports[`#batchSetGlobal Overwrites previously buffered values with new values for the same key: two requests, foo=d in final 1`] = `
Array [
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
]
`;
exports[`#batchSetGlobal buffers changes while first request is in progress, sends buffered changes after first request completes: final, includes both requests 1`] = `
Array [
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
]
`;
exports[`#batchSetGlobal rejects all promises for batched requests that fail: promise rejections 1`] = `
Array [
Object {
"error": [Error: invalid],
"isRejected": true,
},
Object {
"error": [Error: invalid],
"isRejected": true,
},
Object {
"error": [Error: invalid],
"isRejected": true,
},
]
`;
exports[`#batchSetGlobal rejects on 301 1`] = `"Moved Permanently"`;
exports[`#batchSetGlobal rejects on 404 response 1`] = `"Request failed with status code: 404"`;
exports[`#batchSetGlobal rejects on 500 1`] = `"Request failed with status code: 500"`;
exports[`#batchSetGlobal sends a single change immediately: single change 1`] = `
Array [
Array [
"/foo/bar/api/kibana/global_settings",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
},
"method": "POST",
},
],
]
`;

View file

@ -162,6 +162,123 @@ describe('#batchSet', () => {
});
});
describe('#batchSetGlobal', () => {
it('sends a single change immediately', async () => {
fetchMock.mock('*', {
body: { settings: {} },
});
const { uiSettingsApi } = setup();
await uiSettingsApi.batchSetGlobal('foo', 'bar');
expect(fetchMock.calls()).toMatchSnapshot('single change');
});
it('buffers changes while first request is in progress, sends buffered changes after first request completes', async () => {
fetchMock.mock('*', {
body: { settings: {} },
});
const { uiSettingsApi } = setup();
uiSettingsApi.batchSetGlobal('foo', 'bar');
const finalPromise = uiSettingsApi.batchSet('box', 'bar');
expect(uiSettingsApi.hasPendingChanges()).toBe(true);
await finalPromise;
expect(fetchMock.calls()).toMatchSnapshot('final, includes both requests');
});
it('Overwrites previously buffered values with new values for the same key', async () => {
fetchMock.mock('*', {
body: { settings: {} },
});
const { uiSettingsApi } = setup();
uiSettingsApi.batchSetGlobal('foo', 'a');
uiSettingsApi.batchSetGlobal('foo', 'b');
uiSettingsApi.batchSetGlobal('foo', 'c');
await uiSettingsApi.batchSetGlobal('foo', 'd');
expect(fetchMock.calls()).toMatchSnapshot('two requests, foo=d in final');
});
it('Buffers are always clear of previously buffered changes', async () => {
fetchMock.mock('*', {
body: { settings: {} },
});
const { uiSettingsApi } = setup();
uiSettingsApi.batchSetGlobal('foo', 'bar');
uiSettingsApi.batchSetGlobal('bar', 'foo');
await uiSettingsApi.batchSetGlobal('bar', 'box');
expect(fetchMock.calls()).toMatchSnapshot('two requests, second only sends bar, not foo');
});
it('rejects on 404 response', async () => {
fetchMock.mock('*', {
status: 404,
body: 'not found',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
it('rejects on 301', async () => {
fetchMock.mock('*', {
status: 301,
body: 'redirect',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
it('rejects on 500', async () => {
fetchMock.mock('*', {
status: 500,
body: 'redirect',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.batchSetGlobal('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
it('rejects all promises for batched requests that fail', async () => {
fetchMock.once('*', {
body: { settings: {} },
});
fetchMock.once(
'*',
{
status: 400,
body: { message: 'invalid' },
},
{
overwriteRoutes: false,
}
);
const { uiSettingsApi } = setup();
// trigger the initial sync request, which enabled buffering
uiSettingsApi.batchSetGlobal('foo', 'bar');
// buffer some requests so they will be sent together
await expect(
Promise.all([
settlePromise(uiSettingsApi.batchSetGlobal('foo', 'a')),
settlePromise(uiSettingsApi.batchSetGlobal('bar', 'b')),
settlePromise(uiSettingsApi.batchSetGlobal('baz', 'c')),
])
).resolves.toMatchSnapshot('promise rejections');
// ensure only two requests were sent
expect(fetchMock.calls()).toHaveLength(2);
});
});
describe('#getLoadingCount$()', () => {
it('emits the current number of active requests', async () => {
fetchMock.once('*', {

View file

@ -10,6 +10,7 @@ import { BehaviorSubject } from 'rxjs';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { UiSettingsState } from '@kbn/core-ui-settings-browser';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
export interface UiSettingsApiResponse {
settings: UiSettingsState;
@ -64,7 +65,32 @@ export class UiSettingsApi {
},
};
this.flushPendingChanges();
this.flushPendingChanges('namespace');
});
}
public batchSetGlobal(key: string, value: any) {
return new Promise<UiSettingsApiResponse>((resolve, reject) => {
const prev = this.pendingChanges || NOOP_CHANGES;
this.pendingChanges = {
values: {
...prev.values,
[key]: value,
},
callback(error, resp) {
prev.callback(error, resp);
if (error) {
reject(error);
} else {
resolve(resp!);
}
},
};
this.flushPendingChanges('global');
});
}
@ -97,7 +123,7 @@ export class UiSettingsApi {
* progress) then another request will be started until all pending changes have been
* sent to the server.
*/
private async flushPendingChanges() {
private async flushPendingChanges(scope: UiSettingsScope) {
if (!this.pendingChanges) {
return;
}
@ -111,10 +137,10 @@ export class UiSettingsApi {
try {
this.sendInProgress = true;
const path = scope === 'namespace' ? '/api/kibana/settings' : '/api/kibana/global_settings';
changes.callback(
undefined,
await this.sendRequest('POST', '/api/kibana/settings', {
await this.sendRequest('POST', path, {
changes: changes.values,
})
);
@ -122,7 +148,7 @@ export class UiSettingsApi {
changes.callback(error);
} finally {
this.sendInProgress = false;
this.flushPendingChanges();
this.flushPendingChanges(scope);
}
}

View file

@ -19,17 +19,21 @@ function setup(options: { defaults?: any; initialSettings?: any } = {}) {
const batchSet = jest.fn(() => ({
settings: {},
}));
const batchSetGlobal = jest.fn(() => ({
settings: {},
}));
done$ = new Subject();
const client = new UiSettingsClient({
defaults,
initialSettings,
api: {
batchSet,
batchSetGlobal,
} as any,
done$,
});
return { client, batchSet };
return { client, batchSet, batchSetGlobal };
}
afterEach(() => {

View file

@ -6,131 +6,15 @@
* Side Public License, v 1.
*/
import { cloneDeep, defaultsDeep } from 'lodash';
import { Observable, Subject, concat, defer, of } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { UserProvidedValues, PublicUiSettingsParams } from '@kbn/core-ui-settings-common';
import { IUiSettingsClient, UiSettingsState } from '@kbn/core-ui-settings-browser';
import { UiSettingsApi } from './ui_settings_api';
interface UiSettingsClientParams {
api: UiSettingsApi;
defaults: Record<string, PublicUiSettingsParams>;
initialSettings?: UiSettingsState;
done$: Observable<unknown>;
}
export class UiSettingsClient implements IUiSettingsClient {
private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>();
private readonly updateErrors$ = new Subject<Error>();
private readonly api: UiSettingsApi;
private readonly defaults: Record<string, PublicUiSettingsParams>;
private cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
import { defaultsDeep } from 'lodash';
import { UiSettingsClientCommon, UiSettingsClientParams } from './ui_settings_client_common';
export class UiSettingsClient extends UiSettingsClientCommon {
constructor(params: UiSettingsClientParams) {
this.api = params.api;
this.defaults = cloneDeep(params.defaults);
this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings));
params.done$.subscribe({
complete: () => {
this.update$.complete();
this.updateErrors$.complete();
},
});
super(params);
}
getAll() {
return cloneDeep(this.cache);
}
get<T = any>(key: string, defaultOverride?: T) {
const declared = this.isDeclared(key);
if (!declared && defaultOverride !== undefined) {
return defaultOverride;
}
if (!declared) {
throw new Error(
`Unexpected \`IUiSettingsClient.get("${key}")\` call on unrecognized configuration setting "${key}".
Setting an initial value via \`IUiSettingsClient.set("${key}", value)\` before attempting to retrieve
any custom setting value for "${key}" may fix this issue.
You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just return
\`defaultValue\` when the key is unrecognized.`
);
}
const type = this.cache[key].type;
const userValue = this.cache[key].userValue;
const defaultValue = defaultOverride !== undefined ? defaultOverride : this.cache[key].value;
const value = userValue == null ? defaultValue : userValue;
if (type === 'json') {
return JSON.parse(value);
}
if (type === 'number') {
return parseFloat(value);
}
return value;
}
get$<T = any>(key: string, defaultOverride?: T) {
return concat(
defer(() => of(this.get(key, defaultOverride))),
this.update$.pipe(
filter((update) => update.key === key),
map(() => this.get(key, defaultOverride))
)
);
}
async set(key: string, value: any) {
return await this.update(key, value);
}
async remove(key: string) {
return await this.update(key, null);
}
isDeclared(key: string) {
return key in this.cache;
}
isDefault(key: string) {
return !this.isDeclared(key) || this.cache[key].userValue == null;
}
isCustom(key: string) {
return this.isDeclared(key) && !('value' in this.cache[key]);
}
isOverridden(key: string) {
return this.isDeclared(key) && Boolean(this.cache[key].isOverridden);
}
getUpdate$() {
return this.update$.asObservable();
}
getUpdateErrors$() {
return this.updateErrors$.asObservable();
}
private assertUpdateAllowed(key: string) {
if (this.isOverridden(key)) {
throw new Error(
`Unable to update "${key}" because its value is overridden by the Kibana server`
);
}
}
private async update(key: string, newVal: any): Promise<boolean> {
async update(key: string, newVal: any): Promise<boolean> {
this.assertUpdateAllowed(key);
const declared = this.isDeclared(key);
@ -156,27 +40,4 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r
return false;
}
}
private setLocally(key: string, newValue: any) {
this.assertUpdateAllowed(key);
if (!this.isDeclared(key)) {
this.cache[key] = {};
}
const oldValue = this.get(key);
if (newValue === null) {
delete this.cache[key].userValue;
} else {
const { type } = this.cache[key];
if (type === 'json' && typeof newValue !== 'string') {
this.cache[key].userValue = JSON.stringify(newValue);
} else {
this.cache[key].userValue = newValue;
}
}
this.update$.next({ key, newValue, oldValue });
}
}

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { cloneDeep, defaultsDeep } from 'lodash';
import { Observable, Subject, concat, defer, of } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { UserProvidedValues, PublicUiSettingsParams } from '@kbn/core-ui-settings-common';
import { IUiSettingsClient, UiSettingsState } from '@kbn/core-ui-settings-browser';
import { UiSettingsApi } from './ui_settings_api';
export interface UiSettingsClientParams {
api: UiSettingsApi;
defaults: Record<string, PublicUiSettingsParams>;
initialSettings?: UiSettingsState;
done$: Observable<unknown>;
}
export abstract class UiSettingsClientCommon implements IUiSettingsClient {
protected readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>();
protected readonly updateErrors$ = new Subject<Error>();
protected readonly api: UiSettingsApi;
protected readonly defaults: Record<string, PublicUiSettingsParams>;
protected cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
constructor(params: UiSettingsClientParams) {
this.api = params.api;
this.defaults = cloneDeep(params.defaults);
this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings));
params.done$.subscribe({
complete: () => {
this.update$.complete();
this.updateErrors$.complete();
},
});
}
getAll() {
return cloneDeep(this.cache);
}
get<T = any>(key: string, defaultOverride?: T) {
const declared = this.isDeclared(key);
if (!declared && defaultOverride !== undefined) {
return defaultOverride;
}
if (!declared) {
throw new Error(
`Unexpected \`IUiSettingsClient.get("${key}")\` call on unrecognized configuration setting "${key}".
Setting an initial value via \`IUiSettingsClient.set("${key}", value)\` before attempting to retrieve
any custom setting value for "${key}" may fix this issue.
You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just return
\`defaultValue\` when the key is unrecognized.`
);
}
const type = this.cache[key].type;
const userValue = this.cache[key].userValue;
const defaultValue = defaultOverride !== undefined ? defaultOverride : this.cache[key].value;
const value = userValue == null ? defaultValue : userValue;
if (type === 'json') {
return JSON.parse(value);
}
if (type === 'number') {
return parseFloat(value);
}
return value;
}
get$<T = any>(key: string, defaultOverride?: T) {
return concat(
defer(() => of(this.get(key, defaultOverride))),
this.update$.pipe(
filter((update) => update.key === key),
map(() => this.get(key, defaultOverride))
)
);
}
async set(key: string, value: any) {
return await this.update(key, value);
}
async remove(key: string) {
return await this.update(key, null);
}
isDeclared(key: string) {
return key in this.cache;
}
isDefault(key: string) {
return !this.isDeclared(key) || this.cache[key].userValue == null;
}
isCustom(key: string) {
return this.isDeclared(key) && !('value' in this.cache[key]);
}
isOverridden(key: string) {
return this.isDeclared(key) && Boolean(this.cache[key].isOverridden);
}
getUpdate$() {
return this.update$.asObservable();
}
getUpdateErrors$() {
return this.updateErrors$.asObservable();
}
protected assertUpdateAllowed(key: string) {
if (this.isOverridden(key)) {
throw new Error(
`Unable to update "${key}" because its value is overridden by the Kibana server`
);
}
}
protected abstract update(key: string, newVal: any): Promise<boolean>;
protected setLocally(key: string, newValue: any) {
this.assertUpdateAllowed(key);
if (!this.isDeclared(key)) {
this.cache[key] = {};
}
const oldValue = this.get(key);
if (newValue === null) {
delete this.cache[key].userValue;
} else {
const { type } = this.cache[key];
if (type === 'json' && typeof newValue !== 'string') {
this.cache[key].userValue = JSON.stringify(newValue);
} else {
this.cache[key].userValue = newValue;
}
}
this.update$.next({ key, newValue, oldValue });
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Subject } from 'rxjs';
import { take, toArray } from 'rxjs/operators';
import { UiSettingsGlobalClient } from './ui_settings_global_client';
let done$: Subject<unknown>;
function setup(options: { defaults?: any; initialSettings?: any } = {}) {
const { defaults = { dateFormat: { value: 'Browser' } }, initialSettings = {} } = options;
const batchSetGlobal = jest.fn(() => ({
settings: {},
}));
done$ = new Subject();
const client = new UiSettingsGlobalClient({
defaults,
initialSettings,
api: {
batchSetGlobal,
} as any,
done$,
});
return { client, batchSetGlobal };
}
afterEach(() => {
done$.complete();
});
describe('#get$', () => {
it('emits the default override if no value is set, or if the value is removed', async () => {
const { client } = setup();
setTimeout(() => {
client.set('dateFormat', 'new format');
}, 10);
setTimeout(() => {
client.remove('dateFormat');
}, 20);
const values = await client
.get$('dateFormat', 'my default')
.pipe(take(3), toArray())
.toPromise();
expect(values).toEqual(['my default', 'new format', 'my default']);
});
});
describe('#set', () => {
it('resolves to false on failure', async () => {
const { client, batchSetGlobal } = setup();
batchSetGlobal.mockImplementation(() => {
throw new Error('Error in request');
});
await expect(client.set('foo', 'bar')).resolves.toBe(false);
});
});
describe('#remove', () => {
it('resolves to false on failure', async () => {
const { client, batchSetGlobal } = setup();
batchSetGlobal.mockImplementation(() => {
throw new Error('Error in request');
});
await expect(client.remove('dateFormat')).resolves.toBe(false);
});
});

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { defaultsDeep } from 'lodash';
import { UiSettingsClientCommon, UiSettingsClientParams } from './ui_settings_client_common';
export class UiSettingsGlobalClient extends UiSettingsClientCommon {
constructor(params: UiSettingsClientParams) {
super(params);
}
async update(key: string, newVal: any): Promise<boolean> {
this.assertUpdateAllowed(key);
const declared = this.isDeclared(key);
const defaults = this.defaults;
const oldVal = declared ? this.cache[key].userValue : undefined;
const unchanged = oldVal === newVal;
if (unchanged) {
return true;
}
const initialVal = declared ? this.get(key) : undefined;
this.setLocally(key, newVal);
try {
const { settings } = await this.api.batchSetGlobal(key, newVal);
this.cache = defaultsDeep({}, defaults, settings);
return true;
} catch (error) {
this.setLocally(key, initialVal);
this.updateErrors$.next(error);
return false;
}
}
}

View file

@ -9,8 +9,11 @@
import { schema } from '@kbn/config-schema';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import type { InternalUiSettingsRouter } from '../internal_types';
import { CannotOverrideError } from '../ui_settings_errors';
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
const validate = {
params: schema.object({
@ -19,33 +22,47 @@ const validate = {
};
export function registerDeleteRoute(router: InternalUiSettingsRouter) {
const deleteFromRequest = async (
uiSettingsClient: IUiSettingsClient,
context: InternalUiSettingsRequestHandlerContext,
request: KibanaRequest<Readonly<{} & { key: string }>, unknown, unknown, 'delete'>,
response: KibanaResponseFactory
) => {
try {
await uiSettingsClient.remove(request.params.key);
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
if (error instanceof CannotOverrideError) {
return response.badRequest({ body: error });
}
throw error;
}
};
router.delete(
{ path: '/api/kibana/settings/{key}', validate },
async (context, request, response) => {
try {
const uiSettingsClient = (await context.core).uiSettings.client;
await uiSettingsClient.remove(request.params.key);
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
if (error instanceof CannotOverrideError) {
return response.badRequest({ body: error });
}
throw error;
}
const uiSettingsClient = (await context.core).uiSettings.client;
return await deleteFromRequest(uiSettingsClient, context, request, response);
}
);
router.delete(
{ path: '/api/kibana/global_settings/{key}', validate },
async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.globalClient;
return await deleteFromRequest(uiSettingsClient, context, request, response);
}
);
}

View file

@ -7,29 +7,47 @@
*/
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
import type { InternalUiSettingsRouter } from '../internal_types';
export function registerGetRoute(router: InternalUiSettingsRouter) {
const getFromRequest = async (
uiSettingsClient: IUiSettingsClient,
context: InternalUiSettingsRequestHandlerContext,
request: KibanaRequest<unknown, unknown, unknown, 'get'>,
response: KibanaResponseFactory
) => {
try {
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
throw error;
}
};
router.get(
{ path: '/api/kibana/settings', validate: false },
async (context, request, response) => {
try {
const uiSettingsClient = (await context.core).uiSettings.client;
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
throw error;
}
const uiSettingsClient = (await context.core).uiSettings.client;
return await getFromRequest(uiSettingsClient, context, request, response);
}
);
router.get(
{ path: '/api/kibana/global_settings', validate: false },
async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.globalClient;
return await getFromRequest(uiSettingsClient, context, request, response);
}
);
}

View file

@ -7,9 +7,13 @@
*/
import { schema, ValidationError } from '@kbn/config-schema';
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import type { InternalUiSettingsRouter } from '../internal_types';
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type {
InternalUiSettingsRequestHandlerContext,
InternalUiSettingsRouter,
} from '../internal_types';
import { CannotOverrideError } from '../ui_settings_errors';
const validate = {
@ -22,36 +26,55 @@ const validate = {
};
export function registerSetRoute(router: InternalUiSettingsRouter) {
const setFromRequest = async (
uiSettingsClient: IUiSettingsClient,
context: InternalUiSettingsRequestHandlerContext,
request: KibanaRequest<
Readonly<{} & { key: string }>,
unknown,
Readonly<{ value?: any } & {}>,
'post'
>,
response: KibanaResponseFactory
) => {
try {
const { key } = request.params;
const { value } = request.body;
await uiSettingsClient.set(key, value);
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
return response.badRequest({ body: error });
}
throw error;
}
};
router.post(
{ path: '/api/kibana/settings/{key}', validate },
async (context, request, response) => {
try {
const uiSettingsClient = (await context.core).uiSettings.client;
const { key } = request.params;
const { value } = request.body;
await uiSettingsClient.set(key, value);
return response.ok({
body: {
settings: await uiSettingsClient.getUserProvided(),
},
});
} catch (error) {
if (SavedObjectsErrorHelpers.isSavedObjectsClientError(error)) {
return response.customError({
body: error,
statusCode: error.output.statusCode,
});
}
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
return response.badRequest({ body: error });
}
throw error;
}
const uiSettingsClient = (await context.core).uiSettings.client;
return await setFromRequest(uiSettingsClient, context, request, response);
}
);
router.post(
{ path: '/api/kibana/global_settings/{key}', validate },
async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.globalClient;
return await setFromRequest(uiSettingsClient, context, request, response);
}
);
}

View file

@ -7,10 +7,12 @@
*/
import { schema, ValidationError } from '@kbn/config-schema';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { InternalUiSettingsRouter } from '../internal_types';
import { CannotOverrideError } from '../ui_settings_errors';
import { InternalUiSettingsRequestHandlerContext } from '../internal_types';
const validate = {
body: schema.object({
@ -19,10 +21,13 @@ const validate = {
};
export function registerSetManyRoute(router: InternalUiSettingsRouter) {
router.post({ path: '/api/kibana/settings', validate }, async (context, request, response) => {
const setManyFromRequest = async (
uiSettingsClient: IUiSettingsClient,
context: InternalUiSettingsRequestHandlerContext,
request: KibanaRequest<unknown, unknown, Readonly<{} & { changes?: any & {} }>, 'post'>,
response: KibanaResponseFactory
) => {
try {
const uiSettingsClient = (await context.core).uiSettings.client;
const { changes } = request.body;
await uiSettingsClient.setMany(changes);
@ -46,5 +51,17 @@ export function registerSetManyRoute(router: InternalUiSettingsRouter) {
throw error;
}
};
router.post({ path: '/api/kibana/settings', validate }, async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.client;
return await setManyFromRequest(uiSettingsClient, context, request, response);
});
router.post(
{ path: '/api/kibana/global_settings', validate },
async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.globalClient;
return await setManyFromRequest(uiSettingsClient, context, request, response);
}
);
}

View file

@ -65,7 +65,7 @@ export const uiSettingsGlobalType: SavedObjectsType = {
};
},
getTitle(obj) {
return `Global Setting [${obj.id}]`;
return `Global Settings [${obj.id}]`;
},
},
};

View file

@ -11,8 +11,8 @@ import type {
UiSettingsServiceSetup,
UiSettingsServiceStart,
} from '@kbn/core-ui-settings-server';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { UiSettingsParams } from '@kbn/core-ui-settings-common';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import type { UiSettingsParams } from '@kbn/core-ui-settings-common';
import type { Logger } from '@kbn/logging';
/** @internal */

View file

@ -19,6 +19,7 @@ import type { InternalUiSettingsServiceStart } from './types';
*/
export class CoreUiSettingsRouteHandlerContext implements UiSettingsRequestHandlerContext {
#client?: IUiSettingsClient;
#globalClient?: IUiSettingsClient;
constructor(
private readonly uiSettingsStart: InternalUiSettingsServiceStart,
@ -33,4 +34,13 @@ export class CoreUiSettingsRouteHandlerContext implements UiSettingsRequestHandl
}
return this.#client;
}
public get globalClient() {
if (this.#globalClient == null) {
this.#globalClient = this.uiSettingsStart.globalAsScopedToClient(
this.savedObjectsRouterHandlerContext.client
);
}
return this.#globalClient;
}
}

View file

@ -11,6 +11,11 @@ jest.doMock('./clients/ui_settings_client', () => ({
UiSettingsClient: MockUiSettingsClientConstructor,
}));
export const MockUiSettingsGlobalClientConstructor = jest.fn();
jest.doMock('./clients/ui_settings_global_client', () => ({
UiSettingsGlobalClient: MockUiSettingsGlobalClientConstructor,
}));
export const MockUiSettingsDefaultsClientConstructor = jest.fn();
jest.doMock('./clients/ui_settings_defaults_client', () => ({
UiSettingsDefaultsClient: MockUiSettingsDefaultsClientConstructor,

View file

@ -13,6 +13,7 @@ import { mockCoreContext } from '@kbn/core-base-server-mocks';
import { httpServiceMock } from '@kbn/core-http-server-mocks';
import {
MockUiSettingsClientConstructor,
MockUiSettingsGlobalClientConstructor,
MockUiSettingsDefaultsClientConstructor,
getCoreSettingsMock,
} from './ui_settings_service.test.mock';
@ -53,6 +54,7 @@ describe('uiSettings', () => {
afterEach(() => {
MockUiSettingsClientConstructor.mockClear();
MockUiSettingsGlobalClientConstructor.mockClear();
getCoreSettingsMock.mockClear();
});
@ -95,6 +97,20 @@ describe('uiSettings', () => {
`"uiSettings for the key [foo] has been already registered"`
);
});
it('throws if registers the same key twice to global settings', async () => {
const setup = await service.setup(setupDeps);
setup.registerGlobal(defaults);
expect(() => setup.registerGlobal(defaults)).toThrowErrorMatchingInlineSnapshot(
`"Global uiSettings for the key [foo] has been already registered"`
);
});
it('does not throw when registering a global and namespaced setting with the same name', async () => {
const setup = await service.setup(setupDeps);
setup.register(defaults);
expect(() => setup.registerGlobal(defaults)).not.toThrow();
});
});
});
@ -118,6 +134,20 @@ describe('uiSettings', () => {
);
});
it('throws if validation schema is not provided for global settings', async () => {
const { registerGlobal } = await service.setup(setupDeps);
registerGlobal({
// @ts-expect-error schema is required key
custom: {
value: 42,
},
});
await expect(service.start()).rejects.toMatchInlineSnapshot(
`[Error: Validation schema is not provided for [custom] Global UI Setting]`
);
});
it('validates registered definitions', async () => {
const { register } = await service.setup(setupDeps);
register({
@ -132,6 +162,20 @@ describe('uiSettings', () => {
);
});
it('validates registered definitions for global settings', async () => {
const { registerGlobal } = await service.setup(setupDeps);
registerGlobal({
custom: {
value: 42,
schema: schema.string(),
},
});
await expect(service.start()).rejects.toMatchInlineSnapshot(
`[Error: expected value of type [string] but got [number]]`
);
});
it('validates overrides', async () => {
const coreContext = mockCoreContext.create();
coreContext.configService.atPath.mockReturnValueOnce(
@ -201,5 +245,35 @@ describe('uiSettings', () => {
expect(MockUiSettingsClientConstructor.mock.calls[0][0].defaults).not.toBe(defaults);
});
});
describe('#asScopedToGlobalClient', () => {
it('passes saved object type "config-global" to UiSettingsGlobalClient', async () => {
await service.setup(setupDeps);
const start = await service.start();
start.globalAsScopedToClient(savedObjectsClient);
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].type).toBe('config-global');
});
it('passes overrides to UiSettingsGlobalClient', async () => {
await service.setup(setupDeps);
const start = await service.start();
start.globalAsScopedToClient(savedObjectsClient);
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].overrides).toEqual({});
});
it('passes a copy of set defaults to UiSettingsGlobalClient', async () => {
const setup = await service.setup(setupDeps);
setup.register(defaults);
const start = await service.start();
start.globalAsScopedToClient(savedObjectsClient);
expect(MockUiSettingsGlobalClientConstructor).toBeCalledTimes(1);
expect(MockUiSettingsGlobalClientConstructor.mock.calls[0][0].defaults).toEqual({});
});
});
});
});

View file

@ -14,9 +14,9 @@ import type { CoreContext, CoreService } from '@kbn/core-base-server-internal';
import type { InternalHttpServiceSetup } from '@kbn/core-http-server-internal';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import type { InternalSavedObjectsServiceSetup } from '@kbn/core-saved-objects-server-internal';
import type { UiSettingsParams } from '@kbn/core-ui-settings-common';
import type { UiSettingsParams, UiSettingsScope } from '@kbn/core-ui-settings-common';
import { UiSettingsConfigType, uiSettingsConfig as uiConfigDefinition } from './ui_settings_config';
import { UiSettingsClient } from './clients/ui_settings_client';
import { UiSettingsClient, UiSettingsClientFactory, UiSettingsGlobalClient } from './clients';
import type {
InternalUiSettingsServicePreboot,
InternalUiSettingsServiceSetup,
@ -32,6 +32,11 @@ export interface SetupDeps {
http: InternalHttpServiceSetup;
savedObjects: InternalSavedObjectsServiceSetup;
}
type ClientType<T> = T extends 'global'
? UiSettingsGlobalClient
: T extends 'namespace'
? UiSettingsClient
: never;
/** @internal */
export class UiSettingsService
@ -41,6 +46,7 @@ export class UiSettingsService
private readonly config$: Observable<UiSettingsConfigType>;
private readonly isDist: boolean;
private readonly uiSettingsDefaults = new Map<string, UiSettingsParams>();
private readonly uiSettingsGlobalDefaults = new Map<string, UiSettingsParams>();
private overrides: Record<string, any> = {};
constructor(private readonly coreContext: CoreContext) {
@ -78,7 +84,8 @@ export class UiSettingsService
this.overrides = config.overrides;
return {
register: this.register.bind(this),
register: this.register,
registerGlobal: this.registerGlobal,
};
}
@ -87,36 +94,52 @@ export class UiSettingsService
this.validatesOverrides();
return {
asScopedToClient: this.getScopedClientFactory(),
asScopedToClient: this.getScopedClientFactory('namespace'),
globalAsScopedToClient: this.getScopedClientFactory('global'),
};
}
public async stop() {}
private getScopedClientFactory(): (
savedObjectsClient: SavedObjectsClientContract
) => UiSettingsClient {
private getScopedClientFactory<T extends UiSettingsScope>(
scope: UiSettingsScope
): (savedObjectsClient: SavedObjectsClientContract) => ClientType<T> {
const { version, buildNum } = this.coreContext.env.packageInfo;
return (savedObjectsClient: SavedObjectsClientContract) =>
new UiSettingsClient({
type: 'config',
return (savedObjectsClient: SavedObjectsClientContract): ClientType<T> => {
const isNamespaceScope = scope === 'namespace';
const options = {
type: (isNamespaceScope ? 'config' : 'config-global') as 'config' | 'config-global',
id: version,
buildNum,
savedObjectsClient,
defaults: mapToObject(this.uiSettingsDefaults),
overrides: this.overrides,
defaults: isNamespaceScope
? mapToObject(this.uiSettingsDefaults)
: mapToObject(this.uiSettingsGlobalDefaults),
overrides: isNamespaceScope ? this.overrides : {},
log: this.log,
});
};
return UiSettingsClientFactory.create(options) as ClientType<T>;
};
}
private register(settings: Record<string, UiSettingsParams> = {}) {
private register = (settings: Record<string, UiSettingsParams> = {}) => {
Object.entries(settings).forEach(([key, value]) => {
if (this.uiSettingsDefaults.has(key)) {
throw new Error(`uiSettings for the key [${key}] has been already registered`);
}
this.uiSettingsDefaults.set(key, value);
});
}
};
private registerGlobal = (settings: Record<string, UiSettingsParams> = {}) => {
Object.entries(settings).forEach(([key, value]) => {
if (this.uiSettingsGlobalDefaults.has(key)) {
throw new Error(`Global uiSettings for the key [${key}] has been already registered`);
}
this.uiSettingsGlobalDefaults.set(key, value);
});
};
private validatesDefinitions() {
for (const [key, definition] of this.uiSettingsDefaults) {
@ -125,6 +148,12 @@ export class UiSettingsService
}
definition.schema.validate(definition.value, {}, `ui settings defaults [${key}]`);
}
for (const [key, definition] of this.uiSettingsGlobalDefaults) {
if (!definition.schema) {
throw new Error(`Validation schema is not provided for [${key}] Global UI Setting`);
}
definition.schema.validate(definition.value, {});
}
}
private validatesOverrides() {

View file

@ -48,6 +48,7 @@ const createPrebootMock = () => {
const createSetupMock = () => {
const mocked: jest.Mocked<InternalUiSettingsServiceSetup> = {
register: jest.fn(),
registerGlobal: jest.fn(),
};
return mocked;
@ -56,6 +57,7 @@ const createSetupMock = () => {
const createStartMock = () => {
const mocked: jest.Mocked<InternalUiSettingsServiceStart> = {
asScopedToClient: jest.fn(),
globalAsScopedToClient: jest.fn(),
};
mocked.asScopedToClient.mockReturnValue(createClientMock());

View file

@ -13,7 +13,7 @@ import type { IUiSettingsClient } from './ui_settings_client';
/** @public */
export interface UiSettingsServiceSetup {
/**
* Sets settings with default values for the uiSettings.
* Sets settings with default values for the uiSettings
* @param settings
*
* @example
@ -30,6 +30,24 @@ export interface UiSettingsServiceSetup {
* ```
*/
register(settings: Record<string, UiSettingsParams>): void;
/**
* Sets settings with default values for the global uiSettings
* @param settings
*
* @example
* ```ts
* setup(core: CoreSetup){
* core.uiSettings.register([{
* foo: {
* name: i18n.translate('my foo settings'),
* value: true,
* description: 'add some awesomeness',
* },
* }]);
* }
* ```
*/
registerGlobal(settings: Record<string, UiSettingsParams>): void;
}
/** @public */
@ -49,4 +67,20 @@ export interface UiSettingsServiceStart {
* ```
*/
asScopedToClient(savedObjectsClient: SavedObjectsClientContract): IUiSettingsClient;
/**
* Creates a global {@link IUiSettingsClient} with provided *scoped* saved objects client.
*
* This should only be used in the specific case where the client needs to be accessed
* from outside of the scope of a {@link RequestHandler}.
*
* @example
* ```ts
* start(core: CoreStart) {
* const soClient = core.savedObjects.getScopedClient(arbitraryRequest);
* const uiSettingsClient = core.uiSettings.asScopedToGlobalClient(soClient);
* }
* ```
*/
globalAsScopedToClient(savedObjectsClient: SavedObjectsClientContract): IUiSettingsClient;
}

View file

@ -14,4 +14,5 @@ import type { IUiSettingsClient } from './ui_settings_client';
*/
export interface UiSettingsRequestHandlerContext {
client: IUiSettingsClient;
globalClient: IUiSettingsClient;
}

View file

@ -12,7 +12,7 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
async function setup(options: { initialSettings?: Record<string, any> } = {}) {
const { initialSettings } = options;
const { uiSettings, esClient, supertest } = getServices();
const { uiSettings, uiSettingsGlobal, esClient, supertest } = getServices();
// delete the kibana index to ensure we start fresh
await esClient.deleteByQuery({
@ -27,9 +27,10 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
if (initialSettings) {
await uiSettings.setMany(initialSettings);
await uiSettingsGlobal.setMany(uiSettingsGlobal);
}
return { uiSettings, supertest };
return { uiSettings, uiSettingsGlobal, supertest };
}
describe('get route', () => {
@ -190,4 +191,168 @@ export const docExistsSuite = (savedObjectsIndex: string) => () => {
});
});
});
describe('global', () => {
describe('get route', () => {
it('returns a 200 and includes userValues', async () => {
const defaultIndex = chance.word({ length: 10 });
const { supertest } = await setup({
initialSettings: {
defaultIndex,
},
});
const { body } = await supertest('get', '/api/kibana/global_settings').expect(200);
expect(body).toMatchObject({
settings: {
buildNum: {
userValue: expect.any(Number),
},
defaultIndex: {
userValue: defaultIndex,
},
foo: {
userValue: 'bar',
isOverridden: true,
},
},
});
});
});
describe('set route', () => {
it('returns a 200 and all values including update', async () => {
const { supertest } = await setup();
const defaultIndex = chance.word();
const { body } = await supertest('post', '/api/kibana/global_settings/defaultIndex')
.send({
value: defaultIndex,
})
.expect(200);
expect(body).toMatchObject({
settings: {
buildNum: {
userValue: expect.any(Number),
},
defaultIndex: {
userValue: defaultIndex,
},
foo: {
userValue: 'bar',
isOverridden: true,
},
},
});
});
it('returns a 400 if trying to set overridden value', async () => {
const { supertest } = await setup();
const { body } = await supertest('delete', '/api/kibana/global_settings/foo')
.send({
value: 'baz',
})
.expect(400);
expect(body).toEqual({
error: 'Bad Request',
message: 'Unable to update "foo" because it is overridden',
statusCode: 400,
});
});
});
describe('setMany route', () => {
it('returns a 200 and all values including updates', async () => {
const { supertest } = await setup();
const defaultIndex = chance.word();
const { body } = await supertest('post', '/api/kibana/global_settings')
.send({
changes: {
defaultIndex,
},
})
.expect(200);
expect(body).toMatchObject({
settings: {
buildNum: {
userValue: expect.any(Number),
},
defaultIndex: {
userValue: defaultIndex,
},
foo: {
userValue: 'bar',
isOverridden: true,
},
},
});
});
it('returns a 400 if trying to set overridden value', async () => {
const { supertest } = await setup();
const { body } = await supertest('post', '/api/kibana/global_settings')
.send({
changes: {
foo: 'baz',
},
})
.expect(400);
expect(body).toEqual({
error: 'Bad Request',
message: 'Unable to update "foo" because it is overridden',
statusCode: 400,
});
});
});
describe('delete route', () => {
it('returns a 200 and deletes the setting', async () => {
const defaultIndex = chance.word({ length: 10 });
const { uiSettingsGlobal, supertest } = await setup({
initialSettings: { defaultIndex },
});
expect(await uiSettingsGlobal.get('defaultIndex')).toBe(defaultIndex);
const { body } = await supertest(
'delete',
'/api/kibana/global_settings/defaultIndex'
).expect(200);
expect(body).toMatchObject({
settings: {
buildNum: {
userValue: expect.any(Number),
},
foo: {
userValue: 'bar',
isOverridden: true,
},
},
});
});
it('returns a 400 if deleting overridden value', async () => {
const { supertest } = await setup();
const { body } = await supertest('delete', '/api/kibana/global_settings/foo').expect(400);
expect(body).toEqual({
error: 'Bad Request',
message: 'Unable to update "foo" because it is overridden',
statusCode: 400,
});
});
});
});
};

View file

@ -28,6 +28,7 @@ interface AllServices {
savedObjectsClient: SavedObjectsClientContract;
esClient: Client;
uiSettings: IUiSettingsClient;
uiSettingsGlobal: IUiSettingsClient;
supertest: (method: HttpMethod, path: string) => supertest.Test;
}
@ -62,12 +63,14 @@ export function getServices() {
);
const uiSettings = kbn.coreStart.uiSettings.asScopedToClient(savedObjectsClient);
const uiSettingsGlobal = kbn.coreStart.uiSettings.globalAsScopedToClient(savedObjectsClient);
services = {
supertest: (method: HttpMethod, path: string) => getSupertest(kbn.root, method, path),
esClient,
savedObjectsClient,
uiSettings,
uiSettingsGlobal,
};
return services;

View file

@ -55,5 +55,32 @@ describe('ui settings service', () => {
);
});
});
describe('global', () => {
describe('set', () => {
it('validates value', async () => {
const response = await request
.post(root, '/api/kibana/global_settings/custom')
.send({ value: 100 })
.expect(400);
expect(response.body.message).toBe(
'[validation [custom]]: expected value of type [string] but got [number]'
);
});
});
describe('set many', () => {
it('validates value', async () => {
const response = await request
.post(root, '/api/kibana/global_settings')
.send({ changes: { custom: 100, foo: 'bar' } })
.expect(400);
expect(response.body.message).toBe(
'[validation [custom]]: expected value of type [string] but got [number]'
);
});
});
});
});
});

View file

@ -124,6 +124,7 @@ function createCoreRequestHandlerContextMock() {
},
uiSettings: {
client: uiSettingsServiceMock.createClient(),
globalClient: uiSettingsServiceMock.createClient(),
},
deprecations: {
client: deprecationsServiceMock.createClient(),