Skip to content

Commit 27ba438

Browse files
lvthillovicheeyseshubaws
authored
Feat allow dict values in generate event cmd (#7938)
* feat(sam): allow generation of dict values in generate-event cmd * feat: support dictionary values in generate-event command + add tests * fix: run black, remove unused import + add test * fix: pr remark + add test for nested query string params --------- Co-authored-by: vicheey <181402101+vicheey@users.noreply.github.com> Co-authored-by: seshubaws <116689586+seshubaws@users.noreply.github.com>
1 parent 4f63f21 commit 27ba438

5 files changed

Lines changed: 197 additions & 10 deletions

File tree

samcli/lib/generated_sample_events/event-mapping.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@
241241
"type": "string",
242242
"default": "path/to/resource"
243243
},
244+
"querystringparameters": {
245+
"type": "dict",
246+
"default": "{\"foo\": \"bar\"}"
247+
},
244248
"account-id": {
245249
"default": "123456789012"
246250
},

samcli/lib/generated_sample_events/events.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import os
88
import warnings
9-
from typing import Dict
9+
from typing import Any, Dict, Optional
1010
from urllib.parse import quote as url_quote
1111

1212
with warnings.catch_warnings():
@@ -42,7 +42,7 @@ def __init__(self):
4242
with open(file_name) as f:
4343
self.event_mapping = json.load(f)
4444

