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

[ResponseOps] adds mustache lambdas and array.asJSON #150572

Merged
merged 3 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
201 changes: 201 additions & 0 deletions x-pack/plugins/actions/server/lib/mustache_lambdas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import dedent from 'dedent';

import { renderMustacheString } from './mustache_renderer';

describe('mustache lambdas', () => {
describe('FormatDate', () => {
it('date with defaults is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} {{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual('2022-11-29 03:52pm');
});

it('date with a time zone is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} ; America/New_York {{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual('2022-11-29 10:52am');
});

it('date with a format is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} ;; dddd MMM Do YYYY HH:mm:ss.SSS {{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual(
'Tuesday Nov 29th 2022 15:52:44.000'
);
});

it('date with a format and timezone is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}};America/New_York;dddd MMM Do YYYY HH:mm:ss.SSS {{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'Tuesday Nov 29th 2022 10:52:44.000'
);
});

it('empty date produces error', () => {
const timeStamp = '';
const template = dedent`
{{#FormatDate}} {{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}} {{/FormatDate}}": date is empty'
);
});

it('invalid date produces error', () => {
const timeStamp = 'this is not a d4t3';
const template = dedent`
{{#FormatDate}}{{timeStamp}}{{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}}{{timeStamp}}{{/FormatDate}}": invalid date "this is not a d4t3"'
);
});

it('invalid timezone produces error', () => {
const timeStamp = '2023-04-10T23:52:39';
const template = dedent`
{{#FormatDate}}{{timeStamp}};NotATime Zone!{{/FormatDate}}
`.trim();

expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}}{{timeStamp}};NotATime Zone!{{/FormatDate}}": unknown timeZone value "NotATime Zone!"'
);
});

it('invalid format produces error', () => {
const timeStamp = '2023-04-10T23:52:39';
const template = dedent`
{{#FormatDate}}{{timeStamp}};;garbage{{/FormatDate}}
`.trim();

// not clear how to force an error, it pretty much does something with
// ANY string
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'gamrbamg2' // a => am/pm (so am here); e => day of week
);
});
});

describe('EvalMath', () => {
it('math is successful', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const template = dedent`
{{#EvalMath}} 1 + 0 {{/EvalMath}}
{{#EvalMath}} 1 + context.a.b {{/EvalMath}}
{{#context}}
{{#EvalMath}} 1 + c.d {{/EvalMath}}
{{/context}}
`.trim();

const result = renderMustacheString(template, vars, 'none');
expect(result).toEqual(`1\n2\n3\n`);
});

it('invalid expression produces error', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const template = dedent`
{{#EvalMath}} ) 1 ++++ 0 ( {{/EvalMath}}
`.trim();

const result = renderMustacheString(template, vars, 'none');
expect(result).toEqual(
`error rendering mustache template "{{#EvalMath}} ) 1 ++++ 0 ( {{/EvalMath}}": error evaluating tinymath expression ") 1 ++++ 0 (": Failed to parse expression. Expected "(", function, literal, or whitespace but ")" found.`
);
});
});

describe('ParseHJson', () => {
it('valid Hjson is successful', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const hjson = `
{
# specify rate in requests/second (because comments are helpful!)
rate: 1000

a: {{context.a}}
a_b: {{context.a.b}}
c: {{context.c}}
c_d: {{context.c.d}}

# list items can be separated by lines, or commas, and trailing
# commas permitted
list: [
1 2
3
4,5,6,
]
}`;
const template = dedent`
{{#ParseHjson}} ${hjson} {{/ParseHjson}}
`.trim();

const result = renderMustacheString(template, vars, 'none');
expect(JSON.parse(result)).toMatchInlineSnapshot(`
Object {
"a": Object {
"b": 1,
},
"a_b": 1,
"c": Object {
"d": 2,
},
"c_d": 2,
"list": Array [
"1 2",
3,
4,
5,
6,
],
"rate": 1000,
}
`);
});

it('renders an error message on parse errors', () => {
const template = dedent`
{{#ParseHjson}} [1,2,3,,] {{/ParseHjson}}
`.trim();

const result = renderMustacheString(template, {}, 'none');
expect(result).toMatch(/^error rendering mustache template .*/);
});
});
});
114 changes: 114 additions & 0 deletions x-pack/plugins/actions/server/lib/mustache_lambdas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import * as tinymath from '@kbn/tinymath';
import { parse as hjsonParse } from 'hjson';

import moment from 'moment-timezone';

type Variables = Record<string, unknown>;

const DefaultDateTimeZone = 'UTC';
const DefaultDateFormat = 'YYYY-MM-DD hh:mma';

export function getMustacheLambdas(): Variables {
return getLambdas();
}

const TimeZoneSet = new Set(moment.tz.names());

type RenderFn = (text: string) => string;

