Skip to content

Commit

Permalink
[actions] expand object context variables as JSON (#85903) (#86031)
Browse files Browse the repository at this point in the history
resolves #75601

Previously, if a context variable that is an object is referenced in a
mustache template used as an action parameter, the resulting variable
expansion will be `[Object object]`.  In this PR, we change this so that
the expansion is a JSON representation of the object.

This is primarily for diagnostic purposes, so that customers can see
all the context variables available, and their values, while testing
testing their alerting actions.
  • Loading branch information
pmuellr authored Dec 16, 2020
1 parent 9784e71 commit 762004e
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 9 deletions.
25 changes: 22 additions & 3 deletions x-pack/plugins/actions/server/lib/mustache_renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ describe('mustache_renderer', () => {
expect(renderMustacheString('{{c}}', variables, escape)).toBe('false');
expect(renderMustacheString('{{d}}', variables, escape)).toBe('');
expect(renderMustacheString('{{e}}', variables, escape)).toBe('');
if (escape === 'markdown') {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]');
if (escape === 'json') {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{\\"g\\":3,\\"h\\":null}');
} else if (escape === 'markdown') {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\{"g":3,"h":null\\}');
} else {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]');
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{"g":3,"h":null}');
}
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
Expand Down Expand Up @@ -180,4 +182,21 @@ describe('mustache_renderer', () => {
`);
});
});

describe('augmented object variables', () => {
const deepVariables = {
a: 1,
b: { c: 2, d: [3, 4] },
e: [5, { f: 6, g: 7 }],
};
expect(renderMustacheObject({ x: '{{a}} - {{b}} -- {{e}} ' }, deepVariables))
.toMatchInlineSnapshot(`
Object {
"x": "1 - {\\"c\\":2,\\"d\\":[3,4]} -- 5,{\\"f\\":6,\\"g\\":7} ",
}
`);

const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}';
expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected);
});
});
38 changes: 35 additions & 3 deletions x-pack/plugins/actions/server/lib/mustache_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
*/

import Mustache from 'mustache';
import { isString, cloneDeepWith } from 'lodash';
import { isString, isPlainObject, cloneDeepWith } from 'lodash';

export type Escape = 'markdown' | 'slack' | 'json' | 'none';
type Variables = Record<string, unknown>;

// 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 previousMustacheEscape = Mustache.escape;
Mustache.escape = getEscape(escape);

try {
return Mustache.render(`${string}`, variables);
return Mustache.render(`${string}`, augmentedVariables);
} catch (err) {
// log error; the mustache code does not currently leak variables
return `error rendering mustache template "${string}": ${err.message}`;
Expand All @@ -27,11 +28,12 @@ export function renderMustacheString(string: string, variables: Variables, escap

// return a cloned object with all strings rendered as mustache templates
export function renderMustacheObject<Params>(params: Params, variables: Variables): Params {
const augmentedVariables = augmentObjectVariables(variables);
const result = cloneDeepWith(params, (value: unknown) => {
if (!isString(value)) return;

// since we're rendering a JS object, no escaping needed
return renderMustacheString(value, variables, 'none');
return renderMustacheString(value, augmentedVariables, 'none');
});

// The return type signature for `cloneDeep()` ends up taking the return
Expand All @@ -40,6 +42,36 @@ export function renderMustacheObject<Params>(params: Params, variables: Variable
return (result as unknown) as Params;
}

// return variables cloned, with a toString() added to objects
function augmentObjectVariables(variables: Variables): Variables {
const result = JSON.parse(JSON.stringify(variables));
addToStringDeep(result);
return result;
}

function addToStringDeep(object: unknown): void {
// for objects, add a toString method, and then walk
if (isNonNullObject(object)) {
if (!object.hasOwnProperty('toString')) {
object.toString = () => JSON.stringify(object);
}
Object.values(object).forEach((value) => addToStringDeep(value));
}

// walk arrays, but don't add a toString() as mustache already does something
if (Array.isArray(object)) {
object.forEach((element) => addToStringDeep(element));
return;
}
}

function isNonNullObject(object: unknown): object is Record<string, unknown> {
if (object == null) return false;
if (typeof object !== 'object') return false;
if (!isPlainObject(object)) return false;
return true;
}

function getEscape(escape: Escape): (value: unknown) => string {
if (escape === 'markdown') return escapeMarkdown;
if (escape === 'slack') return escapeSlack;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ export const EscapableStrings = {
escapableLineFeed: 'line\x0afeed',
};

export const DeepContextVariables = {
objectA: {
stringB: 'B',
arrayC: [
{ stringD: 'D1', numberE: 42 },
{ stringD: 'D2', numberE: 43 },
],
objectF: {
stringG: 'G',
nullG: null,
undefinedG: undefined,
},
},
stringH: 'H',
arrayI: [44, 45],
nullJ: null,
undefinedK: undefined,
};

function getAlwaysFiringAlertType() {
const paramsSchema = schema.object({
index: schema.string(),
Expand Down Expand Up @@ -410,7 +429,10 @@ function getPatternFiringAlertType() {
for (const [instanceId, instancePattern] of Object.entries(pattern)) {
const scheduleByPattern = instancePattern[patternIndex];
if (scheduleByPattern === true) {
services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings);
services.alertInstanceFactory(instanceId).scheduleActions('default', {
...EscapableStrings,
deep: DeepContextVariables,
});
} else if (typeof scheduleByPattern === 'string') {
services
.alertInstanceFactory(instanceId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');

// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}';

const alertResponse = await supertest
Expand Down Expand Up @@ -128,7 +129,8 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');

// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate =
'{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}';

Expand Down Expand Up @@ -162,6 +164,58 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
);
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- &lt;&amp;&gt;");
});

it('should handle context variable object expansion', async () => {
const actionResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing context variable expansion',
actionTypeId: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');

// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const varsTemplate = '{{context.deep}}';

const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
name: 'testing context variable expansion',
alertTypeId: 'test.patternFiring',
params: {
pattern: { instance: [true, true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
message: `message {{alertId}} - ${varsTemplate}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts');

const body = await retry.try(async () =>
waitForActionBody(slackSimulatorURL, createdAlert.id)
);
expect(body).to.be(
'{"objectA":{"stringB":"B","arrayC":[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}],"objectF":{"stringG":"G","nullG":null}},"stringH":"H","arrayI":[44,45],"nullJ":null}'
);
});
});

async function waitForActionBody(url: string, id: string): Promise<string> {
Expand Down

0 comments on commit 762004e

Please sign in to comment.