Skip to content

Commit

Permalink
[7.5] [Index template] Fix editor should support mappings types (#55804
Browse files Browse the repository at this point in the history
…) (#56279)
  • Loading branch information
sebelga committed Jan 31, 2020
1 parent e6d89f6 commit 8cd0a03
Show file tree
Hide file tree
Showing 12 changed files with 381 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* 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.
*/

export { doMappingsHaveType } from './lib';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 { ValidationError } from 'io-ts';
import { fold } from 'fp-ts/lib/Either';
import { Reporter } from 'io-ts/lib/Reporter';

export type ReporterResult = Array<{ path: string[]; message: string }>;

const failure = (validation: ValidationError[]): ReporterResult => {
return validation.map(e => {
const path: string[] = [];
let validationName = '';

e.context.forEach((ctx, idx) => {
if (ctx.key) {
path.push(ctx.key);
}

if (idx === e.context.length - 1) {
validationName = ctx.type.name;
}
});
const lastItemName = path[path.length - 1];
return {
path,
message:
'Invalid value ' +
JSON.stringify(e.value) +
` supplied to ${lastItemName}(${validationName})`,
};
});
};

const empty: never[] = [];
const success = () => empty;

export const errorReporter: Reporter<ReporterResult> = {
report: fold(failure, success),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 { isPlainObject } from 'lodash';

import { GenericObject } from '../types';
import { validateMappingsConfiguration, VALID_MAPPINGS_PARAMETERS } from './mappings_validator';

interface MappingsWithType {
type?: string;
mappings: GenericObject;
}

const isMappingDefinition = (obj: GenericObject): boolean => {
const areAllKeysValid = Object.keys(obj).every(key => VALID_MAPPINGS_PARAMETERS.includes(key));

if (!areAllKeysValid) {
return false;
}

const { properties, dynamic_templates: dynamicTemplates, ...mappingsConfiguration } = obj;

const { errors } = validateMappingsConfiguration(mappingsConfiguration);
const isConfigurationValid = errors.length === 0;
const isPropertiesValid = properties === undefined || isPlainObject(properties);
const isDynamicTemplatesValid = dynamicTemplates === undefined || Array.isArray(dynamicTemplates);

// If the configuration, the properties and the dynamic templates are valid
// we can assume that the mapping is declared at root level (no types)
return isConfigurationValid && isPropertiesValid && isDynamicTemplatesValid;
};

const getMappingsDefinitionWithType = (mappings: GenericObject): MappingsWithType[] => {
if (isMappingDefinition(mappings)) {
// No need to go any further
return [{ mappings }];
}

// At this point there must be one or more type mappings
const typedMappings = Object.entries(mappings).reduce(
(acc: Array<{ type: string; mappings: GenericObject }>, [type, value]) => {
if (isMappingDefinition(value)) {
acc.push({ type, mappings: value as GenericObject });
}
return acc;
},
[]
);

return typedMappings;
};

export const doMappingsHaveType = (mappings: GenericObject = {}): boolean =>
getMappingsDefinitionWithType(mappings).filter(({ type }) => type !== undefined).length > 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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.
*/

export * from './mappings_validator';

export * from './extract_mappings_definition';
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* 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 { pick } from 'lodash';
import * as t from 'io-ts';
import { ordString } from 'fp-ts/lib/Ord';
import { toArray } from 'fp-ts/lib/Set';
import { isLeft } from 'fp-ts/lib/Either';

import { errorReporter } from './error_reporter';

type MappingsValidationError =
| { code: 'ERR_CONFIG'; configName: string }
| { code: 'ERR_FIELD'; fieldPath: string }
| { code: 'ERR_PARAMETER'; paramName: string; fieldPath: string };

/**
* Single source of truth to validate the *configuration* of the mappings.
* Whenever a user loads a JSON object it will be validate against this Joi schema.
*/
const mappingsConfigurationSchema = t.exact(
t.partial({
dynamic: t.union([t.literal(true), t.literal(false), t.literal('strict')]),
date_detection: t.boolean,
numeric_detection: t.boolean,
dynamic_date_formats: t.array(t.string),
_source: t.exact(
t.partial({
enabled: t.boolean,
includes: t.array(t.string),
excludes: t.array(t.string),
})
),
_meta: t.UnknownRecord,
_routing: t.interface({
required: t.boolean,
}),
})
);

const mappingsConfigurationSchemaKeys = Object.keys(mappingsConfigurationSchema.type.props);
const sourceConfigurationSchemaKeys = Object.keys(
mappingsConfigurationSchema.type.props._source.type.props
);

export const validateMappingsConfiguration = (
mappingsConfiguration: any
): { value: any; errors: MappingsValidationError[] } => {
// Set to keep track of invalid configuration parameters.
const configurationRemoved: Set<string> = new Set();

let copyOfMappingsConfig = { ...mappingsConfiguration };
const result = mappingsConfigurationSchema.decode(mappingsConfiguration);
const isSchemaInvalid = isLeft(result);

const unknownConfigurationParameters = Object.keys(mappingsConfiguration).filter(
key => mappingsConfigurationSchemaKeys.includes(key) === false
);

const unknownSourceConfigurationParameters =
mappingsConfiguration._source !== undefined
? Object.keys(mappingsConfiguration._source).filter(
key => sourceConfigurationSchemaKeys.includes(key) === false
)
: [];

if (isSchemaInvalid) {
/**
* To keep the logic simple we will strip out the parameters that contain errors
*/
const errors = errorReporter.report(result);
errors.forEach(error => {
const configurationName = error.path[0];
configurationRemoved.add(configurationName);
delete copyOfMappingsConfig[configurationName];
});
}

if (unknownConfigurationParameters.length > 0) {
unknownConfigurationParameters.forEach(configName => configurationRemoved.add(configName));
}

if (unknownSourceConfigurationParameters.length > 0) {
configurationRemoved.add('_source');
delete copyOfMappingsConfig._source;
}

copyOfMappingsConfig = pick(copyOfMappingsConfig, mappingsConfigurationSchemaKeys);

const errors: MappingsValidationError[] = toArray<string>(ordString)(configurationRemoved)
.map(configName => ({
code: 'ERR_CONFIG',
configName,
}))
.sort((a, b) => a.configName.localeCompare(b.configName)) as MappingsValidationError[];

return { value: copyOfMappingsConfig, errors };
};

export const VALID_MAPPINGS_PARAMETERS = [
...mappingsConfigurationSchemaKeys,
'dynamic_templates',
'properties',
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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 { ReactNode } from 'react';

export interface DataTypeDefinition {
label: string;
value: DataType;
documentation?: {
main: string;
[key: string]: string;
};
subTypes?: { label: string; types: SubType[] };
description?: () => ReactNode;
}

export type MainType =
| 'text'
| 'keyword'
| 'numeric'
| 'binary'
| 'boolean'
| 'range'
| 'object'
| 'nested'
| 'alias'
| 'completion'
| 'dense_vector'
| 'flattened'
| 'ip'
| 'join'
| 'percolator'
| 'rank_feature'
| 'rank_features'
| 'shape'
| 'search_as_you_type'
| 'date'
| 'date_nanos'
| 'geo_point'
| 'geo_shape'
| 'token_count';

export type SubType = NumericType | RangeType;

export type DataType = MainType | SubType;

export type NumericType =
| 'long'
| 'integer'
| 'short'
| 'byte'
| 'double'
| 'float'
| 'half_float'
| 'scaled_float';

export type RangeType =
| 'integer_range'
| 'float_range'
| 'long_range'
| 'ip_range'
| 'double_range'
| 'date_range';

export type ParameterName =
| 'name'
| 'type'
| 'store'
| 'index'
| 'fielddata'
| 'fielddata_frequency_filter'
| 'fielddata_frequency_filter_percentage'
| 'fielddata_frequency_filter_absolute'
| 'doc_values'
| 'doc_values_binary'
| 'coerce'
| 'coerce_shape'
| 'ignore_malformed'
| 'null_value'
| 'null_value_numeric'
| 'null_value_boolean'
| 'null_value_geo_point'
| 'null_value_ip'
| 'copy_to'
| 'dynamic'
| 'dynamic_toggle'
| 'dynamic_strict'
| 'enabled'
| 'boost'
| 'locale'
| 'format'
| 'analyzer'
| 'search_analyzer'
| 'search_quote_analyzer'
| 'index_options'
| 'index_options_flattened'
| 'index_options_keyword'
| 'eager_global_ordinals'
| 'eager_global_ordinals_join'
| 'index_prefixes'
| 'index_phrases'
| 'norms'
| 'norms_keyword'
| 'term_vector'
| 'position_increment_gap'
| 'similarity'
| 'normalizer'
| 'ignore_above'
| 'split_queries_on_whitespace'
| 'scaling_factor'
| 'max_input_length'
| 'preserve_separators'
| 'preserve_position_increments'
| 'ignore_z_value'
| 'enable_position_increments'
| 'orientation'
| 'points_only'
| 'path'
| 'dims'
| 'depth_limit'
| 'relations'
| 'max_shingle_size';

interface FieldBasic {
name: string;
type: DataType;
subType?: SubType;
properties?: { [key: string]: Omit<Field, 'name'> };
fields?: { [key: string]: Omit<Field, 'name'> };
}

type FieldParams = {
[K in ParameterName]: unknown;
};

export type Field = FieldBasic & Partial<FieldParams>;

export interface GenericObject {
[key: string]: any;
}
7 changes: 5 additions & 2 deletions x-pack/legacy/plugins/index_management/public/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { TAB_SETTINGS, TAB_MAPPING, TAB_STATS } from '../constants';
import { trackUiMetric, METRIC_TYPE } from './track_ui_metric';
import { useRequest, sendRequest } from './use_request';
import { Template } from '../../common/types';
import { doMappingsHaveType } from '../components/mappings_editor';

let httpClient: ng.IHttpService;

Expand Down Expand Up @@ -225,8 +226,9 @@ export function loadIndexTemplate(name: Template['name']) {
}

export async function saveTemplate(template: Template, isClone?: boolean) {
const includeTypeName = doMappingsHaveType(template.mappings);
const result = sendRequest({
path: `${apiPrefix}/templates`,
path: `${apiPrefix}/templates?include_type_name=${includeTypeName}`,
method: 'put',
body: template,
});
Expand All @@ -239,9 +241,10 @@ export async function saveTemplate(template: Template, isClone?: boolean) {
}

export async function updateTemplate(template: Template) {
const includeTypeName = doMappingsHaveType(template.mappings);
const { name } = template;
const result = sendRequest({
path: `${apiPrefix}/templates/${encodeURIComponent(name)}`,
path: `${apiPrefix}/templates/${encodeURIComponent(name)}?include_type_name=${includeTypeName}`,
method: 'put',
body: template,
});
Expand Down
Loading

0 comments on commit 8cd0a03

Please sign in to comment.