/* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { i18n } from '@kbn/i18n'; import { keys, last, mapValues, reduce, zipObject } from 'lodash'; import { Executor } from '../executor'; import { createExecutionContainer, ExecutionContainer } from './container'; import { createError } from '../util'; import { Defer, now } from '../../../kibana_utils/common'; import { toPromise } from '../../../data/common/utils/abort_utils'; import { RequestAdapter, DataAdapter, Adapters } from '../../../inspector/common'; import { isExpressionValueError, ExpressionValueError } from '../expression_types/specs/error'; import { ExpressionAstExpression, ExpressionAstFunction, parse, formatExpression, parseExpression, ExpressionAstNode, } from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; import { getType, ExpressionValue } from '../expression_types'; import { ArgumentType, ExpressionFunction } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; import { ExecutionContract } from './execution_contract'; import { ExpressionExecutionParams } from '../service'; const createAbortErrorValue = () => createError({ message: 'The expression was aborted.', name: 'AbortError', }); export interface ExecutionParams { executor: Executor; ast?: ExpressionAstExpression; expression?: string; params: ExpressionExecutionParams; } const createDefaultInspectorAdapters = (): DefaultInspectorAdapters => ({ requests: new RequestAdapter(), data: new DataAdapter(), }); export class Execution< Input = unknown, Output = unknown, InspectorAdapters extends Adapters = ExpressionExecutionParams['inspectorAdapters'] extends object ? ExpressionExecutionParams['inspectorAdapters'] : DefaultInspectorAdapters > { /** * Dynamic state of the execution. */ public readonly state: ExecutionContainer; /** * Initial input of the execution. * * N.B. It is initialized to `null` rather than `undefined` for legacy reasons, * because in legacy interpreter it was set to `null` by default. */ public input: Input = null as any; /** * Execution context - object that allows to do side-effects. Context is passed * to every function. */ public readonly context: ExecutionContext; /** * AbortController to cancel this Execution. */ private readonly abortController = new AbortController(); /** * Promise that rejects if/when abort controller sends "abort" signal. */ private readonly abortRejection = toPromise(this.abortController.signal); /** * Races a given promise against the "abort" event of `abortController`. */ private race(promise: Promise): Promise { return Promise.race([this.abortRejection, promise]); } /** * Whether .start() method has been called. */ private hasStarted: boolean = false; /** * Future that tracks result or error of this execution. */ private readonly firstResultFuture = new Defer(); /** * Contract is a public representation of `Execution` instances. Contract we * can return to other plugins for their consumption. */ public readonly contract: ExecutionContract< Input, Output, InspectorAdapters > = new ExecutionContract(this); public readonly expression: string; public get result(): Promise { return this.firstResultFuture.promise; } public get inspectorAdapters(): InspectorAdapters { return this.context.inspectorAdapters; } constructor(public readonly execution: ExecutionParams) { const { executor } = execution; if (!execution.ast && !execution.expression) { throw new TypeError('Execution params should contain at least .ast or .expression key.'); } else if (execution.ast && execution.expression) { throw new TypeError('Execution params cannot contain both .ast and .expression key.'); } this.expression = execution.expression || formatExpression(execution.ast!); const ast = execution.ast || parseExpression(this.expression); this.state = createExecutionContainer({ ...executor.state.get(), state: 'not-started', ast, }); this.context = { getSearchContext: () => this.execution.params.searchContext || {}, getSearchSessionId: () => execution.params.searchSessionId, variables: execution.params.variables || {}, types: executor.getTypes(), abortSignal: this.abortController.signal, inspectorAdapters: execution.params.inspectorAdapters || createDefaultInspectorAdapters(), ...(execution.params as any).extraContext, }; } /** * Stop execution of expression. */ cancel() { this.abortController.abort(); } /** * Call this method to start execution. * * N.B. `input` is initialized to `null` rather than `undefined` for legacy reasons, * because in legacy interpreter it was set to `null` by default. */ public start(input: Input = null as any) { if (this.hasStarted) throw new Error('Execution already started.'); this.hasStarted = true; this.input = input; this.state.transitions.start(); const { resolve, reject } = this.firstResultFuture; const chainPromise = this.invokeChain(this.state.get().ast.chain, input); this.race(chainPromise).then(resolve, (error) => { if (this.abortController.signal.aborted) resolve(createAbortErrorValue()); else reject(error); }); this.firstResultFuture.promise.then( (result) => { this.state.transitions.setResult(result); }, (error) => { this.state.transitions.setError(error); } ); } async invokeChain(chainArr: ExpressionAstFunction[], input: unknown): Promise { if (!chainArr.length) return input; for (const link of chainArr) { const { function: fnName, arguments: fnArgs } = link; const fn = getByAlias(this.state.get().functions, fnName); if (!fn) { return createError({ name: 'fn not found', message: i18n.translate('expressions.execution.functionNotFound', { defaultMessage: `Function {fnName} could not be found.`, values: { fnName, }, }), }); } if (fn.disabled) { return createError({ name: 'fn is disabled', message: i18n.translate('expressions.execution.functionDisabled', { defaultMessage: `Function {fnName} is disabled.`, values: { fnName, }, }), }); } let args: Record = {}; let timeStart: number | undefined; try { // `resolveArgs` returns an object because the arguments themselves might // actually have a `then` function which would be treated as a `Promise`. const { resolvedArgs } = await this.race(this.resolveArgs(fn, input, fnArgs)); args = resolvedArgs; timeStart = this.execution.params.debug ? now() : 0; const output = await this.race(this.invokeFunction(fn, input, resolvedArgs)); if (this.execution.params.debug) { const timeEnd: number = now(); (link as ExpressionAstFunction).debug = { success: true, fn: fn.name, input, args: resolvedArgs, output, duration: timeEnd - timeStart, }; } if (getType(output) === 'error') return output; input = output; } catch (rawError) { const timeEnd: number = this.execution.params.debug ? now() : 0; const error = createError(rawError) as ExpressionValueError; error.error.message = `[${fnName}] > ${error.error.message}`; if (this.execution.params.debug) { (link as ExpressionAstFunction).debug = { success: false, fn: fn.name, input, args, error, rawError, duration: timeStart ? timeEnd - timeStart : undefined, }; } return error; } } return input; } async invokeFunction( fn: ExpressionFunction, input: unknown, args: Record ): Promise { const normalizedInput = this.cast(input, fn.inputTypes); const output = await this.race(fn.fn(normalizedInput, args, this.context)); // Validate that the function returned the type it said it would. // This isn't required, but it keeps function developers honest. const returnType = getType(output); const expectedType = fn.type; if (expectedType && returnType !== expectedType) { throw new Error( `Function '${fn.name}' should return '${expectedType}',` + ` actually returned '${returnType}'` ); } // Validate the function output against the type definition's validate function. const type = this.context.types[fn.type]; if (type && type.validate) { try { type.validate(output); } catch (e) { throw new Error(`Output of '${fn.name}' is not a valid type '${fn.type}': ${e}`); } } return output; } public cast(value: any, toTypeNames?: string[]) { // If you don't give us anything to cast to, you'll get your input back if (!toTypeNames || toTypeNames.length === 0) return value; // No need to cast if node is already one of the valid types const fromTypeName = getType(value); if (toTypeNames.includes(fromTypeName)) return value; const { types } = this.state.get(); const fromTypeDef = types[fromTypeName]; for (const toTypeName of toTypeNames) { // First check if the current type can cast to this type if (fromTypeDef && fromTypeDef.castsTo(toTypeName)) { return fromTypeDef.to(value, toTypeName, types); } // If that isn't possible, check if this type can cast from the current type const toTypeDef = types[toTypeName]; if (toTypeDef && toTypeDef.castsFrom(fromTypeName)) return toTypeDef.from(value, types); } throw new Error(`Can not cast '${fromTypeName}' to any of '${toTypeNames.join(', ')}'`); } // Processes the multi-valued AST argument values into arguments that can be passed to the function async resolveArgs(fnDef: ExpressionFunction, input: unknown, argAsts: any): Promise { const argDefs = fnDef.args; // Use the non-alias name from the argument definition const dealiasedArgAsts = reduce( argAsts, (acc, argAst, argName) => { const argDef = getByAlias(argDefs, argName); if (!argDef) { throw new Error(`Unknown argument '${argName}' passed to function '${fnDef.name}'`); } acc[argDef.name] = (acc[argDef.name] || []).concat(argAst); return acc; }, {} as any ); // Check for missing required arguments. for (const argDef of Object.values(argDefs)) { const { aliases, default: argDefault, name: argName, required } = argDef as ArgumentType< any > & { name: string }; if ( typeof argDefault !== 'undefined' || !required || typeof dealiasedArgAsts[argName] !== 'undefined' ) continue; if (!aliases || aliases.length === 0) { throw new Error(`${fnDef.name} requires an argument`); } // use an alias if _ is the missing arg const errorArg = argName === '_' ? aliases[0] : argName; throw new Error(`${fnDef.name} requires an "${errorArg}" argument`); } // Fill in default values from argument definition const argAstsWithDefaults = reduce( argDefs, (acc: any, argDef: any, argName: any) => { if (typeof acc[argName] === 'undefined' && typeof argDef.default !== 'undefined') { acc[argName] = [parse(argDef.default, 'argument')]; } return acc; }, dealiasedArgAsts ); // Create the functions to resolve the argument ASTs into values // These are what are passed to the actual functions if you opt out of resolving const resolveArgFns = mapValues(argAstsWithDefaults, (asts, argName) => { return asts.map((item: ExpressionAstExpression) => { return async (subInput = input) => { const output = await this.interpret(item, subInput); if (isExpressionValueError(output)) throw output.error; const casted = this.cast(output, argDefs[argName as any].types); return casted; }; }); }); const argNames = keys(resolveArgFns); // Actually resolve unless the argument definition says not to const resolvedArgValues = await Promise.all( argNames.map((argName) => { const interpretFns = resolveArgFns[argName]; if (!argDefs[argName].resolve) return interpretFns; return Promise.all(interpretFns.map((fn: any) => fn())); }) ); const resolvedMultiArgs = zipObject(argNames, resolvedArgValues); // Just return the last unless the argument definition allows multiple const resolvedArgs = mapValues(resolvedMultiArgs, (argValues, argName) => { if (argDefs[argName as any].multi) return argValues; return last(argValues as any); }); // Return an object here because the arguments themselves might actually have a 'then' // function which would be treated as a promise return { resolvedArgs }; } public async interpret(ast: ExpressionAstNode, input: T): Promise { switch (getType(ast)) { case 'expression': const execution = this.execution.executor.createExecution( ast as ExpressionAstExpression, this.execution.params ); execution.start(input); return await execution.result; case 'string': case 'number': case 'null': case 'boolean': return ast; default: throw new Error(`Unknown AST object: ${JSON.stringify(ast)}`); } } }