45-
def transform(self, tags, values_to_sub):
45+
def transform(self, tags: Dict[str, Any], values_to_sub: Dict[str, Any]) -> Dict[str, Any]:
4646
"""
4747
transform (if needed) values_to_sub with given tags
4848
@@ -68,21 +68,25 @@ def transform(self, tags, values_to_sub):
6868
values_to_sub[child_tag] = child_val
6969
return values_to_sub
7070

71-
def transform_val(self, properties, val):
71+
def transform_val(self, properties: Dict[str, Any], val: Optional[str]) -> str:
7272
"""
7373
transform (if needed) given val with given properties
7474
7575
Parameters
7676
----------
7777
properties: dict
7878
set of properties to be used for transformation
79-
val: string
79+
val: string or None
8080
the value to undergo transformation
8181
Returns
8282
-------
8383
transformed
84-
the transformed value
84+
the transformed value (always a string for template rendering)
8585
"""
86+
# Handle None values by using the default
87+
if val is None:
88+
val = properties.get("default", "")
89+
8690
transformed = val
8791

8892
# encode if needed
@@ -95,6 +99,9 @@ def transform_val(self, properties, val):
9599
if hashing is not None:
96100
transformed = self.hash(hashing, transformed)
97101

102+
# Note: dict type is handled separately in generate_event() using placeholders
103+
# to avoid JSON escaping issues during template rendering
104+
98105
return transformed
99106

100107
@staticmethod
@@ -166,6 +173,15 @@ def generate_event(self, service_name: str, event_type: str, values_to_sub: Dict
166173

167174
# set variables for easy calling
168175
tags = self.event_mapping[service_name][event_type]["tags"]
176+
177+
# Store original dict-type values before transformation
178+
dict_type_values = {}
179+
for tag_name, tag_properties in tags.items():
180+
if tag_properties.get("type") == "dict" and tag_name in values_to_sub:
181+
dict_type_values[tag_name] = values_to_sub[tag_name]
182+
# Replace with a placeholder for safe template rendering
183+
values_to_sub[tag_name] = f"__DICT_PLACEHOLDER_{tag_name}__"
184+
169185
values_to_sub = self.transform(tags, values_to_sub)
170186

171187
# construct the path to the Events json file
@@ -180,4 +196,24 @@ def generate_event(self, service_name: str, event_type: str, values_to_sub: Dict
180196
data = json.dumps(data, indent=2)
181197

182198
# return the substituted file (A string containing the rendered template.)
183-
return renderer.render(data, values_to_sub)
199+
rendered = renderer.render(data, values_to_sub)
200+
201+
# Parse the rendered result
202+
rendered_json = json.loads(rendered)
203+
204+
# Replace placeholders with actual dict values
205+
key_map = {k.lower(): k for k in rendered_json.keys()}
206+
for tag_name, original_value in dict_type_values.items():
207+
original_key = key_map.get(tag_name.lower())
208+
if original_key:
209+
placeholder = f"__DICT_PLACEHOLDER_{tag_name}__"
210+
if isinstance(rendered_json[original_key], str) and rendered_json[original_key] == placeholder:
211+
try:
212+
# Parse the original JSON string value to a dict
213+
rendered_json[original_key] = json.loads(original_value)
214+
except (json.JSONDecodeError, TypeError):
215+
# If parsing fails, keep the original string value
216+
rendered_json[original_key] = original_value
217+
218+
# Return the final JSON with proper dictionary values
219+
return json.dumps(rendered_json, indent=2)

samcli/lib/generated_sample_events/events/apigateway/AwsProxy.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
"path": "/{{{path}}}",
55
"httpMethod": "{{{method}}}",
66
"isBase64Encoded": true,
7-
"queryStringParameters": {
8-
"foo": "bar"
9-
},
7+
"queryStringParameters": "{{{querystringparameters}}}",
108
"multiValueQueryStringParameters": {
119
"foo": [
1210
"bar"

tests/integration/local/generate_event/test_cli_integ.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import TestCase
2-
from subprocess import Popen
2+
from subprocess import Popen, PIPE
33
import os
4+
import json
45

56
from tests.testing_utils import get_sam_command
67

@@ -10,3 +11,78 @@ def test_generate_event_substitution(self):
1011
process = Popen([get_sam_command(), "local", "generate-event", "s3", "put"])
1112
process.communicate()
1213
self.assertEqual(process.returncode, 0)
14+
15+
def test_generate_event_with_dict_value(self):
16+
"""Test that dictionary values are properly handled in generate-event"""
17+
process = Popen(
18+
[
19+
get_sam_command(),
20+
"local",
21+
"generate-event",
22+
"apigateway",
23+
"aws-proxy",
24+
"--method",
25+
"GET",
26+
"--path",
27+
"document",
28+
"--body",
29+
"",
30+
"--querystringparameters",
31+
'{"documentId": "1044", "versionId": "v_1"}',
32+
],
33+
stdout=PIPE,
34+
stderr=PIPE,
35+
)
36+
stdout, stderr = process.communicate()
37+
self.assertEqual(process.returncode, 0)
38+
39+
# Parse the output JSON
40+
result = json.loads(stdout.decode("utf-8"))
41+
42+
# Verify that queryStringParameters is a dict, not a string
43+
self.assertIsInstance(result["queryStringParameters"], dict)
44+
self.assertEqual(result["queryStringParameters"]["documentId"], "1044")
45+
self.assertEqual(result["queryStringParameters"]["versionId"], "v_1")
46+
47+
# Verify other fields are still properly substituted
48+
self.assertEqual(result["httpMethod"], "GET")
49+
self.assertEqual(result["path"], "/document")
50+
51+
def test_generate_event_with_multiple_query_params(self):
52+
"""Test that multiple query string parameters are properly handled"""
53+
process = Popen(
54+
[
55+
get_sam_command(),
56+
"local",
57+
"generate-event",
58+
"apigateway",
59+
"aws-proxy",
60+
"--method",
61+
"POST",
62+
"--path",
63+
"api/search",
64+
"--body",
65+
'{"query": "test"}',
66+
"--querystringparameters",
67+
'{"filter": "active", "sort": "desc", "limit": "10", "offset": "0"}',
68+
],
69+
stdout=PIPE,
70+
stderr=PIPE,
71+
)
72+
stdout, stderr = process.communicate()
73+
self.assertEqual(process.returncode, 0)
74+
75+
# Parse the output JSON
76+
result = json.loads(stdout.decode("utf-8"))
77+
78+
# Verify that queryStringParameters is a dict with all parameters
79+
self.assertIsInstance(result["queryStringParameters"], dict)
80+
self.assertEqual(len(result["queryStringParameters"]), 4)
81+
self.assertEqual(result["queryStringParameters"]["filter"], "active")
82+
self.assertEqual(result["queryStringParameters"]["sort"], "desc")
83+
self.assertEqual(result["queryStringParameters"]["limit"], "10")
84+
self.assertEqual(result["queryStringParameters"]["offset"], "0")
85+
86+
# Verify other fields
87+
self.assertEqual(result["httpMethod"], "POST")
88+
self.assertEqual(result["path"], "/api/search")

tests/unit/commands/local/generate_event/test_event_generation.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23

34
from unittest import TestCase
@@ -73,6 +74,78 @@ def test_transform(self):
7374
}
7475
self.assertEqual(result, expected)
7576

77+
def test_transform_val_dict_type_kept_as_string(self):
78+
"""Test that dict type values are kept as strings during transform"""
79+
properties = {"type": "dict"}
80+
val = '{"key1": "value1", "key2": "value2"}'
81+
result = events.Events().transform_val(properties, val)
82+
# Should remain a string for template rendering
83+
self.assertEqual(result, val)
84+
self.assertIsInstance(result, str)
85+
86+
def test_generate_event_with_dict_value(self):
87+
"""Test that generate_event properly handles dictionary values"""
88+
events_lib = events.Events()
89+
# Test with apigateway aws-proxy which has querystringparameters as dict type
90+
# Pass as JSON string (as it would come from CLI)
91+
result = events_lib.generate_event(
92+
"apigateway",
93+
"aws-proxy",
94+
{
95+
"method": "GET",
96+
"path": "test",
97+
"body": "",
98+
"querystringparameters": '{"documentId": "1044", "versionId": "v_1"}',
99+
},
100+
)
101+
102+
result_json = json.loads(result)
103+
# Verify that queryStringParameters is a dict, not a string
104+
self.assertIsInstance(result_json["queryStringParameters"], dict)
105+
self.assertEqual(result_json["queryStringParameters"]["documentId"], "1044")
106+
self.assertEqual(result_json["queryStringParameters"]["versionId"], "v_1")
107+
108+
def test_generate_event_with_complex_dict_value(self):
109+
"""Test that generate_event handles complex dict values with special characters"""
110+
events_lib = events.Events()
111+
# Test with complex JSON including special characters
112+
result = events_lib.generate_event(
113+
"apigateway",
114+
"aws-proxy",
115+
{
116+
"method": "POST",
117+
"path": "api/search",
118+
"body": "",
119+
"querystringparameters": '{"filter": "status=active", "sort": "desc", "limit": "10"}',
120+
},
121+
)
122+
123+
result_json = json.loads(result)
124+
# Verify complex dict is properly parsed
125+
self.assertIsInstance(result_json["queryStringParameters"], dict)
126+
self.assertEqual(result_json["queryStringParameters"]["filter"], "status=active")
127+
self.assertEqual(result_json["queryStringParameters"]["sort"], "desc")
128+
self.assertEqual(result_json["queryStringParameters"]["limit"], "10")
129+
130+
def test_generate_event_with_nested_dict_value(self):
131+
"""Test that generate_event preserves nested objects inside dict-type values"""
132+
events_lib = events.Events()
133+
result = events_lib.generate_event(
134+
"apigateway",
135+
"aws-proxy",
136+
{
137+
"method": "GET",
138+
"path": "test",
139+
"body": "",
140+
"querystringparameters": '{"outer": {"inner": "x"}, "items": ["a", "b"]}',
141+
},
142+
)
143+
144+
result_json = json.loads(result)
145+
self.assertIsInstance(result_json["queryStringParameters"], dict)
146+
self.assertEqual(result_json["queryStringParameters"]["outer"]["inner"], "x")
147+
self.assertEqual(result_json["queryStringParameters"]["items"], ["a", "b"])
148+
76149

77150
class TestServiceCommand(TestCase):
78151
def setUp(self):

0 commit comments

Comments
 (0)