Skip to content

Commit 48e146f

Browse files
committed
Enforce dataset name validation (client and server side)
1 parent 72b1d32 commit 48e146f

17 files changed

Lines changed: 359 additions & 89 deletions

File tree

packages/kbn-custom-integrations/src/components/create/form.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ import {
2828
} from '../../state_machines/create/types';
2929
import { Dataset, IntegrationError } from '../../types';
3030
import { hasFailedSelector } from '../../state_machines/create/selectors';
31+
import {
32+
datasetNameWillBePrefixed,
33+
getDatasetNamePrefix,
34+
getDatasetNameWithoutPrefix,
35+
prefixDatasetName,
36+
} from '../../state_machines/create/pipelines/fields';
3137

3238
// NOTE: Hardcoded for now. We will likely extend the functionality here to allow the selection of the type.
3339
// And also to allow adding multiple datasets.
@@ -50,6 +56,7 @@ export const ConnectedCreateCustomIntegrationForm = ({
5056
testSubjects?: CreateTestSubjects;
5157
}) => {
5258
const [state, send] = useActor(machineRef);
59+
5360
const updateIntegrationName = useCallback(
5461
(integrationName: string) => {
5562
send({ type: 'UPDATE_FIELDS', fields: { integrationName } });
@@ -181,25 +188,40 @@ export const CreateCustomIntegrationForm = ({
181188
<EuiIconTip
182189
content={i18n.translate('customIntegrationsPackage.create.dataset.name.tooltip', {
183190
defaultMessage:
184-
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration.',
191+
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration. ',
185192
})}
186193
position="right"
187194
/>
188195
</EuiFlexItem>
189196
</EuiFlexGroup>
190197
}
191-
helpText={i18n.translate('customIntegrationsPackage.create.dataset.helper', {
192-
defaultMessage:
193-
"All lowercase, max 100 chars, special characters will be replaced with '_'.",
194-
})}
198+
helpText={[
199+
i18n.translate('customIntegrationsPackage.create.dataset.helper', {
200+
defaultMessage:
201+
"All lowercase, max 100 chars, special characters will be replaced with '_'.",
202+
}),
203+
datasetNameWillBePrefixed(datasetName, integrationName)
204+
? i18n.translate(
205+
'customIntegrationsPackage.create.dataset.name.tooltipPrefixMessage',
206+
{
207+
defaultMessage:
208+
'This name will be prefixed with {prefixValue}, e.g. {prefixedDatasetName}',
209+
values: {
210+
prefixValue: getDatasetNamePrefix(integrationName),
211+
prefixedDatasetName: prefixDatasetName(datasetName, integrationName),
212+
},
213+
}
214+
)
215+
: '',
216+
].join(' ')}
195217
isInvalid={hasErrors(errors?.fields?.datasets?.[0]?.name) && touchedFields.datasets}
196218
error={errorsList(errors?.fields?.datasets?.[0]?.name)}
197219
>
198220
<EuiFieldText
199221
placeholder={i18n.translate('customIntegrationsPackage.create.dataset.placeholder', {
200222
defaultMessage: "Give your integration's dataset a name",
201223
})}
202-
value={datasetName}
224+
value={getDatasetNameWithoutPrefix(datasetName, integrationName)}
203225
onChange={(event) => updateDatasetName(event.target.value)}
204226
isInvalid={hasErrors(errors?.fields?.datasets?.[0].name) && touchedFields.datasets}
205227
max={100}

packages/kbn-custom-integrations/src/components/create/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* Side Public License, v 1.
77
*/
88

9-
export const replaceSpecialChars = (filename: string) => {
9+
export const replaceSpecialChars = (value: string) => {
1010
// Replace special characters with _
11-
const replacedSpecialCharacters = filename.replaceAll(/[^a-zA-Z0-9_]/g, '_');
11+
const replacedSpecialCharacters = value.replaceAll(/[^a-zA-Z0-9_]/g, '_');
1212
// Allow only one _ in a row
1313
const noRepetitions = replacedSpecialCharacters.replaceAll(/[\_]{2,}/g, '_');
1414
return noRepetitions;

packages/kbn-custom-integrations/src/state_machines/create/notifications.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,20 @@ export type CreateCustomIntegrationNotificationEvent =
2727
};
2828

2929
export const CreateIntegrationNotificationEventSelectors = {
30-
integrationCreated: (context: CreateCustomIntegrationContext) =>
31-
({
32-
type: 'INTEGRATION_CREATED',
33-
fields: context.fields,
34-
} as CreateCustomIntegrationNotificationEvent),
30+
integrationCreated: (
31+
context: CreateCustomIntegrationContext,
32+
event: CreateCustomIntegrationEvent
33+
) => {
34+
return 'data' in event && 'integrationName' in event.data && 'datasets' in event.data
35+
? ({
36+
type: 'INTEGRATION_CREATED',
37+
fields: {
38+
integrationName: event.data.integrationName,
39+
datasets: event.data.datasets,
40+
},
41+
} as CreateCustomIntegrationNotificationEvent)
42+
: null;
43+
},
3544
integrationCleanup: (
3645
context: CreateCustomIntegrationContext,
3746
event: CreateCustomIntegrationEvent
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
import { pipe } from 'fp-ts/lib/pipeable';
10+
import { replaceSpecialChars } from '../../../components/create/utils';
11+
import { CreateCustomIntegrationContext, UpdateFieldsEvent, WithTouchedFields } from '../types';
12+
13+
type ValuesTuple = [CreateCustomIntegrationContext, UpdateFieldsEvent];
14+
15+
// Pipeline for updating the fields and touchedFields properties within context
16+
export const executeFieldsPipeline = (
17+
context: CreateCustomIntegrationContext,
18+
event: UpdateFieldsEvent
19+
) => {
20+
return pipe(
21+
[context, event] as ValuesTuple,
22+
updateFields(context),
23+
updateTouchedFields(context),
24+
maybeMatchDatasetNameToIntegrationName(context),
25+
replaceSpecialCharacters(context)
26+
);
27+
};
28+
29+
const updateFields =
30+
(originalContext: CreateCustomIntegrationContext) =>
31+
(values: ValuesTuple): ValuesTuple => {
32+
const [context, event] = values;
33+
34+
const mergedContext = {
35+
...context,
36+
fields: {
37+
...context.fields,
38+
...event.fields,
39+
},
40+
};
41+
return [mergedContext, event];
42+
};
43+
44+
const updateTouchedFields =
45+
(originalContext: CreateCustomIntegrationContext) =>
46+
(values: ValuesTuple): ValuesTuple => {
47+
const [context, event] = values;
48+
49+
const mergedContext = {
50+
...context,
51+
touchedFields: {
52+
...context.touchedFields,
53+
...Object.keys(event.fields).reduce<WithTouchedFields['touchedFields']>(
54+
(acc, field) => ({ ...acc, [field]: true }),
55+
{} as WithTouchedFields['touchedFields']
56+
),
57+
},
58+
};
59+
return [mergedContext, event];
60+
};
61+
62+
const maybeMatchDatasetNameToIntegrationName =
63+
(originalContext: CreateCustomIntegrationContext) =>
64+
(values: ValuesTuple): ValuesTuple => {
65+
const [context, event] = values;
66+
if (context.touchedFields.integrationName && !context.touchedFields.datasets) {
67+
return [
68+
{
69+
...context,
70+
fields: {
71+
...context.fields,
72+
datasets: context.fields.datasets.map((dataset, index) => ({
73+
...dataset,
74+
name: index === 0 ? context.fields.integrationName : dataset.name,
75+
})),
76+
},
77+
},
78+
event,
79+
];
80+
} else {
81+
return [context, event];
82+
}
83+
};
84+
85+
const replaceSpecialCharacters =
86+
(originalContext: CreateCustomIntegrationContext) =>
87+
(values: ValuesTuple): ValuesTuple => {
88+
const [context, event] = values;
89+
90+
const mergedContext = {
91+
...context,
92+
fields: {
93+
...context.fields,
94+
integrationName: replaceSpecialChars(context.fields.integrationName),
95+
datasets: context.fields.datasets.map((dataset) => ({
96+
...dataset,
97+
name: replaceSpecialChars(dataset.name),
98+
})),
99+
},
100+
};
101+
102+
return [mergedContext, event];
103+
};
104+
105+
export const getDatasetNamePrefix = (integrationName: string) => `${integrationName}.`;
106+
export const datasetNameIsPrefixed = (datasetName: string, integrationName: string) =>
107+
datasetName.startsWith(getDatasetNamePrefix(integrationName));
108+
export const datasetNameWillBePrefixed = (datasetName: string, integrationName: string) =>
109+
datasetName !== integrationName;
110+
export const prefixDatasetName = (datasetName: string, integrationName: string) =>
111+
`${getDatasetNamePrefix(integrationName)}${datasetName}`;
112+
113+
// The content after the integration name prefix.
114+
export const getDatasetNameWithoutPrefix = (datasetName: string, integrationName: string) =>
115+
datasetNameIsPrefixed(datasetName, integrationName)
116+
? datasetName.split(getDatasetNamePrefix(integrationName))[1]
117+
: datasetName;
118+
119+
// The machine holds unprefixed names internally to dramatically reduce complexity and improve performance for input changes in the UI.
120+
// Prefixed names are used at the outermost edges e.g. when providing initial state and submitting to the API.
121+
export const normalizeDatasetNames = (fields: UpdateFieldsEvent['fields']) => {
122+
const value = {
123+
...fields,
124+
...(fields.datasets !== undefined && fields.integrationName !== undefined
125+
? {
126+
datasets: fields.datasets.map((dataset) => ({
127+
...dataset,
128+
name: getDatasetNameWithoutPrefix(dataset.name, fields.integrationName!),
129+
})),
130+
}
131+
: {}),
132+
};
133+
return value;
134+
};

packages/kbn-custom-integrations/src/state_machines/create/state_machine.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import {
2828
DefaultCreateCustomIntegrationContext,
2929
WithErrors,
3030
WithPreviouslyCreatedIntegration,
31-
WithTouchedFields,
32-
WithFields,
3331
} from './types';
34-
import { replaceSpecialChars } from '../../components/create/utils';
32+
import {
33+
datasetNameWillBePrefixed,
34+
executeFieldsPipeline,
35+
prefixDatasetName,
36+
} from './pipelines/fields';
3537

3638
export const createPureCreateCustomIntegrationStateMachine = (
3739
initialContext: DefaultCreateCustomIntegrationContext = DEFAULT_CONTEXT
@@ -211,32 +213,12 @@ export const createPureCreateCustomIntegrationStateMachine = (
211213
: {};
212214
}),
213215
storeFields: actions.assign((context, event) => {
214-
return event.type === 'UPDATE_FIELDS'
215-
? ({
216-
fields: {
217-
...context.fields,
218-
...event.fields,
219-
integrationName:
220-
event.fields.integrationName !== undefined
221-
? replaceSpecialChars(event.fields.integrationName)
222-
: context.fields.integrationName,
223-
datasets:
224-
event.fields.datasets !== undefined
225-
? event.fields.datasets.map((dataset) => ({
226-
...dataset,
227-
name: replaceSpecialChars(dataset.name),
228-
}))
229-
: context.fields.datasets,
230-
},
231-
touchedFields: {
232-
...context.touchedFields,
233-
...Object.keys(event.fields).reduce<WithTouchedFields['touchedFields']>(
234-
(acc, field) => ({ ...acc, [field]: true }),
235-
{} as WithTouchedFields['touchedFields']
236-
),
237-
},
238-
} as WithFields & WithTouchedFields)
239-
: {};
216+
if (event.type === 'UPDATE_FIELDS') {
217+
const [contextResult] = executeFieldsPipeline(context, event);
218+
return contextResult;
219+
} else {
220+
return {};
221+
}
240222
}),
241223
resetValues: actions.assign((context, event) => {
242224
return {
@@ -315,7 +297,15 @@ export const createCreateCustomIntegrationStateMachine = ({
315297
}),
316298
}),
317299
save: (context) => {
318-
return integrationsClient.createCustomIntegration(context.fields);
300+
return integrationsClient.createCustomIntegration({
301+
...context.fields,
302+
datasets: context.fields.datasets.map((dataset) => ({
303+
...dataset,
304+
name: datasetNameWillBePrefixed(dataset.name, context.fields.integrationName)
305+
? prefixDatasetName(dataset.name, context.fields.integrationName)
306+
: dataset.name,
307+
})),
308+
});
319309
},
320310
cleanup: (context) => {
321311
return integrationsClient.deleteCustomIntegration({

packages/kbn-custom-integrations/src/state_machines/create/types.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export interface WithTouchedFields {
1919
touchedFields: Record<keyof CreateCustomIntegrationOptions, boolean>;
2020
}
2121

22-
export type CreateInitialState = WithOptions & WithFields & WithPreviouslyCreatedIntegration;
22+
export type CreateInitialState = Partial<WithOptions> &
23+
Partial<WithFields> &
24+
WithPreviouslyCreatedIntegration;
2325

2426
export interface WithOptions {
2527
options: {
@@ -91,11 +93,13 @@ export type CreateCustomIntegrationTypestate =
9193

9294
export type CreateCustomIntegrationContext = CreateCustomIntegrationTypestate['context'];
9395

96+
export interface UpdateFieldsEvent {
97+
type: 'UPDATE_FIELDS';
98+
fields: Partial<CreateCustomIntegrationOptions>;
99+
}
100+
94101
export type CreateCustomIntegrationEvent =
95-
| {
96-
type: 'UPDATE_FIELDS';
97-
fields: Partial<CreateCustomIntegrationOptions>;
98-
}
102+
| UpdateFieldsEvent
99103
| {
100104
type: 'INITIALIZE';
101105
}

packages/kbn-custom-integrations/src/state_machines/custom_integrations/state_machine.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
import { createCreateCustomIntegrationStateMachine } from '../create/state_machine';
2121
import { IIntegrationsClient } from '../services/integrations_client';
2222
import { CustomIntegrationsNotificationChannel } from './notifications';
23+
import { executeFieldsPipeline, normalizeDatasetNames } from '../create/pipelines/fields';
24+
import { CreateInitialState } from '../create/types';
2325

2426
export const createPureCustomIntegrationsStateMachine = (
2527
initialContext: DefaultCustomIntegrationsContext = DEFAULT_CONTEXT
@@ -95,22 +97,32 @@ export const createCustomIntegrationsStateMachine = ({
9597
return createPureCustomIntegrationsStateMachine(initialContext).withConfig({
9698
services: {
9799
createCustomIntegration: (context) => {
100+
const getInitialContextForCreate = (initialCreateState: CreateInitialState) => {
101+
const baseAndOptions = {
102+
...DEFAULT_CREATE_CONTEXT,
103+
...(initialCreateState ? initialCreateState : {}),
104+
options: {
105+
...DEFAULT_CREATE_CONTEXT.options,
106+
...(initialCreateState?.options ? initialCreateState.options : {}),
107+
},
108+
};
109+
const fields = initialCreateState.fields
110+
? executeFieldsPipeline(baseAndOptions, {
111+
type: 'UPDATE_FIELDS',
112+
fields: normalizeDatasetNames(initialCreateState.fields),
113+
})[0]
114+
: {};
115+
return {
116+
...baseAndOptions,
117+
...fields,
118+
};
119+
};
120+
98121
return createCreateCustomIntegrationStateMachine({
99122
integrationsClient,
100123
initialContext:
101-
initialState.mode === 'create'
102-
? {
103-
...DEFAULT_CREATE_CONTEXT,
104-
...(initialState?.context ? initialState?.context : {}),
105-
options: {
106-
...DEFAULT_CREATE_CONTEXT.options,
107-
...(initialState?.context?.options ? initialState.context.options : {}),
108-
},
109-
fields: {
110-
...DEFAULT_CREATE_CONTEXT.fields,
111-
...(initialState?.context?.fields ? initialState.context.fields : {}),
112-
},
113-
}
124+
initialState.mode === 'create' && initialState.context
125+
? getInitialContextForCreate(initialState.context)
114126
: DEFAULT_CREATE_CONTEXT,
115127
});
116128
},

0 commit comments

Comments
 (0)