Skip to content

Commit

Permalink
[ResponseOps] adds mustache lambdas and array.asJSON (#150572)
Browse files Browse the repository at this point in the history
partially resolves some issues in #84217

Adds Mustache lambdas for alerting actions to format dates with `{{#FormatDate}}`, evaluate math expressions with `{{#EvalMath}}`, and provide easier JSON formatting with `{{#ParseHjson}}` and a new `asJSON` property added to arrays.
  • Loading branch information
pmuellr authored Apr 24, 2023
1 parent 953437f commit 4382e1c
Show file tree
Hide file tree
Showing 9 changed files with 534 additions and 232 deletions.
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) {
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

0 comments on commit 4382e1c

Please sign in to comment.