From 4570767df909c778d960e2bcc1c55b07660b4860 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Fri, 11 Sep 2026 18:45:29 +0000 Subject: [PATCH 1/4] fix: remove unused Platform serialization code path --- .../api/client/EventHandlerLoader.java | 68 +++---------------- .../api/client/EventHandlerLoaderTest.java | 17 +++++ .../test/lambda/handlers/POJOHanlderImpl.java | 14 ++++ 3 files changed, 42 insertions(+), 57 deletions(-) diff --git a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java index f679c217c..9eb7efe7b 100644 --- a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java +++ b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java @@ -5,7 +5,6 @@ package com.amazonaws.services.lambda.runtime.api.client; -import com.amazonaws.services.lambda.runtime.ClientContext; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.LambdaLogger; import com.amazonaws.services.lambda.runtime.LambdaRuntimeInternal; @@ -38,11 +37,9 @@ import java.lang.reflect.TypeVariable; import java.util.Arrays; import java.util.Comparator; -import java.util.EnumMap; import java.util.HashMap; import java.util.LinkedList; import java.util.Map; -import java.util.Objects; import java.util.Optional; import static com.amazonaws.services.lambda.runtime.api.client.UserFault.filterStackTrace; import static com.amazonaws.services.lambda.runtime.api.client.UserFault.makeUserFault; @@ -51,16 +48,10 @@ public final class EventHandlerLoader { private static final byte[] _JsonNull = new byte[]{'n', 'u', 'l', 'l'}; - private enum Platform { - ANDROID, - IOS, - UNKNOWN - } - private static volatile ThreadLocal> contextSerializer = new ThreadLocal<>(); private static volatile ThreadLocal> cognitoSerializer = new ThreadLocal<>(); - private static final ThreadLocal>>> typeCache = ThreadLocal.withInitial(() -> new EnumMap<>(Platform.class)); + private static final ThreadLocal>> typeCache = ThreadLocal.withInitial(HashMap::new); private static final Comparator methodPriority = new Comparator() { public int compare(Method lhs, Method rhs) { @@ -97,16 +88,14 @@ private EventHandlerLoader() { } /** - * returns the appropriate serializer for the class based on platform and whether the class is a supported event + * returns the appropriate serializer for the class based on whether the class is a supported event * - * @param platform enum platform - * @param type Type of object used + * @param type Type of object used * @return PojoSerializer - * @see Platform for which platforms are used * @see LambdaEventSerializers for how mixins and modules are added to the serializer */ @SuppressWarnings({"unchecked", "rawtypes"}) - private static PojoSerializer getSerializer(Platform platform, Type type) { + private static PojoSerializer getSerializer(Type type) { PojoSerializer customSerializer = PojoSerializerLoader.getCustomerSerializer(type); if (customSerializer != null) { return customSerializer; @@ -119,24 +108,15 @@ private static PojoSerializer getSerializer(Platform platform, Type type return LambdaEventSerializers.serializerFor(clazz, AWSLambda.getCustomerClassLoader()); } } - // else platform dependent (Android uses GSON but all other platforms use Jackson) - if (Objects.requireNonNull(platform) == Platform.ANDROID) { - return GsonFactory.getInstance().getSerializer(type); - } return JacksonFactory.getInstance().getSerializer(type); } - private static PojoSerializer getSerializerCached(Platform platform, Type type) { - EnumMap>> threadTypeCache = typeCache.get(); - Map> cache = threadTypeCache.get(platform); - if (cache == null) { - cache = new HashMap<>(); - threadTypeCache.put(platform, cache); - } + private static PojoSerializer getSerializerCached(Type type) { + Map> cache = typeCache.get(); PojoSerializer serializer = cache.get(type); if (serializer == null) { - serializer = getSerializer(platform, type); + serializer = getSerializer(type); cache.put(type, serializer); } @@ -158,31 +138,6 @@ private static PojoSerializer getCognitoSerializer() { } - private static Platform getPlatform(Context context) { - ClientContext cc = context.getClientContext(); - if (cc == null) { - return Platform.UNKNOWN; - } - - Map env = cc.getEnvironment(); - if (env == null) { - return Platform.UNKNOWN; - } - - String platform = env.get("platform"); - if (platform == null) { - return Platform.UNKNOWN; - } - - if ("Android".equalsIgnoreCase(platform)) { - return Platform.ANDROID; - } else if ("iPhoneOS".equalsIgnoreCase(platform)) { - return Platform.IOS; - } else { - return Platform.UNKNOWN; - } - } - private static boolean isVoid(Type type) { return Void.TYPE.equals(type) || (type instanceof Class) && Void.class.isAssignableFrom((Class) type); } @@ -629,11 +584,11 @@ public PojoHandlerAsStreamHandler( if (inputType.isPresent()) { - getSerializerCached(Platform.UNKNOWN, inputType.get()); + getSerializerCached(inputType.get()); } if (outputType.isPresent()) { - getSerializerCached(Platform.UNKNOWN, outputType.get()); + getSerializerCached(outputType.get()); } } @@ -642,10 +597,9 @@ public PojoHandlerAsStreamHandler( public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException { final Object input; - final Platform platform = getPlatform(context); try { if (inputType.isPresent()) { - input = getSerializerCached(platform, inputType.get()).fromJson(inputStream); + input = getSerializerCached(inputType.get()).fromJson(inputStream); } else { input = null; } @@ -662,7 +616,7 @@ public void handleRequest(InputStream inputStream, OutputStream outputStream, Co try { if (outputType.isPresent()) { - PojoSerializer serializer = getSerializerCached(platform, outputType.get()); + PojoSerializer serializer = getSerializerCached(outputType.get()); serializer.toJson(output, outputStream); } else { outputStream.write(_JsonNull); diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java index aae2f1afe..4d39b6696 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class EventHandlerLoaderTest { @@ -59,6 +60,22 @@ void PojoHandlerTest_twoParams() throws Exception { assertSuccessfulInvocation(lambdaRequestHandler); } + @Test + void PojoHandler_outputSerializer_ignoresClientContextPlatform() throws Exception { + LambdaRequestHandler handler = + getLambdaRequestHandler("test.lambda.handlers.POJOHanlderImpl::pojoOutputHandler"); + + InvocationRequest request = getTestInvocationRequest(); + request.setClientContext("{\"env\":{\"platform\":\"Android\"}}"); + + String result = handler.call(request).toString(); + + assertTrue(result.contains("beanProperty"), + "expected property-based (Jackson) serialization, got: " + result); + assertFalse(result.contains("internalField"), + "caller ClientContext must not switch serialization to field-based (Gson), got: " + result); + } + private LambdaRequestHandler getLambdaRequestHandler(String handler) throws ClassNotFoundException { ClassLoader cl = this.getClass().getClassLoader(); HandlerInfo handlerInfo = HandlerInfo.fromString(handler, cl); diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java index ca1a6bd4f..7b2c7cea9 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java @@ -23,4 +23,18 @@ public String oneParamHandler_context(Context context) { public String twoParamsHandler(String event, Context context) { return "success"; } + + @SuppressWarnings("unused") + public PojoOutput pojoOutputHandler(String event) { + return new PojoOutput(); + } + + @SuppressWarnings("unused") + public static class PojoOutput { + private final String internalField = "field-based-value"; + + public String getBeanProperty() { + return "property-based-value"; + } + } } From 3b539c82936739d526ce84f53b39ebbb3b235db9 Mon Sep 17 00:00:00 2001 From: Davide Melfi Date: Wed, 30 Sep 2026 11:04:14 +0000 Subject: [PATCH 2/4] test: add new test checking old/new serialization added a new test that checks a case that would have been serialized on input on GSON. --- .../api/client/EventHandlerLoaderTest.java | 12 ++++++++++ .../test/lambda/handlers/POJOHanlderImpl.java | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java index 4d39b6696..113c6d55b 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java @@ -76,6 +76,18 @@ void PojoHandler_outputSerializer_ignoresClientContextPlatform() throws Exceptio "caller ClientContext must not switch serialization to field-based (Gson), got: " + result); } + @Test + void PojoHandler_inputSerializer_bindsOnlyBeanProperties() throws Exception { + LambdaRequestHandler handler = + getLambdaRequestHandler("test.lambda.handlers.POJOHanlderImpl::pojoInputHandler"); + + InvocationRequest request = getTestInvocationRequest(); + request.setClientContext("{\"env\":{\"platform\":\"Android\"}}"); + request.setContent("{\"name\":\"x\",\"locked\":true}".getBytes()); + + assertEquals("false", handler.call(request).toString()); + } + private LambdaRequestHandler getLambdaRequestHandler(String handler) throws ClassNotFoundException { ClassLoader cl = this.getClass().getClassLoader(); HandlerInfo handlerInfo = HandlerInfo.fromString(handler, cl); diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java index 7b2c7cea9..c6402dbb3 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java @@ -37,4 +37,27 @@ public String getBeanProperty() { return "property-based-value"; } } + + @SuppressWarnings("unused") + public boolean pojoInputHandler(PojoInput input) { + return input.isLocked(); + } + + @SuppressWarnings("unused") + public static class PojoInput { + private boolean locked = false; + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isLocked() { + return locked; + } + } } From 1e90d342f964f32127c064398f821e3e0dcc1062 Mon Sep 17 00:00:00 2001 From: Davide Melfi Date: Wed, 30 Sep 2026 11:10:24 +0000 Subject: [PATCH 3/4] chore: update release note --- aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md index 9ca18e745..7ffd5658d 100644 --- a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md +++ b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md @@ -1,3 +1,7 @@ +### September 30, 2026 +`2.13.0` +- **Behavior change:** POJO handler input and output are now always serialized with the runtime's Jackson serializer. Previously, invocations whose client context reported `env.platform` as `Android` (as set by the archived AWS Mobile SDK for Android `LambdaInvokerFactory`) were serialized with Gson, which binds fields directly instead of bean properties. Handlers invoked this way may see different JSON for types that rely on private fields without getters and setters. To keep the previous format, add getters and setters (or public fields) to the affected types. If that is not possible, register a [`CustomPojoSerializer`](https://github.com/aws/aws-lambda-java-libs/tree/main/samples/custom-serialization/gson); note that a custom serializer applies to every invocation of the function, not only to Android callers. + ### September 2, 2026 `2.12.1` - Emit a structured `runtime_worker_pool_initializing` DEBUG log event once during INIT in multi-concurrent (Lambda Managed Instances) mode, reporting the worker pool size (`workerCount`) and the maximum concurrency the execution environment supports (`executionEnvironmentMaxConcurrency`). Only visible when the function log level is DEBUG or lower; not emitted for standard on-demand functions. From efc77ff9ba8197c8081b358f2df12174f060766f Mon Sep 17 00:00:00 2001 From: Davide Melfi Date: Fri, 9 Oct 2026 11:28:58 +0000 Subject: [PATCH 4/4] test: added some tests --- .../RELEASE.CHANGELOG.md | 12 +- .../api/client/EventHandlerLoader.java | 3 +- .../api/client/EventHandlerLoaderTest.java | 42 +- .../test/lambda/handlers/POJOHanlderImpl.java | 4 +- .../GsonToJacksonPojoSerializationTest.java | 505 ++++++++++++++++++ 5 files changed, 548 insertions(+), 18 deletions(-) create mode 100644 aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/GsonToJacksonPojoSerializationTest.java diff --git a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md index 7ffd5658d..f95e951f9 100644 --- a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md +++ b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md @@ -1,6 +1,16 @@ ### September 30, 2026 `2.13.0` -- **Behavior change:** POJO handler input and output are now always serialized with the runtime's Jackson serializer. Previously, invocations whose client context reported `env.platform` as `Android` (as set by the archived AWS Mobile SDK for Android `LambdaInvokerFactory`) were serialized with Gson, which binds fields directly instead of bean properties. Handlers invoked this way may see different JSON for types that rely on private fields without getters and setters. To keep the previous format, add getters and setters (or public fields) to the affected types. If that is not possible, register a [`CustomPojoSerializer`](https://github.com/aws/aws-lambda-java-libs/tree/main/samples/custom-serialization/gson); note that a custom serializer applies to every invocation of the function, not only to Android callers. +- **Behavior change:** POJO handler input and output are now always serialized with the runtime's Jackson serializer. Previously, invocations whose client context reported `env.platform` as `Android` (as set by the archived AWS Mobile SDK for Android `LambdaInvokerFactory`) were serialized with Gson, which binds fields directly instead of bean properties. Invocations without that client context are unaffected. Android callers now receive the same JSON as every other caller, which differs from before as follows: + - Private fields without getters or setters are no longer read from the request or written to the response. Add getters and setters, or make the fields public. + - Response keys follow getter names instead of field names: a field `isActive` with `isActive()` becomes `active`, and a field `mName` with `getName()` becomes `name`. Name the accessors after the JSON key (`getIsActive`), or use a public field with that name. + - Getters without a backing field, and `transient` fields that have getters, add keys to the response. Rename methods that should not be serialized so they are not getters. + - Fields with a getter but no setter keep their default value instead of the request value. Add a setter. + - Types without a no-argument constructor fail the invocation with `An error occurred during JSON parsing`. Add a no-argument constructor and setters. + - Numbers in `Object` or `Map` fields are read as `Integer`, `Long` or `Double` instead of always `Double`, so `1` is no longer echoed as `1.0`. + - `java.util.Date` values are written as numbers instead of formatted text. + - All of the above also apply to nested objects and list elements. + + Each case and its fix is covered in [`GsonToJacksonPojoSerializationTest`](https://github.com/aws/aws-lambda-java-libs/blob/main/aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/GsonToJacksonPojoSerializationTest.java). If changing the types is not possible, register a [`CustomPojoSerializer`](https://github.com/aws/aws-lambda-java-libs/tree/main/samples/custom-serialization/gson); note that a custom serializer applies to every invocation of the function, not only to Android callers. ### September 2, 2026 `2.12.1` diff --git a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java index 9eb7efe7b..85165ae42 100644 --- a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java +++ b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoader.java @@ -88,7 +88,8 @@ private EventHandlerLoader() { } /** - * returns the appropriate serializer for the class based on whether the class is a supported event + * returns the serializer for the type: the customer's CustomPojoSerializer if one is registered, the event + * serializer if the type is a supported Lambda event, and Jackson otherwise * * @param type Type of object used * @return PojoSerializer diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java index 113c6d55b..afe7e9dd2 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/EventHandlerLoaderTest.java @@ -2,6 +2,8 @@ import com.amazonaws.services.lambda.runtime.api.client.runtimeapi.dto.InvocationRequest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.io.ByteArrayOutputStream; import java.util.ArrayList; @@ -11,9 +13,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class EventHandlerLoaderTest { @@ -60,32 +62,44 @@ void PojoHandlerTest_twoParams() throws Exception { assertSuccessfulInvocation(lambdaRequestHandler); } - @Test - void PojoHandler_outputSerializer_ignoresClientContextPlatform() throws Exception { + // Before 2.13.0, a client context with env.platform equal to "Android" (case-insensitive) switched POJO + // serialization to Gson, which binds fields: pojoOutputHandler returned {"internalField":"field-based-value"} + // and pojoInputHandler returned "x:true". Every client context must now produce the Jackson result. + + static Stream clientContexts() { + return Stream.of( + null, + "{\"env\":{\"platform\":\"Android\"}}", + "{\"env\":{\"platform\":\"android\"}}", + "{\"env\":{\"platform\":\"iPhoneOS\"}}", + "{\"env\":{}}", + "{}"); + } + + @ParameterizedTest(name = "clientContext={0}") + @MethodSource("clientContexts") + void PojoHandler_outputSerializer_ignoresClientContextPlatform(String clientContext) throws Exception { LambdaRequestHandler handler = getLambdaRequestHandler("test.lambda.handlers.POJOHanlderImpl::pojoOutputHandler"); InvocationRequest request = getTestInvocationRequest(); - request.setClientContext("{\"env\":{\"platform\":\"Android\"}}"); - - String result = handler.call(request).toString(); + request.setClientContext(clientContext); - assertTrue(result.contains("beanProperty"), - "expected property-based (Jackson) serialization, got: " + result); - assertFalse(result.contains("internalField"), - "caller ClientContext must not switch serialization to field-based (Gson), got: " + result); + assertEquals("{\"beanProperty\":\"property-based-value\"}", handler.call(request).toString()); } - @Test - void PojoHandler_inputSerializer_bindsOnlyBeanProperties() throws Exception { + @ParameterizedTest(name = "clientContext={0}") + @MethodSource("clientContexts") + void PojoHandler_inputSerializer_ignoresClientContextPlatform(String clientContext) throws Exception { LambdaRequestHandler handler = getLambdaRequestHandler("test.lambda.handlers.POJOHanlderImpl::pojoInputHandler"); InvocationRequest request = getTestInvocationRequest(); - request.setClientContext("{\"env\":{\"platform\":\"Android\"}}"); + request.setClientContext(clientContext); request.setContent("{\"name\":\"x\",\"locked\":true}".getBytes()); - assertEquals("false", handler.call(request).toString()); + // name has a setter and binds; locked has only a getter and keeps its default + assertEquals("\"x:false\"", handler.call(request).toString()); } private LambdaRequestHandler getLambdaRequestHandler(String handler) throws ClassNotFoundException { diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java index c6402dbb3..a6b628b60 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/test/lambda/handlers/POJOHanlderImpl.java @@ -39,8 +39,8 @@ public String getBeanProperty() { } @SuppressWarnings("unused") - public boolean pojoInputHandler(PojoInput input) { - return input.isLocked(); + public String pojoInputHandler(PojoInput input) { + return input.getName() + ":" + input.isLocked(); } @SuppressWarnings("unused") diff --git a/aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/GsonToJacksonPojoSerializationTest.java b/aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/GsonToJacksonPojoSerializationTest.java new file mode 100644 index 000000000..4b04b684d --- /dev/null +++ b/aws-lambda-java-tests/src/test/java/com/amazonaws/services/lambda/runtime/tests/GsonToJacksonPojoSerializationTest.java @@ -0,0 +1,505 @@ +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.ObjectMapper; +import com.amazonaws.lambda.thirdparty.com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import com.amazonaws.services.lambda.runtime.serialization.PojoSerializer; +import com.amazonaws.services.lambda.runtime.serialization.factories.GsonFactory; +import com.amazonaws.services.lambda.runtime.serialization.factories.JacksonFactory; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pins the POJO serialization differences between Gson, used before RIC 2.13.0 for + * invocations whose client context reported {@code env.platform = Android}, and Jackson, + * used for every invocation since. Each pattern asserts the old and new JSON, and a + * type change that makes Jackson produce the old JSON. + * + *

+ * The runtime relocates Gson and Jackson, so customer {@code @SerializedName} and + * {@code @JsonProperty} annotations are ignored by both and are not covered here. + *

+ * + *

+ * Delete this class when {@code GsonFactory} is removed. + *

+ */ +public class GsonToJacksonPojoSerializationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 1. Private fields without getters or setters. + // Symptom: request fields arrive as defaults and the response is {}. + // Fix: add getters and setters (or make the fields public). + + public static class PrivateFields { + private String name; + } + + public static class PrivateFieldsFixed { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Test + void privateFields_areDroppedByJackson() { + assertEquals("x", gson(PrivateFields.class).fromJson("{\"name\":\"x\"}").name); + assertNull(jackson(PrivateFields.class).fromJson("{\"name\":\"x\"}").name); + + PrivateFields value = new PrivateFields(); + value.name = "x"; + assertEquals("{\"name\":\"x\"}", toJson(gson(PrivateFields.class), value)); + assertEquals("{}", toJson(jackson(PrivateFields.class), value)); + } + + @Test + void privateFields_fix_addGettersAndSetters() { + assertEquals("x", jackson(PrivateFieldsFixed.class).fromJson("{\"name\":\"x\"}").getName()); + + PrivateFieldsFixed value = new PrivateFieldsFixed(); + value.setName("x"); + assertEquals("{\"name\":\"x\"}", toJson(jackson(PrivateFieldsFixed.class), value)); + } + + // 2. Field name differs from the getter name, such as the Android "m" prefix. + // Symptom: the JSON key changes from "mName" to "name". + // Fix: expose a public field named after the JSON key. Renaming the accessors to getMName and + // setMName does not work: the runtime's mapper uses USE_STD_BEAN_NAMING, which maps them to "MName". + + public static class PrefixedField { + private String mName; + + public String getName() { + return mName; + } + + public void setName(String name) { + this.mName = name; + } + } + + public static class PrefixedFieldFixed { + public String mName; + } + + @Test + void prefixedField_keyChangesUnderJackson() { + PrefixedField value = new PrefixedField(); + value.setName("x"); + assertEquals("{\"mName\":\"x\"}", toJson(gson(PrefixedField.class), value)); + assertEquals("{\"name\":\"x\"}", toJson(jackson(PrefixedField.class), value)); + + assertNull(jackson(PrefixedField.class).fromJson("{\"mName\":\"x\"}").getName()); + } + + @Test + void prefixedField_fix_publicFieldNamedAfterTheKey() { + assertEquals("x", jackson(PrefixedFieldFixed.class).fromJson("{\"mName\":\"x\"}").mName); + + PrefixedFieldFixed value = new PrefixedFieldFixed(); + value.mName = "x"; + assertEquals("{\"mName\":\"x\"}", toJson(jackson(PrefixedFieldFixed.class), value)); + } + + // 3. Boolean field named isX with an isX() getter. + // Symptom: the JSON key changes from "isActive" to "active". + // Fix: name the accessors getIsActive and setIsActive. + + public static class BooleanIsField { + private boolean isActive; + + public boolean isActive() { + return isActive; + } + + public void setActive(boolean active) { + this.isActive = active; + } + } + + public static class BooleanIsFieldFixed { + private boolean isActive; + + public boolean getIsActive() { + return isActive; + } + + public void setIsActive(boolean isActive) { + this.isActive = isActive; + } + } + + @Test + void booleanIsField_keyChangesUnderJackson() { + BooleanIsField value = new BooleanIsField(); + value.setActive(true); + assertEquals("{\"isActive\":true}", toJson(gson(BooleanIsField.class), value)); + assertEquals("{\"active\":true}", toJson(jackson(BooleanIsField.class), value)); + } + + @Test + void booleanIsField_fix_getIsAccessors() { + assertTrue(jackson(BooleanIsFieldFixed.class).fromJson("{\"isActive\":true}").getIsActive()); + + BooleanIsFieldFixed value = new BooleanIsFieldFixed(); + value.setIsActive(true); + assertEquals("{\"isActive\":true}", toJson(jackson(BooleanIsFieldFixed.class), value)); + } + + // 4. No no-argument constructor. + // Symptom: the invocation fails while deserializing the request. + // Fix: add a no-argument constructor and setters. + + public static class NoDefaultConstructor { + private final String name; + + public NoDefaultConstructor(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + public static class NoDefaultConstructorFixed { + private String name; + + public NoDefaultConstructorFixed() { + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Test + void noDefaultConstructor_failsUnderJackson() { + assertEquals("x", gson(NoDefaultConstructor.class).fromJson("{\"name\":\"x\"}").getName()); + UncheckedIOException e = assertThrows(UncheckedIOException.class, + () -> jackson(NoDefaultConstructor.class).fromJson("{\"name\":\"x\"}")); + assertInstanceOf(InvalidDefinitionException.class, e.getCause()); + } + + @Test + void noDefaultConstructor_fix_addNoArgConstructor() { + assertEquals("x", jackson(NoDefaultConstructorFixed.class).fromJson("{\"name\":\"x\"}").getName()); + } + + // 5. java.util.Date. + // Symptom: dates change from a formatted string to epoch milliseconds. + // Fix: use a String (formatted by the handler) or a long, so both sides agree on the format. + + public static class DateField { + private Date createdAt; + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + } + + public static class DateFieldFixed { + private long createdAt; + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + } + + @Test + void dateField_formatChangesUnderJackson() { + DateField value = new DateField(); + value.setCreatedAt(new Date(1577836800000L)); + + // Only the JSON type is checked. Gson's text depends on the JVM locale and time zone, and + // Jackson writes millis until any serializer from LambdaEventSerializers registers DateModule + // on the shared mapper, after which it writes seconds. + assertTrue(tree(toJson(gson(DateField.class), value)).get("createdAt").isTextual()); + assertTrue(tree(toJson(jackson(DateField.class), value)).get("createdAt").isNumber()); + } + + @Test + void dateField_fix_useEpochMillis() { + DateFieldFixed value = new DateFieldFixed(); + value.setCreatedAt(1577836800000L); + assertEquals(toJson(gson(DateFieldFixed.class), value), toJson(jackson(DateFieldFixed.class), value)); + } + + // 6. Getter without a backing field. + // Symptom: the response gains a key for every computed getter. + // Fix: rename the method so it is not a getter. + + public static class ComputedGetter { + public String name; + + public String getDisplayName() { + return "Name: " + name; + } + } + + public static class ComputedGetterFixed { + public String name; + + public String displayName() { + return "Name: " + name; + } + } + + @Test + void computedGetter_addsKeyUnderJackson() { + ComputedGetter value = new ComputedGetter(); + value.name = "x"; + assertEquals("{\"name\":\"x\"}", toJson(gson(ComputedGetter.class), value)); + assertEquals("{\"name\":\"x\",\"displayName\":\"Name: x\"}", toJson(jackson(ComputedGetter.class), value)); + } + + @Test + void computedGetter_fix_renameMethod() { + ComputedGetterFixed value = new ComputedGetterFixed(); + value.name = "x"; + assertEquals("{\"name\":\"x\"}", toJson(jackson(ComputedGetterFixed.class), value)); + } + + // 7. Getter without a setter. + // Symptom: the request value is ignored and the field keeps its default. + // Fix: add a setter. + + public static class ReadOnlyProperty { + private boolean locked; + + public boolean isLocked() { + return locked; + } + } + + public static class ReadOnlyPropertyFixed { + private boolean locked; + + public boolean isLocked() { + return locked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + } + + @Test + void readOnlyProperty_inputIgnoredByJackson() { + assertTrue(gson(ReadOnlyProperty.class).fromJson("{\"locked\":true}").isLocked()); + assertFalse(jackson(ReadOnlyProperty.class).fromJson("{\"locked\":true}").isLocked()); + } + + @Test + void readOnlyProperty_fix_addSetter() { + assertTrue(jackson(ReadOnlyPropertyFixed.class).fromJson("{\"locked\":true}").isLocked()); + } + + // 8. Transient field with a getter. + // Symptom: the response gains a key for the transient field, as it already did for non-Android callers. + // Fix: rename the accessors so they are not a bean property. + + public static class TransientField { + public String name; + private transient String cache; + + public String getCache() { + return cache; + } + + public void setCache(String cache) { + this.cache = cache; + } + } + + public static class TransientFieldFixed { + public String name; + private transient String cache; + + public String cache() { + return cache; + } + + public void cache(String cache) { + this.cache = cache; + } + } + + @Test + void transientField_writtenByJackson() { + TransientField value = new TransientField(); + value.name = "x"; + value.setCache("y"); + assertEquals("{\"name\":\"x\"}", toJson(gson(TransientField.class), value)); + assertEquals("{\"name\":\"x\",\"cache\":\"y\"}", toJson(jackson(TransientField.class), value)); + } + + @Test + void transientField_fix_renameAccessors() { + TransientFieldFixed value = new TransientFieldFixed(); + value.name = "x"; + value.cache("y"); + assertEquals("{\"name\":\"x\"}", toJson(jackson(TransientFieldFixed.class), value)); + } + + // 9. Numbers inside Object or Map. + // Symptom: Gson read every number as Double, Jackson reads Integer, Long or Double, so casts to + // Double throw ClassCastException and echoed values change from 1.0 to 1. + // Fix: declare the value type, or read values through Number. + + public static class UntypedMap { + public Map attributes; + } + + public static class UntypedMapFixed { + public Map attributes; + } + + @Test + void untypedMap_numberTypeChangesUnderJackson() { + String json = "{\"attributes\":{\"count\":1}}"; + UntypedMap fromGson = gson(UntypedMap.class).fromJson(json); + UntypedMap fromJackson = jackson(UntypedMap.class).fromJson(json); + + assertEquals(Double.class, fromGson.attributes.get("count").getClass()); + assertEquals(Integer.class, fromJackson.attributes.get("count").getClass()); + + assertEquals("{\"attributes\":{\"count\":1.0}}", toJson(gson(UntypedMap.class), fromGson)); + assertEquals("{\"attributes\":{\"count\":1}}", toJson(jackson(UntypedMap.class), fromJackson)); + } + + @Test + void untypedMap_fix_declareValueType() { + String json = "{\"attributes\":{\"count\":1}}"; + assertEquals(Integer.valueOf(1), gson(UntypedMapFixed.class).fromJson(json).attributes.get("count")); + assertEquals(Integer.valueOf(1), jackson(UntypedMapFixed.class).fromJson(json).attributes.get("count")); + } + + // 10. Nested types. + // Symptom: patterns 1 to 9 apply to every nested object and list element, even when the + // top-level type has getters and setters. + // Fix: apply the fix to the nested types as well. + + public static class Outer { + private PrivateFields inner; + private List items; + + public PrivateFields getInner() { + return inner; + } + + public void setInner(PrivateFields inner) { + this.inner = inner; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + } + + public static class OuterFixed { + private PrivateFieldsFixed inner; + private List items; + + public PrivateFieldsFixed getInner() { + return inner; + } + + public void setInner(PrivateFieldsFixed inner) { + this.inner = inner; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + } + + @Test + void nestedTypes_droppedByJackson() { + String json = "{\"inner\":{\"name\":\"x\"},\"items\":[{\"name\":\"y\"}]}"; + + Outer fromGson = gson(Outer.class).fromJson(json); + assertEquals("x", fromGson.getInner().name); + assertEquals("y", fromGson.getItems().get(0).name); + + Outer fromJackson = jackson(Outer.class).fromJson(json); + assertNull(fromJackson.getInner().name); + assertNull(fromJackson.getItems().get(0).name); + + assertEquals(json, toJson(gson(Outer.class), fromGson)); + assertEquals("{\"inner\":{},\"items\":[{}]}", toJson(jackson(Outer.class), fromGson)); + } + + @Test + void nestedTypes_fix_applyToNestedTypes() { + String json = "{\"inner\":{\"name\":\"x\"},\"items\":[{\"name\":\"y\"}]}"; + + OuterFixed value = jackson(OuterFixed.class).fromJson(json); + assertEquals("x", value.getInner().getName()); + assertEquals("y", value.getItems().get(0).getName()); + assertEquals(json, toJson(jackson(OuterFixed.class), value)); + } + + // Same factory instances and Type overload as EventHandlerLoader.getSerializer in the RIC. + + @SuppressWarnings("unchecked") + private static PojoSerializer gson(Class type) { + return (PojoSerializer) (PojoSerializer) GsonFactory.getInstance().getSerializer((Type) type); + } + + @SuppressWarnings("unchecked") + private static PojoSerializer jackson(Class type) { + return (PojoSerializer) (PojoSerializer) JacksonFactory.getInstance().getSerializer((Type) type); + } + + private static String toJson(PojoSerializer serializer, T value) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + serializer.toJson(value, output); + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + + private static JsonNode tree(String json) { + try { + return MAPPER.readTree(json); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +}