diff --git a/.changes/next-release/feature-AWSSDKforJavav2-439f346.json b/.changes/next-release/feature-AWSSDKforJavav2-439f346.json
new file mode 100644
index 000000000000..46ea293d42cc
--- /dev/null
+++ b/.changes/next-release/feature-AWSSDKforJavav2-439f346.json
@@ -0,0 +1,6 @@
+{
+ "type": "feature",
+ "category": "AWS SDK for Java v2",
+ "contributor": "",
+ "description": "Optimized JSON marshalling performance for JSON RPC and REST JSON protocols."
+}
diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
index 2dd0016c9f09..998bbf8b4139 100644
--- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
+++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
@@ -530,7 +530,10 @@
whose NULL marshallers handle null validation. -->
-
+
+
+
+
diff --git a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStream.java b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStream.java
new file mode 100644
index 000000000000..ce321c373377
--- /dev/null
+++ b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStream.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.protocols.json;
+
+import java.io.ByteArrayOutputStream;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+
+/**
+ * A thin subclass of {@link ByteArrayOutputStream} that exposes the internal buffer and count
+ * without copying. This allows {@link SdkJsonGenerator} to create a {@code ContentStreamProvider}
+ * that wraps the buffer directly via {@code ByteArrayInputStream(buf, 0, count)}, avoiding the
+ * contiguous copy that {@link ByteArrayOutputStream#toByteArray()} performs.
+ *
+ *
The write path is identical to {@code ByteArrayOutputStream} — no overhead is added.
+ * Only the final "get the bytes" step is optimized.
+ *
+ *
This class is not thread-safe.
+ */
+@SdkInternalApi
+final class ExposedByteArrayOutputStream extends ByteArrayOutputStream {
+
+ ExposedByteArrayOutputStream(int size) {
+ super(size);
+ }
+
+ /**
+ * Returns the internal buffer. The valid data is in {@code buf[0..count-1]}.
+ * The returned array may be larger than {@link #size()}; callers must use
+ * {@link #size()} to determine the valid range.
+ *
+ *
Warning: The returned array is the live internal buffer. Do not modify it,
+ * and do not write to this stream after capturing the reference — the buffer may be
+ * replaced by a larger one on the next write if growth is needed.
+ */
+ byte[] buf() {
+ return buf;
+ }
+}
diff --git a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/SdkJsonGenerator.java b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/SdkJsonGenerator.java
index bfd819708b33..905f00f09efb 100644
--- a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/SdkJsonGenerator.java
+++ b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/SdkJsonGenerator.java
@@ -15,7 +15,7 @@
package software.amazon.awssdk.protocols.json;
-import java.io.ByteArrayOutputStream;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -23,6 +23,7 @@
import java.time.Instant;
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.http.ContentStreamProvider;
import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
import software.amazon.awssdk.thirdparty.jackson.core.JsonGenerator;
import software.amazon.awssdk.utils.BinaryUtils;
@@ -39,7 +40,7 @@ public class SdkJsonGenerator implements StructuredJsonGenerator {
* prevent frequent resizings but small enough to avoid wasted allocations for small requests.
*/
private static final int DEFAULT_BUFFER_SIZE = 1024;
- private final ByteArrayOutputStream baos = new ByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
+ private final ExposedByteArrayOutputStream baos = new ExposedByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
private final JsonGenerator generator;
private final String contentType;
@@ -206,6 +207,16 @@ public StructuredJsonGenerator writeValue(ByteBuffer bytes) {
return this;
}
+ @Override
+ public StructuredJsonGenerator writeBinaryValue(byte[] bytes) {
+ try {
+ generator.writeBinary(bytes);
+ } catch (IOException e) {
+ throw new JsonGenerationException(e);
+ }
+ return this;
+ }
+
@Override
//TODO: This date formatting is coupled to AWS's format. Should generalize it
public StructuredJsonGenerator writeValue(Instant instant) {
@@ -277,6 +288,28 @@ public byte[] getBytes() {
return baos.toByteArray();
}
+ /**
+ * Returns the size of the generated content in bytes without copying.
+ */
+ public int contentSize() {
+ close();
+ return baos.size();
+ }
+
+ /**
+ * Returns a {@link ContentStreamProvider} that wraps the internal buffer directly,
+ * avoiding the contiguous copy that {@link #getBytes()} performs via
+ * {@code ByteArrayOutputStream.toByteArray()}. Each call to
+ * {@link ContentStreamProvider#newStream()} creates a fresh {@code ByteArrayInputStream}
+ * over the same buffer for retry safety.
+ */
+ public ContentStreamProvider contentStreamProvider() {
+ close();
+ byte[] buf = baos.buf();
+ int count = baos.size();
+ return () -> new ByteArrayInputStream(buf, 0, count);
+ }
+
@Override
public String getContentType() {
return contentType;
diff --git a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/StructuredJsonGenerator.java b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/StructuredJsonGenerator.java
index 8d02b2ea78f8..db883f8c5325 100644
--- a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/StructuredJsonGenerator.java
+++ b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/StructuredJsonGenerator.java
@@ -169,6 +169,15 @@ default StructuredJsonGenerator writeValue(byte val) {
StructuredJsonGenerator writeValue(ByteBuffer bytes);
+ /**
+ * Writes binary data directly from a byte array, avoiding the overhead of wrapping in a
+ * {@link ByteBuffer}. The default implementation wraps the array and delegates to
+ * {@link #writeValue(ByteBuffer)}.
+ */
+ default StructuredJsonGenerator writeBinaryValue(byte[] bytes) {
+ return writeValue(ByteBuffer.wrap(bytes));
+ }
+
StructuredJsonGenerator writeValue(Instant instant);
StructuredJsonGenerator writeNumber(String number);
diff --git a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java
index c76aa851d997..8db37cfc530c 100644
--- a/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java
+++ b/core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java
@@ -22,22 +22,28 @@
import static software.amazon.awssdk.http.Header.TRANSFER_ENCODING;
import java.io.ByteArrayInputStream;
+import java.math.BigDecimal;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Collections;
import java.util.EnumMap;
+import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.core.SdkField;
import software.amazon.awssdk.core.SdkPojo;
+import software.amazon.awssdk.core.document.Document;
import software.amazon.awssdk.core.protocol.MarshallLocation;
+import software.amazon.awssdk.core.protocol.MarshallingKnownType;
import software.amazon.awssdk.core.protocol.MarshallingType;
import software.amazon.awssdk.core.traits.PayloadTrait;
import software.amazon.awssdk.core.traits.RequiredTrait;
import software.amazon.awssdk.core.traits.TimestampFormatTrait;
import software.amazon.awssdk.core.traits.TraitType;
+import software.amazon.awssdk.http.ContentStreamProvider;
import software.amazon.awssdk.http.SdkHttpFullRequest;
import software.amazon.awssdk.protocols.core.InstantToString;
import software.amazon.awssdk.protocols.core.OperationInfo;
@@ -47,6 +53,7 @@
import software.amazon.awssdk.protocols.json.AwsJsonProtocol;
import software.amazon.awssdk.protocols.json.AwsJsonProtocolMetadata;
import software.amazon.awssdk.protocols.json.BaseAwsJsonProtocolFactory;
+import software.amazon.awssdk.protocols.json.SdkJsonGenerator;
import software.amazon.awssdk.protocols.json.StructuredJsonGenerator;
import software.amazon.awssdk.protocols.json.internal.ProtocolFact;
@@ -61,6 +68,12 @@ public class JsonProtocolMarshaller implements ProtocolMarshaller, JsonMarshaller> MARSHALLER_CACHE =
+ new ConcurrentHashMap<>();
+
private final URI endpoint;
private final StructuredJsonGenerator jsonGenerator;
private final SdkHttpFullRequest.Builder request;
@@ -214,17 +227,21 @@ void doMarshall(SdkPojo pojo) {
} else if (isExplicitPayloadMember(field)) {
marshallExplicitJsonPayload(field, val);
} else if (val != null) {
- marshallField(field, val);
+ if (field.location() == MarshallLocation.PAYLOAD) {
+ // HOT PATH: switch-based dispatch, no registry, no interface dispatch
+ marshallPayloadField(field, val);
+ } else {
+ // WARM PATH: cached registry lookup + interface dispatch
+ marshallFieldViaRegistry(field, val);
+ }
} else if (field.location() != MarshallLocation.PAYLOAD) {
- // Null payload fields that aren't required are no-op in the marshaller registry.
- // We short circuit to avoid the registry lookup and dispatch overhead.
- // Non payload locations (path, header, query) have null marshallers with
- // different behavior, so they must still go through marshallField.
- marshallField(field, val);
+ // Null non-payload: must go through registry (null marshallers vary by location)
+ marshallFieldViaRegistry(field, val);
} else if (field.containsTrait(RequiredTrait.class, TraitType.REQUIRED_TRAIT)) {
throw new IllegalArgumentException(
String.format("Parameter '%s' must not be null", field.locationName()));
}
+ // else: null payload field, not required → no-op
}
}
@@ -273,12 +290,24 @@ private SdkHttpFullRequest finishMarshalling() {
jsonGenerator.writeEndObject();
}
- byte[] content = jsonGenerator.getBytes();
+ if (jsonGenerator instanceof SdkJsonGenerator) {
+ // Optimized path: stream directly from chunked buffers, avoiding a single
+ // contiguous byte[] allocation that can cause G1GC humongous allocations.
+ SdkJsonGenerator sdkGenerator = (SdkJsonGenerator) jsonGenerator;
+ ContentStreamProvider contentProvider = sdkGenerator.contentStreamProvider();
+ request.contentStreamProvider(contentProvider);
+ int contentSize = sdkGenerator.contentSize();
+ if (contentSize > 0) {
+ request.putHeader(CONTENT_LENGTH, Integer.toString(contentSize));
+ }
+ } else {
+ byte[] content = jsonGenerator.getBytes();
- if (content != null) {
- request.contentStreamProvider(() -> new ByteArrayInputStream(content));
- if (content.length > 0) {
- request.putHeader(CONTENT_LENGTH, Integer.toString(content.length));
+ if (content != null) {
+ request.contentStreamProvider(() -> new ByteArrayInputStream(content));
+ if (content.length > 0) {
+ request.putHeader(CONTENT_LENGTH, Integer.toString(content.length));
+ }
}
}
}
@@ -312,6 +341,103 @@ private SdkHttpFullRequest finishMarshalling() {
return request.build();
}
+ /**
+ * Marshalls a PAYLOAD-location field using a switch on {@link MarshallingKnownType} instead of
+ * registry lookup and interface dispatch. Each case is a monomorphic call site that the JIT can inline.
+ */
+ @SuppressWarnings("unchecked")
+ private void marshallPayloadField(SdkField> field, Object val) {
+ MarshallingKnownType knownType = field.marshallingType().getKnownType();
+ if (knownType == null) {
+ marshallFieldViaRegistry(field, val);
+ return;
+ }
+
+ StructuredJsonGenerator gen = marshallerContext.jsonGenerator();
+ String fieldName = field.locationName();
+
+ switch (knownType) {
+ case STRING:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((String) val);
+ break;
+ case INTEGER:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((int) (Integer) val);
+ break;
+ case LONG:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((long) (Long) val);
+ break;
+ case SHORT:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((short) (Short) val);
+ break;
+ case BYTE:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((byte) (Byte) val);
+ break;
+ case FLOAT:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((float) (Float) val);
+ break;
+ case DOUBLE:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((double) (Double) val);
+ break;
+ case BIG_DECIMAL:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((BigDecimal) val);
+ break;
+ case BOOLEAN:
+ gen.writeFieldName(fieldName);
+ gen.writeValue((boolean) (Boolean) val);
+ break;
+ case INSTANT:
+ // Delegate to existing INSTANT marshaller to preserve TimestampFormatTrait handling.
+ // Note: INSTANT marshaller writes the field name itself.
+ SimpleTypeJsonMarshaller.INSTANT.marshall((Instant) val, marshallerContext,
+ fieldName, (SdkField) field);
+ break;
+ case SDK_BYTES:
+ gen.writeFieldName(fieldName);
+ gen.writeBinaryValue(((SdkBytes) val).asByteArrayUnsafe());
+ break;
+ case SDK_POJO:
+ SimpleTypeJsonMarshaller.SDK_POJO.marshall((SdkPojo) val, marshallerContext,
+ fieldName, (SdkField) field);
+ break;
+ case LIST:
+ SimpleTypeJsonMarshaller.LIST.marshall((List>) val, marshallerContext,
+ fieldName, (SdkField>) field);
+ break;
+ case MAP:
+ SimpleTypeJsonMarshaller.MAP.marshall((Map) val, marshallerContext,
+ fieldName, (SdkField>) field);
+ break;
+ case DOCUMENT:
+ SimpleTypeJsonMarshaller.DOCUMENT.marshall((Document) val, marshallerContext,
+ fieldName, (SdkField) field);
+ break;
+ default:
+ // Unknown type — fall back to registry lookup
+ marshallFieldViaRegistry(field, val);
+ break;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void marshallFieldViaRegistry(SdkField> field, Object val) {
+ if (val == null) {
+ MARSHALLER_REGISTRY.getMarshaller(field.location(), field.marshallingType(), val)
+ .marshall(val, marshallerContext, field.locationName(), (SdkField) field);
+ return;
+ }
+ JsonMarshaller marshaller = MARSHALLER_CACHE.computeIfAbsent(field,
+ f -> MARSHALLER_REGISTRY.getMarshaller(f.location(), f.marshallingType(), val));
+ marshaller.marshall(val, marshallerContext, field.locationName(), (SdkField) field);
+ }
+
private void marshallField(SdkField> field, Object val) {
MARSHALLER_REGISTRY.getMarshaller(field.location(), field.marshallingType(), val)
.marshall(val, marshallerContext, field.locationName(), (SdkField) field);
diff --git a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStreamTest.java b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStreamTest.java
new file mode 100644
index 000000000000..81980dca9cc4
--- /dev/null
+++ b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/ExposedByteArrayOutputStreamTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.protocols.json;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+class ExposedByteArrayOutputStreamTest {
+
+ @Test
+ void emptyStream_hasZeroSize() {
+ ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
+ assertThat(stream.size()).isEqualTo(0);
+ assertThat(stream.toByteArray()).isEmpty();
+ }
+
+ @Test
+ void buf_returnsInternalBuffer() {
+ ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
+ byte[] data = {1, 2, 3, 4, 5};
+ stream.write(data, 0, data.length);
+
+ byte[] buf = stream.buf();
+ // buf is the live internal buffer — it may be larger than size()
+ assertThat(buf.length).isGreaterThanOrEqualTo(stream.size());
+ // The valid data in buf[0..size()-1] matches what was written
+ assertThat(Arrays.copyOf(buf, stream.size())).isEqualTo(data);
+ }
+
+ @Test
+ void buf_reflectsWrittenData_afterGrowth() {
+ // Start with a tiny buffer to force growth
+ ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(4);
+ byte[] data = new byte[100];
+ for (int i = 0; i < data.length; i++) {
+ data[i] = (byte) i;
+ }
+ stream.write(data, 0, data.length);
+
+ byte[] buf = stream.buf();
+ assertThat(stream.size()).isEqualTo(100);
+ assertThat(Arrays.copyOf(buf, stream.size())).isEqualTo(data);
+ }
+
+ @Test
+ void toByteArray_returnsCopy_notSameReference() {
+ ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
+ stream.write(new byte[]{1, 2, 3}, 0, 3);
+
+ byte[] copy = stream.toByteArray();
+ byte[] buf = stream.buf();
+ // toByteArray returns a copy, buf returns the live buffer
+ assertThat(copy).isNotSameAs(buf);
+ assertThat(copy).isEqualTo(Arrays.copyOf(buf, stream.size()));
+ }
+
+ @Test
+ void singleByteWrite_worksCorrectly() {
+ ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
+ stream.write(0x42);
+ assertThat(stream.size()).isEqualTo(1);
+ assertThat(stream.buf()[0]).isEqualTo((byte) 0x42);
+ }
+}
diff --git a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/SdkJsonGeneratorTest.java b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/SdkJsonGeneratorTest.java
index bba1caedfb0d..0ab737ca91f5 100644
--- a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/SdkJsonGeneratorTest.java
+++ b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/SdkJsonGeneratorTest.java
@@ -21,11 +21,13 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.time.Instant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.http.ContentStreamProvider;
import software.amazon.awssdk.protocols.jsoncore.JsonNode;
import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
import software.amazon.awssdk.thirdparty.jackson.core.StreamReadFeature;
@@ -178,4 +180,131 @@ private JsonNode toJsonNode() throws IOException {
return JsonNode.parser().parse(new ByteArrayInputStream(jsonGenerator.getBytes()));
}
+ @Test
+ public void contentSize_matchesGetBytesLength() {
+ SdkJsonGenerator gen = newSdkJsonGenerator();
+ gen.writeStartObject();
+ gen.writeFieldName("key").writeValue("value");
+ gen.writeFieldName("num").writeValue(42);
+ gen.writeEndObject();
+
+ byte[] bytes = gen.getBytes();
+
+ SdkJsonGenerator gen2 = newSdkJsonGenerator();
+ gen2.writeStartObject();
+ gen2.writeFieldName("key").writeValue("value");
+ gen2.writeFieldName("num").writeValue(42);
+ gen2.writeEndObject();
+
+ assertEquals(bytes.length, gen2.contentSize());
+ }
+
+ @Test
+ public void contentStreamProvider_producesSameBytesAsGetBytes() throws IOException {
+ SdkJsonGenerator gen = newSdkJsonGenerator();
+ gen.writeStartObject();
+ gen.writeFieldName("hello").writeValue("world");
+ gen.writeFieldName("count").writeValue(123);
+ gen.writeEndObject();
+
+ byte[] expected = gen.getBytes();
+
+ SdkJsonGenerator gen2 = newSdkJsonGenerator();
+ gen2.writeStartObject();
+ gen2.writeFieldName("hello").writeValue("world");
+ gen2.writeFieldName("count").writeValue(123);
+ gen2.writeEndObject();
+
+ ContentStreamProvider provider = gen2.contentStreamProvider();
+ byte[] actual = readAllBytes(provider.newStream());
+
+ assertTrue(java.util.Arrays.equals(expected, actual),
+ "contentStreamProvider should produce identical bytes to getBytes");
+ }
+
+ @Test
+ public void contentStreamProvider_isResettable() throws IOException {
+ SdkJsonGenerator gen = newSdkJsonGenerator();
+ gen.writeStartObject();
+ gen.writeFieldName("data").writeValue("test");
+ gen.writeEndObject();
+
+ ContentStreamProvider provider = gen.contentStreamProvider();
+ byte[] first = readAllBytes(provider.newStream());
+ byte[] second = readAllBytes(provider.newStream());
+
+ assertTrue(java.util.Arrays.equals(first, second),
+ "Multiple calls to newStream() should produce identical content");
+ assertTrue(first.length > 0, "Content should not be empty");
+ }
+
+ @Test
+ public void emptyGenerator_contentSizeIsZero() throws IOException {
+ SdkJsonGenerator gen = newSdkJsonGenerator();
+ assertEquals(0, gen.contentSize());
+
+ ContentStreamProvider provider = gen.contentStreamProvider();
+ assertTrue(provider != null, "Provider should not be null even for empty content");
+ byte[] content = readAllBytes(provider.newStream());
+ assertEquals(0, content.length, "Empty generator should produce empty stream");
+ }
+
+ @Test
+ public void largePayload_contentStreamProviderStreamsCorrectData() throws IOException {
+ // Generate JSON exceeding 64 KB to verify contentStreamProvider works for large payloads
+ SdkJsonGenerator gen = newSdkJsonGenerator();
+ gen.writeStartObject();
+ gen.writeFieldName("items");
+ gen.writeStartArray();
+ for (int i = 0; i < 2000; i++) {
+ gen.writeStartObject();
+ gen.writeFieldName("index").writeValue(i);
+ gen.writeFieldName("description").writeValue(
+ "This is a moderately long string value for item number " + i +
+ " that helps push the total payload size beyond the 64KB chunk boundary.");
+ gen.writeEndObject();
+ }
+ gen.writeEndArray();
+ gen.writeEndObject();
+
+ byte[] expected = gen.getBytes();
+ assertTrue(expected.length > 64 * 1024, "Payload should exceed 64 KB");
+
+ SdkJsonGenerator gen2 = newSdkJsonGenerator();
+ gen2.writeStartObject();
+ gen2.writeFieldName("items");
+ gen2.writeStartArray();
+ for (int i = 0; i < 2000; i++) {
+ gen2.writeStartObject();
+ gen2.writeFieldName("index").writeValue(i);
+ gen2.writeFieldName("description").writeValue(
+ "This is a moderately long string value for item number " + i +
+ " that helps push the total payload size beyond the 64KB chunk boundary.");
+ gen2.writeEndObject();
+ }
+ gen2.writeEndArray();
+ gen2.writeEndObject();
+
+ assertEquals(expected.length, gen2.contentSize());
+ byte[] actual = readAllBytes(gen2.contentStreamProvider().newStream());
+ assertTrue(java.util.Arrays.equals(expected, actual),
+ "Large payload should stream correctly via contentStreamProvider");
+ }
+
+ private SdkJsonGenerator newSdkJsonGenerator() {
+ return new SdkJsonGenerator(JsonFactory.builder()
+ .enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
+ .build(), "application/json");
+ }
+
+ private static byte[] readAllBytes(InputStream is) throws IOException {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[1024];
+ int n;
+ while ((n = is.read(buf)) != -1) {
+ bos.write(buf, 0, n);
+ }
+ return bos.toByteArray();
+ }
+
}
diff --git a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/CachedNonPayloadMarshallingTest.java b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/CachedNonPayloadMarshallingTest.java
new file mode 100644
index 000000000000..929ae22c38ce
--- /dev/null
+++ b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/CachedNonPayloadMarshallingTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.protocols.json.internal.marshall;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.SdkField;
+import software.amazon.awssdk.core.SdkPojo;
+import software.amazon.awssdk.core.protocol.MarshallLocation;
+import software.amazon.awssdk.core.protocol.MarshallingType;
+import software.amazon.awssdk.core.traits.LocationTrait;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.protocols.core.OperationInfo;
+import software.amazon.awssdk.protocols.core.ProtocolMarshaller;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocol;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocolMetadata;
+import software.amazon.awssdk.protocols.json.internal.AwsStructuredPlainJsonFactory;
+
+/**
+ * Tests that the cached non-payload marshalling path in
+ * {@link JsonProtocolMarshaller#marshallFieldViaRegistry} produces correct output
+ * and that the cache is populated after the first call.
+ *
+ * Validates: Property 3 — Cached non-payload marshalling equivalence
+ * Validates: Requirements 7.3, 7.4
+ */
+class CachedNonPayloadMarshallingTest {
+
+ private static final URI ENDPOINT = URI.create("http://localhost");
+ private static final String CONTENT_TYPE = "application/x-amz-json-1.0";
+ private static final OperationInfo OP_INFO = OperationInfo.builder()
+ .httpMethod(SdkHttpMethod.POST)
+ .hasImplicitPayloadMembers(true)
+ .build();
+ private static final AwsJsonProtocolMetadata METADATA =
+ AwsJsonProtocolMetadata.builder()
+ .protocol(AwsJsonProtocol.AWS_JSON)
+ .contentType(CONTENT_TYPE)
+ .build();
+
+ // ---- HEADER tests ----
+
+ @Test
+ void header_string_producesCorrectHeader() {
+ SdkField field = headerField("x-custom-header", obj -> "headerValue");
+ SdkPojo pojo = new SimplePojo(field);
+
+ SdkHttpFullRequest result = createMarshaller().marshall(pojo);
+
+ assertThat(result.firstMatchingHeader("x-custom-header"))
+ .isPresent()
+ .hasValue("headerValue");
+ }
+
+ @Test
+ void header_string_secondCall_usesCachedMarshaller() {
+ // Use the SAME SdkField instance for both calls so the cache is shared
+ SdkField field = headerField("x-custom-header", obj -> "headerValue");
+
+ // First call — populates the internal marshaller cache
+ SdkPojo pojo1 = new SimplePojo(field);
+ SdkHttpFullRequest result1 = createMarshaller().marshall(pojo1);
+
+ // Second call — should use cached marshaller
+ SdkPojo pojo2 = new SimplePojo(field);
+ SdkHttpFullRequest result2 = createMarshaller().marshall(pojo2);
+
+ // Both calls produce identical header output, confirming the cached path works
+ assertThat(result1.firstMatchingHeader("x-custom-header"))
+ .isPresent()
+ .hasValue("headerValue");
+ assertThat(result2.firstMatchingHeader("x-custom-header"))
+ .isPresent()
+ .hasValue("headerValue");
+ }
+
+ // ---- QUERY_PARAM tests ----
+
+ @Test
+ void queryParam_string_producesCorrectQueryParam() {
+ SdkField field = queryParamField("myParam", obj -> "paramValue");
+ SdkPojo pojo = new SimplePojo(field);
+
+ SdkHttpFullRequest result = createMarshaller().marshall(pojo);
+
+ assertThat(result.rawQueryParameters().get("myParam"))
+ .isNotNull()
+ .containsExactly("paramValue");
+ }
+
+ // ---- Helper methods ----
+
+ private static SdkField headerField(String headerName,
+ java.util.function.Function getter) {
+ return SdkField.builder(MarshallingType.STRING)
+ .memberName(headerName)
+ .getter(getter)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.HEADER)
+ .locationName(headerName)
+ .build())
+ .build();
+ }
+
+ private static SdkField queryParamField(String paramName,
+ java.util.function.Function getter) {
+ return SdkField.builder(MarshallingType.STRING)
+ .memberName(paramName)
+ .getter(getter)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.QUERY_PARAM)
+ .locationName(paramName)
+ .build())
+ .build();
+ }
+
+ private static ProtocolMarshaller createMarshaller() {
+ return JsonProtocolMarshallerBuilder.create()
+ .endpoint(ENDPOINT)
+ .jsonGenerator(AwsStructuredPlainJsonFactory
+ .SDK_JSON_FACTORY.createWriter(CONTENT_TYPE))
+ .contentType(CONTENT_TYPE)
+ .operationInfo(OP_INFO)
+ .sendExplicitNullForPayload(false)
+ .protocolMetadata(METADATA)
+ .build();
+ }
+
+ private static final class SimplePojo implements SdkPojo {
+ private final List> fields;
+
+ SimplePojo(SdkField>... fields) {
+ this.fields = Arrays.asList(fields);
+ }
+
+ @Override
+ public List> sdkFields() {
+ return fields;
+ }
+
+ @Override
+ public boolean equalsBySdkFields(Object other) {
+ return other instanceof SimplePojo;
+ }
+
+ @Override
+ public Map> sdkFieldNameToField() {
+ return Collections.emptyMap();
+ }
+ }
+}
diff --git a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/PayloadMarshallingEquivalenceTest.java b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/PayloadMarshallingEquivalenceTest.java
new file mode 100644
index 000000000000..a83192056584
--- /dev/null
+++ b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/PayloadMarshallingEquivalenceTest.java
@@ -0,0 +1,580 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.protocols.json.internal.marshall;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.math.BigDecimal;
+import java.net.URI;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.SdkField;
+import software.amazon.awssdk.core.SdkPojo;
+import software.amazon.awssdk.core.document.Document;
+import software.amazon.awssdk.core.protocol.MarshallLocation;
+import software.amazon.awssdk.core.protocol.MarshallingType;
+import software.amazon.awssdk.core.traits.ListTrait;
+import software.amazon.awssdk.core.traits.LocationTrait;
+import software.amazon.awssdk.core.traits.MapTrait;
+import software.amazon.awssdk.core.traits.TimestampFormatTrait;
+import software.amazon.awssdk.core.util.DefaultSdkAutoConstructList;
+import software.amazon.awssdk.core.util.DefaultSdkAutoConstructMap;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.protocols.core.OperationInfo;
+import software.amazon.awssdk.protocols.core.ProtocolMarshaller;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocol;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocolMetadata;
+import software.amazon.awssdk.protocols.json.internal.AwsStructuredPlainJsonFactory;
+
+/**
+ * Tests that the switch-based payload dispatch in {@link JsonProtocolMarshaller#marshallPayloadField}
+ * produces correct JSON output for all 16 {@code MarshallingKnownType} values.
+ *
+ * Validates: Property 1 — Payload marshalling behavioral equivalence
+ * Validates: Requirements 2.1–2.12, 3.1–3.5, 4.1, 5.1–5.3, 6.1–6.4
+ */
+class PayloadMarshallingEquivalenceTest {
+
+ private static final URI ENDPOINT = URI.create("http://localhost");
+ private static final String CONTENT_TYPE = "application/x-amz-json-1.0";
+ private static final OperationInfo OP_INFO = OperationInfo.builder()
+ .httpMethod(SdkHttpMethod.POST)
+ .hasImplicitPayloadMembers(true)
+ .build();
+ private static final AwsJsonProtocolMetadata METADATA =
+ AwsJsonProtocolMetadata.builder()
+ .protocol(AwsJsonProtocol.AWS_JSON)
+ .contentType(CONTENT_TYPE)
+ .build();
+
+ // ---- STRING ----
+
+ @Test
+ void string_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.STRING, obj -> "hello world");
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":\"hello world\"");
+ }
+
+ // ---- INTEGER ----
+
+ @Test
+ void integer_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.INTEGER, obj -> 42);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":42");
+ }
+
+ // ---- LONG ----
+
+ @Test
+ void long_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.LONG, obj -> 123456789L);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":123456789");
+ }
+
+ // ---- SHORT ----
+
+ @Test
+ void short_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.SHORT, obj -> (short) 7);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":7");
+ }
+
+ // ---- BYTE ----
+
+ @Test
+ void byte_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.BYTE, obj -> (byte) 3);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":3");
+ }
+
+ // ---- FLOAT ----
+
+ @Test
+ void float_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.FLOAT, obj -> 1.5f);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":1.5");
+ }
+
+ // ---- DOUBLE ----
+
+ @Test
+ void double_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.DOUBLE, obj -> 3.14);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":3.14");
+ }
+
+ // ---- BIG_DECIMAL ----
+
+ @Test
+ void bigDecimal_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.BIG_DECIMAL,
+ obj -> new BigDecimal("99.99"));
+ String body = marshallAndGetBody(field);
+ // BigDecimal is serialized as a quoted string by the JSON generator
+ assertThat(body).contains("\"fieldName\":\"99.99\"");
+ }
+
+ // ---- BOOLEAN ----
+
+ @Test
+ void boolean_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.BOOLEAN, obj -> true);
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":true");
+ }
+
+ // ---- INSTANT (default format — UNIX_TIMESTAMP for PAYLOAD) ----
+
+ @Test
+ void instant_defaultFormat_producesUnixTimestamp() {
+ SdkField field = payloadField("fieldName", MarshallingType.INSTANT,
+ obj -> Instant.ofEpochSecond(1000));
+ String body = marshallAndGetBody(field);
+ // Default PAYLOAD format is UNIX_TIMESTAMP — written via jsonGenerator.writeValue(Instant)
+ // which for plain JSON writes epoch seconds (e.g. 1000.0 or 1000)
+ assertThat(body).contains("\"fieldName\":");
+ assertThat(body).contains("1000");
+ }
+
+ // ---- INSTANT with UNIX_TIMESTAMP trait ----
+
+ @Test
+ void instant_unixTimestampTrait_producesUnixTimestamp() {
+ SdkField field = SdkField.builder(MarshallingType.INSTANT)
+ .memberName("fieldName")
+ .getter(obj -> Instant.ofEpochSecond(1000))
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ TimestampFormatTrait.create(TimestampFormatTrait.Format.UNIX_TIMESTAMP))
+ .build();
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":");
+ assertThat(body).contains("1000");
+ }
+
+ // ---- INSTANT with RFC_822 trait ----
+
+ @Test
+ void instant_rfc822Trait_producesRfc822String() {
+ SdkField field = SdkField.builder(MarshallingType.INSTANT)
+ .memberName("fieldName")
+ .getter(obj -> Instant.ofEpochSecond(1000))
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ TimestampFormatTrait.create(TimestampFormatTrait.Format.RFC_822))
+ .build();
+ String body = marshallAndGetBody(field);
+ // RFC 822 format: e.g. "Thu, 01 Jan 1970 00:16:40 GMT"
+ assertThat(body).contains("\"fieldName\":\"");
+ assertThat(body).contains("1970");
+ }
+
+ // ---- INSTANT with ISO_8601 trait ----
+
+ @Test
+ void instant_iso8601Trait_producesIso8601String() {
+ SdkField field = SdkField.builder(MarshallingType.INSTANT)
+ .memberName("fieldName")
+ .getter(obj -> Instant.ofEpochSecond(1000))
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ TimestampFormatTrait.create(TimestampFormatTrait.Format.ISO_8601))
+ .build();
+ String body = marshallAndGetBody(field);
+ // ISO 8601 format: e.g. "1970-01-01T00:16:40Z"
+ assertThat(body).contains("\"fieldName\":\"");
+ assertThat(body).contains("1970-01-01T");
+ }
+
+ // ---- SDK_BYTES ----
+
+ @Test
+ void sdkBytes_producesBase64EncodedJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.SDK_BYTES,
+ obj -> SdkBytes.fromUtf8String("data"));
+ String body = marshallAndGetBody(field);
+ // "data" base64 encoded is "ZGF0YQ=="
+ assertThat(body).contains("\"fieldName\":\"ZGF0YQ==\"");
+ }
+
+ // ---- SDK_POJO (nested) ----
+
+ @Test
+ void sdkPojo_producesNestedObjectJson() {
+ // Inner pojo with a single string field
+ SdkField innerField = payloadField("innerField", MarshallingType.STRING, obj -> "innerValue");
+ SimplePojo innerPojo = new SimplePojo(innerField);
+
+ SdkField outerField = SdkField.builder(MarshallingType.SDK_POJO)
+ .memberName("fieldName")
+ .getter(obj -> innerPojo)
+ .setter((obj, val) -> { })
+ .constructor(() -> innerPojo)
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(outerField);
+ assertThat(body).contains("\"fieldName\":{\"innerField\":\"innerValue\"}");
+ }
+
+ // ---- LIST (non-empty) ----
+
+ @Test
+ void list_nonEmpty_producesArrayJson() {
+ List listValue = Arrays.asList("a", "b", "c");
+
+ SdkField memberField = SdkField.builder(MarshallingType.STRING)
+ .memberName("member")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("member")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.LIST)
+ .memberName("fieldName")
+ .getter(obj -> listValue)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ ListTrait.builder()
+ .memberFieldInfo(memberField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":[\"a\",\"b\",\"c\"]");
+ }
+
+ // ---- LIST (empty SdkAutoConstructList — should be skipped) ----
+
+ @Test
+ void list_emptySdkAutoConstructList_isSkipped() {
+ List autoList = DefaultSdkAutoConstructList.getInstance();
+
+ SdkField memberField = SdkField.builder(MarshallingType.STRING)
+ .memberName("member")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("member")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.LIST)
+ .memberName("fieldName")
+ .getter(obj -> autoList)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ ListTrait.builder()
+ .memberFieldInfo(memberField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).doesNotContain("fieldName");
+ }
+
+ // ---- LIST (empty regular list — should emit empty array) ----
+
+ @Test
+ void list_emptyRegularList_producesEmptyArray() {
+ List emptyList = new ArrayList<>();
+
+ SdkField memberField = SdkField.builder(MarshallingType.STRING)
+ .memberName("member")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("member")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.LIST)
+ .memberName("fieldName")
+ .getter(obj -> emptyList)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ ListTrait.builder()
+ .memberFieldInfo(memberField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":[]");
+ }
+
+ // ---- MAP (non-empty) ----
+
+ @Test
+ void map_nonEmpty_producesObjectJson() {
+ // Use LinkedHashMap for deterministic ordering
+ Map mapValue = new LinkedHashMap<>();
+ mapValue.put("key1", "val1");
+ mapValue.put("key2", "val2");
+
+ SdkField valueField = SdkField.builder(MarshallingType.STRING)
+ .memberName("value")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("value")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.MAP)
+ .memberName("fieldName")
+ .getter(obj -> mapValue)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ MapTrait.builder()
+ .valueFieldInfo(valueField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":{\"key1\":\"val1\",\"key2\":\"val2\"}");
+ }
+
+ // ---- MAP (empty SdkAutoConstructMap — should be skipped) ----
+
+ @Test
+ void map_emptySdkAutoConstructMap_isSkipped() {
+ Map autoMap = DefaultSdkAutoConstructMap.getInstance();
+
+ SdkField valueField = SdkField.builder(MarshallingType.STRING)
+ .memberName("value")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("value")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.MAP)
+ .memberName("fieldName")
+ .getter(obj -> autoMap)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ MapTrait.builder()
+ .valueFieldInfo(valueField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).doesNotContain("fieldName");
+ }
+
+ // ---- MAP (empty regular map — should emit empty object) ----
+
+ @Test
+ void map_emptyRegularMap_producesEmptyObject() {
+ Map emptyMap = new HashMap<>();
+
+ SdkField valueField = SdkField.builder(MarshallingType.STRING)
+ .memberName("value")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("value")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.MAP)
+ .memberName("fieldName")
+ .getter(obj -> emptyMap)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ MapTrait.builder()
+ .valueFieldInfo(valueField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":{}");
+ }
+
+ // ---- MAP with null value entry — entry is skipped ----
+
+ @Test
+ void map_nullValueEntry_isSkipped() {
+ Map mapValue = new LinkedHashMap<>();
+ mapValue.put("key1", "val1");
+ mapValue.put("key2", null);
+ mapValue.put("key3", "val3");
+
+ SdkField valueField = SdkField.builder(MarshallingType.STRING)
+ .memberName("value")
+ .getter(obj -> null)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("value")
+ .build())
+ .build();
+
+ SdkField> field = SdkField.>builder(MarshallingType.MAP)
+ .memberName("fieldName")
+ .getter(obj -> mapValue)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("fieldName")
+ .build(),
+ MapTrait.builder()
+ .valueFieldInfo(valueField)
+ .build())
+ .build();
+
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"key1\":\"val1\"");
+ assertThat(body).doesNotContain("key2");
+ assertThat(body).contains("\"key3\":\"val3\"");
+ }
+
+ // ---- DOCUMENT ----
+
+ @Test
+ void document_producesCorrectJson() {
+ SdkField field = payloadField("fieldName", MarshallingType.DOCUMENT,
+ obj -> Document.fromString("test"));
+ String body = marshallAndGetBody(field);
+ assertThat(body).contains("\"fieldName\":\"test\"");
+ }
+
+ // ---- Helper methods ----
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static SdkField payloadField(String name,
+ MarshallingType marshallingType,
+ Function getter) {
+ return (SdkField) SdkField.builder(marshallingType)
+ .memberName(name)
+ .getter((Function) getter)
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName(name)
+ .build())
+ .build();
+ }
+
+ private String marshallAndGetBody(SdkField>... fields) {
+ SdkPojo pojo = new SimplePojo(fields);
+ SdkHttpFullRequest result = createMarshaller().marshall(pojo);
+ return bodyAsString(result);
+ }
+
+ private static ProtocolMarshaller createMarshaller() {
+ return JsonProtocolMarshallerBuilder.create()
+ .endpoint(ENDPOINT)
+ .jsonGenerator(AwsStructuredPlainJsonFactory
+ .SDK_JSON_FACTORY.createWriter(CONTENT_TYPE))
+ .contentType(CONTENT_TYPE)
+ .operationInfo(OP_INFO)
+ .sendExplicitNullForPayload(false)
+ .protocolMetadata(METADATA)
+ .build();
+ }
+
+ private static String bodyAsString(SdkHttpFullRequest request) {
+ return request.contentStreamProvider()
+ .map(p -> {
+ try {
+ return software.amazon.awssdk.utils.IoUtils.toUtf8String(p.newStream());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .orElse("");
+ }
+
+ private static final class SimplePojo implements SdkPojo {
+ private final List> fields;
+
+ SimplePojo(SdkField>... fields) {
+ this.fields = Arrays.asList(fields);
+ }
+
+ @Override
+ public List> sdkFields() {
+ return fields;
+ }
+
+ @Override
+ public boolean equalsBySdkFields(Object other) {
+ return other instanceof SimplePojo;
+ }
+
+ @Override
+ public Map> sdkFieldNameToField() {
+ return Collections.emptyMap();
+ }
+ }
+}
diff --git a/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/UnknownMarshallingKnownTypeFallbackTest.java b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/UnknownMarshallingKnownTypeFallbackTest.java
new file mode 100644
index 000000000000..6886452c2dc1
--- /dev/null
+++ b/core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/internal/marshall/UnknownMarshallingKnownTypeFallbackTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.protocols.json.internal.marshall;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.SdkField;
+import software.amazon.awssdk.core.SdkPojo;
+import software.amazon.awssdk.core.protocol.MarshallLocation;
+import software.amazon.awssdk.core.protocol.MarshallingKnownType;
+import software.amazon.awssdk.core.protocol.MarshallingType;
+import software.amazon.awssdk.core.traits.LocationTrait;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.protocols.core.OperationInfo;
+import software.amazon.awssdk.protocols.core.ProtocolMarshaller;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocol;
+import software.amazon.awssdk.protocols.json.AwsJsonProtocolMetadata;
+import software.amazon.awssdk.protocols.json.internal.AwsStructuredPlainJsonFactory;
+
+/**
+ * Tests that when {@code getKnownType()} returns null, the marshaller falls back to the
+ * registry-based path without throwing a {@link NullPointerException} from the switch statement.
+ *
+ * Validates: Requirements 1.3, 1.4
+ */
+class UnknownMarshallingKnownTypeFallbackTest {
+
+ private static final URI ENDPOINT = URI.create("http://localhost");
+ private static final String CONTENT_TYPE = "application/x-amz-json-1.0";
+ private static final OperationInfo OP_INFO = OperationInfo.builder()
+ .httpMethod(SdkHttpMethod.POST)
+ .hasImplicitPayloadMembers(true)
+ .build();
+ private static final AwsJsonProtocolMetadata METADATA =
+ AwsJsonProtocolMetadata.builder()
+ .protocol(AwsJsonProtocol.AWS_JSON)
+ .contentType(CONTENT_TYPE)
+ .build();
+
+ /**
+ * A custom MarshallingType whose {@code getKnownType()} returns null.
+ * This simulates a future or third-party MarshallingType that is not in the known enum set.
+ */
+ private static final MarshallingType CUSTOM_NULL_KNOWN_TYPE = new MarshallingType() {
+ @Override
+ public Class super String> getTargetClass() {
+ return String.class;
+ }
+
+ @Override
+ public MarshallingKnownType getKnownType() {
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "CUSTOM_NULL_KNOWN_TYPE";
+ }
+ };
+
+ /**
+ * Validates Requirement 1.4: When {@code getKnownType()} returns null, the marshaller falls back
+ * to the registry-based path without throwing a NullPointerException from the switch statement.
+ *
+ * Since the custom type is not registered in the static MARSHALLER_REGISTRY, the registry
+ * fallback will fail — but the failure must NOT be a NullPointerException from the switch.
+ * It should be a NullPointerException from invoking {@code .marshall()} on the null result
+ * returned by the registry lookup (since the custom type is unregistered).
+ */
+ @Test
+ void nullKnownType_fallsBackToRegistryPath_doesNotThrowNpeFromSwitch() {
+ SdkField field = SdkField.builder(CUSTOM_NULL_KNOWN_TYPE)
+ .memberName("customField")
+ .getter(obj -> "someValue")
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("customField")
+ .build())
+ .build();
+
+ SdkPojo pojo = new SimplePojo(field);
+
+ // The null-knownType guard in marshallPayloadField should redirect to marshallFieldViaRegistry.
+ // Since CUSTOM_NULL_KNOWN_TYPE is not registered in the static MARSHALLER_REGISTRY,
+ // the registry returns null and a NullPointerException occurs when invoking .marshall() on it.
+ // The critical assertion: the NPE stack trace must NOT originate from the switch statement
+ // in marshallPayloadField — it must come from the registry fallback path.
+ assertThatThrownBy(() -> createMarshaller().marshall(pojo))
+ .isInstanceOf(NullPointerException.class)
+ .satisfies(thrown -> {
+ // Verify the NPE comes from marshallFieldViaRegistry (the fallback),
+ // not from marshallPayloadField's switch statement
+ StackTraceElement[] stack = thrown.getStackTrace();
+ boolean fromRegistryPath = false;
+ for (StackTraceElement element : stack) {
+ if ("marshallFieldViaRegistry".equals(element.getMethodName())) {
+ fromRegistryPath = true;
+ break;
+ }
+ }
+ assertThat(fromRegistryPath)
+ .as("NPE should originate from marshallFieldViaRegistry (registry fallback), "
+ + "not from the switch in marshallPayloadField")
+ .isTrue();
+ });
+ }
+
+ /**
+ * Validates Requirement 1.3: A standard MarshallingType (STRING) with a known type is handled
+ * by the switch path, confirming the switch dispatch works for recognized types.
+ * This serves as a control test — if the switch were broken, this would fail too.
+ */
+ @Test
+ void knownType_string_isHandledBySwitchPath() {
+ SdkField field = SdkField.builder(MarshallingType.STRING)
+ .memberName("normalField")
+ .getter(obj -> "hello")
+ .setter((obj, val) -> { })
+ .traits(LocationTrait.builder()
+ .location(MarshallLocation.PAYLOAD)
+ .locationName("normalField")
+ .build())
+ .build();
+
+ SdkPojo pojo = new SimplePojo(field);
+
+ SdkHttpFullRequest result = createMarshaller().marshall(pojo);
+ String body = bodyAsString(result);
+ assertThat(body).contains("\"normalField\":\"hello\"");
+ }
+
+ // ---- Helper methods ----
+
+ private static ProtocolMarshaller createMarshaller() {
+ return JsonProtocolMarshallerBuilder.create()
+ .endpoint(ENDPOINT)
+ .jsonGenerator(AwsStructuredPlainJsonFactory
+ .SDK_JSON_FACTORY.createWriter(CONTENT_TYPE))
+ .contentType(CONTENT_TYPE)
+ .operationInfo(OP_INFO)
+ .sendExplicitNullForPayload(false)
+ .protocolMetadata(METADATA)
+ .build();
+ }
+
+ private static String bodyAsString(SdkHttpFullRequest request) {
+ return request.contentStreamProvider()
+ .map(p -> {
+ try {
+ return software.amazon.awssdk.utils.IoUtils.toUtf8String(p.newStream());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .orElse("");
+ }
+
+ private static final class SimplePojo implements SdkPojo {
+ private final List> fields;
+
+ SimplePojo(SdkField>... fields) {
+ this.fields = Arrays.asList(fields);
+ }
+
+ @Override
+ public List> sdkFields() {
+ return fields;
+ }
+
+ @Override
+ public boolean equalsBySdkFields(Object other) {
+ return other instanceof SimplePojo;
+ }
+
+ @Override
+ public Map> sdkFieldNameToField() {
+ return Collections.emptyMap();
+ }
+ }
+}