Skip to content

Commit e0afb62

Browse files
committed
[actions] expand object context variables as JSON
resolves elastic#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.
1 parent 9e14968 commit e0afb62

File tree

4 files changed

+136
-9
lines changed

4 files changed

+136
-9
lines changed

x-pack/plugins/actions/server/lib/mustache_renderer.test.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ describe('mustache_renderer', () => {
4141
expect(renderMustacheString('{{c}}', variables, escape)).toBe('false');
4242
expect(renderMustacheString('{{d}}', variables, escape)).toBe('');
4343
expect(renderMustacheString('{{e}}', variables, escape)).toBe('');
44-
if (escape === 'markdown') {
45-
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]');
44+
if (escape === 'json') {
45+
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{\\"g\\":3,\\"h\\":null}');
46+
} else if (escape === 'markdown') {
47+
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\{"g":3,"h":null\\}');
4648
} else {
47-
expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]');
49+
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{"g":3,"h":null}');
4850
}
4951
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
5052
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
@@ -180,4 +182,21 @@ describe('mustache_renderer', () => {
180182
`);
181183
});
182184
});
185+
186+
describe('augmented object variables', () => {
187+
const deepVariables = {
188+
a: 1,
189+
b: { c: 2, d: [3, 4] },
190+
e: [5, { f: 6, g: 7 }],
191+
};
192+
expect(renderMustacheObject({ x: '{{a}} - {{b}} -- {{e}} ' }, deepVariables))
193+
.toMatchInlineSnapshot(`
194+
Object {
195+
"x": "1 - {\\"c\\":2,\\"d\\":[3,4]} -- 5,{\\"f\\":6,\\"g\\":7} ",
196+
}
197+
`);
198+
199+
const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}';
200+
expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected);
201+
});
183202
});

x-pack/plugins/actions/server/lib/mustache_renderer.ts

+35-3
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@
55
*/
66

77
import Mustache from 'mustache';
8-
import { isString, cloneDeepWith } from 'lodash';
8+
import { isString, isPlainObject, cloneDeepWith } from 'lodash';
99

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

1313
// return a rendered mustache template given the specified variables and escape
1414
export function renderMustacheString(string: string, variables: Variables, escape: Escape): string {
15+
const augmentedVariables = augmentObjectVariables(variables);
1516
const previousMustacheEscape = Mustache.escape;
1617
Mustache.escape = getEscape(escape);
1718

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

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

3335
// since we're rendering a JS object, no escaping needed
34-
return renderMustacheString(value, variables, 'none');
36+
return renderMustacheString(value, augmentedVariables, 'none');
3537
});
3638

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

45+
// return variables cloned, with a toString() added to objects
46+
function augmentObjectVariables(variables: Variables): Variables {
47+
const result = JSON.parse(JSON.stringify(variables));
48+
addToStringDeep(result);
49+
return result;
50+
}
51+
52+
function addToStringDeep(object: unknown): void {
53+
// for objects, add a toString method, and then walk
54+
if (isNonNullObject(object)) {
55+
if (!object.hasOwnProperty('toString')) {
56+
object.toString = () => JSON.stringify(object);
57+
}
58+
Object.values(object).forEach((value) => addToStringDeep(value));
59+
}
60+
61+
// walk arrays, but don't add a toString() as mustache already does something
62+
if (Array.isArray(object)) {
63+
object.forEach((element) => addToStringDeep(element));
64+
return;
65+
}
66+
}
67+
68+
function isNonNullObject(object: unknown): object is Record<string, unknown> {
69+
if (object == null) return false;
70+
if (typeof object !== 'object') return false;
71+
if (!isPlainObject(object)) return false;
72+
return true;
73+
}
74+
4375
function getEscape(escape: Escape): (value: unknown) => string {
4476
if (escape === 'markdown') return escapeMarkdown;
4577
if (escape === 'slack') return escapeSlack;

x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@ export const EscapableStrings = {
2424
escapableLineFeed: 'line\x0afeed',
2525
};
2626

27+
export const DeepContextVariables = {
28+
objectA: {
29+
stringB: 'B',
30+
arrayC: [
31+
{ stringD: 'D1', numberE: 42 },
32+
{ stringD: 'D2', numberE: 43 },
33+
],
34+
objectF: {
35+
stringG: 'G',
36+
nullG: null,
37+
undefinedG: undefined,
38+
},
39+
},
40+
stringH: 'H',
41+
arrayI: [44, 45],
42+
nullJ: null,
43+
undefinedK: undefined,
44+
};
45+
2746
function getAlwaysFiringAlertType() {
2847
const paramsSchema = schema.object({
2948
index: schema.string(),
@@ -403,7 +422,10 @@ function getPatternFiringAlertType() {
403422
for (const [instanceId, instancePattern] of Object.entries(pattern)) {
404423
const scheduleByPattern = instancePattern[patternIndex];
405424
if (scheduleByPattern === true) {
406-
services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings);
425+
services.alertInstanceFactory(instanceId).scheduleActions('default', {
426+
...EscapableStrings,
427+
deep: DeepContextVariables,
428+
});
407429
} else if (typeof scheduleByPattern === 'string') {
408430
services
409431
.alertInstanceFactory(instanceId)

x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts

+56-2
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
7979
const createdAction = actionResponse.body;
8080
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');
8181

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

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

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

@@ -162,6 +164,58 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
162164
);
163165
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- &lt;&amp;&gt;");
164166
});
167+
168+
it('should handle context variable object expansion', async () => {
169+
const actionResponse = await supertest
170+
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
171+
.set('kbn-xsrf', 'test')
172+
.send({
173+
name: 'testing context variable expansion',
174+
actionTypeId: '.slack',
175+
secrets: {
176+
webhookUrl: slackSimulatorURL,
177+
},
178+
});
179+
expect(actionResponse.status).to.eql(200);
180+
const createdAction = actionResponse.body;
181+
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');
182+
183+
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
184+
// const DeepContextVariables
185+
const varsTemplate = '{{context.deep}}';
186+
187+
const alertResponse = await supertest
188+
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
189+
.set('kbn-xsrf', 'foo')
190+
.send(
191+
getTestAlertData({
192+
name: 'testing context variable expansion',
193+
alertTypeId: 'test.patternFiring',
194+
params: {
195+
pattern: { instance: [true, true] },
196+
},
197+
actions: [
198+
{
199+
id: createdAction.id,
200+
group: 'default',
201+
params: {
202+
message: `message {{alertId}} - ${varsTemplate}`,
203+
},
204+
},
205+
],
206+
})
207+
);
208+
expect(alertResponse.status).to.eql(200);
209+
const createdAlert = alertResponse.body;
210+
objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts');
211+
212+
const body = await retry.try(async () =>
213+
waitForActionBody(slackSimulatorURL, createdAlert.id)
214+
);
215+
expect(body).to.be(
216+
'{"objectA":{"stringB":"B","arrayC":[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}],"objectF":{"stringG":"G","nullG":null}},"stringH":"H","arrayI":[44,45],"nullJ":null}'
217+
);
218+
});
165219
});
166220

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

0 commit comments

Comments
 (0)