[actions] expand object context variables as JSON (#85903)
resolves https://github.com/elastic/kibana/issues/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.
This commit is contained in:
parent
6672e26fe5
commit
f693697c18
|
@ -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('');
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
@ -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}}';
|
||||
|
||||
|
@ -162,6 +164,58 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
|
|||
);
|
||||
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- <&>");
|
||||
});
|
||||
|
||||
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> {
|
||||
|
|
Loading…
Reference in a new issue