Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Logs+] Enforce dataset names #166654

Merged
merged 7 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
);
Comment on lines +20 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏

};

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