Skip to content

Commit

Permalink
[Logs+] Enforce dataset names (#166654)
Browse files Browse the repository at this point in the history
## Summary

Closes #163830

This adds server side validation to enforce dataset name format rules
for custom integrations. It then enhances the custom integrations Kibana
package to handle this seamlessly in the create form.

There is no client side validation for the rules per se because as long
as the dataset name passes other validations (length etc) then it is
always valid - it just comes down to whether it's prefixed or not.

## Other notes

- Added a "fields pipeline" to improve the readability of the context
update.

## UI / UX changes

- Users are informed when a prefix will be added.

<img width="886" alt="Screenshot 2023-09-20 at 13 19 49"
src="https://github.com/elastic/kibana/assets/471693/764d2bd0-03ef-40ce-8dae-107079c15feb">

- If the integration name has been touched, and the dataset name is
untouched, the dataset name will automatically match the integration
name.


![matching](https://github.com/elastic/kibana/assets/471693/b72604c0-23f9-4ff1-8db7-9b6c523b36c6)

---------

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
Kerry350 and kibanamachine authored Sep 21, 2023
1 parent fabaa2f commit 795ec3e
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 89 deletions.
31 changes: 26 additions & 5 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,11 @@ import {
} from '../../state_machines/create/types';
import { Dataset, IntegrationError } from '../../types';
import { hasFailedSelector } from '../../state_machines/create/selectors';
import {
datasetNameWillBePrefixed,
getDatasetNamePrefix,
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 +55,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,17 +187,32 @@ 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)}
>
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 795ec3e

Please sign in to comment.