Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ experimental/aws-lambda-java-profiler/integration_tests/helloworld/bin
.vscode
.kiro
build
mise.toml
2 changes: 0 additions & 2 deletions aws-lambda-java-serialization/mise.toml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@
import com.fasterxml.jackson.databind.module.SimpleModule;

/**
* The AWS API represents a date as a double, which specifies the fractional
* number of seconds since the epoch. Java's Date, however, represents a date as
* a long, which specifies the number of milliseconds since the epoch. This
* class is used to translate between these two formats.
* The AWS API represents a date as a double (fractional seconds since epoch).
* Java's Date uses a long (milliseconds since epoch). This module translates
* between the two formats.
*
* <p>
* <b>Round-trip caveats:</b> The serializer always writes via
* {@link JsonGenerator#writeNumber(double)}, so integer epochs
* (e.g. {@code 1428537600}) round-trip as decimal ({@code 1.4285376E9}).
* Sub-millisecond precision is lost because {@link java.util.Date}
* has milliseconds precision.
* </p>
*
* This class is copied from LambdaEventBridgeservice
* com.amazon.aws.lambda.stream.ddb.DateModule
Expand Down
4 changes: 4 additions & 0 deletions aws-lambda-java-tests/RELEASE.CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### March 26, 2026
`1.1.2`:
- Add serialization round-trip tests covering 66 event classes

### August 26, 2021
`1.1.1`:
- Bumped `aws-lambda-java-events` to version `3.11.0`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.amazonaws.services.lambda.runtime.tests;

import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.JsonNode;
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Iterator;
import java.util.List;
import java.util.TreeSet;
import java.util.regex.Pattern;

import org.joda.time.DateTime;

/**
* Utility methods for working with shaded Jackson {@link JsonNode} trees.
*
* <p>
* Package-private — not part of the public API.
* </p>
*/
class JsonNodeUtils {

private static final Pattern ISO_DATE_REGEX = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T.+");

private JsonNodeUtils() {
}

/**
* Recursively removes all fields whose value is {@code null} from the
* tree. This mirrors the serializer's {@code Include.NON_NULL} behaviour
* so that explicit nulls in the fixture don't cause false-positive diffs.
*/
static JsonNode stripNulls(JsonNode node) {
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
Iterator<String> fieldNames = obj.fieldNames();
while (fieldNames.hasNext()) {
String field = fieldNames.next();
if (obj.get(field).isNull()) {
fieldNames.remove();
} else {
stripNulls(obj.get(field));
}
}
} else if (node.isArray()) {
for (JsonNode element : node) {
stripNulls(element);
}
}
return node;
}

/**
* Recursively walks both trees and collects human-readable diff lines.
*/
static void diffNodes(String path, JsonNode expected, JsonNode actual, List<String> diffs) {
if (expected.equals(actual))
return;

// Compares two datetime strings by parsed instant, because DateTimeModule
// normalizes the format on serialization (e.g. "+0000" → "Z", "Z" → ".000Z")
if (areSameDateTime(expected.textValue(), actual.textValue())) {
return;
}

if (expected.isObject() && actual.isObject()) {
TreeSet<String> allKeys = new TreeSet<>();
expected.fieldNames().forEachRemaining(allKeys::add);
actual.fieldNames().forEachRemaining(allKeys::add);
for (String key : allKeys) {
diffChild(path + "." + key, expected.get(key), actual.get(key), diffs);
}
} else if (expected.isArray() && actual.isArray()) {
for (int i = 0; i < Math.max(expected.size(), actual.size()); i++) {
diffChild(path + "[" + i + "]", expected.get(i), actual.get(i), diffs);
}
} else {
diffs.add("CHANGED " + path + " : " + summarize(expected) + " -> " + summarize(actual));
}
}

/**
* Compares two strings by parsed instant when both look like ISO-8601 dates,
* because DateTimeModule normalizes format on serialization
* (e.g. "+0000" → "Z", "Z" → ".000Z").
*/
private static boolean areSameDateTime(String expected, String actual) {
if (expected == null || actual == null
|| !ISO_DATE_REGEX.matcher(expected).matches()
|| !ISO_DATE_REGEX.matcher(actual).matches()) {
return false;
}
return DateTime.parse(expected).equals(DateTime.parse(actual));
}

private static void diffChild(String path, JsonNode expected, JsonNode actual, List<String> diffs) {
if (expected == null)
diffs.add("ADDED " + path + " = " + summarize(actual));
else if (actual == null)
diffs.add("MISSING " + path + " (was " + summarize(expected) + ")");
else
diffNodes(path, expected, actual, diffs);
}

