From 67917f6f66c5b2fa13adf4bc956d3f75392223be Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Fri, 4 Jun 2021 11:37:35 -0500 Subject: [PATCH] feat(events): support embedded string variables (#13487) Event Bridge transformers have been updated to support embedded variable replacement within strings within objects. ``` { data: "some string " } ``` Previously input transformers only supported string when they were the only value of an object, or just static strings. ``` // Before Event Bridges's change { data: , // OK data2: "some string", // OK data3: "some string " // NOT OK } ``` The CDK solution was to assume that developers knew this restriction, wrap the string variable in special characters, and replace the double quotes plus special character set with nothing after token replacement. This caused issues like #9191. Where string tokens (`EventField`) within a string would give a cryptic error during Cfn deployment due the resulting invalid object string generated (missing a closing double quote and leaving the special characters behind). ### Solution: Removed the special character sequence addition and stripping and instead only replace any instances of `""` that are added. * Iterate over the known input transform keys to reduce possible unexpected impact and give developers a backdoor to change their keys in the worst case. * Edge Case: `""` can appear with escaped quote sequences `"something \"quoted\""`. This is a valid string variable replacement case. Used a lookback regex (`(?\"`) to avoid the prefix escaped quote when replacing transform input keys with quote-less keys. ### Tradeoffs Removed the addition of special characters to find the keys in the final json string. Instead search for the specific pattern of transform input keys that should exist within the output and handle the edge case describe above. This SHOULD cover all edge cases as it is not valid to have a trailing quote without an escape (`""" //not valid`) and it is not valid to have a prefix quote that is not escaped (`""" // not valid`). This was done to reduce the small change of overlapping with a developer's content, to be more targeted, and because the above should prove that the edge case is covered. https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_InputTransformer.html fixes #9191 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-events/README.md | 12 ++ packages/@aws-cdk/aws-events/lib/input.ts | 44 ++--- .../@aws-cdk/aws-events/test/test.input.ts | 156 ++++++++++++++++++ 3 files changed, 181 insertions(+), 31 deletions(-) diff --git a/packages/@aws-cdk/aws-events/README.md b/packages/@aws-cdk/aws-events/README.md index 4bf4c2b390dbe..f659c2aa48d66 100644 --- a/packages/@aws-cdk/aws-events/README.md +++ b/packages/@aws-cdk/aws-events/README.md @@ -80,6 +80,18 @@ onCommitRule.addTarget(new targets.SnsTopic(topic, { })); ``` +Or using an Object: + +```ts +onCommitRule.addTarget(new targets.SnsTopic(topic, { + message: events.RuleTargetInput.fromObject( + { + DataType: `custom_${events.EventField.fromPath('$.detail-type')}` + } + ) +})); +``` + ## Scheduling You can configure a Rule to run on a schedule (cron or rate). diff --git a/packages/@aws-cdk/aws-events/lib/input.ts b/packages/@aws-cdk/aws-events/lib/input.ts index 1fd68a1754119..77798ceebd3a1 100644 --- a/packages/@aws-cdk/aws-events/lib/input.ts +++ b/packages/@aws-cdk/aws-events/lib/input.ts @@ -151,8 +151,6 @@ class FieldAwareEventInput extends RuleTargetInput { return key; } - const self = this; - class EventFieldReplacer extends DefaultTokenResolver { constructor() { super(new StringConcat()); @@ -167,7 +165,7 @@ class FieldAwareEventInput extends RuleTargetInput { } inputPathsMap[key] = t.path; - return self.keyPlaceholder(key); + return `<${key}>`; } } @@ -188,35 +186,32 @@ class FieldAwareEventInput extends RuleTargetInput { })); } - if (Object.keys(inputPathsMap).length === 0) { + const keys = Object.keys(inputPathsMap); + + if (keys.length === 0) { // Nothing special, just return 'input' return { input: resolved }; } return { - inputTemplate: this.unquoteKeyPlaceholders(resolved), + inputTemplate: this.unquoteKeyPlaceholders(resolved, keys), inputPathsMap, }; } - /** - * Return a template placeholder for the given key - * - * In object scope we'll need to get rid of surrounding quotes later on, so - * return a bracing that's unlikely to occur naturally (like tokens). - */ - private keyPlaceholder(key: string) { - if (this.inputType !== InputType.Object) { return `<${key}>`; } - return UNLIKELY_OPENING_STRING + key + UNLIKELY_CLOSING_STRING; - } - /** * Removing surrounding quotes from any object placeholders + * when key is the lone value. * * Those have been put there by JSON.stringify(), but we need to * remove them. + * + * Do not remove quotes when the key is part of a larger string. + * + * Valid: { "data": "Some string with \"quotes\"" } // key will be string + * Valid: { "data": } // Key could be number, bool, obj, or string */ - private unquoteKeyPlaceholders(sub: string) { + private unquoteKeyPlaceholders(sub: string, keys: string[]) { if (this.inputType !== InputType.Object) { return sub; } return Lazy.uncachedString({ produce: (ctx: IResolveContext) => Token.asString(deepUnquote(ctx.resolve(sub))) }); @@ -230,19 +225,13 @@ class FieldAwareEventInput extends RuleTargetInput { } return resolved; } else if (typeof(resolved) === 'string') { - return resolved.replace(OPENING_STRING_REGEX, '<').replace(CLOSING_STRING_REGEX, '>'); + return keys.reduce((r, key) => r.replace(new RegExp(`(?\"`, 'g'), `<${key}>`), resolved); } return resolved; } } } -const UNLIKELY_OPENING_STRING = '<<${'; -const UNLIKELY_CLOSING_STRING = '}>>'; - -const OPENING_STRING_REGEX = new RegExp(regexQuote('"' + UNLIKELY_OPENING_STRING), 'g'); -const CLOSING_STRING_REGEX = new RegExp(regexQuote(UNLIKELY_CLOSING_STRING + '"'), 'g'); - /** * Represents a field in the event pattern */ @@ -339,10 +328,3 @@ function isEventField(x: any): x is EventField { } const EVENT_FIELD_SYMBOL = Symbol.for('@aws-cdk/aws-events.EventField'); - -/** - * Quote a string for use in a regex - */ -function regexQuote(s: string) { - return s.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&'); -} diff --git a/packages/@aws-cdk/aws-events/test/test.input.ts b/packages/@aws-cdk/aws-events/test/test.input.ts index bbcc80d4afc66..43c763ef35f74 100644 --- a/packages/@aws-cdk/aws-events/test/test.input.ts +++ b/packages/@aws-cdk/aws-events/test/test.input.ts @@ -67,6 +67,162 @@ export = { test.done(); }, + 'can use joined JSON containing refs in JSON object with tricky inputs'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const rule = new Rule(stack, 'Rule', { + schedule: Schedule.rate(cdk.Duration.minutes(1)), + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromObject({ + data: `they said \"hello\"${EventField.fromPath('$')}`, + stackName: cdk.Aws.STACK_NAME, + }))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + InputTransformer: { + InputPathsMap: { + f1: '$', + }, + InputTemplate: { + 'Fn::Join': [ + '', + [ + '{"data":"they said \\\"hello\\\"","stackName":"', + { Ref: 'AWS::StackName' }, + '"}', + ], + ], + }, + }, + }, + ], + })); + + test.done(); + }, + + 'can use joined JSON containing refs in JSON object and concat'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const rule = new Rule(stack, 'Rule', { + schedule: Schedule.rate(cdk.Duration.minutes(1)), + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromObject({ + data: `more text ${EventField.fromPath('$')}`, + stackName: cdk.Aws.STACK_NAME, + }))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + InputTransformer: { + InputPathsMap: { + f1: '$', + }, + InputTemplate: { + 'Fn::Join': [ + '', + [ + '{"data":"more text ","stackName":"', + { Ref: 'AWS::StackName' }, + '"}', + ], + ], + }, + }, + }, + ], + })); + + test.done(); + }, + + 'can use joined JSON containing refs in JSON object and quotes'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const rule = new Rule(stack, 'Rule', { + schedule: Schedule.rate(cdk.Duration.minutes(1)), + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromObject({ + data: `more text "${EventField.fromPath('$')}"`, + stackName: cdk.Aws.STACK_NAME, + }))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + InputTransformer: { + InputPathsMap: { + f1: '$', + }, + InputTemplate: { + 'Fn::Join': [ + '', + [ + '{"data":"more text \\\"\\\"","stackName":"', + { Ref: 'AWS::StackName' }, + '"}', + ], + ], + }, + }, + }, + ], + })); + + test.done(); + }, + + 'can use joined JSON containing refs in JSON object and multiple keys'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const rule = new Rule(stack, 'Rule', { + schedule: Schedule.rate(cdk.Duration.minutes(1)), + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromObject({ + data: `${EventField.fromPath('$')}${EventField.fromPath('$.other')}`, + stackName: cdk.Aws.STACK_NAME, + }))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + InputTransformer: { + InputPathsMap: { + f1: '$', + }, + InputTemplate: { + 'Fn::Join': [ + '', + [ + '{"data":"","stackName":"', + { Ref: 'AWS::StackName' }, + '"}', + ], + ], + }, + }, + }, + ], + })); + + test.done(); + }, + 'can use token'(test: Test) { // GIVEN const stack = new cdk.Stack();