Skip to content

Commit 005c679

Browse files
tianlehSuZhou-Joe
authored andcommittedMar 4, 2024
Add support for dynamic application configurations (opensearch-project#5855)
* Add application configuration service Signed-off-by: Tianle Huang <tianleh@amazon.com> * update API path name Signed-off-by: Tianle Huang <tianleh@amazon.com> * implement two APIs/interfaces Signed-off-by: Tianle Huang <tianleh@amazon.com> * expose get function for other plugins to use Signed-off-by: Tianle Huang <tianleh@amazon.com> * update interfaces Signed-off-by: Tianle Huang <tianleh@amazon.com> * implement the APIs and interfaces Signed-off-by: Tianle Huang <tianleh@amazon.com> * add license and jsdoc Signed-off-by: Tianle Huang <tianleh@amazon.com> * update docs Signed-off-by: Tianle Huang <tianleh@amazon.com> * add more docs Signed-off-by: Tianle Huang <tianleh@amazon.com> * update variable name Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove unnecessary dependency Signed-off-by: Tianle Huang <tianleh@amazon.com> * format readme Signed-off-by: Tianle Huang <tianleh@amazon.com> * use osd version Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove debugging info Signed-off-by: Tianle Huang <tianleh@amazon.com> * update logging Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove lint js Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove logs Signed-off-by: Tianle Huang <tianleh@amazon.com> * update name style Signed-off-by: Tianle Huang <tianleh@amazon.com> * update Signed-off-by: Tianle Huang <tianleh@amazon.com> * update function visibility and error function Signed-off-by: Tianle Huang <tianleh@amazon.com> * fix unit test failures Signed-off-by: Tianle Huang <tianleh@amazon.com> * add unit test Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove lint file Signed-off-by: Tianle Huang <tianleh@amazon.com> * add more tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * add unit tests for routes Signed-off-by: Tianle Huang <tianleh@amazon.com> * add remaining unit tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * add enabled to this plugin Signed-off-by: Tianle Huang <tianleh@amazon.com> * update readme to mention experimental Signed-off-by: Tianle Huang <tianleh@amazon.com> * update change log Signed-off-by: Tianle Huang <tianleh@amazon.com> * dummy commit to trigger workflow rerun Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove experimental Signed-off-by: Tianle Huang <tianleh@amazon.com> * add key to yml file Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove i18n Signed-off-by: Tianle Huang <tianleh@amazon.com> * remove lint rc Signed-off-by: Tianle Huang <tianleh@amazon.com> * update comment style Signed-off-by: Tianle Huang <tianleh@amazon.com> * add input validation Signed-off-by: Tianle Huang <tianleh@amazon.com> * update unit tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * prevent multiple registration Signed-off-by: Tianle Huang <tianleh@amazon.com> * add return types Signed-off-by: Tianle Huang <tianleh@amazon.com> * update readme wording Signed-off-by: Tianle Huang <tianleh@amazon.com> * add unit test to the plugin class about double register Signed-off-by: Tianle Huang <tianleh@amazon.com> * move related ymls Signed-off-by: Tianle Huang <tianleh@amazon.com> * move validation to a function Signed-off-by: Tianle Huang <tianleh@amazon.com> * use trimmed versions Signed-off-by: Tianle Huang <tianleh@amazon.com> * reword changelog entry Signed-off-by: Tianle Huang <tianleh@amazon.com> * readability Signed-off-by: Tianle Huang <tianleh@amazon.com> * add back yml change Signed-off-by: Tianle Huang <tianleh@amazon.com> --------- Signed-off-by: Tianle Huang <tianleh@amazon.com>
1 parent 50cecdc commit 005c679

21 files changed

+1464
-1
lines changed
 

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
7474
- [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881))
7575
- [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882))
7676
- [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916))
77+
- [[Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855))
7778

7879
### 🐛 Bug Fixes
7980

‎config/opensearch_dashboards.yml

+7
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
# dashboards. OpenSearch Dashboards creates a new index if the index doesn't already exist.
3030
#opensearchDashboards.index: ".opensearch_dashboards"
3131

32+
# OpenSearch Dashboards uses an index in OpenSearch to store dynamic configurations.
33+
# This shall be a different index from opensearchDashboards.index.
34+
# opensearchDashboards.configIndex: ".opensearch_dashboards_config"
35+
36+
# Set the value of this setting to true to enable plugin application config. By default it is disabled.
37+
# application_config.enabled: false
38+
3239
# The default application to load.
3340
#opensearchDashboards.defaultAppId: "home"
3441

