Skip to content

Commit 3e23521

Browse files
darklight3itDavide Melfi
andauthored
Add serialization round-trip tests for event types (#598)
* test: 🧪 add roundtrip serialization test utility * test: fix false positives epoch format errors, added comment about this in the serialization package. * test: fixed false positives DateTime differences * test: fixing error in lex event fixture * test: fixing connect event * test: fixing api gateway proxy event false negative * test: fixing CloudFront and S3 event false negatives * build: adding mise to .gitignore * test: fix MSKFirehose, LexEvent, RabbitMQ, APIGatewayV2Auth and ActiveMQ serialization test fixtures * test: Add round-trip fixtures for 4 registered events * test: Add round-trip tests for 11 response event types * test: including IAM Policy Response roundtrip test * test: add test for JsonNodeUtils * docs: add tests 1.1.2 changelog entry --------- Co-authored-by: Davide Melfi <dmelfi@amazon.com>
1 parent 09c4b4e commit 3e23521

File tree

59 files changed

+1783
-55
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+1783
-55
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ experimental/aws-lambda-java-profiler/integration_tests/helloworld/bin
3838
.vscode
3939
.kiro
4040
build
41+
mise.toml

aws-lambda-java-serialization/mise.toml

Lines changed: 0 additions & 2 deletions
This file was deleted.

aws-lambda-java-serialization/src/main/java/com/amazonaws/services/lambda/runtime/serialization/events/modules/DateModule.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@
1515
import com.fasterxml.jackson.databind.module.SimpleModule;
1616

1717
/**
18-
* The AWS API represents a date as a double, which specifies the fractional
19-
* number of seconds since the epoch. Java's Date, however, represents a date as
20-
* a long, which specifies the number of milliseconds since the epoch. This
21-
* class is used to translate between these two formats.
18+
* The AWS API represents a date as a double (fractional seconds since epoch).
19+
* Java's Date uses a long (milliseconds since epoch). This module translates
20+
* between the two formats.
21+
*
22+
* <p>
23+
* <b>Round-trip caveats:</b> The serializer always writes via
24+
* {@link JsonGenerator#writeNumber(double)}, so integer epochs
25+
* (e.g. {@code 1428537600}) round-trip as decimal ({@code 1.4285376E9}).
26+
* Sub-millisecond precision is lost because {@link java.util.Date}
27+
* has milliseconds precision.
28+
* </p>
2229
*
2330
* This class is copied from LambdaEventBridgeservice
2431
* com.amazon.aws.lambda.stream.ddb.DateModule
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.amazonaws.services.lambda.runtime.tests;
2+
3+
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.JsonNode;
4+
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.node.ObjectNode;
5+
import java.util.Iterator;
6+
import java.util.List;
7+
import java.util.TreeSet;
8+
import java.util.regex.Pattern;
9+
10+
import org.joda.time.DateTime;
11+
12+
/**
13+
* Utility methods for working with shaded Jackson {@link JsonNode} trees.
14+
*
15+
* <p>
16+
* Package-private — not part of the public API.
17+
* </p>
18+
*/
19+
class JsonNodeUtils {
20+
21+
private static final Pattern ISO_DATE_REGEX = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T.+");
22+
23+
private JsonNodeUtils() {
24+
}
25+
26+
/**
27+
* Recursively removes all fields whose value is {@code null} from the
28+
* tree. This mirrors the serializer's {@code Include.NON_NULL} behaviour
29+
* so that explicit nulls in the fixture don't cause false-positive diffs.
30+
*/
31+
static JsonNode stripNulls(JsonNode node) {
32+
if (node.isObject()) {
33+
ObjectNode obj = (ObjectNode) node;
34+
Iterator<String> fieldNames = obj.fieldNames();
35+
while (fieldNames.hasNext()) {
36+
String field = fieldNames.next();
37+
if (obj.get(field).isNull()) {
38+
fieldNames.remove();
39+
} else {
40+
stripNulls(obj.get(field));
41+
}
42+
}
43+
} else if (node.isArray()) {
44+
for (JsonNode element : node) {
45+
stripNulls(element);
46+
}
47+
}
48+
return node;
49+
}
50+
51+
/**
52+
* Recursively walks both trees and collects human-readable diff lines.
53+
*/
54+
static void diffNodes(String path, JsonNode expected, JsonNode actual, List<String> diffs) {
55+
if (expected.equals(actual))
56+
return;
57+
58+
// Compares two datetime strings by parsed instant, because DateTimeModule
59+
// normalizes the format on serialization (e.g. "+0000" → "Z", "Z" → ".000Z")
60+
if (areSameDateTime(expected.textValue(), actual.textValue())) {
61+
return;
62+
}
63+
64+
if (expected.isObject() && actual.isObject()) {
65+
TreeSet<String> allKeys = new TreeSet<>();
66+
expected.fieldNames().forEachRemaining(allKeys::add);
67+
actual.fieldNames().forEachRemaining(allKeys::add);
68+
for (String key : allKeys) {
69+
diffChild(path + "." + key, expected.get(key), actual.get(key), diffs);
70+
}
71+
} else if (expected.isArray() && actual.isArray()) {
72+
for (int i = 0; i < Math.max(expected.size(), actual.size()); i++) {
73+
diffChild(path + "[" + i + "]", expected.get(i), actual.get(i), diffs);
74+
}
75+
} else {
76+
diffs.add("CHANGED " + path + " : " + summarize(expected) + " -> " + summarize(actual));
77+
}
78+
}
79+
80+
/**
81+
* Compares two strings by parsed instant when both look like ISO-8601 dates,
82+
* because DateTimeModule normalizes format on serialization
83+
* (e.g. "+0000" → "Z", "Z" → ".000Z").
84+
*/
85+
private static boolean areSameDateTime(String expected, String actual) {
86+
if (expected == null || actual == null
87+
|| !ISO_DATE_REGEX.matcher(expected).matches()
88+
|| !ISO_DATE_REGEX.matcher(actual).matches()) {
89+
return false;
90+
}
91+
return DateTime.parse(expected).equals(DateTime.parse(actual));
92+
}
93+
94+
private static void diffChild(String path, JsonNode expected, JsonNode actual, List<String> diffs) {
95+
if (expected == null)
96+
diffs.add("ADDED " + path + " = " + summarize(actual));
97+
else if (actual == null)
98+
diffs.add("MISSING " + path + " (was " + summarize(expected) + ")");
99+
else
100+
diffNodes(path, expected, actual, diffs);
101+
}
102+
103+
private static String summarize(JsonNode node) {
104+
if (node == null) {
105+
return "<absent>";
106+
}
107+
String text = node.toString();
108+
return text.length() > 80 ? text.substring(0, 77) + "..." : text;
109+
}
110+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.amazonaws.services.lambda.runtime.tests;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.ByteArrayOutputStream;
5+
import java.io.IOException;
6+
import java.io.InputStream;
7+
import java.io.UncheckedIOException;
8+
import com.amazonaws.services.lambda.runtime.serialization.PojoSerializer;
9+
import com.amazonaws.services.lambda.runtime.serialization.events.LambdaEventSerializers;
10+
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.JsonNode;
11+
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ObjectMapper;
12+
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
16+
/**
17+
* Framework-agnostic assertion utilities for verifying Lambda event
18+
* serialization.
19+
*
20+
* <p>
21+
* When opentest4j is on the classpath (e.g. JUnit 5.x / JUnit Platform),
22+
* assertion failures are reported as
23+
* {@code org.opentest4j.AssertionFailedError}
24+
* which enables rich diff support in IDEs. Otherwise, falls back to plain
25+
* {@link AssertionError}.
26+
* </p>
27+
*
28+
* <p>
29+
* This class is intentionally package-private to support updates to
30+
* the aws-lambda-java-events and aws-lambda-java-serialization packages.
31+
* Consider making it public if there's a real request for it.
32+
* </p>
33+
*/
34+
class LambdaEventAssert {
35+
36+
private static final ObjectMapper MAPPER = new ObjectMapper();
37+
38+
/**
39+
* Round-trip using the registered {@link LambdaEventSerializers} path
40+
* (Jackson + mixins + DateModule + DateTimeModule + naming strategies).
41+
*
42+
* <p>
43+
* The check performs two consecutive round-trips
44+
* (JSON &rarr; POJO &rarr; JSON &rarr; POJO &rarr; JSON) and compares the
45+
* original JSON tree against the final output tree. A single structural
46+
* comparison catches both:
47+
* </p>
48+
* <ul>
49+
* <li>Fields silently dropped during deserialization</li>
50+
* <li>Non-idempotent serialization (output changes across round-trips)</li>
51+
* </ul>
52+
*
53+
* @param fileName classpath resource name (must end with {@code .json})
54+
* @param targetClass the event class to deserialize into
55+
* @throws AssertionError if the original and final JSON trees differ
56+
*/
57+
public static <T> void assertSerializationRoundTrip(String fileName, Class<T> targetClass) {
58+
PojoSerializer<T> serializer = LambdaEventSerializers.serializerFor(targetClass,
59+
ClassLoader.getSystemClassLoader());
60+
61+
if (!fileName.endsWith(".json")) {
62+
throw new IllegalArgumentException("File " + fileName + " must have json extension");
63+
}
64+
65+
byte[] originalBytes;
66+
try (InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName)) {
67+
if (stream == null) {
68+
throw new IllegalArgumentException("Could not load resource '" + fileName + "' from classpath");
69+
}
70+
originalBytes = toBytes(stream);
71+
} catch (IOException e) {
72+
throw new UncheckedIOException("Failed to read resource " + fileName, e);
73+
}
74+
75+
// Two round-trips: original → POJO → JSON → POJO → JSON
76+
// We are doing 2 passes so we can check instability problems
77+
// like UnstablePojo in LambdaEventAssertTest
78+
ByteArrayOutputStream firstOutput = roundTrip(new ByteArrayInputStream(originalBytes), serializer);
79+
ByteArrayOutputStream secondOutput = roundTrip(
80+
new ByteArrayInputStream(firstOutput.toByteArray()), serializer);
81+
82+
// Compare original tree against final tree.
83+
// Strip explicit nulls from the original because the serializer is
84+
// configured with Include.NON_NULL — null fields are intentionally
85+
// omitted and that is not a data-loss bug.
86+
try {
87+
JsonNode originalTree = JsonNodeUtils.stripNulls(MAPPER.readTree(originalBytes));
88+
JsonNode finalTree = MAPPER.readTree(secondOutput.toByteArray());
89+
90+
if (!originalTree.equals(finalTree)) {
91+
List<String> diffs = new ArrayList<>();
92+
JsonNodeUtils.diffNodes("", originalTree, finalTree, diffs);
93+
94+
if (!diffs.isEmpty()) {
95+
StringBuilder msg = new StringBuilder();
96+
msg.append("Serialization round-trip failure for ")
97+
.append(targetClass.getSimpleName())
98+
.append(" (").append(diffs.size()).append(" difference(s)):\n");
99+
for (String diff : diffs) {
100+
msg.append(" ").append(diff).append('\n');
101+
}
102+
103+
String expected = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(originalTree);
104+
String actual = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(finalTree);
105+
throw buildAssertionError(msg.toString(), expected, actual);
106+
}
107+
}
108+
} catch (IOException e) {
109+
throw new UncheckedIOException("Failed to parse JSON for tree comparison", e);
110+
}
111+
}
112+
113+
private static <T> ByteArrayOutputStream roundTrip(InputStream stream, PojoSerializer<T> serializer) {
114+
T event = serializer.fromJson(stream);
115+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
116+
serializer.toJson(event, outputStream);
117+
return outputStream;
118+
}
119+
120+
private static byte[] toBytes(InputStream stream) throws IOException {
121+
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
122+
byte[] chunk = new byte[4096];
123+
int n;
124+
while ((n = stream.read(chunk)) != -1) {
125+
buffer.write(chunk, 0, n);
126+
}
127+
return buffer.toByteArray();
128+
}
129+
130+
/**
131+
* Tries to create an opentest4j AssertionFailedError for rich IDE diff
132+
* support. Falls back to plain AssertionError if opentest4j is not on
133+
* the classpath.
134+
*/
135+
private static AssertionError buildAssertionError(String message, String expected, String actual) {
136+
try {
137+
// opentest4j is provided by JUnit Platform (5.x) and enables
138+
// IDE diff viewers to show expected vs actual side-by-side.
139+
Class<?> cls = Class.forName("org.opentest4j.AssertionFailedError");
140+
return (AssertionError) cls
141+
.getConstructor(String.class, Object.class, Object.class)
142+
.newInstance(message, expected, actual);
143+
} catch (ReflectiveOperationException e) {
144+
return new AssertionError(message + "\nExpected:\n" + expected + "\nActual:\n" + actual);
145+
}
146+
}
147+
}

aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/EventLoaderTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ public void testLoadAPIGatewayV2CustomAuthorizerEvent() {
8181

8282
assertThat(event).isNotNull();
8383
assertThat(event.getRequestContext().getHttp().getMethod()).isEqualTo("POST");
84+
// getTime() converts the raw string "12/Mar/2020:19:03:58 +0000" into a DateTime object;
85+
// Jackson then serializes it as ISO-8601 "2020-03-12T19:03:58.000Z"
86+
assertThat(event.getRequestContext().getTime().toInstant().getMillis())
87+
.isEqualTo(DateTime.parse("2020-03-12T19:03:58.000Z").toInstant().getMillis());
88+
// getTimeEpoch() converts the raw long into an Instant;
89+
// Jackson then serializes it as a decimal seconds value
8490
assertThat(event.getRequestContext().getTimeEpoch()).isEqualTo(Instant.ofEpochMilli(1583348638390L));
8591
}
8692

@@ -136,6 +142,9 @@ public void testLoadLexEvent() {
136142
assertThat(event.getCurrentIntent().getName()).isEqualTo("BookHotel");
137143
assertThat(event.getCurrentIntent().getSlots()).hasSize(4);
138144
assertThat(event.getBot().getName()).isEqualTo("BookTrip");
145+
// Jackson leniently coerces the JSON number for "Nights" into a String
146+
// because slots is typed as Map<String, String>
147+
assertThat(event.getCurrentIntent().getSlots().get("Nights")).isInstanceOf(String.class);
139148
}
140149

141150
@Test
@@ -159,6 +168,10 @@ public void testLoadMSKFirehoseEvent() {
159168
assertThat(event.getRecords().get(0).getKafkaRecordValue().array()).asString().isEqualTo("{\"Name\":\"Hello World\"}");
160169
assertThat(event.getRecords().get(0).getApproximateArrivalTimestamp()).asString().isEqualTo("1716369573887");
161170
assertThat(event.getRecords().get(0).getMskRecordMetadata()).asString().isEqualTo("{offset=0, partitionId=1, approximateArrivalTimestamp=1716369573887}");
171+
// Jackson leniently coerces the JSON number in mskRecordMetadata into a String
172+
// because the map is typed as Map<String, String>
173+
Map<String, String> metadata = event.getRecords().get(0).getMskRecordMetadata();
174+
assertThat(metadata.get("approximateArrivalTimestamp")).isInstanceOf(String.class);
162175
}
163176

164177
@Test
@@ -408,6 +421,8 @@ public void testLoadRabbitMQEvent() {
408421
.returns("AIDACKCEVSQ6C2EXAMPLE", from(RabbitMQEvent.BasicProperties::getUserId))
409422
.returns(80, from(RabbitMQEvent.BasicProperties::getBodySize))
410423
.returns("Jan 1, 1970, 12:33:41 AM", from(RabbitMQEvent.BasicProperties::getTimestamp));
424+
// Jackson leniently coerces the JSON string "60000" for expiration into int
425+
// because the model field is typed as int
411426

412427
Map<String, Object> headers = basicProperties.getHeaders();
413428
assertThat(headers).hasSize(3);

0 commit comments

Comments
 (0)