Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
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