Skip to content

Commit

Permalink
Enforce dataset name validation (client and server side)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kerry350 committed Sep 20, 2023
1 parent 72b1d32 commit 48e146f
Show file tree
Hide file tree
Showing 17 changed files with 359 additions and 89 deletions.
34 changes: 28 additions & 6 deletions packages/kbn-custom-integrations/src/components/create/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ import {
} from '../../state_machines/create/types';
import { Dataset, IntegrationError } from '../../types';
import { hasFailedSelector } from '../../state_machines/create/selectors';
import {
datasetNameWillBePrefixed,
getDatasetNamePrefix,
getDatasetNameWithoutPrefix,
prefixDatasetName,
} from '../../state_machines/create/pipelines/fields';

// NOTE: Hardcoded for now. We will likely extend the functionality here to allow the selection of the type.
// And also to allow adding multiple datasets.
Expand All @@ -50,6 +56,7 @@ export const ConnectedCreateCustomIntegrationForm = ({
testSubjects?: CreateTestSubjects;
}) => {
const [state, send] = useActor(machineRef);

const updateIntegrationName = useCallback(
(integrationName: string) => {
send({ type: 'UPDATE_FIELDS', fields: { integrationName } });
Expand Down Expand Up @@ -181,25 +188,40 @@ export const CreateCustomIntegrationForm = ({
<EuiIconTip
content={i18n.translate('customIntegrationsPackage.create.dataset.name.tooltip', {
defaultMessage:
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration.',
'Provide a dataset name to help organise these custom logs. This dataset will be associated with the integration. ',
})}
position="right"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
helpText={i18n.translate('customIntegrationsPackage.create.dataset.helper', {
defaultMessage:
"All lowercase, max 100 chars, special characters will be replaced with '_'.",
})}
helpText={[
i18n.translate('customIntegrationsPackage.create.dataset.helper', {
defaultMessage:
"All lowercase, max 100 chars, special characters will be replaced with '_'.",
}),
datasetNameWillBePrefixed(datasetName, integrationName)
? i18n.translate(
'customIntegrationsPackage.create.dataset.name.tooltipPrefixMessage',
{
defaultMessage:
'This name will be prefixed with {prefixValue}, e.g. {prefixedDatasetName}',
values: {
prefixValue: getDatasetNamePrefix(integrationName),
prefixedDatasetName: prefixDatasetName(datasetName, integrationName),
},
}
)
: '',
].join(' ')}
isInvalid={hasErrors(errors?.fields?.datasets?.[0]?.name) && touchedFields.datasets}
error={errorsList(errors?.fields?.datasets?.[0]?.name)}
>
<EuiFieldText
placeholder={i18n.translate('customIntegrationsPackage.create.dataset.placeholder', {
defaultMessage: "Give your integration's dataset a name",
})}
value={datasetName}
value={getDatasetNameWithoutPrefix(datasetName, integrationName)}
onChange={(event) => updateDatasetName(event.target.value)}
isInvalid={hasErrors(errors?.fields?.datasets?.[0].name) && touchedFields.datasets}
max={100}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
* Side Public License, v 1.
*/

export const replaceSpecialChars = (filename: string) => {
export const replaceSpecialChars = (value: string) => {
// Replace special characters with _
const replacedSpecialCharacters = filename.replaceAll(/[^a-zA-Z0-9_]/g, '_');
const replacedSpecialCharacters = value.replaceAll(/[^a-zA-Z0-9_]/g, '_');
// Allow only one _ in a row
const noRepetitions = replacedSpecialCharacters.replaceAll(/[\_]{2,}/g, '_');
return noRepetitions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ export type CreateCustomIntegrationNotificationEvent =
};

export const CreateIntegrationNotificationEventSelectors = {
integrationCreated: (context: CreateCustomIntegrationContext) =>
({
type: 'INTEGRATION_CREATED',
fields: context.fields,
} as CreateCustomIntegrationNotificationEvent),
integrationCreated: (
context: CreateCustomIntegrationContext,
event: CreateCustomIntegrationEvent
) => {
return 'data' in event && 'integrationName' in event.data && 'datasets' in event.data
? ({
type: 'INTEGRATION_CREATED',
fields: {
integrationName: event.data.integrationName,
datasets: event.data.datasets,
},
} as CreateCustomIntegrationNotificationEvent)
: null;
},
integrationCleanup: (
context: CreateCustomIntegrationContext,
event: CreateCustomIntegrationEvent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { replaceSpecialChars } from '../../../components/create/utils';
import { CreateCustomIntegrationContext, UpdateFieldsEvent, WithTouchedFields } from '../types';

type ValuesTuple = [CreateCustomIntegrationContext, UpdateFieldsEvent];

// Pipeline for updating the fields and touchedFields properties within context
export const executeFieldsPipeline = (
context: CreateCustomIntegrationContext,
event: UpdateFieldsEvent
) => {
return pipe(
[context, event] as ValuesTuple,
updateFields(context),
updateTouchedFields(context),
maybeMatchDatasetNameToIntegrationName(context),
replaceSpecialCharacters(context)
);
};

const updateFields =
(originalContext: CreateCustomIntegrationContext) =>
(values: ValuesTuple): ValuesTuple => {
const [context, event] = values;

const mergedContext = {
...context,
fields: {
...context.fields,
...event.fields,
},
};
return [mergedContext, event];
};

const updateTouchedFields =
(originalContext: CreateCustomIntegrationContext) =>
(values: ValuesTuple): ValuesTuple => {
const [context, event] = values;

const mergedContext = {
...context,
touchedFields: {
...context.touchedFields,
...Object.keys(event.fields).reduce<WithTouchedFields['touchedFields']>(
(acc, field) => ({ ...acc, [field]: true }),
{} as WithTouchedFields['touchedFields']
),
},
};
return [mergedContext, event];
};

const maybeMatchDatasetNameToIntegrationName =
(originalContext: CreateCustomIntegrationContext) =>
(values: ValuesTuple): ValuesTuple => {
const [context, event] = values;
if (context.touchedFields.integrationName && !context.touchedFields.datasets) {
return [
{
...context,
fields: {
...context.fields,
datasets: context.fields.datasets.map((dataset, index) => ({
...dataset,
name: index === 0 ? context.fields.integrationName : dataset.name,
})),
},
},
event,
];
} else {
return [context, event];
}
};

const replaceSpecialCharacters =
(originalContext: CreateCustomIntegrationContext) =>
(values: ValuesTuple): ValuesTuple => {
const [context, event] = values;

const mergedContext = {
...context,
fields: {
...context.fields,
integrationName: replaceSpecialChars(context.fields.integrationName),
datasets: context.fields.datasets.map((dataset) => ({
...dataset,
name: replaceSpecialChars(dataset.name),
})),
},
};

return [mergedContext, event];
};

export const getDatasetNamePrefix = (integrationName: string) => `${integrationName}.`;
export const datasetNameIsPrefixed = (datasetName: string, integrationName: string) =>
datasetName.startsWith(getDatasetNamePrefix(integrationName));
export const datasetNameWillBePrefixed = (datasetName: string, integrationName: string) =>
datasetName !== integrationName;
export const prefixDatasetName = (datasetName: string, integrationName: string) =>
`${getDatasetNamePrefix(integrationName)}${datasetName}`;

// The content after the integration name prefix.
export const getDatasetNameWithoutPrefix = (datasetName: string, integrationName: string) =>
datasetNameIsPrefixed(datasetName, integrationName)
? datasetName.split(getDatasetNamePrefix(integrationName))[1]
: datasetName;

// The machine holds unprefixed names internally to dramatically reduce complexity and improve performance for input changes in the UI.
// Prefixed names are used at the outermost edges e.g. when providing initial state and submitting to the API.
export const normalizeDatasetNames = (fields: UpdateFieldsEvent['fields']) => {
const value = {
...fields,
...(fields.datasets !== undefined && fields.integrationName !== undefined
? {
datasets: fields.datasets.map((dataset) => ({
...dataset,
name: getDatasetNameWithoutPrefix(dataset.name, fields.integrationName!),
})),
}
: {}),
};
return value;
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import {
DefaultCreateCustomIntegrationContext,
WithErrors,
WithPreviouslyCreatedIntegration,
WithTouchedFields,
WithFields,
} from './types';
import { replaceSpecialChars } from '../../components/create/utils';
import {
datasetNameWillBePrefixed,
executeFieldsPipeline,
prefixDatasetName,
} from './pipelines/fields';

export const createPureCreateCustomIntegrationStateMachine = (
initialContext: DefaultCreateCustomIntegrationContext = DEFAULT_CONTEXT
Expand Down Expand Up @@ -211,32 +213,12 @@ export const createPureCreateCustomIntegrationStateMachine = (
: {};
}),
storeFields: actions.assign((context, event) => {
return event.type === 'UPDATE_FIELDS'
? ({
fields: {
...context.fields,
...event.fields,
integrationName:
event.fields.integrationName !== undefined
? replaceSpecialChars(event.fields.integrationName)
: context.fields.integrationName,
datasets:
event.fields.datasets !== undefined
? event.fields.datasets.map((dataset) => ({
...dataset,
name: replaceSpecialChars(dataset.name),
}))
: context.fields.datasets,
},
touchedFields: {
...context.touchedFields,
...Object.keys(event.fields).reduce<WithTouchedFields['touchedFields']>(
(acc, field) => ({ ...acc, [field]: true }),
{} as WithTouchedFields['touchedFields']
),
},
} as WithFields & WithTouchedFields)
: {};
if (event.type === 'UPDATE_FIELDS') {
const [contextResult] = executeFieldsPipeline(context, event);
return contextResult;
} else {
return {};
}
}),
resetValues: actions.assign((context, event) => {
return {
Expand Down Expand Up @@ -315,7 +297,15 @@ export const createCreateCustomIntegrationStateMachine = ({
}),
}),
save: (context) => {
return integrationsClient.createCustomIntegration(context.fields);
return integrationsClient.createCustomIntegration({
...context.fields,
datasets: context.fields.datasets.map((dataset) => ({
...dataset,
name: datasetNameWillBePrefixed(dataset.name, context.fields.integrationName)
? prefixDatasetName(dataset.name, context.fields.integrationName)
: dataset.name,
})),
});
},
cleanup: (context) => {
return integrationsClient.deleteCustomIntegration({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export interface WithTouchedFields {
touchedFields: Record<keyof CreateCustomIntegrationOptions, boolean>;
}

export type CreateInitialState = WithOptions & WithFields & WithPreviouslyCreatedIntegration;
export type CreateInitialState = Partial<WithOptions> &
Partial<WithFields> &
WithPreviouslyCreatedIntegration;

export interface WithOptions {
options: {
Expand Down Expand Up @@ -91,11 +93,13 @@ export type CreateCustomIntegrationTypestate =

export type CreateCustomIntegrationContext = CreateCustomIntegrationTypestate['context'];

export interface UpdateFieldsEvent {
type: 'UPDATE_FIELDS';
fields: Partial<CreateCustomIntegrationOptions>;
}

export type CreateCustomIntegrationEvent =
| {
type: 'UPDATE_FIELDS';
fields: Partial<CreateCustomIntegrationOptions>;
}
| UpdateFieldsEvent
| {
type: 'INITIALIZE';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import { createCreateCustomIntegrationStateMachine } from '../create/state_machine';
import { IIntegrationsClient } from '../services/integrations_client';
import { CustomIntegrationsNotificationChannel } from './notifications';
import { executeFieldsPipeline, normalizeDatasetNames } from '../create/pipelines/fields';
import { CreateInitialState } from '../create/types';

export const createPureCustomIntegrationsStateMachine = (
initialContext: DefaultCustomIntegrationsContext = DEFAULT_CONTEXT
Expand Down Expand Up @@ -95,22 +97,32 @@ export const createCustomIntegrationsStateMachine = ({
return createPureCustomIntegrationsStateMachine(initialContext).withConfig({
services: {
createCustomIntegration: (context) => {
const getInitialContextForCreate = (initialCreateState: CreateInitialState) => {
const baseAndOptions = {
...DEFAULT_CREATE_CONTEXT,
...(initialCreateState ? initialCreateState : {}),
options: {
...DEFAULT_CREATE_CONTEXT.options,
...(initialCreateState?.options ? initialCreateState.options : {}),
},
};
const fields = initialCreateState.fields
? executeFieldsPipeline(baseAndOptions, {
type: 'UPDATE_FIELDS',
fields: normalizeDatasetNames(initialCreateState.fields),
})[0]
: {};
return {
...baseAndOptions,
...fields,
};
};

return createCreateCustomIntegrationStateMachine({
integrationsClient,
initialContext:
initialState.mode === 'create'
? {
...DEFAULT_CREATE_CONTEXT,
...(initialState?.context ? initialState?.context : {}),
options: {
...DEFAULT_CREATE_CONTEXT.options,
...(initialState?.context?.options ? initialState.context.options : {}),
},
fields: {
...DEFAULT_CREATE_CONTEXT.fields,
...(initialState?.context?.fields ? initialState.context.fields : {}),
},
}
initialState.mode === 'create' && initialState.context
? getInitialContextForCreate(initialState.context)
: DEFAULT_CREATE_CONTEXT,
});
},
Expand Down
Loading

0 comments on commit 48e146f

Please sign in to comment.