Skip to content

Commit

Permalink
[Ingest pipelines] Add support for URI parts processor (#86163)
Browse files Browse the repository at this point in the history
  • Loading branch information
alisonelizabeth authored Dec 17, 2020
1 parent c733233 commit 2b98dc6
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import React from 'react';
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';

/* eslint-disable @kbn/eslint/no-restricted-paths */
import { usageCollectionPluginMock } from 'src/plugins/usage_collection/public/mocks';

import { registerTestBed, TestBed } from '@kbn/test/jest';
import { stubWebWorker } from '@kbn/test/jest';
import { uiMetricService, apiService } from '../../../../services';
import { Props } from '../../';
import { initHttpRequests } from '../http_requests.helpers';
import { ProcessorsEditorWithDeps } from '../processors_editor';

stubWebWorker();

jest.mock('../../../../../../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../../../../../../src/plugins/kibana_react/public');
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
CodeEditor: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-currentvalue={props.value}
onChange={(e: any) => {
props.onChange(e.jsonContent);
}}
/>
),
};
});

jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
// Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions,
// which does not produce a valid component wrapper
EuiComboBox: (props: any) => (
<input
data-test-subj={props['data-test-subj']}
data-currentvalue={props.selectedOptions}
onChange={async (syntheticEvent: any) => {
props.onChange([syntheticEvent['0']]);
}}
/>
),
};
});

jest.mock('react-virtualized', () => {
const original = jest.requireActual('react-virtualized');

return {
...original,
AutoSizer: ({ children }: { children: any }) => (
<div>{children({ height: 500, width: 500 })}</div>
),
};
});

const testBedSetup = registerTestBed<TestSubject>(
(props: Props) => <ProcessorsEditorWithDeps {...props} />,
{
doMountAsync: false,
}
);

export interface SetupResult extends TestBed<TestSubject> {
actions: ReturnType<typeof createActions>;
}

const createActions = (testBed: TestBed<TestSubject>) => {
const { find, component } = testBed;

return {
async saveNewProcessor() {
await act(async () => {
find('addProcessorForm.submitButton').simulate('click');
});
component.update();
},

async addProcessorType({ type, label }: { type: string; label: string }) {
await act(async () => {
find('processorTypeSelector.input').simulate('change', [{ value: type, label }]);
});
component.update();
},

addProcessor() {
find('addProcessorButton').simulate('click');
},
};
};

export const setup = async (props: Props): Promise<SetupResult> => {
const testBed = await testBedSetup(props);
return {
...testBed,
actions: createActions(testBed),
};
};

const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });

export const setupEnvironment = () => {
// Initialize mock services
uiMetricService.setup(usageCollectionPluginMock.createSetupContract());
// @ts-ignore
apiService.setup(mockHttpClient, uiMetricService);

const { server, httpRequestsMockHelpers } = initHttpRequests();

return {
server,
httpRequestsMockHelpers,
};
};

type TestSubject =
| 'addProcessorForm.submitButton'
| 'addProcessorButton'
| 'addProcessorForm.submitButton'
| 'processorTypeSelector.input'
| 'fieldNameField.input'
| 'targetField.input'
| 'keepOriginalField.input'
| 'removeIfSuccessfulField.input';
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import { setup, SetupResult } from './processor.helpers';

// Default parameter values automatically added to the URI parts processor when saved
const defaultUriPartsParameters = {
keep_original: undefined,
remove_if_successful: undefined,
ignore_failure: undefined,
description: undefined,
};