function getLambdas() {
return {
EvalMath: () =>
// mustache invokes lamdas with `this` set to the current "view" (variables)
function (this: Variables, text: string, render: RenderFn) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about use vars instead of this? Or do you like this more on some reason?

Copy link
Member Author

@pmuellr pmuellr Apr 17, 2023

Choose a reason for hiding this comment

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

Not an option. The way these lambdas are set up by mustache, is that they call the function with this set to the "variables" available to the lambda. We HAVE to define it this way.

Worth a comment though, I think, because it's obviously a little confusing, especially using this as a "function parameter" like this (it's a special typescript thing). see: https://www.typescriptlang.org/docs/handbook/2/functions.html#declaring-this-in-a-function

return evalMath(this, render(text.trim()));
},
ParseHjson: () =>
function (text: string, render: RenderFn) {
return parseHjson(render(text.trim()));
},
FormatDate: () =>
function (text: string, render: RenderFn) {
const dateString = render(text.trim()).trim();
return formatDate(dateString);
},
};
}

function evalMath(vars: Variables, o: unknown): string {
const expr = `${o}`;
try {
const result = tinymath.evaluate(expr, vars);
return `${result}`;
} catch (err) {
throw new Error(`error evaluating tinymath expression "${expr}": ${err.message}`);
}
}

function parseHjson(o: unknown): string {
const hjsonObject = `${o}`;
let object: unknown;

try {
object = hjsonParse(hjsonObject);
} catch (err) {
throw new Error(`error parsing Hjson "${hjsonObject}": ${err.message}`);
}

return JSON.stringify(object);
}

function formatDate(dateString: unknown): string {
const { date, timeZone, format } = splitDateString(`${dateString}`);

if (date === '') {
throw new Error(`date is empty`);
}

if (isNaN(new Date(date).valueOf())) {
throw new Error(`invalid date "${date}"`);
}

let mDate: moment.Moment;
try {
mDate = moment(date);
if (!mDate.isValid()) {
throw new Error(`date is invalid`);
}
} catch (err) {
throw new Error(`error evaluating moment date "${date}": ${err.message}`);
}

if (!TimeZoneSet.has(timeZone)) {
throw new Error(`unknown timeZone value "${timeZone}"`);
}

try {
mDate.tz(timeZone);
} catch (err) {
throw new Error(`error evaluating moment timeZone "${timeZone}": ${err.message}`);
}

try {
return mDate.format(format);
} catch (err) {
throw new Error(`error evaluating moment format "${format}": ${err.message}`);
}
}

function splitDateString(dateString: string) {
const parts = dateString.split(';', 3).map((s) => s.trim());
const [date = '', timeZone = '', format = ''] = parts;
return {
date,
timeZone: timeZone || DefaultDateTimeZone,
format: format || DefaultDateFormat,
};
}
11 changes: 11 additions & 0 deletions x-pack/plugins/actions/server/lib/mustache_renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ describe('mustache_renderer', () => {
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44');

if (escape === 'markdown') {
expect(renderMustacheString('{{i.asJSON}}', variables, escape)).toBe('\\[42,43,44\\]');
} else {
expect(renderMustacheString('{{i.asJSON}}', variables, escape)).toBe('[42,43,44]');
}
});
}

Expand Down Expand Up @@ -339,6 +345,11 @@ describe('mustache_renderer', () => {

const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}';
expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected);

expect(renderMustacheString('{{e}}', deepVariables, 'none')).toEqual('5,{"f":6,"g":7}');
expect(renderMustacheString('{{e.asJSON}}', deepVariables, 'none')).toEqual(
'[5,{"f":6,"g":7}]'
);
});

describe('converting dot variables', () => {
Expand Down
9 changes: 8 additions & 1 deletion x-pack/plugins/actions/server/lib/mustache_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

import Mustache from 'mustache';
import { isString, isPlainObject, cloneDeepWith, merge } from 'lodash';
import { getMustacheLambdas } from './mustache_lambdas';

export type Escape = 'markdown' | 'slack' | 'json' | 'none';

type Variables = Record<string, unknown>;

// return a rendered mustache template with no escape given the specified variables and escape
Expand All @@ -25,11 +27,13 @@ export function renderMustacheStringNoEscape(string: string, variables: Variable
// return a rendered mustache template given the specified variables and escape
export function renderMustacheString(string: string, variables: Variables, escape: Escape): string {
const augmentedVariables = augmentObjectVariables(variables);
const lambdas = getMustacheLambdas();

const previousMustacheEscape = Mustache.escape;
Mustache.escape = getEscape(escape);

try {
return Mustache.render(`${string}`, augmentedVariables);
return Mustache.render(`${string}`, { ...lambdas, ...augmentedVariables });
} catch (err) {
// log error; the mustache code does not currently leak variables
return `error rendering mustache template "${string}": ${err.message}`;
Expand Down Expand Up @@ -98,6 +102,9 @@ function addToStringDeep(object: unknown): void {

// walk arrays, but don't add a toString() as mustache already does something
if (Array.isArray(object)) {
// instead, add an asJSON()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(object as any).asJSON = () => JSON.stringify(object);
object.forEach((element) => addToStringDeep(element));
return;
}
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/actions/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"@kbn/logging-mocks",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/safer-lodash-set",
"@kbn/core-http-server-mocks"
"@kbn/core-http-server-mocks",
"@kbn/tinymath",
],
"exclude": [
"target/**/*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function initPlugin() {
}

// store a message that was posted to be remembered
const match = text.match(/^message (.*)$/);
const match = text.match(/^message ([\S\s]*)$/);
if (match) {
messages.push(match[1]);
response.statusCode = 200;
Expand Down
Loading