‎src/core/server/mocks.ts

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function pluginInitializerContextConfigMock<T>(config: T) {
7777
const globalConfig: SharedGlobalConfig = {
7878
opensearchDashboards: {
7979
index: '.opensearch_dashboards_tests',
80+
configIndex: '.opensearch_dashboards_config_tests',
8081
autocompleteTerminateAfter: duration(100000),
8182
autocompleteTimeout: duration(1000),
8283
},

‎src/core/server/opensearch_dashboards_config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const config = {
4848
schema: schema.object({
4949
enabled: schema.boolean({ defaultValue: true }),
5050
index: schema.string({ defaultValue: '.kibana' }),
51+
configIndex: schema.string({ defaultValue: '.opensearch_dashboards_config' }),
5152
autocompleteTerminateAfter: schema.duration({ defaultValue: 100000 }),
5253
autocompleteTimeout: schema.duration({ defaultValue: 1000 }),
5354
branding: schema.object({

‎src/core/server/plugins/plugin_context.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ describe('createPluginInitializerContext', () => {
9898
expect(configObject).toStrictEqual({
9999
opensearchDashboards: {
100100
index: '.kibana',
101+
configIndex: '.opensearch_dashboards_config',
101102
autocompleteTerminateAfter: duration(100000),
102103
autocompleteTimeout: duration(1000),
103104
},

‎src/core/server/plugins/types.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,12 @@ export interface Plugin<
287287

288288
export const SharedGlobalConfigKeys = {
289289
// We can add more if really needed
290-
opensearchDashboards: ['index', 'autocompleteTerminateAfter', 'autocompleteTimeout'] as const,
290+
opensearchDashboards: [
291+
'index',
292+
'configIndex',
293+
'autocompleteTerminateAfter',
294+
'autocompleteTimeout',
295+
] as const,
291296
opensearch: ['shardTimeout', 'requestTimeout', 'pingTimeout'] as const,
292297
path: ['data'] as const,
293298
savedObjects: ['maxImportPayloadBytes'] as const,

‎src/legacy/server/config/schema.js

+1
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export default () =>
227227
opensearchDashboards: Joi.object({
228228
enabled: Joi.boolean().default(true),
229229
index: Joi.string().default('.kibana'),
230+
configIndex: Joi.string().default('.opensearch_dashboards_config'),
230231
autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000),
231232
// TODO Also allow units here like in opensearch config once this is moved to the new platform
232233
autocompleteTimeout: Joi.number().integer().min(1).default(1000),
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# ApplicationConfig Plugin
2+
3+
An OpenSearch Dashboards plugin for application configuration and a default implementation based on OpenSearch as storage.
4+
5+
---
6+
7+
## Introduction
8+
9+
This plugin introduces the support of dynamic application configurations as opposed to the existing static configuration in OSD YAML file `opensearch_dashboards.yml`. It stores the configuration in an index whose default name is `.opensearch_dashboards_config` and could be customized through the key `opensearchDashboards.configIndex` in OSD YAML file. Initially the new index does not exist. Only OSD users who need dynamic configurations will create it.
10+
11+
It also provides an interface `ConfigurationClient` for future extensions of external configuration clients. A default implementation based on OpenSearch as database is used.
12+
13+
This plugin is disabled by default.
14+
15+
## Configuration
16+
17+
OSD users who want to set up application configurations will first need to enable this plugin by the following line in OSD YML.
18+
19+
```
20+
application_config.enabled: true
21+
22+
```
23+
24+
Then they can perform configuration operations through CURL the OSD APIs.
25+
26+
(Note that the commands following could be first obtained from a copy as curl option from the network tab of a browser development tool and then replaced with the API names)
27+
28+
Below is the CURL command to view all configurations.
29+
30+
```
31+
curl '{osd endpoint}/api/appconfig' -X GET
32+
```
33+
34+
Below is the CURL command to view the configuration of an entity.
35+
36+
```
37+
curl '{osd endpoint}/api/appconfig/{entity}' -X GET
38+
39+
```
40+
41+
Below is the CURL command to update the configuration of an entity.
42+
43+
```
44+
curl '{osd endpoint}/api/appconfig/{entity}' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"{new value}"}'
45+
```
46+
47+
Below is the CURL command to delete the configuration of an entity.
48+
49+
```
50+
curl '{osd endpoint}/api/appconfig/{entity}' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty'
51+
52+
```
53+
54+
55+
## External Configuration Clients
56+
57+
While a default OpenSearch based client is implemented, OSD users can use external configuration clients through an OSD plugin (outside OSD).
58+
59+
Let's call this plugin `MyConfigurationClientPlugin`.
60+
61+
First, this plugin will need to implement a class `MyConfigurationClient` based on interface `ConfigurationClient` defined in the `types.ts` under directory `src/plugins/application_config/server/types.ts`. Below are the functions inside the interface.
62+
63+
```
64+
getConfig(): Promise<Map<string, string>>;
65+
66+
getEntityConfig(entity: string): Promise<string>;
67+
68+
updateEntityConfig(entity: string, newValue: string): Promise<string>;
69+
70+
deleteEntityConfig(entity: string): Promise<string>;
71+
```
72+
73+
Second, this plugin needs to declare `applicationConfig` as its dependency by adding it to `requiredPlugins` in its own `opensearch_dashboards.json`.
74+
75+
Third, the plugin will define a new type called `AppPluginSetupDependencies` as follows in its own `types.ts`.
76+
77+
```
78+
export interface AppPluginSetupDependencies {
79+
applicationConfig: ApplicationConfigPluginSetup;
80+
}
81+
82+
```
83+
84+
Then the plugin will import the new type `AppPluginSetupDependencies` and add to its own setup input. Below is the skeleton of the class `MyConfigurationClientPlugin`.
85+
86+
```
87+
// MyConfigurationClientPlugin
88+
public setup(core: CoreSetup, { applicationConfig }: AppPluginSetupDependencies) {
89+
90+
...
91+
// The function createClient provides an instance of ConfigurationClient which
92+
// could have a underlying DynamoDB or Postgres implementation.
93+
const myConfigurationClient: ConfigurationClient = this.createClient();
94+
95+
applicationConfig.registerConfigurationClient(myConfigurationClient);
96+
...
97+
return {};
98+
}
99+
100+
```
101+
102+
## Onboarding Configurations
103+
104+
Since the APIs and interfaces can take an entity, a new use case to this plugin could just pass their entity into the parameters. There is no need to implement new APIs or interfaces. To programmatically call the functions in `ConfigurationClient` from a plugin (the caller plugin), below is the code example.
105+
106+
Similar to [section](#external-configuration-clients), a new type `AppPluginSetupDependencies` which encapsulates `ApplicationConfigPluginSetup` is needed. Then it can be imported into the `setup` function of the caller plugin. Then the caller plugin will have access to the `getConfigurationClient` and `registerConfigurationClient` exposed by `ApplicationConfigPluginSetup`.
107+
108+
## Development
109+
110+
See the [OpenSearch Dashboards contributing
111+
guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions
112+
setting up your development environment.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export const PLUGIN_ID = 'applicationConfig';
7+
export const PLUGIN_NAME = 'application_config';
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { schema, TypeOf } from '@osd/config-schema';
7+
8+
export const configSchema = schema.object({
9+
enabled: schema.boolean({ defaultValue: false }),
10+
});
11+
12+
export type ApplicationConfigSchema = TypeOf<typeof configSchema>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "applicationConfig",
3+
"version": "opensearchDashboards",
4+
"opensearchDashboardsVersion": "opensearchDashboards",
5+
"server": true,
6+
"ui": false,
7+
"requiredPlugins": [],
8+
"optionalPlugins": []
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server';
7+
import { ApplicationConfigSchema, configSchema } from '../config';
8+
import { ApplicationConfigPlugin } from './plugin';
9+
10+
/*
11+
This exports static code and TypeScript types,
12+
as well as, OpenSearch Dashboards Platform `plugin()` initializer.
13+
*/
14+
15+
export const config: PluginConfigDescriptor<ApplicationConfigSchema> = {
16+
schema: configSchema,
17+
};
18+
19+
export function plugin(initializerContext: PluginInitializerContext) {
20+
return new ApplicationConfigPlugin(initializerContext);
21+
}
22+
23+
export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { ResponseError } from '@opensearch-project/opensearch/lib/errors';
7+
import { OpenSearchConfigurationClient } from './opensearch_config_client';
8+
import { MockedLogger, loggerMock } from '@osd/logging/target/mocks';
9+
10+
const INDEX_NAME = 'test_index';
11+
const ERROR_MESSAGE = 'Service unavailable';
12+
const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!';
13+
const EMPTY_INPUT = ' ';
14+
15+
describe('OpenSearch Configuration Client', () => {
16+
let logger: MockedLogger;
17+
18+
beforeEach(() => {
19+
logger = loggerMock.create();
20+
});
21+
22+
describe('getConfig', () => {
23+
it('returns configurations from the index', async () => {
24+
const opensearchClient = {
25+
asInternalUser: {
26+
search: jest.fn().mockImplementation(() => {
27+
return {
28+
body: {
29+
hits: {
30+
hits: [
31+
{
32+
_id: 'config1',
33+
_source: {
34+
value: 'value1',
35+
},
36+
},
37+
{
38+
_id: 'config2',
39+
_source: {
40+
value: 'value2',
41+
},
42+
},
43+
],
44+
},
45+
},
46+
};
47+
}),
48+
},
49+
};
50+
51+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
52+
53+
const value = await client.getConfig();
54+
55+
expect(JSON.stringify(value)).toBe(JSON.stringify({ config1: 'value1', config2: 'value2' }));
56+
});
57+
58+
it('throws error when opensearch errors happen', async () => {
59+
const error = new ResponseError({
60+
statusCode: 401,
61+
body: {
62+
error: {
63+
type: ERROR_MESSAGE,
64+
},
65+
},
66+
warnings: [],
67+
headers: {
68+
'WWW-Authenticate': 'content',
69+
},
70+
meta: {} as any,
71+
});
72+
73+
const opensearchClient = {
74+
asInternalUser: {
75+
search: jest.fn().mockImplementation(() => {
76+
throw error;
77+
}),
78+
},
79+
};
80+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
81+
82+
await expect(client.getConfig()).rejects.toThrowError(ERROR_MESSAGE);
83+
});
84+
});
85+
86+
describe('getEntityConfig', () => {
87+
it('return configuration value from the document in the index', async () => {
88+
const opensearchClient = {
89+
asInternalUser: {
90+
get: jest.fn().mockImplementation(() => {
91+
return {
92+
body: {
93+
_source: {
94+
value: 'value1',
95+
},
96+
},
97+
};
98+
}),
99+
},
100+
};
101+
102+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
103+
104+
const value = await client.getEntityConfig('config1');
105+
106+
expect(value).toBe('value1');
107+
});
108+
109+
it('throws error when input is empty', async () => {
110+
const opensearchClient = {
111+
asInternalUser: {
112+
get: jest.fn().mockImplementation(() => {
113+
return {
114+
body: {
115+
_source: {
116+
value: 'value1',
117+
},
118+
},
119+
};
120+
}),
121+
},
122+
};
123+
124+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
125+
126+
await expect(client.getEntityConfig(EMPTY_INPUT)).rejects.toThrowError(
127+
ERROR_MESSSAGE_FOR_EMPTY_INPUT
128+
);
129+
});
130+
131+
it('throws error when opensearch errors happen', async () => {
132+
const error = new ResponseError({
133+
statusCode: 401,
134+
body: {
135+
error: {
136+
type: ERROR_MESSAGE,
137+
},
138+
},
139+
warnings: [],
140+
headers: {
141+
'WWW-Authenticate': 'content',
142+
},
143+
meta: {} as any,
144+
});
145+
146+
const opensearchClient = {
147+
asInternalUser: {
148+
get: jest.fn().mockImplementation(() => {
149+
throw error;
150+
}),
151+
},
152+
};
153+
154+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
155+
156+
await expect(client.getEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE);
157+
});
158+
});
159+
160+
describe('deleteEntityConfig', () => {
161+
it('return deleted entity when opensearch deletes successfully', async () => {
162+
const opensearchClient = {
163+
asCurrentUser: {
164+
delete: jest.fn().mockImplementation(() => {
165+
return {};
166+
}),
167+
},
168+
};
169+
170+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
171+
172+
const value = await client.deleteEntityConfig('config1');
173+
174+
expect(value).toBe('config1');
175+
});
176+
177+
it('throws error when input entity is empty', async () => {
178+
const opensearchClient = {
179+
asCurrentUser: {
180+
delete: jest.fn().mockImplementation(() => {
181+
return {};
182+
}),
183+
},
184+
};
185+
186+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
187+
188+
await expect(client.deleteEntityConfig(EMPTY_INPUT)).rejects.toThrowError(
189+
ERROR_MESSSAGE_FOR_EMPTY_INPUT
190+
);
191+
});
192+
193+
it('return deleted document entity when deletion fails due to index not found', async () => {
194+
const error = new ResponseError({
195+
statusCode: 401,
196+
body: {
197+
error: {
198+
type: 'index_not_found_exception',
199+
},
200+
},
201+
warnings: [],
202+
headers: {
203+
'WWW-Authenticate': 'content',
204+
},
205+
meta: {} as any,
206+
});
207+
208+
const opensearchClient = {
209+
asCurrentUser: {
210+
delete: jest.fn().mockImplementation(() => {
211+
throw error;
212+
}),
213+
},
214+
};
215+
216+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
217+
218+
const value = await client.deleteEntityConfig('config1');
219+
220+
expect(value).toBe('config1');
221+
});
222+
223+
it('return deleted document entity when deletion fails due to document not found', async () => {
224+
const error = new ResponseError({
225+
statusCode: 401,
226+
body: {
227+
result: 'not_found',
228+
},
229+
warnings: [],
230+
headers: {
231+
'WWW-Authenticate': 'content',
232+
},
233+
meta: {} as any,
234+
});
235+
236+
const opensearchClient = {
237+
asCurrentUser: {
238+
delete: jest.fn().mockImplementation(() => {
239+
throw error;
240+
}),
241+
},
242+
};
243+
244+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
245+
246+
const value = await client.deleteEntityConfig('config1');
247+
248+
expect(value).toBe('config1');
249+
});
250+
251+
it('throws error when opensearch throws error', async () => {
252+
const error = new ResponseError({
253+
statusCode: 401,
254+
body: {
255+
error: {
256+
type: ERROR_MESSAGE,
257+
},
258+
},
259+
warnings: [],
260+
headers: {
261+
'WWW-Authenticate': 'content',
262+
},
263+
meta: {} as any,
264+
});
265+
266+
const opensearchClient = {
267+
asCurrentUser: {
268+
delete: jest.fn().mockImplementation(() => {
269+
throw error;
270+
}),
271+
},
272+
};
273+
274+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
275+
276+
await expect(client.deleteEntityConfig('config1')).rejects.toThrowError(ERROR_MESSAGE);
277+
});
278+
});
279+
280+
describe('updateEntityConfig', () => {
281+
it('returns updated value when opensearch updates successfully', async () => {
282+
const opensearchClient = {
283+
asCurrentUser: {
284+
index: jest.fn().mockImplementation(() => {
285+
return {};
286+
}),
287+
},
288+
};
289+
290+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
291+
292+
const value = await client.updateEntityConfig('config1', 'newValue1');
293+
294+
expect(value).toBe('newValue1');
295+
});
296+
297+
it('throws error when entity is empty ', async () => {
298+
const opensearchClient = {
299+
asCurrentUser: {
300+
index: jest.fn().mockImplementation(() => {
301+
return {};
302+
}),
303+
},
304+
};
305+
306+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
307+
308+
await expect(client.updateEntityConfig(EMPTY_INPUT, 'newValue1')).rejects.toThrowError(
309+
ERROR_MESSSAGE_FOR_EMPTY_INPUT
310+
);
311+
});
312+
313+
it('throws error when new value is empty ', async () => {
314+
const opensearchClient = {
315+
asCurrentUser: {
316+
index: jest.fn().mockImplementation(() => {
317+
return {};
318+
}),
319+
},
320+
};
321+
322+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
323+
324+
await expect(client.updateEntityConfig('config1', EMPTY_INPUT)).rejects.toThrowError(
325+
ERROR_MESSSAGE_FOR_EMPTY_INPUT
326+
);
327+
});
328+
329+
it('throws error when opensearch throws error', async () => {
330+
const error = new ResponseError({
331+
statusCode: 401,
332+
body: {
333+
error: {
334+
type: ERROR_MESSAGE,
335+
},
336+
},
337+
warnings: [],
338+
headers: {
339+
'WWW-Authenticate': 'content',
340+
},
341+
meta: {} as any,
342+
});
343+
344+
const opensearchClient = {
345+
asCurrentUser: {
346+
index: jest.fn().mockImplementation(() => {
347+
throw error;
348+
}),
349+
},
350+
};
351+
352+
const client = new OpenSearchConfigurationClient(opensearchClient, INDEX_NAME, logger);
353+
354+
await expect(client.updateEntityConfig('config1', 'newValue1')).rejects.toThrowError(
355+
ERROR_MESSAGE
356+
);
357+
});
358+
});
359+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { IScopedClusterClient, Logger } from '../../../../src/core/server';
7+
8+
import { ConfigurationClient } from './types';
9+
import { validate } from './string_utils';
10+
11+
export class OpenSearchConfigurationClient implements ConfigurationClient {
12+
private client: IScopedClusterClient;
13+
private configurationIndexName: string;
14+
private readonly logger: Logger;
15+
16+
constructor(
17+
scopedClusterClient: IScopedClusterClient,
18+
configurationIndexName: string,
19+
logger: Logger
20+
) {
21+
this.client = scopedClusterClient;
22+
this.configurationIndexName = configurationIndexName;
23+
this.logger = logger;
24+
}
25+
26+
async getEntityConfig(entity: string) {
27+
const entityValidated = validate(entity, this.logger);
28+
29+
try {
30+
const data = await this.client.asInternalUser.get({
31+
index: this.configurationIndexName,
32+
id: entityValidated,
33+
});
34+
35+
return data?.body?._source?.value || '';
36+
} catch (e) {
37+
const errorMessage = `Failed to get entity ${entityValidated} due to error ${e}`;
38+
39+
this.logger.error(errorMessage);
40+
41+
throw e;
42+
}
43+
}
44+
45+
async updateEntityConfig(entity: string, newValue: string) {
46+
const entityValidated = validate(entity, this.logger);
47+
const newValueValidated = validate(newValue, this.logger);
48+
49+
try {
50+
await this.client.asCurrentUser.index({
51+
index: this.configurationIndexName,
52+
id: entityValidated,
53+
body: {
54+
value: newValueValidated,
55+
},
56+
});
57+
58+
return newValueValidated;
59+
} catch (e) {
60+
const errorMessage = `Failed to update entity ${entityValidated} with newValue ${newValueValidated} due to error ${e}`;
61+
62+
this.logger.error(errorMessage);
63+
64+
throw e;
65+
}
66+
}
67+
68+
async deleteEntityConfig(entity: string) {
69+
const entityValidated = validate(entity, this.logger);
70+
71+
try {
72+
await this.client.asCurrentUser.delete({
73+
index: this.configurationIndexName,
74+
id: entityValidated,
75+
});
76+
77+
return entityValidated;
78+
} catch (e) {
79+
if (e?.body?.error?.type === 'index_not_found_exception') {
80+
this.logger.info('Attemp to delete a not found index.');
81+
return entityValidated;
82+
}
83+
84+
if (e?.body?.result === 'not_found') {
85+
this.logger.info('Attemp to delete a not found document.');
86+
return entityValidated;
87+
}
88+
89+
const errorMessage = `Failed to delete entity ${entityValidated} due to error ${e}`;
90+
91+
this.logger.error(errorMessage);
92+
93+
throw e;
94+
}
95+
}
96+
97+
async getConfig(): Promise<Map<string, string>> {
98+
try {
99+
const data = await this.client.asInternalUser.search({
100+
index: this.configurationIndexName,
101+
});
102+
103+
return this.transformIndexSearchResponse(data.body.hits.hits);
104+
} catch (e) {
105+
const errorMessage = `Failed to call getConfig due to error ${e}`;
106+
107+
this.logger.error(errorMessage);
108+
109+
throw e;
110+
}
111+
}
112+
113+
transformIndexSearchResponse(hits): Map<string, string> {
114+
const configurations = {};
115+
116+
for (let i = 0; i < hits.length; i++) {
117+
const doc = hits[i];
118+
configurations[doc._id] = doc?._source?.value;
119+
}
120+
121+
return configurations;
122+
}
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { of } from 'rxjs';
7+
import { ApplicationConfigPlugin } from './plugin';
8+
import { ConfigurationClient } from './types';
9+
10+
describe('application config plugin', () => {
11+
it('throws error when trying to register twice', async () => {
12+
const initializerContext = {
13+
logger: {
14+
get: jest.fn().mockImplementation(() => {
15+
return {
16+
info: jest.fn(),
17+
error: jest.fn(),
18+
};
19+
}),
20+
},
21+
config: {
22+
legacy: {
23+
globalConfig$: of({
24+
opensearchDashboards: {
25+
configIndex: '.osd_test',
26+
},
27+
}),
28+
},
29+
},
30+
};
31+
32+
const plugin = new ApplicationConfigPlugin(initializerContext);
33+
34+
const coreSetup = {
35+
http: {
36+
createRouter: jest.fn().mockImplementation(() => {
37+
return {
38+
get: jest.fn(),
39+
post: jest.fn(),
40+
delete: jest.fn(),
41+
};
42+
}),
43+
},
44+
};
45+
46+
const setup = await plugin.setup(coreSetup);
47+
48+
const client1: ConfigurationClient = {
49+
getConfig: jest.fn(),
50+
getEntityConfig: jest.fn(),
51+
updateEntityConfig: jest.fn(),
52+
deleteEntityConfig: jest.fn(),
53+
};
54+
55+
setup.registerConfigurationClient(client1);
56+
57+
const scopedClient = {};
58+
expect(setup.getConfigurationClient(scopedClient)).toBe(client1);
59+
60+
const client2: ConfigurationClient = {
61+
getConfig: jest.fn(),
62+
getEntityConfig: jest.fn(),
63+
updateEntityConfig: jest.fn(),
64+
deleteEntityConfig: jest.fn(),
65+
};
66+
67+
// call the register function again
68+
const secondCall = () => setup.registerConfigurationClient(client2);
69+
70+
expect(secondCall).toThrowError(
71+
'Configuration client is already registered! Cannot register again!'
72+
);
73+
74+
expect(setup.getConfigurationClient(scopedClient)).toBe(client1);
75+
});
76+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Observable } from 'rxjs';
7+
import { first } from 'rxjs/operators';
8+
9+
import {
10+
PluginInitializerContext,
11+
CoreSetup,
12+
CoreStart,
13+
Plugin,
14+
Logger,
15+
IScopedClusterClient,
16+
SharedGlobalConfig,
17+
} from '../../../core/server';
18+
19+
import {
20+
ApplicationConfigPluginSetup,
21+
ApplicationConfigPluginStart,
22+
ConfigurationClient,
23+
} from './types';
24+
import { defineRoutes } from './routes';
25+
import { OpenSearchConfigurationClient } from './opensearch_config_client';
26+
27+
export class ApplicationConfigPlugin
28+
implements Plugin<ApplicationConfigPluginSetup, ApplicationConfigPluginStart> {
29+
private readonly logger: Logger;
30+
private readonly config$: Observable<SharedGlobalConfig>;
31+
32+
private configurationClient: ConfigurationClient;
33+
private configurationIndexName: string;
34+
35+
constructor(initializerContext: PluginInitializerContext) {
36+
this.logger = initializerContext.logger.get();
37+
this.config$ = initializerContext.config.legacy.globalConfig$;
38+
this.configurationIndexName = '';
39+
}
40+
41+
private registerConfigurationClient(configurationClient: ConfigurationClient) {
42+
this.logger.info('Register a configuration client.');
43+
44+
if (this.configurationClient) {
45+
const errorMessage = 'Configuration client is already registered! Cannot register again!';
46+
this.logger.error(errorMessage);
47+
throw new Error(errorMessage);
48+
}
49+
50+
this.configurationClient = configurationClient;
51+
}
52+
53+
private getConfigurationClient(scopedClusterClient: IScopedClusterClient): ConfigurationClient {
54+
if (this.configurationClient) {
55+
return this.configurationClient;
56+
}
57+
58+
const openSearchConfigurationClient = new OpenSearchConfigurationClient(
59+
scopedClusterClient,
60+
this.configurationIndexName,
61+
this.logger
62+
);
63+
64+
return openSearchConfigurationClient;
65+
}
66+
67+
public async setup(core: CoreSetup) {
68+
const router = core.http.createRouter();
69+
70+
const config = await this.config$.pipe(first()).toPromise();
71+
72+
this.configurationIndexName = config.opensearchDashboards.configIndex;
73+
74+
// Register server side APIs
75+
defineRoutes(router, this.getConfigurationClient.bind(this), this.logger);
76+
77+
return {
78+
getConfigurationClient: this.getConfigurationClient.bind(this),
79+
registerConfigurationClient: this.registerConfigurationClient.bind(this),
80+
};
81+
}
82+
83+
public start(core: CoreStart) {
84+
return {};
85+
}
86+
87+
public stop() {}
88+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { httpServiceMock } from '../../../../core/server/mocks';
7+
import { loggerMock } from '@osd/logging/target/mocks';
8+
import {
9+
defineRoutes,
10+
handleDeleteEntityConfig,
11+
handleGetConfig,
12+
handleGetEntityConfig,
13+
handleUpdateEntityConfig,
14+
} from '.';
15+
16+
const ERROR_MESSAGE = 'Service unavailable';
17+
18+
const ERROR_RESPONSE = {
19+
statusCode: 500,
20+
};
21+
22+
const ENTITY_NAME = 'config1';
23+
const ENTITY_VALUE = 'value1';
24+
const ENTITY_NEW_VALUE = 'newValue1';
25+
26+
describe('application config routes', () => {
27+
describe('defineRoutes', () => {
28+
it('check route paths are defined', () => {
29+
const router = httpServiceMock.createRouter();
30+
const configurationClient = {
31+
existsCspRules: jest.fn().mockReturnValue(true),
32+
getCspRules: jest.fn().mockReturnValue(''),
33+
};
34+
35+
const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);
36+
37+
const logger = loggerMock.create();
38+
39+
defineRoutes(router, getConfigurationClient, logger);
40+
41+
expect(router.get).toHaveBeenCalledWith(
42+
expect.objectContaining({
43+
path: '/api/appconfig',
44+
}),
45+
expect.any(Function)
46+
);
47+
48+
expect(router.get).toHaveBeenCalledWith(
49+
expect.objectContaining({
50+
path: '/api/appconfig/{entity}',
51+
}),
52+
expect.any(Function)
53+
);
54+
55+
expect(router.post).toHaveBeenCalledWith(
56+
expect.objectContaining({
57+
path: '/api/appconfig/{entity}',
58+
}),
59+
expect.any(Function)
60+
);
61+
62+
expect(router.delete).toHaveBeenCalledWith(
63+
expect.objectContaining({
64+
path: '/api/appconfig/{entity}',
65+
}),
66+
expect.any(Function)
67+
);
68+
});
69+
});
70+
71+
describe('handleGetConfig', () => {
72+
it('returns configurations when client returns', async () => {
73+
const configurations = {
74+
config1: 'value1',
75+
config2: 'value2',
76+
};
77+
78+
const client = {
79+
getConfig: jest.fn().mockReturnValue(configurations),
80+
};
81+
82+
const okResponse = {
83+
statusCode: 200,
84+
};
85+
86+
const response = {
87+
ok: jest.fn().mockReturnValue(okResponse),
88+
};
89+
90+
const logger = loggerMock.create();
91+
92+
const returnedResponse = await handleGetConfig(client, response, logger);
93+
94+
expect(returnedResponse).toBe(okResponse);
95+
96+
expect(response.ok).toBeCalledWith({
97+
body: {
98+
value: configurations,
99+
},
100+
});
101+
});
102+
103+
it('return error response when client throws error', async () => {
104+
const error = new Error(ERROR_MESSAGE);
105+
106+
const client = {
107+
getConfig: jest.fn().mockImplementation(() => {
108+
throw error;
109+
}),
110+
};
111+
112+
const response = {
113+
customError: jest.fn().mockReturnValue(ERROR_RESPONSE),
114+
};
115+
116+
const logger = loggerMock.create();
117+
118+
const returnedResponse = await handleGetConfig(client, response, logger);
119+
120+
expect(returnedResponse).toBe(ERROR_RESPONSE);
121+
122+
expect(client.getConfig).toBeCalledTimes(1);
123+
124+
expect(response.customError).toBeCalledWith({
125+
body: error,
126+
statusCode: 500,
127+
});
128+
129+
expect(logger.error).toBeCalledWith(error);
130+
});
131+
});
132+
133+
describe('handleGetEntityConfig', () => {
134+
it('returns value when client returns value', async () => {
135+
const client = {
136+
getEntityConfig: jest.fn().mockReturnValue(ENTITY_VALUE),
137+
};
138+
139+
const okResponse = {
140+
statusCode: 200,
141+
};
142+
143+
const request = {
144+
params: {
145+
entity: ENTITY_NAME,
146+
},
147+
};
148+
149+
const response = {
150+
ok: jest.fn().mockReturnValue(okResponse),
151+
};
152+
153+
const logger = loggerMock.create();
154+
155+
const returnedResponse = await handleGetEntityConfig(client, request, response, logger);
156+
157+
expect(returnedResponse).toBe(okResponse);
158+
159+
expect(response.ok).toBeCalledWith({
160+
body: {
161+
value: ENTITY_VALUE,
162+
},
163+
});
164+
});
165+
166+
it('return error response when client throws error', async () => {
167+
const error = new Error(ERROR_MESSAGE);
168+
169+
const client = {
170+
getEntityConfig: jest.fn().mockImplementation(() => {
171+
throw error;
172+
}),
173+
};
174+
175+
const request = {
176+
params: {
177+
entity: ENTITY_NAME,
178+
},
179+
};
180+
181+
const response = {
182+
customError: jest.fn().mockReturnValue(ERROR_RESPONSE),
183+
};
184+
185+
const logger = loggerMock.create();
186+
187+
const returnedResponse = await handleGetEntityConfig(client, request, response, logger);
188+
189+
expect(returnedResponse).toBe(ERROR_RESPONSE);
190+
191+
expect(client.getEntityConfig).toBeCalledTimes(1);
192+
193+
expect(response.customError).toBeCalledWith({
194+
body: error,
195+
statusCode: 500,
196+
});
197+
198+
expect(logger.error).toBeCalledWith(error);
199+
});
200+
});
201+
202+
describe('handleUpdateEntityConfig', () => {
203+
it('return success when client succeeds', async () => {
204+
const client = {
205+
updateEntityConfig: jest.fn().mockReturnValue(ENTITY_NEW_VALUE),
206+
};
207+
208+
const okResponse = {
209+
statusCode: 200,
210+
};
211+
212+
const request = {
213+
params: {
214+
entity: ENTITY_NAME,
215+
},
216+
body: {
217+
newValue: ENTITY_NEW_VALUE,
218+
},
219+
};
220+
221+
const response = {
222+
ok: jest.fn().mockReturnValue(okResponse),
223+
};
224+
225+
const logger = loggerMock.create();
226+
227+
const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger);
228+
229+
expect(returnedResponse).toBe(okResponse);
230+
231+
expect(client.updateEntityConfig).toBeCalledTimes(1);
232+
233+
expect(response.ok).toBeCalledWith({
234+
body: {
235+
newValue: ENTITY_NEW_VALUE,
236+
},
237+
});
238+
239+
expect(logger.error).not.toBeCalled();
240+
});
241+
242+
it('return error response when client fails', async () => {
243+
const error = new Error(ERROR_MESSAGE);
244+
245+
const client = {
246+
updateEntityConfig: jest.fn().mockImplementation(() => {
247+
throw error;
248+
}),
249+
};
250+
251+
const request = {
252+
params: {
253+
entity: ENTITY_NAME,
254+
},
255+
body: {
256+
newValue: ENTITY_NEW_VALUE,
257+
},
258+
};
259+
260+
const response = {
261+
customError: jest.fn().mockReturnValue(ERROR_RESPONSE),
262+
};
263+
264+
const logger = loggerMock.create();
265+
266+
const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger);
267+
268+
expect(returnedResponse).toBe(ERROR_RESPONSE);
269+
270+
expect(client.updateEntityConfig).toBeCalledTimes(1);
271+
272+
expect(response.customError).toBeCalledWith({
273+
body: error,
274+
statusCode: 500,
275+
});
276+
277+
expect(logger.error).toBeCalledWith(error);
278+
});
279+
});
280+
281+
describe('handleDeleteEntityConfig', () => {
282+
it('returns successful response when client succeeds', async () => {
283+
const client = {
284+
deleteEntityConfig: jest.fn().mockReturnValue(ENTITY_NAME),
285+
};
286+
287+
const okResponse = {
288+
statusCode: 200,
289+
};
290+
291+
const request = {
292+
params: {
293+
entity: ENTITY_NAME,
294+
},
295+
};
296+
297+
const response = {
298+
ok: jest.fn().mockReturnValue(okResponse),
299+
};
300+
301+
const logger = loggerMock.create();
302+
303+
const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger);
304+
305+
expect(returnedResponse).toBe(okResponse);
306+
307+
expect(client.deleteEntityConfig).toBeCalledTimes(1);
308+
309+
expect(response.ok).toBeCalledWith({
310+
body: {
311+
deletedEntity: ENTITY_NAME,
312+
},
313+
});
314+
315+
expect(logger.error).not.toBeCalled();
316+
});
317+
318+
it('return error response when client fails', async () => {
319+
const error = new Error(ERROR_MESSAGE);
320+
321+
const client = {
322+
deleteEntityConfig: jest.fn().mockImplementation(() => {
323+
throw error;
324+
}),
325+
};
326+
327+
const request = {
328+
params: {
329+
entity: ENTITY_NAME,
330+
},
331+
};
332+
333+
const response = {
334+
customError: jest.fn().mockReturnValue(ERROR_RESPONSE),
335+
};
336+
337+
const logger = loggerMock.create();
338+
339+
const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger);
340+
341+
expect(returnedResponse).toBe(ERROR_RESPONSE);
342+
343+
expect(client.deleteEntityConfig).toBeCalledTimes(1);
344+
345+
expect(response.customError).toBeCalledWith({
346+
body: error,
347+
statusCode: 500,
348+
});
349+
350+
expect(logger.error).toBeCalledWith(error);
351+
});
352+
});
353+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { schema } from '@osd/config-schema';
7+
import {
8+
IRouter,
9+
IScopedClusterClient,
10+
Logger,
11+
OpenSearchDashboardsRequest,
12+
OpenSearchDashboardsResponseFactory,
13+
} from '../../../../core/server';
14+
import { ConfigurationClient } from '../types';
15+
16+
export function defineRoutes(
17+
router: IRouter,
18+
getConfigurationClient: (configurationClient: IScopedClusterClient) => ConfigurationClient,
19+
logger: Logger
20+
) {
21+
router.get(
22+
{
23+
path: '/api/appconfig',
24+
validate: false,
25+
},
26+
async (context, request, response) => {
27+
const client = getConfigurationClient(context.core.opensearch.client);
28+
29+
return await handleGetConfig(client, response, logger);
30+
}
31+
);
32+
router.get(
33+
{
34+
path: '/api/appconfig/{entity}',
35+
validate: {
36+
params: schema.object({
37+
entity: schema.string(),
38+
}),
39+
},
40+
},
41+
async (context, request, response) => {
42+
const client = getConfigurationClient(context.core.opensearch.client);
43+
44+
return await handleGetEntityConfig(client, request, response, logger);
45+
}
46+
);
47+
router.post(
48+
{
49+
path: '/api/appconfig/{entity}',
50+
validate: {
51+
params: schema.object({
52+
entity: schema.string(),
53+
}),
54+
body: schema.object({
55+
newValue: schema.string(),
56+
}),
57+
},
58+
},
59+
async (context, request, response) => {
60+
const client = getConfigurationClient(context.core.opensearch.client);
61+
62+
return await handleUpdateEntityConfig(client, request, response, logger);
63+
}
64+
);
65+
router.delete(
66+
{
67+
path: '/api/appconfig/{entity}',
68+
validate: {
69+
params: schema.object({
70+
entity: schema.string(),
71+
}),
72+
},
73+
},
74+
async (context, request, response) => {
75+
const client = getConfigurationClient(context.core.opensearch.client);
76+
77+
return await handleDeleteEntityConfig(client, request, response, logger);
78+
}
79+
);
80+
}
81+
82+
export async function handleGetEntityConfig(
83+
client: ConfigurationClient,
84+
request: OpenSearchDashboardsRequest,
85+
response: OpenSearchDashboardsResponseFactory,
86+
logger: Logger
87+
) {
88+
try {
89+
const result = await client.getEntityConfig(request.params.entity);
90+
return response.ok({
91+
body: {
92+
value: result,
93+
},
94+
});
95+
} catch (e) {
96+
logger.error(e);
97+
return errorResponse(response, e);
98+
}
99+
}
100+
101+
export async function handleUpdateEntityConfig(
102+
client: ConfigurationClient,
103+
request: OpenSearchDashboardsRequest,
104+
response: OpenSearchDashboardsResponseFactory,
105+
logger: Logger
106+
) {
107+
try {
108+
const result = await client.updateEntityConfig(request.params.entity, request.body.newValue);
109+
return response.ok({
110+
body: {
111+
newValue: result,
112+
},
113+
});
114+
} catch (e) {
115+
logger.error(e);
116+
return errorResponse(response, e);
117+
}
118+
}
119+
120+
export async function handleDeleteEntityConfig(
121+
client: ConfigurationClient,
122+
request: OpenSearchDashboardsRequest,
123+
response: OpenSearchDashboardsResponseFactory,
124+
logger: Logger
125+
) {
126+
try {
127+
const result = await client.deleteEntityConfig(request.params.entity);
128+
return response.ok({
129+
body: {
130+
deletedEntity: result,
131+
},
132+
});
133+
} catch (e) {
134+
logger.error(e);
135+
return errorResponse(response, e);
136+
}
137+
}
138+
139+
export async function handleGetConfig(
140+
client: ConfigurationClient,
141+
response: OpenSearchDashboardsResponseFactory,
142+
logger: Logger
143+
) {
144+
try {
145+
const result = await client.getConfig();
146+
return response.ok({
147+
body: {
148+
value: result,
149+
},
150+
});
151+
} catch (e) {
152+
logger.error(e);
153+
return errorResponse(response, e);
154+
}
155+
}
156+
157+
function errorResponse(response: OpenSearchDashboardsResponseFactory, error: any) {
158+
return response.customError({
159+
statusCode: error?.statusCode || 500,
160+
body: error,
161+
});
162+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { validate } from './string_utils';
7+
8+
describe('application config string utils', () => {
9+
it('returns input when input is not empty and no prefix or suffix whitespaces', () => {
10+
const logger = {
11+
error: jest.fn(),
12+
};
13+
14+
const input = 'abc';
15+
16+
const validatedInput = validate(input, logger);
17+
18+
expect(validatedInput).toBe(input);
19+
expect(logger.error).not.toBeCalled();
20+
});
21+
22+
it('returns trimmed input when input is not empty and prefix or suffix whitespaces', () => {
23+
const logger = {
24+
error: jest.fn(),
25+
};
26+
27+
const input = ' abc ';
28+
29+
const validatedInput = validate(input, logger);
30+
31+
expect(validatedInput).toBe('abc');
32+
expect(logger.error).not.toBeCalled();
33+
});
34+
35+
it('throws error when input is empty', () => {
36+
const logger = {
37+
error: jest.fn(),
38+
};
39+
40+
expect(() => {
41+
validate(' ', logger);
42+
}).toThrowError('Input cannot be empty!');
43+
});
44+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Logger } from 'src/core/server';
7+
8+
const ERROR_MESSSAGE_FOR_EMPTY_INPUT = 'Input cannot be empty!';
9+
const ERROR_FOR_EMPTY_INPUT = new Error(ERROR_MESSSAGE_FOR_EMPTY_INPUT);
10+
11+
function isEmpty(input: string): boolean {
12+
if (!input) {
13+
return true;
14+
}
15+
16+
return !input.trim();
17+
}
18+
19+
export function validate(input: string, logger: Logger): string {
20+
if (isEmpty(input)) {
21+
logger.error(ERROR_MESSSAGE_FOR_EMPTY_INPUT);
22+
throw ERROR_FOR_EMPTY_INPUT;
23+
}
24+
25+
return input.trim();
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { IScopedClusterClient } from 'src/core/server';
7+
8+
export interface ApplicationConfigPluginSetup {
9+
getConfigurationClient: (inputOpenSearchClient: IScopedClusterClient) => ConfigurationClient;
10+
registerConfigurationClient: (inputConfigurationClient: ConfigurationClient) => void;
11+
}
12+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
13+
export interface ApplicationConfigPluginStart {}
14+
15+
/**
16+
* The interface defines the operations against the application configurations at both entity level and whole level.
17+
*
18+
*/
19+
export interface ConfigurationClient {
20+
/**
21+
* Get all the configurations.
22+
*
23+
* @param {array} array of connections
24+
* @returns {ConnectionPool}
25+
*/
26+
getConfig(): Promise<Map<string, string>>;
27+
28+
/**
29+
* Get the value for the input entity.
30+
*
31+
* @param {entity} name of the entity
32+
* @returns {string} value of the entity
33+
*/
34+
getEntityConfig(entity: string): Promise<string>;
35+
36+
/**
37+
* Update the input entity with a new value.
38+
*
39+
* @param {entity} name of the entity
40+
* @param {newValue} new configuration value of the entity
41+
* @returns {string} updated configuration value of the entity
42+
*/
43+
updateEntityConfig(entity: string, newValue: string): Promise<string>;
44+
45+
/**
46+
* Delete the input entity from configurations.
47+
*
48+
* @param {entity} name of the entity
49+
* @returns {string} name of the deleted entity
50+
*/
51+
deleteEntityConfig(entity: string): Promise<string>;
52+
}

0 commit comments

Comments
 (0)
Please sign in to comment.