private static String summarize(JsonNode node) {
if (node == null) {
return "<absent>";
}
String text = node.toString();
return text.length() > 80 ? text.substring(0, 77) + "..." : text;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.amazonaws.services.lambda.runtime.tests;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import com.amazonaws.services.lambda.runtime.serialization.PojoSerializer;
import com.amazonaws.services.lambda.runtime.serialization.events.LambdaEventSerializers;
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.JsonNode;
import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.ObjectMapper;

import java.util.ArrayList;
import java.util.List;

/**
* Framework-agnostic assertion utilities for verifying Lambda event
* serialization.
*
* <p>
* When opentest4j is on the classpath (e.g. JUnit 5.x / JUnit Platform),
* assertion failures are reported as
* {@code org.opentest4j.AssertionFailedError}
* which enables rich diff support in IDEs. Otherwise, falls back to plain
* {@link AssertionError}.
* </p>
*
* <p>
* This class is intentionally package-private to support updates to
* the aws-lambda-java-events and aws-lambda-java-serialization packages.
* Consider making it public if there's a real request for it.
* </p>
*/
class LambdaEventAssert {

private static final ObjectMapper MAPPER = new ObjectMapper();

/**
* Round-trip using the registered {@link LambdaEventSerializers} path
* (Jackson + mixins + DateModule + DateTimeModule + naming strategies).
*
* <p>
* The check performs two consecutive round-trips
* (JSON &rarr; POJO &rarr; JSON &rarr; POJO &rarr; JSON) and compares the
* original JSON tree against the final output tree. A single structural
* comparison catches both:
* </p>
* <ul>
* <li>Fields silently dropped during deserialization</li>
* <li>Non-idempotent serialization (output changes across round-trips)</li>
* </ul>
*
* @param fileName classpath resource name (must end with {@code .json})
* @param targetClass the event class to deserialize into
* @throws AssertionError if the original and final JSON trees differ
*/
public static <T> void assertSerializationRoundTrip(String fileName, Class<T> targetClass) {
PojoSerializer<T> serializer = LambdaEventSerializers.serializerFor(targetClass,
ClassLoader.getSystemClassLoader());

if (!fileName.endsWith(".json")) {
throw new IllegalArgumentException("File " + fileName + " must have json extension");
}

byte[] originalBytes;
try (InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName)) {
if (stream == null) {
throw new IllegalArgumentException("Could not load resource '" + fileName + "' from classpath");
}
originalBytes = toBytes(stream);
} catch (IOException e) {
throw new UncheckedIOException("Failed to read resource " + fileName, e);
}

// Two round-trips: original → POJO → JSON → POJO → JSON
// We are doing 2 passes so we can check instability problems
// like UnstablePojo in LambdaEventAssertTest
ByteArrayOutputStream firstOutput = roundTrip(new ByteArrayInputStream(originalBytes), serializer);
ByteArrayOutputStream secondOutput = roundTrip(
new ByteArrayInputStream(firstOutput.toByteArray()), serializer);

// Compare original tree against final tree.
// Strip explicit nulls from the original because the serializer is
// configured with Include.NON_NULL — null fields are intentionally
// omitted and that is not a data-loss bug.
try {
JsonNode originalTree = JsonNodeUtils.stripNulls(MAPPER.readTree(originalBytes));
JsonNode finalTree = MAPPER.readTree(secondOutput.toByteArray());

if (!originalTree.equals(finalTree)) {
List<String> diffs = new ArrayList<>();
JsonNodeUtils.diffNodes("", originalTree, finalTree, diffs);

if (!diffs.isEmpty()) {
StringBuilder msg = new StringBuilder();
msg.append("Serialization round-trip failure for ")
.append(targetClass.getSimpleName())
.append(" (").append(diffs.size()).append(" difference(s)):\n");
for (String diff : diffs) {
msg.append(" ").append(diff).append('\n');
}

String expected = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(originalTree);
String actual = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(finalTree);
throw buildAssertionError(msg.toString(), expected, actual);
}
}
} catch (IOException e) {
throw new UncheckedIOException("Failed to parse JSON for tree comparison", e);
}
}

private static <T> ByteArrayOutputStream roundTrip(InputStream stream, PojoSerializer<T> serializer) {
T event = serializer.fromJson(stream);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
serializer.toJson(event, outputStream);
return outputStream;
}

private static byte[] toBytes(InputStream stream) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = stream.read(chunk)) != -1) {
buffer.write(chunk, 0, n);
}
return buffer.toByteArray();
}

/**
* Tries to create an opentest4j AssertionFailedError for rich IDE diff
* support. Falls back to plain AssertionError if opentest4j is not on
* the classpath.
*/
private static AssertionError buildAssertionError(String message, String expected, String actual) {
try {
// opentest4j is provided by JUnit Platform (5.x) and enables
// IDE diff viewers to show expected vs actual side-by-side.
Class<?> cls = Class.forName("org.opentest4j.AssertionFailedError");
return (AssertionError) cls
.getConstructor(String.class, Object.class, Object.class)
.newInstance(message, expected, actual);
} catch (ReflectiveOperationException e) {
return new AssertionError(message + "\nExpected:\n" + expected + "\nActual:\n" + actual);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public void testLoadAPIGatewayV2CustomAuthorizerEvent() {

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

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

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

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

Map<String, Object> headers = basicProperties.getHeaders();
assertThat(headers).hasSize(3);
Expand Down
Loading
Loading