describe('Processor: URI parts', () => {
let onUpdate: jest.Mock;
let testBed: SetupResult;

beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

beforeEach(async () => {
onUpdate = jest.fn();

await act(async () => {
testBed = await setup({
value: {
processors: [],
},
onFlyoutOpen: jest.fn(),
onUpdate,
});
});
testBed.component.update();
});

test('prevents form submission if required fields are not provided', async () => {
const {
actions: { addProcessor, saveNewProcessor, addProcessorType },
form,
} = testBed;

// Open flyout to add new processor
addProcessor();
// Click submit button without entering any fields
await saveNewProcessor();

// Expect form error as a processor type is required
expect(form.getErrorsMessages()).toEqual(['A type is required.']);

// Add type (the other fields are not visible until a type is selected)
await addProcessorType({ type: 'uri_parts', label: 'URI parts' });

// Click submit button with only the type defined
await saveNewProcessor();

// Expect form error as "field" is required parameter
expect(form.getErrorsMessages()).toEqual(['A field value is required.']);
});

test('saves with default parameter values', async () => {
const {
actions: { addProcessor, saveNewProcessor, addProcessorType },
form,
} = testBed;

// Open flyout to add new processor
addProcessor();
// Add type (the other fields are not visible until a type is selected)
await addProcessorType({ type: 'uri_parts', label: 'URI parts' });
// Add "field" value (required)
form.setInputValue('fieldNameField.input', 'field_1');
// Save the field
await saveNewProcessor();

const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1];
const { processors } = onUpdateResult.getData();
expect(processors[0].uri_parts).toEqual({
field: 'field_1',
...defaultUriPartsParameters,
});
});

test('allows optional parameters to be set', async () => {
const {
actions: { addProcessor, addProcessorType, saveNewProcessor },
form,
} = testBed;

// Open flyout to add new processor
addProcessor();
// Add type (the other fields are not visible until a type is selected)
await addProcessorType({ type: 'uri_parts', label: 'URI parts' });
// Add "field" value (required)
form.setInputValue('fieldNameField.input', 'field_1');

// Set optional parameteres
form.setInputValue('targetField.input', 'target_field');
form.toggleEuiSwitch('keepOriginalField.input');
form.toggleEuiSwitch('removeIfSuccessfulField.input');

// Save the field with new changes
await saveNewProcessor();

const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1];
const { processors } = onUpdateResult.getData();
expect(processors[0].uri_parts).toEqual({
description: undefined,
field: 'field_1',
ignore_failure: undefined,
keep_original: false,
remove_if_successful: true,
target_field: 'target_field',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,6 @@ export const FieldNameField: FunctionComponent<Props> = ({ helpText, additionalV
}}
component={Field}
path="fields.field"
data-test-subj="fieldNameField"
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const TargetField: FunctionComponent<Props> = (props) => {
}}
component={Field}
path={TARGET_FIELD_PATH}
data-test-subj="targetField"
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export { Trim } from './trim';
export { Uppercase } from './uppercase';
export { UrlDecode } from './url_decode';
export { UserAgent } from './user_agent';
export { UriParts } from './uri_parts';

export { FormFieldsComponent } from './shared';
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCode } from '@elastic/eui';

import { FIELD_TYPES, UseField, ToggleField } from '../../../../../../shared_imports';

import { FieldsConfig, to, from } from './shared';

import { FieldNameField } from './common_fields/field_name_field';
import { TargetField } from './common_fields/target_field';

export const fieldsConfig: FieldsConfig = {
keep_original: {
type: FIELD_TYPES.TOGGLE,
defaultValue: true,
deserializer: to.booleanOrUndef,
serializer: from.undefinedIfValue(true),
label: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.commonFields.keepOriginalFieldLabel',
{
defaultMessage: 'Keep original',
}
),
helpText: (
<FormattedMessage
id="xpack.ingestPipelines.pipelineEditor.commonFields.keepOriginalFieldHelpText"
defaultMessage="Copy the unparsed URI to {field}."
values={{
field: <EuiCode>{'<target_field>.original'}</EuiCode>,
}}
/>
),
},
remove_if_successful: {
type: FIELD_TYPES.TOGGLE,
defaultValue: false,
deserializer: to.booleanOrUndef,
serializer: from.undefinedIfValue(false),
label: i18n.translate(
'xpack.ingestPipelines.pipelineEditor.commonFields.removeIfSuccessfulFieldLabel',
{
defaultMessage: 'Remove if successful',
}
),
helpText: (
<FormattedMessage
id="xpack.ingestPipelines.pipelineEditor.commonFields.removeIfSuccessfulFieldHelpText"
defaultMessage="Remove the field after parsing the URI string."
/>
),
},
};

export const UriParts: FunctionComponent = () => {
return (
<>
<FieldNameField
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.uriPartsForm.fieldNameHelpText',
{ defaultMessage: 'Field containing URI string.' }
)}
/>

<TargetField />

<UseField
config={fieldsConfig.keep_original}
component={ToggleField}
path="fields.keep_original"
data-test-subj="keepOriginalField"
/>

<UseField
config={fieldsConfig.remove_if_successful}
component={ToggleField}
path="fields.remove_if_successful"
data-test-subj="removeIfSuccessfulField"
/>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
UrlDecode,
UserAgent,
FormFieldsComponent,
UriParts,
} from '../processor_form/processors';

interface FieldDescriptor {
Expand Down Expand Up @@ -438,6 +439,17 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = {
defaultMessage: "Extracts values from a browser's user agent string.",
}),
},
uri_parts: {
FieldsComponent: UriParts,
docLinkPath: '/uri-parts-processor.html',
label: i18n.translate('xpack.ingestPipelines.processors.label.uriPartsLabel', {
defaultMessage: 'URI parts',
}),
description: i18n.translate('xpack.ingestPipelines.processors.uriPartsDescription', {
defaultMessage:
'Parses a Uniform Resource Identifier (URI) string and extracts its components as an object.',
}),
},
};

export type ProcessorType = keyof typeof mapProcessorTypeToDescriptor;
Expand Down

0 comments on commit 2b98dc6

Please sign in to comment.