From 97633bd51ad5f414b3340d0fc8219b0cd32053b0 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 15 May 2026 23:51:07 -0500 Subject: [PATCH 1/2] Optimize HTTP binding and XML overhead --- .../serde/AwsQueryDeserializeBenchmark.java | 10 +- .../serde/RestXmlDeserializeBenchmark.java | 7 +- .../smithy/java/xml/EmptyXmlDeserializer.java | 109 +++ .../amazon/smithy/java/xml/XmlCodec.java | 4 + .../amazon/smithy/java/xml/XmlCodecTest.java | 11 + .../java/core/schema/DeferredRootSchema.java | 6 + .../java/core/schema/ResolvedRootSchema.java | 5 + .../smithy/java/core/schema/RootSchema.java | 5 + .../smithy/java/core/schema/Schema.java | 16 + .../java/core/serde/HttpDateFormat.java | 257 +++++++ .../java/core/serde/TimestampFormatter.java | 16 +- .../java/core/serde/HttpDateFormatTest.java | 203 +++++ .../java/http/binding/BindingMatcher.java | 145 ---- .../http/binding/HeaderErrorSerializer.java | 4 +- .../smithy/java/http/binding/HttpBinding.java | 15 +- .../http/binding/HttpBindingDeserializer.java | 281 ++++--- .../binding/HttpBindingSchemaExtensions.java | 707 ++++++++++++++++++ .../http/binding/HttpBindingSerializer.java | 606 ++++++++++++--- .../http/binding/HttpHeaderSerializer.java | 122 --- .../http/binding/HttpLabelSerializer.java | 88 --- .../http/binding/HttpQuerySerializer.java | 194 ----- .../java/http/binding/PathSerializer.java | 176 +++++ .../java/http/binding/PayloadSerializer.java | 10 +- .../http/binding/RequestDeserializer.java | 10 +- .../java/http/binding/RequestSerializer.java | 19 +- .../http/binding/ResponseDeserializer.java | 17 +- .../java/http/binding/ResponseSerializer.java | 25 +- .../binding/ResponseStatusSerializer.java | 24 - .../binding/SpecificHttpHeaderSerializer.java | 84 --- .../java/http/binding/StructBodyProxy.java | 50 ++ ...y.java.core.schema.SchemaExtensionProvider | 1 + .../http/binding/HttpLabelSerializerTest.java | 30 - 32 files changed, 2288 insertions(+), 969 deletions(-) create mode 100644 codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/EmptyXmlDeserializer.java create mode 100644 core/src/main/java/software/amazon/smithy/java/core/serde/HttpDateFormat.java create mode 100644 core/src/test/java/software/amazon/smithy/java/core/serde/HttpDateFormatTest.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/BindingMatcher.java create mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpHeaderSerializer.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpLabelSerializer.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpQuerySerializer.java create mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PathSerializer.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseStatusSerializer.java delete mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/SpecificHttpHeaderSerializer.java create mode 100644 http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/StructBodyProxy.java create mode 100644 http/http-binding/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider delete mode 100644 http/http-binding/src/test/java/software/amazon/smithy/java/http/binding/HttpLabelSerializerTest.java diff --git a/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/AwsQueryDeserializeBenchmark.java b/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/AwsQueryDeserializeBenchmark.java index bf05d94f6..dfc6be7f4 100644 --- a/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/AwsQueryDeserializeBenchmark.java +++ b/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/AwsQueryDeserializeBenchmark.java @@ -31,6 +31,14 @@ public class AwsQueryDeserializeBenchmark { private static final String VERSION = "1999-12-31"; private static final String CONTENT_TYPE = "text/xml"; + /** + * Empty body fixture for response tests that have no body. AWS Query responses are XML, so an empty + * {@code byte[]} cleanly hits the {@code XmlCodec} empty-buffer fast path. None of the current + * {@code GetMetricDataResponse_*} test cases have empty bodies, but keeping the fixture consistent with + * {@link RestXmlDeserializeBenchmark} makes the auto-derive workaround in {@link DeserializeState} unnecessary. + */ + private static final byte[] EMPTY_XML_BODY = new byte[0]; + @Param({ "awsQuery_GetMetricDataResponse_S", "awsQuery_GetMetricDataResponse_M", @@ -44,7 +52,7 @@ public class AwsQueryDeserializeBenchmark { @Setup public void setup() { protocol = new AwsQueryClientProtocol(SERVICE_ID, VERSION); - state = DeserializeState.forTestCase(testCaseId, GENERATED_PACKAGE, null, CONTENT_TYPE, false); + state = DeserializeState.forTestCase(testCaseId, GENERATED_PACKAGE, EMPTY_XML_BODY, CONTENT_TYPE, false); } @Benchmark diff --git a/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/RestXmlDeserializeBenchmark.java b/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/RestXmlDeserializeBenchmark.java index 60ee72420..7a72b51c0 100644 --- a/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/RestXmlDeserializeBenchmark.java +++ b/benchmarks/serde-benchmarks/src/jmh/java/software/amazon/smithy/java/benchmarks/serde/RestXmlDeserializeBenchmark.java @@ -28,8 +28,11 @@ public class RestXmlDeserializeBenchmark { "software.amazon.smithy.java.benchmarks.serde.generated.restxml.model"; private static final ShapeId SERVICE_ID = ShapeId.from("com.amazonaws.sdk.benchmark#AwsRestXmlDataPlane"); - /** Pass null to DeserializeState to use the auto-derived default. */ - private static final byte[] EMPTY_XML_BODY = null; + /** + * Empty body fixture for response tests that have no body. Feeding zero bytes here accurately models the wire + * reality of an empty 200 response and exercises that fast path. + */ + private static final byte[] EMPTY_XML_BODY = new byte[0]; private static final String CONTENT_TYPE = "application/xml"; @Param({ diff --git a/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/EmptyXmlDeserializer.java b/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/EmptyXmlDeserializer.java new file mode 100644 index 000000000..19c30ad62 --- /dev/null +++ b/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/EmptyXmlDeserializer.java @@ -0,0 +1,109 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.xml; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.time.Instant; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.serde.SerializationException; +import software.amazon.smithy.java.core.serde.ShapeDeserializer; +import software.amazon.smithy.java.core.serde.document.Document; + +/** + * A no-op {@link ShapeDeserializer} for empty XML payloads. + */ +final class EmptyXmlDeserializer implements ShapeDeserializer { + + static final EmptyXmlDeserializer INSTANCE = new EmptyXmlDeserializer(); + + private EmptyXmlDeserializer() {} + + @Override + public boolean isNull() { + return true; + } + + @Override + public void readStruct(Schema schema, T state, StructMemberConsumer consumer) {} + + @Override + public void readList(Schema schema, T state, ListMemberConsumer consumer) {} + + @Override + public void readStringMap(Schema schema, T state, MapMemberConsumer consumer) {} + + // Scalar reads are not valid at the top level of an empty XML body. + @Override + public boolean readBoolean(Schema schema) { + throw empty(schema); + } + + @Override + public ByteBuffer readBlob(Schema schema) { + throw empty(schema); + } + + @Override + public byte readByte(Schema schema) { + throw empty(schema); + } + + @Override + public short readShort(Schema schema) { + throw empty(schema); + } + + @Override + public int readInteger(Schema schema) { + throw empty(schema); + } + + @Override + public long readLong(Schema schema) { + throw empty(schema); + } + + @Override + public float readFloat(Schema schema) { + throw empty(schema); + } + + @Override + public double readDouble(Schema schema) { + throw empty(schema); + } + + @Override + public BigInteger readBigInteger(Schema schema) { + throw empty(schema); + } + + @Override + public BigDecimal readBigDecimal(Schema schema) { + throw empty(schema); + } + + @Override + public String readString(Schema schema) { + throw empty(schema); + } + + @Override + public Document readDocument() { + throw new SerializationException("Cannot read a document value from an empty XML payload"); + } + + @Override + public Instant readTimestamp(Schema schema) { + throw empty(schema); + } + + private static SerializationException empty(Schema schema) { + return new SerializationException("Cannot read " + schema.id() + " value from an empty XML payload"); + } +} diff --git a/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/XmlCodec.java b/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/XmlCodec.java index 203998526..e22e45fcc 100644 --- a/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/XmlCodec.java +++ b/codecs/xml-codec/src/main/java/software/amazon/smithy/java/xml/XmlCodec.java @@ -63,6 +63,10 @@ public ShapeSerializer createSerializer(OutputStream sink) { @Override public ShapeDeserializer createDeserializer(ByteBuffer source) { + if (source == null || !source.hasRemaining()) { + return EmptyXmlDeserializer.INSTANCE; + } + try { var reader = xmlInputFactory.createXMLStreamReader(ByteBufferUtils.byteBufferInputStream(source)); return XmlDeserializer.topLevel( diff --git a/codecs/xml-codec/src/test/java/software/amazon/smithy/java/xml/XmlCodecTest.java b/codecs/xml-codec/src/test/java/software/amazon/smithy/java/xml/XmlCodecTest.java index 4e2e9e7b9..623fc831d 100644 --- a/codecs/xml-codec/src/test/java/software/amazon/smithy/java/xml/XmlCodecTest.java +++ b/codecs/xml-codec/src/test/java/software/amazon/smithy/java/xml/XmlCodecTest.java @@ -48,6 +48,17 @@ public void deserializesXml() { } } + @Test + public void deserializesEmptyBody() { + try (var codec = XmlCodec.builder().build()) { + // Empty ByteBuffer: top-level readStruct should be a no-op + var pojo = codec.deserializeShape(ByteBuffer.allocate(0), new TestPojo.Builder()); + assertThat(pojo.name, equalTo(null)); + assertThat(pojo.date, equalTo(null)); + assertThat(pojo.numbers, equalTo(List.of())); + } + } + @Test public void serializesXml() { try (var codec = XmlCodec.builder().build()) { diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/DeferredRootSchema.java b/core/src/main/java/software/amazon/smithy/java/core/schema/DeferredRootSchema.java index e87b0af62..4d7cdef07 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/DeferredRootSchema.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/DeferredRootSchema.java @@ -95,6 +95,12 @@ public Schema member(String memberName) { return resolvedMembers.members.get(memberName); } + @Override + public Schema member(int memberIndex) { + resolveInternal(); + return resolvedMembers.memberList.get(memberIndex); + } + @Override public Set intEnumValues() { return intEnumValues; diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/ResolvedRootSchema.java b/core/src/main/java/software/amazon/smithy/java/core/schema/ResolvedRootSchema.java index ff06e80d1..1feeabe1f 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/ResolvedRootSchema.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/ResolvedRootSchema.java @@ -46,6 +46,11 @@ public Schema member(String memberName) { return members.get(memberName); } + @Override + public Schema member(int memberIndex) { + return memberList.get(memberIndex); + } + @Override public Set intEnumValues() { return intEnumValues; diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/RootSchema.java b/core/src/main/java/software/amazon/smithy/java/core/schema/RootSchema.java index f6665f1eb..58e4dae2a 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/RootSchema.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/RootSchema.java @@ -108,6 +108,11 @@ public Schema member(String memberName) { return members.get(memberName); } + @Override + public Schema member(int memberIndex) { + return memberList.get(memberIndex); + } + @Override public Set intEnumValues() { return intEnumValues; diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java b/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java index 8b904d19d..f7cd89133 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/Schema.java @@ -547,6 +547,22 @@ public Schema member(String memberName) { return null; } + /** + * Get a member by its index. + * + *

Index is the position of the member in declaration order, matching + * what {@link Schema#memberIndex()} returns for that member. Direct array + * indexing — significantly faster than {@link #member(String)} when the + * caller already has a member's index in hand (for example, when walking + * a precomputed plan). + * + * @param memberIndex Index of the member, 0-based in declaration order. + * @return the found member, or null if this schema has no such index. + */ + public Schema member(int memberIndex) { + return null; + } + /** * Get the member of a list. * diff --git a/core/src/main/java/software/amazon/smithy/java/core/serde/HttpDateFormat.java b/core/src/main/java/software/amazon/smithy/java/core/serde/HttpDateFormat.java new file mode 100644 index 000000000..9034410cd --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/serde/HttpDateFormat.java @@ -0,0 +1,257 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.util.Locale; + +/** + * Performance-focused formatter and parser for the HTTP-date / IMF-fixdate format. + */ +final class HttpDateFormat { + + private HttpDateFormat() {} + + /** Length of a well-formed IMF-fixdate string. */ + static final int LENGTH = 29; + + /** + * Concatenated three-letter day-of-week abbreviations, indexed by {@code (DayOfWeek.getValue() - 1) * 3}. + * Order matches {@link java.time.DayOfWeek}: MONDAY → SUNDAY. + */ + private static final char[] DAY_NAMES = "MonTueWedThuFriSatSun".toCharArray(); + + /** + * Concatenated three-letter month abbreviations, indexed by + * {@code (monthValue - 1) * 3}. Order is calendar order, January → December. + */ + private static final char[] MONTH_NAMES = "JanFebMarAprMayJunJulAugSepOctNovDec".toCharArray(); + + /** + * Slow-path formatter/parser used as a fallback when the input does not match strict IMF-fixdate. + */ + private static final DateTimeFormatter FALLBACK = DateTimeFormatter + .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'") + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + private static final long INSTANT_SECONDS_MIN = ChronoField.INSTANT_SECONDS.range().getMinimum(); + private static final long INSTANT_SECONDS_MAX = ChronoField.INSTANT_SECONDS.range().getMaximum(); + + /** + * Format the given {@link java.time.Instant} as an HTTP-date string. + * + * @param epochSecond seconds since the epoch (UTC). + * @return the formatted IMF-fixdate string, always 29 characters long. + */ + static String format(long epochSecond) { + LocalDateTime ldt = LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC); + + int year = ldt.getYear(); + int month = ldt.getMonthValue(); + int day = ldt.getDayOfMonth(); + int hour = ldt.getHour(); + int minute = ldt.getMinute(); + int second = ldt.getSecond(); + int dowValue = ldt.getDayOfWeek().getValue(); + + char[] buf = new char[LENGTH]; + int dowIdx = (dowValue - 1) * 3; + buf[0] = DAY_NAMES[dowIdx]; + buf[1] = DAY_NAMES[dowIdx + 1]; + buf[2] = DAY_NAMES[dowIdx + 2]; + buf[3] = ','; + buf[4] = ' '; + buf[5] = (char) ('0' + day / 10); + buf[6] = (char) ('0' + day % 10); + buf[7] = ' '; + int monIdx = (month - 1) * 3; + buf[8] = MONTH_NAMES[monIdx]; + buf[9] = MONTH_NAMES[monIdx + 1]; + buf[10] = MONTH_NAMES[monIdx + 2]; + buf[11] = ' '; + buf[12] = (char) ('0' + (year / 1000) % 10); + buf[13] = (char) ('0' + (year / 100) % 10); + buf[14] = (char) ('0' + (year / 10) % 10); + buf[15] = (char) ('0' + year % 10); + buf[16] = ' '; + buf[17] = (char) ('0' + hour / 10); + buf[18] = (char) ('0' + hour % 10); + buf[19] = ':'; + buf[20] = (char) ('0' + minute / 10); + buf[21] = (char) ('0' + minute % 10); + buf[22] = ':'; + buf[23] = (char) ('0' + second / 10); + buf[24] = (char) ('0' + second % 10); + buf[25] = ' '; + buf[26] = 'G'; + buf[27] = 'M'; + buf[28] = 'T'; + return new String(buf); + } + + /** + * Format the given instant using the JDK fallback formatter. + */ + static String formatFallback(Instant value) { + return FALLBACK.format(value); + } + + /** + * Parse an HTTP-date string into an {@link java.time.Instant} epoch second. + * + *

Strictly handles the IMF-fixdate form. For non-conforming inputs (different lengths, lowercase day or + * month names, missing GMT, etc.) delegates to the JDK {@link DateTimeFormatter} — the same parser used + * by the previous implementation, so behavioural compatibility is preserved. + * + * @param value the input string. + * @return the parsed instant. + * @throws DateTimeParseException if the input cannot be parsed by either + * the fast path or the JDK fallback. + */ + static Instant parse(String value) { + if (value.length() != LENGTH || !validateLayout(value)) { + return FALLBACK.parse(value, Instant::from); + } + + // Layout matched; from here on, malformed fields throw rather than silently round-trip through the fallback + // (which can be too lenient about overflow values like hour 24). Throw the same exception type + // the fallback would for consistent caller-side handling. + try { + int day = parseTwoDigit(value, 5); + int month = parseMonthName(value, 8); + int year = parseYear(value, 12); + int hour = parseTwoDigit(value, 17); + int minute = parseTwoDigit(value, 20); + int second = parseTwoDigit(value, 23); + LocalDate date = LocalDate.of(year, month, day); + LocalTime time = LocalTime.of(hour, minute, second); + return date.atTime(time).toInstant(ZoneOffset.UTC); + } catch (IllegalArgumentException | DateTimeException e) { + throw new DateTimeParseException("Invalid HTTP-date: " + value, value, 0, e); + } + } + + /** + * Verify that the fixed-position separators of an IMF-fixdate are present. + */ + private static boolean validateLayout(String s) { + return s.charAt(3) == ',' + && s.charAt(4) == ' ' + && s.charAt(7) == ' ' + && s.charAt(11) == ' ' + && s.charAt(16) == ' ' + && s.charAt(19) == ':' + && s.charAt(22) == ':' + && s.charAt(25) == ' ' + && s.charAt(26) == 'G' + && s.charAt(27) == 'M' + && s.charAt(28) == 'T'; + } + + private static int parseTwoDigit(String s, int idx) { + char a = s.charAt(idx); + char b = s.charAt(idx + 1); + if (a < '0' || a > '9' || b < '0' || b > '9') { + throw new NumberFormatException(); + } + return (a - '0') * 10 + (b - '0'); + } + + private static int parseYear(String s, int idx) { + int year = 0; + for (int i = 0; i < 4; i++) { + char c = s.charAt(idx + i); + if (c < '0' || c > '9') { + throw new NumberFormatException(); + } + year = year * 10 + (c - '0'); + } + return year; + } + + /** + * Map a 3-character month abbreviation to its 1-based month number. + * Uses a switch on the first letter (well-predicted by the JIT) and compares the trailing two letters directly. + */ + private static int parseMonthName(String s, int idx) { + char a = s.charAt(idx); + char b = s.charAt(idx + 1); + char c = s.charAt(idx + 2); + switch (a) { + case 'J': + if (b == 'a' && c == 'n') { + return 1; + } else if (b == 'u' && c == 'n') { + return 6; + } else if (b == 'u' && c == 'l') { + return 7; + } + break; + case 'F': + if (b == 'e' && c == 'b') { + return 2; + } + break; + case 'M': + if (b == 'a' && c == 'r') { + return 3; + } else if (b == 'a' && c == 'y') { + return 5; + } + break; + case 'A': + if (b == 'p' && c == 'r') { + return 4; + } else if (b == 'u' && c == 'g') { + return 8; + } + break; + case 'S': + if (b == 'e' && c == 'p') { + return 9; + } + break; + case 'O': + if (b == 'c' && c == 't') { + return 10; + } + break; + case 'N': + if (b == 'o' && c == 'v') { + return 11; + } + break; + case 'D': + if (b == 'e' && c == 'c') { + return 12; + } + break; + } + throw new IllegalArgumentException("Unknown month abbreviation: " + s.substring(idx, idx + 3)); + } + + /** + * Range-check a long that came from {@link Instant#getEpochSecond()}. Year is supported through 9999 (4 digits), + * so epoch-seconds outside that range fall back to the JDK formatter to either succeed or fail uniformly. + */ + static boolean isYearRepresentable(long epochSecond) { + // 0001-01-01T00:00:00Z = -62135596800 + // 9999-12-31T23:59:59Z = 253402300799 + return epochSecond >= -62135596800L && epochSecond <= 253402300799L + // Also need to bound the field range + && epochSecond >= INSTANT_SECONDS_MIN + && epochSecond <= INSTANT_SECONDS_MAX; + } +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/serde/TimestampFormatter.java b/core/src/main/java/software/amazon/smithy/java/core/serde/TimestampFormatter.java index c9f3a59d2..fa6599cfa 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/serde/TimestampFormatter.java +++ b/core/src/main/java/software/amazon/smithy/java/core/serde/TimestampFormatter.java @@ -8,7 +8,6 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.time.Instant; -import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; import software.amazon.smithy.java.core.schema.Schema; @@ -217,20 +216,21 @@ public TimestampFormatTrait.Format format() { @Override public String writeString(Instant value) { - return HTTP_DATE_FORMAT.format(value); + long epoch = value.getEpochSecond(); + if (HttpDateFormat.isYearRepresentable(epoch)) { + return HttpDateFormat.format(epoch); + } + // Out-of-range years (before 0001 or after 9999) — fall through + // to the JDK formatter so behaviour matches the legacy path. + return HttpDateFormat.formatFallback(value); } @Override public Instant readFromString(String value, boolean strict) { - return HTTP_DATE_FORMAT.parse(value, Instant::from); + return HttpDateFormat.parse(value); } }; - private static final DateTimeFormatter HTTP_DATE_FORMAT = DateTimeFormatter - .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'") - .withZone(ZoneId.of("UTC")) - .withLocale(Locale.US); - @Override public String toString() { return format().toString(); diff --git a/core/src/test/java/software/amazon/smithy/java/core/serde/HttpDateFormatTest.java b/core/src/test/java/software/amazon/smithy/java/core/serde/HttpDateFormatTest.java new file mode 100644 index 000000000..6f274c50a --- /dev/null +++ b/core/src/test/java/software/amazon/smithy/java/core/serde/HttpDateFormatTest.java @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.core.serde; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Random; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for the hand-rolled {@link HttpDateFormat} implementation. + * + *

The fast path is verified against the reference {@link DateTimeFormatter} + * to guarantee bit-for-bit compatibility for all valid IMF-fixdate inputs. + */ +public class HttpDateFormatTest { + + private static final DateTimeFormatter REFERENCE = DateTimeFormatter + .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'") + .withZone(ZoneOffset.UTC) + .withLocale(Locale.US); + + /** + * Each case is (Instant, expected formatted string). The expected + * strings were captured from the reference {@link DateTimeFormatter} so + * a regression in either implementation will be caught. + */ + static Stream formatCases() { + return Stream.of( + // Epoch boundary + Arguments.of(Instant.EPOCH, "Thu, 01 Jan 1970 00:00:00 GMT"), + // RFC 7231 §7.1.1.1 example + Arguments.of(Instant.ofEpochSecond(784111777L), "Sun, 06 Nov 1994 08:49:37 GMT"), + // Each day-of-week — pin against the reference formatter (calendar + // history before the proleptic Gregorian era can confuse hardcoded + // values, so we let the JDK be the source of truth here). + referenceCase("2024-01-01T00:00:00Z"), + referenceCase("2024-01-02T00:00:00Z"), + referenceCase("2024-01-03T00:00:00Z"), + referenceCase("2024-01-04T00:00:00Z"), + referenceCase("2024-01-05T00:00:00Z"), + referenceCase("2024-01-06T00:00:00Z"), + referenceCase("2024-01-07T00:00:00Z"), + // Each month + referenceCase("2024-02-29T12:34:56Z"), + referenceCase("2024-03-15T00:00:00Z"), + referenceCase("2024-04-30T23:59:59Z"), + referenceCase("2024-05-15T01:23:45Z"), + referenceCase("2024-06-15T01:23:45Z"), + referenceCase("2024-07-15T01:23:45Z"), + referenceCase("2024-08-15T01:23:45Z"), + referenceCase("2024-09-15T01:23:45Z"), + referenceCase("2024-10-15T01:23:45Z"), + referenceCase("2024-11-15T01:23:45Z"), + referenceCase("2024-12-15T01:23:45Z"), + // Year padding behaviour at zero-padded boundaries + referenceCase("0001-01-01T00:00:00Z"), + referenceCase("0099-12-31T23:59:59Z"), + referenceCase("0999-12-31T23:59:59Z"), + referenceCase("9999-12-31T23:59:59Z"), + // Leap and non-leap year February ends + referenceCase("2000-02-29T00:00:00Z"), + referenceCase("2100-02-28T00:00:00Z"), + // Sub-second component is dropped (HTTP-date is whole-second precision) + Arguments.of(Instant.ofEpochSecond(784111777L, 999_999_999L), "Sun, 06 Nov 1994 08:49:37 GMT")); + } + + private static Arguments referenceCase(String iso) { + Instant value = Instant.parse(iso); + return Arguments.of(value, REFERENCE.format(value)); + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("formatCases") + void formatProducesExpectedString(Instant input, String expected) { + assertEquals(expected, HttpDateFormat.format(input.getEpochSecond())); + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("formatCases") + void formatMatchesReferenceFormatter(Instant input, String expected) { + // Same as above but anchors against the JDK formatter so the test + // fails informatively if either side is wrong. + assertEquals(REFERENCE.format(input.truncatedTo(java.time.temporal.ChronoUnit.SECONDS)), + HttpDateFormat.format(input.getEpochSecond())); + } + + @ParameterizedTest(name = "{1} -> {0}") + @MethodSource("formatCases") + void parseRoundTrips(Instant expected, String input) { + // Drop sub-second precision since HTTP-date doesn't carry it. + Instant truncated = expected.truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + assertEquals(truncated, HttpDateFormat.parse(input)); + } + + @ParameterizedTest(name = "random epoch second {0}") + @MethodSource("randomEpochSeconds") + void roundTripWithReferenceFormatter(long epoch) { + // For arbitrary epoch seconds in a wide but representable range, + // verify both implementations agree. + String fast = HttpDateFormat.format(epoch); + String reference = REFERENCE.format(Instant.ofEpochSecond(epoch)); + assertEquals(reference, fast); + assertEquals(Instant.ofEpochSecond(epoch), HttpDateFormat.parse(fast)); + } + + /** + * Supplies a deterministic stream of random epoch-seconds in years + * 0001 through 9999 to fuzz the fast path against the reference. + */ + static Stream randomEpochSeconds() { + Random rng = new Random(0xC0FFEEL); + // Year 0001 minimum and year 9999 maximum. + long min = LocalDateTime.of(1, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC); + long max = LocalDateTime.of(9999, 12, 31, 23, 59, 59).toEpochSecond(ZoneOffset.UTC); + return Stream.generate(() -> min + (long) (rng.nextDouble() * (max - min))).limit(500); + } + + /** + * Inputs that the strict fast path rejects but the JDK formatter + * accepts; these should still parse correctly via the fallback. + */ + static Stream fallbackCases() { + return Stream.of( + // Wrong-cased month — the JDK pattern is case-sensitive too + // (so this should THROW), grouped here to assert the fallback + // also rejects: + Arguments.of("Sun, 06 nov 1994 08:49:37 GMT", true), + // Wrong length — the fast path rejects up front, fallback also rejects + Arguments.of("Sun, 06 Nov 1994 8:49:37 GMT", true), + Arguments.of("not a date", true), + Arguments.of("", true)); + } + + @ParameterizedTest(name = "fallback ({0})") + @MethodSource("fallbackCases") + void fallbackHandlesMalformedInputs(String input, boolean shouldThrow) { + if (shouldThrow) { + assertThrows(DateTimeParseException.class, () -> HttpDateFormat.parse(input)); + } else { + // Sanity placeholder if the matrix grows to include accepted forms later. + HttpDateFormat.parse(input); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "Mon, 32 Jan 2024 00:00:00 GMT", // day out of range + "Mon, 00 Jan 2024 00:00:00 GMT", // day out of range + "Mon, 31 Feb 2024 00:00:00 GMT", // Feb 31 doesn't exist + "Mon, 30 Feb 2024 00:00:00 GMT", // Feb 30 doesn't exist (leap year still 29) + "Mon, 01 Foo 2024 00:00:00 GMT", // bad month name + "Mon, 01 Jan 2024 24:00:00 GMT", // hour out of range + "Mon, 01 Jan 2024 00:60:00 GMT", // minute out of range + "Mon, 01 Jan 2024 00:00:60 GMT", // second out of range + "Mon, 1A Jan 2024 00:00:00 GMT", // non-digit in day + }) + void rejectsOutOfRangeOrMalformedDates(String input) { + assertThrows(RuntimeException.class, () -> HttpDateFormat.parse(input)); + } + + @Test + void permissiveAboutDayOfWeekField() { + // Real HTTP-date producers occasionally get the day-of-week wrong. + // Prefer accepting the value (matching what real-world tolerant + // parsers do) over strictly rejecting it. Document this contract + // here so future changes that tighten it must update this test. + Instant parsed = HttpDateFormat.parse("Mon, 06 Nov 1994 08:49:37 GMT"); + assertEquals(Instant.ofEpochSecond(784111777L), parsed); + } + + @Test + void honoursOutputFromTimestampFormatter() { + // The TimestampFormatter prelude routes HTTP_DATE through this code. + TimestampFormatter f = TimestampFormatter.Prelude.HTTP_DATE; + Instant value = Instant.ofEpochSecond(784111777L); + assertEquals("Sun, 06 Nov 1994 08:49:37 GMT", f.writeString(value)); + assertEquals(value, f.readFromString("Sun, 06 Nov 1994 08:49:37 GMT", false)); + } + + @Test + void preludeHttpDateIsCachedSingleton() { + // Sanity: confirm we did not accidentally introduce a new instance per + // invocation (would defeat any later caching above this layer). + assertSame(TimestampFormatter.Prelude.HTTP_DATE, TimestampFormatter.Prelude.HTTP_DATE); + } +} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/BindingMatcher.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/BindingMatcher.java deleted file mode 100644 index 36fa43864..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/BindingMatcher.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.schema.TraitKey; - -abstract sealed class BindingMatcher { - - enum Binding { - HEADER, - QUERY, - PAYLOAD, - BODY, - LABEL, - STATUS, - PREFIX_HEADERS, - QUERY_PARAMS - } - - private final Binding[] bindings; - private final int responseStatus; - private final boolean hasBody; - private final boolean hasPayload; - - private BindingMatcher(Schema struct, int responseStatus) { - this.responseStatus = responseStatus; - boolean foundBody = false; - boolean foundPayload = false; - this.bindings = new Binding[struct.members().size()]; - for (var member : struct.members()) { - var binding = doMatch(member); - bindings[member.memberIndex()] = binding; - foundBody = foundBody || binding == Binding.BODY; - foundPayload = foundPayload || binding == Binding.PAYLOAD; - } - - this.hasBody = foundBody; - this.hasPayload = foundPayload; - } - - static BindingMatcher requestMatcher(Schema input) { - return new BindingMatcher.RequestMatcher(input); - } - - static BindingMatcher responseMatcher(Schema output) { - return new BindingMatcher.ResponseMatcher(output); - } - - final Binding match(Schema member) { - return bindings[member.memberIndex()]; - } - - final int responseStatus() { - return responseStatus; - } - - final boolean hasBody() { - return hasBody; - } - - final boolean hasPayload() { - return hasPayload; - } - - final boolean writeBody(boolean omitEmptyPayload) { - return hasBody || (!omitEmptyPayload && !hasPayload); - } - - protected abstract Binding doMatch(Schema member); - - static final class RequestMatcher extends BindingMatcher { - RequestMatcher(Schema input) { - super(input, -1); - } - - protected Binding doMatch(Schema member) { - if (member.hasTrait(TraitKey.HTTP_LABEL_TRAIT)) { - return Binding.LABEL; - } - - if (member.hasTrait(TraitKey.HTTP_QUERY_TRAIT)) { - return Binding.QUERY; - } - - if (member.hasTrait(TraitKey.HTTP_QUERY_PARAMS_TRAIT)) { - return Binding.QUERY_PARAMS; - } - - if (member.hasTrait(TraitKey.HTTP_HEADER_TRAIT)) { - return Binding.HEADER; - } - - if (member.hasTrait(TraitKey.HTTP_PREFIX_HEADERS_TRAIT)) { - return Binding.PREFIX_HEADERS; - } - - if (member.hasTrait(TraitKey.HTTP_PAYLOAD_TRAIT)) { - return Binding.PAYLOAD; - } - - return Binding.BODY; - } - } - - static final class ResponseMatcher extends BindingMatcher { - ResponseMatcher(Schema output) { - super(output, computeResponseStatus(output)); - } - - private static int computeResponseStatus(Schema struct) { - if (struct.hasTrait(TraitKey.HTTP_ERROR_TRAIT)) { - return struct.expectTrait(TraitKey.HTTP_ERROR_TRAIT).getCode(); - } else if (struct.hasTrait(TraitKey.ERROR_TRAIT)) { - return struct.expectTrait(TraitKey.ERROR_TRAIT).getDefaultHttpStatusCode(); - } else { - return -1; - } - } - - @Override - protected Binding doMatch(Schema member) { - if (member.hasTrait(TraitKey.HTTP_RESPONSE_CODE_TRAIT)) { - return Binding.STATUS; - } - - if (member.hasTrait(TraitKey.HTTP_HEADER_TRAIT)) { - return Binding.HEADER; - } - - if (member.hasTrait(TraitKey.HTTP_PREFIX_HEADERS_TRAIT)) { - return Binding.PREFIX_HEADERS; - } - - if (member.hasTrait(TraitKey.HTTP_PAYLOAD_TRAIT)) { - return Binding.PAYLOAD; - } - - return Binding.BODY; - } - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HeaderErrorSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HeaderErrorSerializer.java index b2c15d29e..75de94f0a 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HeaderErrorSerializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HeaderErrorSerializer.java @@ -38,7 +38,9 @@ public interface HeaderErrorSerializer { * Write the error type discriminator for the given error schema. * * @param errorSchema Schema of the error being serialized. - * @param headers Mutable header map to write discriminator headers to. + * @param headers Mutable header map to write discriminator headers to. Keys are + * case-insensitive header names and values are the header value + * list (each entry produces one wire header line). * @param context Context for the current request, which may contain protocol-specific * information such as the service namespace. */ diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBinding.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBinding.java index 54ef9fb6f..c007fca7f 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBinding.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBinding.java @@ -5,18 +5,11 @@ package software.amazon.smithy.java.http.binding; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import software.amazon.smithy.java.core.schema.Schema; - /** * Entry point for handling HTTP bindings. */ public final class HttpBinding { - private final ConcurrentMap REQUEST_CACHE = new ConcurrentHashMap<>(); - private final ConcurrentMap RESPONSE_CACHE = new ConcurrentHashMap<>(); - public HttpBinding() {} /** @@ -25,7 +18,7 @@ public HttpBinding() {} * @return Returns the serializer. */ public RequestSerializer requestSerializer() { - return new RequestSerializer(REQUEST_CACHE); + return new RequestSerializer(); } /** @@ -34,7 +27,7 @@ public RequestSerializer requestSerializer() { * @return Returns the serializer. */ public ResponseSerializer responseSerializer() { - return new ResponseSerializer(RESPONSE_CACHE); + return new ResponseSerializer(); } /** @@ -43,7 +36,7 @@ public ResponseSerializer responseSerializer() { * @return Returns the request deserializer. */ public RequestDeserializer requestDeserializer() { - return new RequestDeserializer(REQUEST_CACHE); + return new RequestDeserializer(); } /** @@ -52,6 +45,6 @@ public RequestDeserializer requestDeserializer() { * @return Returns the response deserializer. */ public ResponseDeserializer responseDeserializer() { - return new ResponseDeserializer(RESPONSE_CACHE); + return new ResponseDeserializer(); } } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java index ce37de78b..d80c6ce71 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java @@ -19,6 +19,7 @@ import software.amazon.smithy.java.core.serde.event.EventDecoderFactory; import software.amazon.smithy.java.core.serde.event.EventStreamReader; import software.amazon.smithy.java.core.serde.event.ProtocolEventStreamReader; +import software.amazon.smithy.java.http.api.HeaderName; import software.amazon.smithy.java.http.api.HttpHeaders; import software.amazon.smithy.java.io.datastream.DataStream; import software.amazon.smithy.java.io.uri.QueryStringParser; @@ -39,7 +40,7 @@ final class HttpBindingDeserializer extends SpecificShapeDeserializer implements private final Map> queryStringParameters; private final int responseStatus; private final Map requestPathLabels; - private final BindingMatcher bindingMatcher; + private final boolean isResponse; private final DataStream body; private final EventDecoderFactory eventDecoderFactory; private final String payloadMediaType; @@ -47,7 +48,7 @@ final class HttpBindingDeserializer extends SpecificShapeDeserializer implements private HttpBindingDeserializer(Builder builder) { this.payloadCodec = Objects.requireNonNull(builder.payloadCodec, "payloadSerializer not set"); this.headers = Objects.requireNonNull(builder.headers, "headers not set"); - this.bindingMatcher = Objects.requireNonNull(builder.bindingMatcher, "bindingMatcher not set"); + this.isResponse = builder.isResponse; this.eventDecoderFactory = builder.eventDecoderFactory; this.body = builder.body == null ? DataStream.ofEmpty() : builder.body; this.queryStringParameters = QueryStringParser.parse(builder.requestRawQueryString); @@ -67,93 +68,175 @@ protected RuntimeException throwForInvalidState(Schema schema) { @Override public void readStruct(Schema schema, T state, StructMemberConsumer structMemberConsumer) { - // First parse members in the framing. - for (Schema member : schema.members()) { - BindingMatcher.Binding bindingLoc = bindingMatcher.match(member); - switch (bindingLoc) { - case LABEL -> { - var labelValue = requestPathLabels.get(member.memberName()); - if (labelValue == null) { - throw new IllegalStateException( - "Expected a label value for " + member.memberName() - + " but it was null."); - } - structMemberConsumer.accept( - state, - member, - new HttpPathLabelDeserializer(labelValue)); - } - case QUERY -> { - var paramValue = queryStringParameters.get( - member.expectTrait(TraitKey.HTTP_QUERY_TRAIT).getValue()); - if (paramValue != null) { - structMemberConsumer.accept(state, member, new HttpQueryStringDeserializer(paramValue)); - } - } - case QUERY_PARAMS -> - structMemberConsumer.accept(state, member, new HttpQueryParamsDeserializer(queryStringParameters)); - case HEADER -> { - var header = member.expectTrait(TraitKey.HTTP_HEADER_TRAIT).getValue(); - if (member.type() == ShapeType.LIST) { - var values = headers.allValues(header); - if (!values.isEmpty()) { - structMemberConsumer.accept(state, member, new HttpHeaderListDeserializer(member, values)); - } - } else { - var headerValue = headers.firstValue(header); - if (headerValue != null) { - structMemberConsumer.accept(state, member, new HttpHeaderDeserializer(headerValue)); - } - } - } - case PREFIX_HEADERS -> - structMemberConsumer.accept(state, member, new HttpPrefixHeadersDeserializer(headers)); - case BODY -> { - } // handled below - case PAYLOAD -> { - if (isEventStream(member)) { - structMemberConsumer.accept(state, member, new SpecificShapeDeserializer() { - @Override - public EventStreamReader readEventStream(Schema schema) { - return ProtocolEventStreamReader.newReader(body, eventDecoderFactory, false); - } - }); - } else if (member.hasTrait(TraitKey.STREAMING_TRAIT)) { - // Set the payload on shape builder directly. This will fail for misconfigured shapes. - structMemberConsumer.accept(state, member, new SpecificShapeDeserializer() { - @Override - public DataStream readDataStream(Schema schema) { - return body; - } - }); - } else if (member.type() == ShapeType.STRUCTURE || member.type() == ShapeType.UNION - || member.type() == ShapeType.LIST) { - // Read the payload into a byte buffer to deserialize a shape in the body. - ByteBuffer bb = bodyAsByteBuffer(); - if (bb.remaining() > 0) { - structMemberConsumer.accept(state, member, payloadCodec.createDeserializer(bb)); - } - } else if (body != null && body.contentLength() > 0) { - structMemberConsumer.accept(state, member, new PayloadDeserializer(payloadCodec, body)); - } - } - case STATUS -> { - structMemberConsumer.accept(state, member, new ResponseStatusDeserializer(responseStatus)); - } - default -> throw new UnsupportedOperationException(bindingLoc + " not supported"); + var ext = schema.getExtension(HttpBindingSchemaExtensions.KEY); + if (!(ext instanceof HttpBindingSchemaExtensions.StructBindings sb)) { + // Schema is not a structure / union — fall back to the legacy + // generic path so we still throw the same UnsupportedOperationException. + throw new IllegalStateException("Expected to parse a structure for HTTP bindings, but found " + schema); + } + + if (isResponse) { + readResponseStruct(schema, state, structMemberConsumer, sb.response()); + } else { + readRequestStruct(schema, state, structMemberConsumer, sb.request()); + } + } + + private void readRequestStruct( + Schema schema, + T state, + StructMemberConsumer structMemberConsumer, + HttpBindingSchemaExtensions.RequestBinding rb + ) { + readHeaderBindings( + state, + structMemberConsumer, + rb.listHeaderMembers, + rb.listHeaderNames, + rb.scalarHeadersByName, + rb.prefixHeadersMembers); + + for (Schema member : rb.labelMembers) { + var labelValue = requestPathLabels.get(member.memberName()); + if (labelValue == null) { + throw new IllegalStateException( + "Expected a label value for " + member.memberName() + " but it was null."); } + structMemberConsumer.accept(state, member, new HttpPathLabelDeserializer(labelValue)); } - // Now parse members in the payload of body. - if (bindingMatcher.hasBody()) { - validateMediaType(); - // Need to read the entire payload into a byte buffer to deserialize via a codec. - ByteBuffer bb = bodyAsByteBuffer(); - payloadCodec.createDeserializer(bb).readStruct(schema, bindingMatcher, (body, m, de) -> { - if (bindingMatcher.match(m) == BindingMatcher.Binding.BODY) { - structMemberConsumer.accept(state, m, de); + for (int i = 0; i < rb.queryMembers.length; i++) { + var paramValue = queryStringParameters.get(rb.queryWireNames[i]); + if (paramValue != null) { + Schema member = rb.queryMembers[i]; + structMemberConsumer.accept(state, member, new HttpQueryStringDeserializer(paramValue)); + } + } + + for (Schema member : rb.queryParamsMembers) { + structMemberConsumer.accept(state, member, new HttpQueryParamsDeserializer(queryStringParameters)); + } + + readPayloadAndBody(schema, state, structMemberConsumer, rb.payloadMember, rb.hasBody, rb.bindings); + } + + private void readResponseStruct( + Schema schema, + T state, + StructMemberConsumer structMemberConsumer, + HttpBindingSchemaExtensions.ResponseBinding rb + ) { + readHeaderBindings( + state, + structMemberConsumer, + rb.listHeaderMembers(), + rb.listHeaderNames(), + rb.scalarHeadersByName(), + rb.prefixHeadersMembers()); + + if (rb.statusMember() != null) { + structMemberConsumer.accept(state, rb.statusMember(), new ResponseStatusDeserializer(responseStatus)); + } + + readPayloadAndBody(schema, state, structMemberConsumer, rb.payloadMember(), rb.hasBody(), rb.bindings()); + } + + /** + * Process the three header-binding kinds (list, scalar, prefix). Common to both + * request and response directions — both bindings expose the same field shapes. + */ + private void readHeaderBindings( + T state, + StructMemberConsumer structMemberConsumer, + Schema[] listHeaderMembers, + HeaderName[] listHeaderNames, + Map scalarHeadersByName, + Schema[] prefixHeadersMembers + ) { + for (int i = 0; i < listHeaderMembers.length; i++) { + var values = headers.allValues(listHeaderNames[i]); + if (!values.isEmpty()) { + Schema member = listHeaderMembers[i]; + structMemberConsumer.accept(state, member, new HttpHeaderListDeserializer(member, values)); + } + } + + if (!scalarHeadersByName.isEmpty()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + HeaderDispatchContext ctx = new HeaderDispatchContext( + scalarHeadersByName, + state, + (StructMemberConsumer) structMemberConsumer); + headers.forEachEntry(ctx, HEADER_DISPATCHER); + } + + for (Schema member : prefixHeadersMembers) { + structMemberConsumer.accept(state, member, new HttpPrefixHeadersDeserializer(headers)); + } + } + + /** + * Process the @httpPayload member (if any) and the codec-driven body. Common to + * both request and response directions. + */ + private void readPayloadAndBody( + Schema schema, + T state, + StructMemberConsumer structMemberConsumer, + Schema payloadMember, + boolean hasBody, + HttpBindingSchemaExtensions.Binding[] bindings + ) { + if (payloadMember != null) { + handlePayload(payloadMember, state, structMemberConsumer); + } + if (hasBody) { + readBody(schema, state, structMemberConsumer, bindings); + } + } + + private void readBody( + Schema schema, + T state, + StructMemberConsumer structMemberConsumer, + HttpBindingSchemaExtensions.Binding[] bindings + ) { + validateMediaType(); + ByteBuffer bb = bodyAsByteBuffer(); + // The codec's readStruct callback receives every member; filter to body members using the direction-specific + // bindings array (which was already chosen by the caller). + payloadCodec.createDeserializer(bb).readStruct(schema, bindings, (body, m, de) -> { + if (body[m.memberIndex()] == HttpBindingSchemaExtensions.Binding.BODY) { + structMemberConsumer.accept(state, m, de); + } + }); + } + + private void handlePayload(Schema member, T state, StructMemberConsumer structMemberConsumer) { + if (isEventStream(member)) { + structMemberConsumer.accept(state, member, new SpecificShapeDeserializer() { + @Override + public EventStreamReader readEventStream(Schema schema) { + return ProtocolEventStreamReader.newReader(body, eventDecoderFactory, false); + } + }); + } else if (member.hasTrait(TraitKey.STREAMING_TRAIT)) { + // Set the payload on shape builder directly. This will fail for misconfigured shapes. + structMemberConsumer.accept(state, member, new SpecificShapeDeserializer() { + @Override + public DataStream readDataStream(Schema schema) { + return body; } }); + } else if (member.type() == ShapeType.STRUCTURE || member.type() == ShapeType.UNION + || member.type() == ShapeType.LIST) { + // Read the payload into a byte buffer to deserialize a shape in the body. + ByteBuffer bb = bodyAsByteBuffer(); + if (bb.remaining() > 0) { + structMemberConsumer.accept(state, member, payloadCodec.createDeserializer(bb)); + } + } else if (body != null && body.contentLength() > 0) { + structMemberConsumer.accept(state, member, new PayloadDeserializer(payloadCodec, body)); } } @@ -221,7 +304,7 @@ static final class Builder implements SmithyBuilder { private int responseStatus; private EventDecoderFactory eventDecoderFactory; private String payloadMediaType; - private BindingMatcher bindingMatcher; + private boolean isResponse; private Builder() {} @@ -312,9 +395,33 @@ Builder payloadMediaType(String payloadMediaType) { return this; } - Builder bindingMatcher(BindingMatcher bindingMatcher) { - this.bindingMatcher = bindingMatcher; + Builder isResponse(boolean isResponse) { + this.isResponse = isResponse; return this; } } + + /** + * Carrier passed to {@link HttpHeaders#forEachEntry(Object, HttpHeaders.HeaderWithValueConsumer)} + * so the per-pair callback doesn't allocate a capturing lambda per {@code readStruct} call. The fields hold + * everything the dispatcher needs to route a matching header value. + */ + private record HeaderDispatchContext( + Map byName, + Object state, + StructMemberConsumer consumer) {} + + /** + * Stateless dispatcher: looks up a header name in the context's name->schema map and emits a + * {@link HttpHeaderDeserializer} for the matching member. + *

+ * Allocated once and reused across all {@code readStruct} invocations. + */ + private static final HttpHeaders.HeaderWithValueConsumer HEADER_DISPATCHER = + (ctx, name, value) -> { + Schema member = ctx.byName.get(name); + if (member != null) { + ctx.consumer.accept(ctx.state, member, new HttpHeaderDeserializer(value)); + } + }; } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java new file mode 100644 index 000000000..fa7a95609 --- /dev/null +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java @@ -0,0 +1,707 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.http.binding; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaExtensionKey; +import software.amazon.smithy.java.core.schema.SchemaExtensionProvider; +import software.amazon.smithy.java.core.schema.TraitKey; +import software.amazon.smithy.java.core.serde.TimestampFormatter; +import software.amazon.smithy.java.http.api.HeaderName; +import software.amazon.smithy.model.shapes.ShapeType; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Pre-computes HTTP binding data on Schema objects at construction time so the per-request hot path can avoid + * trait lookups, type checks, and dispatch on a generic switch. + * + *

All internals are package-private and can be changed freely. + */ +@SmithyInternalApi +public final class HttpBindingSchemaExtensions + implements SchemaExtensionProvider { + + /** + * Extension key for HTTP binding pre-computed data. + */ + public static final SchemaExtensionKey KEY = new SchemaExtensionKey<>(); + + /** + * Look up the {@link MemberBinding} for a member schema. Throws if no binding or not a member. + */ + static MemberBinding memberBindingOf(Schema schema) { + var ext = schema.getExtension(KEY); + if (ext == null) { + throw new IllegalStateException( + "Schema " + schema.id() + " has no HTTP binding extension"); + } + if (!(ext instanceof MemberBinding mb)) { + throw new IllegalStateException( + "Schema " + schema.id() + " has an HTTP binding extension that is not a member binding"); + } + return mb; + } + + /** + * Look up the {@link OperationBinding} for an operation schema. Throws if no binding or not an operation. + */ + static OperationBinding operationBindingOf(Schema schema) { + var ext = schema.getExtension(KEY); + if (ext == null) { + throw new IllegalStateException( + "Schema " + schema.id() + " has no HTTP binding extension"); + } + if (!(ext instanceof OperationBinding ob)) { + throw new IllegalStateException( + "Schema " + schema.id() + " has an HTTP binding extension that is not an operation binding"); + } + return ob; + } + + /** + * Look up the {@link StructBindings} for a structure schema. Throws if no binding or not a structure. + */ + static StructBindings structBindingsOf(Schema schema) { + var ext = schema.getExtension(KEY); + if (ext == null) { + throw new IllegalStateException("Schema " + schema.id() + " has no HTTP binding extension"); + } + if (!(ext instanceof StructBindings sb)) { + throw new IllegalStateException( + "Schema " + schema.id() + " has an HTTP binding extension that is not a structure binding"); + } + return sb; + } + + private static final Schema[] NO_SCHEMAS = new Schema[0]; + private static final String[] NO_QUERY_LITERALS = new String[0]; + + /** + * Binding kind for a single member, derived from the traits applied to it. + * + *

This is the protocol-test-friendly logical kind. The serializer / deserializer use direction-specific arrays + * on {@link StructBindings} which collapse {@link #STATUS} to {@link #BODY} for request direction and + * {@link #LABEL} / {@link #QUERY} / {@link #QUERY_PARAMS} to {@link #BODY} for response direction. + */ + enum Binding { + HEADER, + QUERY, + PAYLOAD, + BODY, + LABEL, + STATUS, + PREFIX_HEADERS, + QUERY_PARAMS + } + + /** + * Sealed marker for HTTP binding extension data attached to a schema. + */ + public sealed interface HttpBindingExt permits MemberBinding, StructBindings, OperationBinding {} + + /** + * Pre-computed binding data for a single member schema. + * + * @param kind the member's binding kind, resolved once from the member's traits. + * @param wireName the resolved wire name: the header field name for {@code @httpHeader}, the query parameter + * name for {@code @httpQuery}, the prefix string for {@code @httpPrefixHeaders}, or {@code null}. + * @param headerName the canonicalized header name for {@code @httpHeader}, or {@code null} for non-header bindings + * and dynamically-generated header names. + * @param isList whether the member's resolved target type is {@link ShapeType#LIST}. + * @param hasMediaType whether the member carries an {@code @mediaType} trait. + * @param timestampFormatter the effective timestamp formatter for this binding location, with any explicit + * {@code @timestampFormat} trait already resolved. Defaults to {@code HTTP_DATE} for + * header bindings, {@code DATE_TIME} for query / label bindings, and {@code null} otherwise. + */ + record MemberBinding( + Binding kind, + String wireName, + HeaderName headerName, + boolean isList, + boolean hasMediaType, + TimestampFormatter timestampFormatter) implements HttpBindingExt {} + + /** + * HTTP binding extension data for a structure or union schema for request and response bindings. + * + *

The volatile lazy fields use a benign-race CAS-free idiom. + */ + static final class StructBindings implements HttpBindingExt { + private final Schema schema; + private volatile RequestBinding request; + private volatile ResponseBinding response; + + StructBindings(Schema schema) { + this.schema = schema; + } + + RequestBinding request() { + var r = request; + if (r == null) { + r = buildRequestBinding(schema); + request = r; + } + return r; + } + + ResponseBinding response() { + var r = response; + if (r == null) { + r = buildResponseBinding(schema); + response = r; + } + return r; + } + } + + /** + * Pre-computed request-direction binding data for one structure / union schema. Built lazily by + * {@link StructBindings#request()} on first request-side access. + */ + static final class RequestBinding { + // Per-member request-direction Binding kind, indexed by member.memberIndex(). + // Used by the binding-serializer's hot-path switch. + final Binding[] bindings; + // Direction-neutral MemberBinding metadata indexed by member.memberIndex(). + // Saves the per-write Schema.getExtension(KEY) lookup chain. + final MemberBinding[] memberBindings; + // @httpHeader members split by target shape. + final Map scalarHeadersByName; + final Schema[] listHeaderMembers; + final HeaderName[] listHeaderNames; + final Schema[] queryMembers; + final String[] queryWireNames; + final Schema[] labelMembers; + final Schema[] queryParamsMembers; + final Schema[] prefixHeadersMembers; + final Schema payloadMember; + final boolean hasBody; + final boolean hasPayload; + // Set of all declared header wire names. Used by HttpPrefixHeadersSerializer to skip + // map keys that already collide with explicit @httpHeader members. + final Set headerWireNames; + // Whether {@code Content-Type} appears as an @httpHeader member of this struct (input-only concept). + final boolean inputContentTypeHeader; + // Estimated initial capacity (in name-value pairs) for the ArrayHttpHeaders we allocate per request. + final int headerCount; + // Lazy PathSerializer — built on first request through {@link #pathSerializer}. + private volatile PathSerializer pathSerializer; + + RequestBinding( + Binding[] bindings, + MemberBinding[] memberBindings, + Map scalarHeadersByName, + Schema[] listHeaderMembers, + HeaderName[] listHeaderNames, + Schema[] queryMembers, + String[] queryWireNames, + Schema[] labelMembers, + Schema[] queryParamsMembers, + Schema[] prefixHeadersMembers, + Schema payloadMember, + boolean hasBody, + boolean hasPayload, + Set headerWireNames, + boolean inputContentTypeHeader, + int headerCount + ) { + this.bindings = bindings; + this.memberBindings = memberBindings; + this.scalarHeadersByName = scalarHeadersByName; + this.listHeaderMembers = listHeaderMembers; + this.listHeaderNames = listHeaderNames; + this.queryMembers = queryMembers; + this.queryWireNames = queryWireNames; + this.labelMembers = labelMembers; + this.queryParamsMembers = queryParamsMembers; + this.prefixHeadersMembers = prefixHeadersMembers; + this.payloadMember = payloadMember; + this.hasBody = hasBody; + this.hasPayload = hasPayload; + this.headerWireNames = headerWireNames; + this.inputContentTypeHeader = inputContentTypeHeader; + this.headerCount = headerCount; + } + + PathSerializer pathSerializer(HttpTrait httpTrait, Schema inputSchema) { + var p = pathSerializer; + if (p != null) { + return p; + } + p = new PathSerializer(httpTrait, inputSchema); + pathSerializer = p; + return p; + } + + boolean writeBody(boolean omitEmptyPayload) { + return hasBody || (!omitEmptyPayload && !hasPayload); + } + } + + /** + * Pre-computed response-direction binding data for one structure / union schema. Built lazily + * by {@link StructBindings#response()} on first response-side access. + */ + record ResponseBinding( + Binding[] bindings, + MemberBinding[] memberBindings, + Map scalarHeadersByName, + Schema[] listHeaderMembers, + HeaderName[] listHeaderNames, + Schema[] prefixHeadersMembers, + Schema payloadMember, + Schema statusMember, + boolean hasBody, + boolean hasPayload, + Set headerWireNames, + int headerCount, + int defaultStatus) { + boolean writeBody(boolean omitEmptyPayload) { + return hasBody || (!omitEmptyPayload && !hasPayload); + } + } + + /** + * Pre-computed HTTP-binding data for an operation schema. + * + * @param httpTrait the cached {@code @http} trait — saves an {@code expectTrait} call per request. + * @param queryLiterals flat array of (name, value) pairs from the URI's static query literals, or empty. + * @param defaultResponseStatus default response status declared by the {@code @http} trait. + */ + record OperationBinding( + HttpTrait httpTrait, + String[] queryLiterals, + int defaultResponseStatus) implements HttpBindingExt {} + + @Override + public SchemaExtensionKey key() { + return KEY; + } + + @Override + public HttpBindingExt provide(Schema schema) { + if (schema.isMember()) { + return forMember(schema); + } + var type = schema.type(); + if (type == ShapeType.STRUCTURE || type == ShapeType.UNION) { + return forStruct(schema); + } + if (type == ShapeType.OPERATION && schema.hasTrait(TraitKey.HTTP_TRAIT)) { + return forOperation(schema); + } + return null; + } + + private static MemberBinding forMember(Schema m) { + // Header binding: needs wire field name and list-ness. + var hdr = m.getTrait(TraitKey.HTTP_HEADER_TRAIT); + if (hdr != null) { + // Header wire names are canonicalized (lowercased) at schema-build time. HTTP header names are + // case-insensitive, so storing them canonical means runtime lookup paths can compare them by + // identity / hash without re-canonicalizing per call. + String canonicalName = HeaderName.canonicalize(hdr.getValue()); + return new MemberBinding( + Binding.HEADER, + canonicalName, + HeaderName.of(canonicalName), + isListMember(m), + m.hasTrait(TraitKey.MEDIA_TYPE_TRAIT), + timestampFormatter(m, TimestampFormatter.Prelude.HTTP_DATE)); + } + + // Query string parameter. + var q = m.getTrait(TraitKey.HTTP_QUERY_TRAIT); + if (q != null) { + return new MemberBinding( + Binding.QUERY, + q.getValue(), + null, + isListMember(m), + false, + timestampFormatter(m, TimestampFormatter.Prelude.DATE_TIME)); + } + + // @httpPrefixHeaders has a prefix string as its value. Canonicalized for the same reason as @httpHeader: + // header name matching is case-insensitive. Empty prefix is permitted by the trait (it means "all headers go + // in this map"); skip canonicalization for it since HeaderName.canonicalize rejects empty input. + var pfx = m.getTrait(TraitKey.HTTP_PREFIX_HEADERS_TRAIT); + if (pfx != null) { + String pfxValue = pfx.getValue(); + String canonicalPrefix = pfxValue.isEmpty() ? pfxValue : HeaderName.canonicalize(pfxValue); + return new MemberBinding( + Binding.PREFIX_HEADERS, + canonicalPrefix, + null, + false, + false, + null); + } + + if (m.hasTrait(TraitKey.HTTP_LABEL_TRAIT)) { + return new MemberBinding( + Binding.LABEL, + m.memberName(), + null, + false, + false, + timestampFormatter(m, TimestampFormatter.Prelude.DATE_TIME)); + } + + if (m.hasTrait(TraitKey.HTTP_QUERY_PARAMS_TRAIT)) { + return new MemberBinding( + Binding.QUERY_PARAMS, + null, + null, + false, + false, + null); + } + + if (m.hasTrait(TraitKey.HTTP_PAYLOAD_TRAIT)) { + return new MemberBinding( + Binding.PAYLOAD, + null, + null, + false, + m.hasTrait(TraitKey.MEDIA_TYPE_TRAIT), + null); + } + + if (m.hasTrait(TraitKey.HTTP_RESPONSE_CODE_TRAIT)) { + return new MemberBinding( + Binding.STATUS, + null, + null, + false, + false, + null); + } + + return new MemberBinding(Binding.BODY, null, null, false, false, null); + } + + private static boolean isListMember(Schema m) { + // Resolve through member -> target if we got a member schema. + var target = m.isMember() ? m.memberTarget() : m; + return target.type() == ShapeType.LIST; + } + + private static TimestampFormatter timestampFormatter(Schema member, TimestampFormatter defaultFormatter) { + var trait = member.getTrait(TraitKey.TIMESTAMP_FORMAT_TRAIT); + return trait == null ? defaultFormatter : TimestampFormatter.of(trait); + } + + private static StructBindings forStruct(Schema struct) { + return new StructBindings(struct); + } + + /** + * Build the request-direction binding for one struct/union schema. Walks members once, classifies each by trait + * kind under the request-direction view, and builds the parallel arrays needed by the binding serializer. + */ + static RequestBinding buildRequestBinding(Schema struct) { + List headers = new ArrayList<>(); + List queries = new ArrayList<>(); + List labels = new ArrayList<>(); + List queryParams = new ArrayList<>(); + List prefixHeaders = new ArrayList<>(); + Schema payload = null; + boolean hasBody = false; + boolean hasPayload = false; + + int memberCount = struct.members().size(); + Binding[] bindings = new Binding[memberCount]; + MemberBinding[] memberBindings = new MemberBinding[memberCount]; + + for (Schema m : struct.members()) { + var ext = (MemberBinding) m.getExtension(KEY); + int idx = m.memberIndex(); + memberBindings[idx] = ext; + switch (ext.kind()) { + case HEADER -> { + headers.add(m); + bindings[idx] = Binding.HEADER; + } + case PREFIX_HEADERS -> { + prefixHeaders.add(m); + bindings[idx] = Binding.PREFIX_HEADERS; + } + case PAYLOAD -> { + payload = m; + hasPayload = true; + bindings[idx] = Binding.PAYLOAD; + } + case QUERY -> { + queries.add(m); + bindings[idx] = Binding.QUERY; + } + case QUERY_PARAMS -> { + queryParams.add(m); + bindings[idx] = Binding.QUERY_PARAMS; + } + case LABEL -> { + labels.add(m); + bindings[idx] = Binding.LABEL; + } + case STATUS -> { + // STATUS has no meaning request-side — fold into BODY. + hasBody = true; + bindings[idx] = Binding.BODY; + } + case BODY -> { + hasBody = true; + bindings[idx] = Binding.BODY; + } + } + } + + Set headerWireNames; + boolean hasContentTypeHeader = false; + if (headers.isEmpty() && prefixHeaders.isEmpty()) { + headerWireNames = Set.of(); + } else { + HashSet set = HashSet.newHashSet(headers.size()); + for (Schema m : headers) { + String wireName = ((MemberBinding) m.getExtension(KEY)).wireName(); + set.add(wireName); + // wireName is already canonical (lowercase) here, so use + // exact equality rather than a case-insensitive compare. + if (!hasContentTypeHeader && wireName.equals("content-type")) { + hasContentTypeHeader = true; + } + } + headerWireNames = Set.copyOf(set); + } + + Schema[] listHeaderMembers = listHeaderMembers(headers); + HeaderName[] listHeaderNames = listHeaderNames(listHeaderMembers); + Schema[] queryArr = toArray(queries); + String[] queryWireNames = queryWireNames(queryArr); + + // +4 covers auto-set headers (Content-Type, Content-Length, …); cap at 32 to avoid + // over-allocating for AWS-S3-style structs that declare 50+ headers but populate few. + int headerCount = Math.min(headers.size() + prefixHeaders.size() + 4, 32); + + return new RequestBinding( + bindings, + memberBindings, + scalarHeadersByName(headers), + listHeaderMembers, + listHeaderNames, + queryArr, + queryWireNames, + toArray(labels), + toArray(queryParams), + toArray(prefixHeaders), + payload, + hasBody, + hasPayload, + headerWireNames, + hasContentTypeHeader, + headerCount); + } + + /** + * Build the response-direction binding for one struct/union schema. Walks members once under the + * response-direction view. + */ + static ResponseBinding buildResponseBinding(Schema struct) { + List headers = new ArrayList<>(); + List prefixHeaders = new ArrayList<>(); + Schema payload = null; + Schema statusMember = null; + boolean hasBody = false; + boolean hasPayload = false; + + int memberCount = struct.members().size(); + Binding[] bindings = new Binding[memberCount]; + MemberBinding[] memberBindings = new MemberBinding[memberCount]; + + for (Schema m : struct.members()) { + var ext = (MemberBinding) m.getExtension(KEY); + int idx = m.memberIndex(); + memberBindings[idx] = ext; + switch (ext.kind()) { + case HEADER -> { + headers.add(m); + bindings[idx] = Binding.HEADER; + } + case PREFIX_HEADERS -> { + prefixHeaders.add(m); + bindings[idx] = Binding.PREFIX_HEADERS; + } + case PAYLOAD -> { + payload = m; + hasPayload = true; + bindings[idx] = Binding.PAYLOAD; + } + case STATUS -> { + statusMember = m; + bindings[idx] = Binding.STATUS; + } + case QUERY, QUERY_PARAMS, LABEL, BODY -> { + // Request-only kinds are folded into BODY in response direction. + hasBody = true; + bindings[idx] = Binding.BODY; + } + } + } + + Set headerWireNames; + if (headers.isEmpty() && prefixHeaders.isEmpty()) { + headerWireNames = Set.of(); + } else { + HashSet set = HashSet.newHashSet(headers.size()); + for (Schema m : headers) { + set.add(((MemberBinding) m.getExtension(KEY)).wireName()); + } + headerWireNames = Set.copyOf(set); + } + + Schema[] listHeaderMembers = listHeaderMembers(headers); + HeaderName[] listHeaderNames = listHeaderNames(listHeaderMembers); + + int headerCount = Math.min(headers.size() + prefixHeaders.size() + 4, 32); + + // Default response status from @httpError or @error trait, or -1 for non-error responses. + int defaultStatus; + if (struct.hasTrait(TraitKey.HTTP_ERROR_TRAIT)) { + defaultStatus = struct.expectTrait(TraitKey.HTTP_ERROR_TRAIT).getCode(); + } else if (struct.hasTrait(TraitKey.ERROR_TRAIT)) { + defaultStatus = struct.expectTrait(TraitKey.ERROR_TRAIT).getDefaultHttpStatusCode(); + } else { + defaultStatus = -1; + } + + return new ResponseBinding( + bindings, + memberBindings, + scalarHeadersByName(headers), + listHeaderMembers, + listHeaderNames, + toArray(prefixHeaders), + payload, + statusMember, + hasBody, + hasPayload, + headerWireNames, + headerCount, + defaultStatus); + } + + /** + * Build a {@code Map} of header members whose target shape is NOT a list. + * Used by the response-driven deserializer loop: iterate the response's actual headers and dispatch + * via the map rather than scanning all declared header members. + * + *

Keys are lowercased once at build time so per-call lookups don't have to canonicalize. ArrayHttpHeaders + * stores names in canonical (lowercase) form already, so {@code map.get(name)} during iteration needs no + * further normalization. + */ + private static Map scalarHeadersByName(List members) { + Map result = null; + for (Schema m : members) { + if (isListMember(m)) { + continue; + } + var memberExt = (MemberBinding) m.getExtension(KEY); + if (result == null) { + result = new HashMap<>(members.size()); + } + result.put(memberExt.wireName(), m); + } + return result == null ? Map.of() : Map.copyOf(result); + } + + /** + * Header members whose target shape IS a list. List headers need + * {@code allValues(name)} rather than {@code firstValue(name)}, so they + * stay on the schema-driven loop. Almost always empty for AWS shapes. + */ + private static Schema[] listHeaderMembers(List members) { + List list = null; + for (Schema m : members) { + if (!isListMember(m)) { + continue; + } + if (list == null) { + list = new ArrayList<>(); + } + list.add(m); + } + return list == null ? NO_SCHEMAS : list.toArray(new Schema[0]); + } + + private static Schema[] toArray(List list) { + return list.isEmpty() ? NO_SCHEMAS : list.toArray(new Schema[0]); + } + + private static final HeaderName[] NO_HEADER_NAMES = new HeaderName[0]; + private static final String[] NO_STRINGS = new String[0]; + + /** + * Pre-resolve canonical {@link HeaderName}s parallel to a {@code Schema[]} of + * list-header members. Reading the name later in the deserializer becomes a + * direct array load instead of a {@code memberBindingOf(member).headerName()} + * lookup chain. + */ + private static HeaderName[] listHeaderNames(Schema[] members) { + if (members.length == 0) { + return NO_HEADER_NAMES; + } + HeaderName[] names = new HeaderName[members.length]; + for (int i = 0; i < members.length; i++) { + names[i] = ((MemberBinding) members[i].getExtension(KEY)).headerName(); + } + return names; + } + + /** + * Pre-resolve query wire names parallel to a {@code Schema[]} of query + * members. Saves a per-member {@code memberBindingOf} call in the + * request-deserializer's query loop. + */ + private static String[] queryWireNames(Schema[] members) { + if (members.length == 0) { + return NO_STRINGS; + } + String[] names = new String[members.length]; + for (int i = 0; i < members.length; i++) { + names[i] = ((MemberBinding) members[i].getExtension(KEY)).wireName(); + } + return names; + } + + private static OperationBinding forOperation(Schema schema) { + var httpTrait = schema.expectTrait(TraitKey.HTTP_TRAIT); + var uriPattern = httpTrait.getUri(); + + // Flatten query literals into a (name, value) pair array. The trait's own map iteration is stable for a + // given trait instance, but this avoids the per-call iterator + Map.Entry allocations. + var queryLiteralMap = uriPattern.getQueryLiterals(); + String[] queryLiterals; + if (queryLiteralMap.isEmpty()) { + queryLiterals = NO_QUERY_LITERALS; + } else { + queryLiterals = new String[2 * queryLiteralMap.size()]; + int i = 0; + for (var entry : queryLiteralMap.entrySet()) { + queryLiterals[i++] = entry.getKey(); + queryLiterals[i++] = entry.getValue(); + } + } + + return new OperationBinding(httpTrait, queryLiterals, httpTrait.getCode()); + } +} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java index 6ca3d08cd..f7f9eafa1 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java @@ -5,36 +5,36 @@ package software.amazon.smithy.java.http.binding; -import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashMap; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.StringJoiner; import java.util.TreeMap; import java.util.function.BiConsumer; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.error.ModeledException; import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.SerializableStruct; -import software.amazon.smithy.java.core.schema.ShapeUtils; import software.amazon.smithy.java.core.schema.TraitKey; import software.amazon.smithy.java.core.serde.Codec; -import software.amazon.smithy.java.core.serde.InterceptingSerializer; -import software.amazon.smithy.java.core.serde.SerializationException; +import software.amazon.smithy.java.core.serde.MapSerializer; import software.amazon.smithy.java.core.serde.ShapeSerializer; import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; import software.amazon.smithy.java.core.serde.event.EventStream; +import software.amazon.smithy.java.http.api.HeaderName; import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.http.api.ModifiableHttpHeaders; +import software.amazon.smithy.java.io.ByteBufferOutputStream; +import software.amazon.smithy.java.io.ByteBufferUtils; import software.amazon.smithy.java.io.datastream.DataStream; import software.amazon.smithy.java.io.uri.QueryStringBuilder; -import software.amazon.smithy.java.io.uri.URLEncoding; -import software.amazon.smithy.model.pattern.SmithyPattern; -import software.amazon.smithy.model.pattern.UriPattern; import software.amazon.smithy.model.shapes.ShapeType; -import software.amazon.smithy.model.traits.HttpTrait; /** * Generic HTTP binding serializer that delegates to another ShapeSerializer when members are encountered that form @@ -48,9 +48,6 @@ final class HttpBindingSerializer extends SpecificShapeSerializer implements Sha private static final String DEFAULT_BLOB_CONTENT_TYPE = "application/octet-stream"; private static final String DEFAULT_STRING_CONTENT_TYPE = "text/plain"; - private final ShapeSerializer headerSerializer; - private final ShapeSerializer querySerializer; - private final ShapeSerializer labelSerializer; private final Codec payloadCodec; private final String payloadMediaType; private final boolean omitEmptyPayload; @@ -58,94 +55,126 @@ final class HttpBindingSerializer extends SpecificShapeSerializer implements Sha private final boolean allowEmptyStructPayload; private final HeaderErrorSerializer headerErrorSerializer; private final Context context; - - private final Map labels = new LinkedHashMap<>(); - private final Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - private final QueryStringBuilder queryStringParams = new QueryStringBuilder(); - private final Set namesFromHttpHeader = new HashSet<>(); + private ModifiableHttpHeaders headers; + private QueryStringBuilder queryStringParams; + + // Stashed during {@link #writeStruct} so {@link #getPath} can read label members directly. + private SerializableStruct outerStruct; + // Direction-specific Binding[] (request or response), cached at writeStruct time so + // each per-member dispatch in {@link BindingSerializer} does a single array load + // instead of a ternary + getter chain. + private HttpBindingSchemaExtensions.Binding[] activeBindings; + // Direction-neutral MemberBinding[] indexed by memberIndex, cached at writeStruct + // time. Used by the BindingSerializer's HEADER and QUERY arms. + private HttpBindingSchemaExtensions.MemberBinding[] memberBindings; + // Path serializer cached at writeStruct time for the request direction. Null on the response direction. + private PathSerializer pathSerializer; + // Initialized in writeStruct. Empty until writeStruct is called. + private Set namesFromHttpHeader = Set.of(); private ShapeSerializer shapeBodySerializer; - private ByteArrayOutputStream shapeBodyOutput; + private ByteBufferOutputStream shapeBodyOutput; private DataStream httpPayload; private EventStream eventStream; private int responseStatus; private boolean contentTypeHeaderInInput; - private final BindingMatcher bindingMatcher; - private final UriPattern uriPattern; - private final BiConsumer headerConsumer = (field, value) -> headers.computeIfAbsent( - field, - f -> new ArrayList<>()).add(value); + private final boolean isResponse; + private final HttpBindingSchemaExtensions.OperationBinding operationBinding; HttpBindingSerializer( - HttpTrait httpTrait, + HttpBindingSchemaExtensions.OperationBinding operationBinding, Codec payloadCodec, String payloadMediaType, - BindingMatcher bindingMatcher, + boolean isResponse, boolean omitEmptyPayload, boolean isFailure, boolean allowEmptyStructPayload, HeaderErrorSerializer headerErrorSerializer, Context context ) { - uriPattern = httpTrait.getUri(); - responseStatus = httpTrait.getCode(); + this.operationBinding = operationBinding; + responseStatus = operationBinding.defaultResponseStatus(); this.payloadCodec = payloadCodec; - this.bindingMatcher = bindingMatcher; + this.isResponse = isResponse; this.payloadMediaType = payloadMediaType; this.omitEmptyPayload = omitEmptyPayload; this.isFailure = isFailure; this.allowEmptyStructPayload = allowEmptyStructPayload; this.headerErrorSerializer = headerErrorSerializer; this.context = context; - headerSerializer = new HttpHeaderSerializer(headerConsumer); - querySerializer = new HttpQuerySerializer(queryStringParams::add); - labelSerializer = new HttpLabelSerializer(labels::put); } @Override public void writeStruct(Schema schema, SerializableStruct struct) { - if (bindingMatcher.responseStatus() != -1) { - responseStatus = bindingMatcher.responseStatus(); - } - - // Add fixed query string parameters from @http trait's uri field - if (!uriPattern.getQueryLiterals().isEmpty()) { - for (var entry : uriPattern.getQueryLiterals().entrySet()) { - queryStringParams.add(entry.getKey(), entry.getValue()); + var bindings = HttpBindingSchemaExtensions.structBindingsOf(schema); + outerStruct = struct; + + boolean writeBody; + int headerCount; + if (isResponse) { + var resp = bindings.response(); + activeBindings = resp.bindings(); + memberBindings = resp.memberBindings(); + namesFromHttpHeader = resp.headerWireNames(); + headerCount = resp.headerCount(); + if (resp.defaultStatus() != -1) { + responseStatus = resp.defaultStatus(); } + writeBody = allowEmptyStructPayload || resp.writeBody(omitEmptyPayload); + } else { + var req = bindings.request(); + activeBindings = req.bindings; + memberBindings = req.memberBindings; + namesFromHttpHeader = req.headerWireNames; + headerCount = req.headerCount; + contentTypeHeaderInInput = req.inputContentTypeHeader; + pathSerializer = req.pathSerializer(operationBinding.httpTrait(), schema); + writeBody = allowEmptyStructPayload || req.writeBody(omitEmptyPayload); } - // Prescanning names from @httpHeader for @httpPrefixHeaders - for (var member : schema.members()) { - if (member.hasTrait(TraitKey.HTTP_HEADER_TRAIT)) { - var headerName = member.expectTrait(TraitKey.HTTP_HEADER_TRAIT).getValue(); - if (!contentTypeHeaderInInput && headerName.equalsIgnoreCase("content-type")) { - contentTypeHeaderInInput = true; - } - namesFromHttpHeader.add(headerName); + headers = HttpHeaders.ofModifiable(headerCount); + + // Add fixed query string parameters from @http trait's uri field. + String[] qLits = operationBinding.queryLiterals(); + if (qLits.length > 0) { + QueryStringBuilder qsb = queryStringParams(); + for (int i = 0; i < qLits.length; i += 2) { + qsb.add(qLits[i], qLits[i + 1]); } } - if (allowEmptyStructPayload || bindingMatcher.writeBody(omitEmptyPayload)) { - shapeBodyOutput = new ByteArrayOutputStream(); + if (writeBody) { + shapeBodyOutput = new ByteBufferOutputStream(); shapeBodySerializer = payloadCodec.createSerializer(shapeBodyOutput); - // Serialize only the body members to the codec. - ShapeUtils.withFilteredMembers(schema, struct, this::bodyBindingPredicate) - .serialize(shapeBodySerializer); - headers.put("content-type", List.of(payloadMediaType)); + // Serialize only the body members to the codec, going through writeStruct so the codec emits the + // struct envelope (e.g. JSON {...}). The BodyMemberSerializer routes each member to the codec's + // per-member serializer (passed inside writeStruct) when its binding is BODY, and to a null serializer + // otherwise so non-body members (headers, labels, etc.) don't leak into the body. + shapeBodySerializer.writeStruct(schema, new StructBodyProxy(struct, activeBindings)); + headers.setHeader(HeaderName.CONTENT_TYPE, payloadMediaType); } if (isFailure) { - responseStatus = ModeledException.getHttpStatusCode(schema); - headerErrorSerializer.writeErrorType(schema, headers, context); + prepareError(schema); } - struct.serializeMembers(new BindingSerializer(this)); + var bindingSerializer = new BindingSerializer(); + struct.serializeMembers(bindingSerializer); + bindingSerializer.flushPayload(); } - private boolean bodyBindingPredicate(Schema member) { - return bindingMatcher.match(member) == BindingMatcher.Binding.BODY; + private void prepareError(Schema schema) { + responseStatus = ModeledException.getHttpStatusCode(schema); + // Adapt the ArrayHttpHeaders to the legacy Map> contract for HeaderErrorSerializer. + Map> errorView = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + headers.forEachEntry(errorView, (ev, name, value) -> { + ev.computeIfAbsent(name, k -> new ArrayList<>()).add(value); + }); + headerErrorSerializer.writeErrorType(schema, errorView, context); + // Replace the header set with the (possibly modified) view. + headers.clear(); + headers.placeHeaders(errorView); } @Override @@ -157,7 +186,7 @@ public void flush() { void setHttpPayload(Schema schema, DataStream value) { httpPayload = value; - if (headers.containsKey("content-type") || contentTypeHeaderInInput) { + if (headers.hasHeader(HeaderName.CONTENT_TYPE) || contentTypeHeaderInInput) { return; } @@ -173,19 +202,27 @@ void setHttpPayload(Schema schema, DataStream value) { : DEFAULT_STRING_CONTENT_TYPE; } } - headers.put("content-type", List.of(contentType)); + headers.setHeader(HeaderName.CONTENT_TYPE, contentType); } HttpHeaders getHeaders() { - return HttpHeaders.of(headers); + return headers; } String getQueryString() { - return queryStringParams.toString(); + return queryStringParams == null ? "" : queryStringParams.toString(); } boolean hasQueryString() { - return !queryStringParams.isEmpty(); + return queryStringParams != null && !queryStringParams.isEmpty(); + } + + // Lazy accessor for {@link #queryStringParams}. + private QueryStringBuilder queryStringParams() { + if (queryStringParams == null) { + queryStringParams = new QueryStringBuilder(); + } + return queryStringParams; } boolean hasBody() { @@ -196,34 +233,14 @@ DataStream getBody() { if (httpPayload != null) { return httpPayload; } else if (shapeBodyOutput != null) { - return DataStream.ofBytes(shapeBodyOutput.toByteArray(), payloadMediaType); + return DataStream.ofByteBuffer(shapeBodyOutput.toByteBuffer(), payloadMediaType); } else { return DataStream.ofEmpty(); } } String getPath() { - StringJoiner joiner = new StringJoiner("/", "/", ""); - for (SmithyPattern.Segment segment : uriPattern.getSegments()) { - String content = segment.getContent(); - if (!segment.isLabel() && !segment.isGreedyLabel()) { - // Append literal labels as-is. - joiner.add(content); - } else if (!labels.containsKey(content)) { - // Labels are inherently required. - throw new SerializationException("HTTP label not set for `" + content + "`"); - } else { - String labelValue = labels.get(segment.getContent()); - if (segment.isGreedyLabel()) { - String encoded = URLEncoding.encodeUnreserved(labelValue, false); - joiner.add(encoded.replace("%2F", "/")); - } else { - joiner.add(URLEncoding.encodeUnreserved(labelValue, false)); - } - } - } - - return joiner.toString(); + return pathSerializer.serialize(outerStruct); } int getResponseStatus() { @@ -243,45 +260,408 @@ void writePayloadContentType() { } public void setContentType(String contentType) { - headers.put("content-type", List.of(contentType)); + headers.setHeader(HeaderName.CONTENT_TYPE, contentType); } - private static final class BindingSerializer extends InterceptingSerializer { - private final HttpBindingSerializer serializer; + @SuppressWarnings("resource") + private final class BindingSerializer extends SpecificShapeSerializer { private PayloadSerializer payloadSerializer; + private Schema payloadSchema; - private BindingSerializer(HttpBindingSerializer serializer) { - this.serializer = serializer; + @Override + public void writeBoolean(Schema schema, boolean value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Boolean.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Boolean.toString(value)); + case PAYLOAD -> payload(schema).writeBoolean(schema, value); + default -> { + } + } } @Override - protected ShapeSerializer before(Schema schema) { - return switch (serializer.bindingMatcher.match(schema)) { - case HEADER -> serializer.headerSerializer; - case QUERY -> serializer.querySerializer; - case LABEL -> serializer.labelSerializer; - case STATUS -> new ResponseStatusSerializer(i -> serializer.responseStatus = i); - case PREFIX_HEADERS -> new HttpPrefixHeadersSerializer( - schema.expectTrait(TraitKey.HTTP_PREFIX_HEADERS_TRAIT).getValue(), - serializer.headerConsumer, - serializer.namesFromHttpHeader); - case QUERY_PARAMS -> new HttpQueryParamsSerializer(serializer.queryStringParams::addForQueryParams); - case BODY -> ShapeSerializer.nullSerializer(); // handled in HttpBindingSerializer#writeStruct. - case PAYLOAD -> { - payloadSerializer = new PayloadSerializer(serializer, serializer.payloadCodec); - yield payloadSerializer; + public void writeShort(Schema schema, short value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Short.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Short.toString(value)); + case STATUS -> responseStatus = value; + case PAYLOAD -> payload(schema).writeShort(schema, value); + default -> { } - }; + } } @Override - protected void after(Schema schema) { - flush(); + public void writeByte(Schema schema, byte value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Byte.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Byte.toString(value)); + case STATUS -> responseStatus = value; + case PAYLOAD -> payload(schema).writeByte(schema, value); + default -> { + } + } + } + + @Override + public void writeInteger(Schema schema, int value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Integer.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Integer.toString(value)); + case STATUS -> responseStatus = value; + case PAYLOAD -> payload(schema).writeInteger(schema, value); + default -> { + } + } + } + + @Override + public void writeLong(Schema schema, long value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Long.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Long.toString(value)); + case PAYLOAD -> payload(schema).writeLong(schema, value); + default -> { + } + } + } + + @Override + public void writeFloat(Schema schema, float value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Float.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Float.toString(value)); + case PAYLOAD -> payload(schema).writeFloat(schema, value); + default -> { + } + } + } + + @Override + public void writeDouble(Schema schema, double value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], Double.toString(value)); + case QUERY -> writeQuery(memberBindings[idx], Double.toString(value)); + case PAYLOAD -> payload(schema).writeDouble(schema, value); + default -> { + } + } + } + + @Override + public void writeBigInteger(Schema schema, BigInteger value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], value.toString()); + case QUERY -> writeQuery(memberBindings[idx], value.toString()); + case PAYLOAD -> payload(schema).writeBigInteger(schema, value); + default -> { + } + } + } + + @Override + public void writeBigDecimal(Schema schema, BigDecimal value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], value.toString()); + case QUERY -> writeQuery(memberBindings[idx], value.toString()); + case PAYLOAD -> payload(schema).writeBigDecimal(schema, value); + default -> { + } + } + } + + @Override + public void writeString(Schema schema, String value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> { + var binding = memberBindings[idx]; + var v = binding.hasMediaType() + ? Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)) + : value; + writeHeader(binding, v); + } + case QUERY -> writeQuery(memberBindings[idx], value); + case PAYLOAD -> payload(schema).writeString(schema, value); + default -> { + } + } + } + + @Override + public void writeBlob(Schema schema, ByteBuffer value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> writeHeader(memberBindings[idx], ByteBufferUtils.base64Encode(value)); + case QUERY -> writeQuery(memberBindings[idx], ByteBufferUtils.base64Encode(value)); + case PAYLOAD -> payload(schema).writeBlob(schema, value); + default -> { + } + } + } + + @Override + public void writeTimestamp(Schema schema, Instant value) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> { + var binding = memberBindings[idx]; + writeHeader(binding, binding.timestampFormatter().writeString(value)); + } + case QUERY -> { + var binding = memberBindings[idx]; + writeQuery(binding, binding.timestampFormatter().writeString(value)); + } + case PAYLOAD -> payload(schema).writeTimestamp(schema, value); + default -> { + } + } + } + + @Override + public void writeStruct(Schema schema, SerializableStruct struct) { + if (activeBindings[schema.memberIndex()] == HttpBindingSchemaExtensions.Binding.PAYLOAD) { + payload(schema).writeStruct(schema, struct); + } + } + + @Override + public void writeList(Schema schema, T listState, int size, BiConsumer consumer) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case HEADER -> + consumer.accept(listState, new HeaderListElementSerializer(memberBindings[idx])); + case QUERY -> + consumer.accept(listState, new QueryListElementSerializer(memberBindings[idx])); + case PAYLOAD -> payload(schema).writeList(schema, listState, size, consumer); + default -> { + } + } + } + + @Override + public void writeMap(Schema schema, T mapState, int size, BiConsumer consumer) { + int idx = schema.memberIndex(); + switch (activeBindings[idx]) { + case PREFIX_HEADERS -> { + var prefix = memberBindings[idx].wireName(); + new HttpPrefixHeadersSerializer(prefix, headers::addHeader, namesFromHttpHeader) + .writeMap(schema, mapState, size, consumer); + } + case QUERY_PARAMS -> new HttpQueryParamsSerializer(queryStringParams()::addForQueryParams) + .writeMap(schema, mapState, size, consumer); + case PAYLOAD -> payload(schema).writeMap(schema, mapState, size, consumer); + default -> { + } + } + } + + @Override + public void writeDataStream(Schema schema, DataStream value) { + if (activeBindings[schema.memberIndex()] == HttpBindingSchemaExtensions.Binding.PAYLOAD) { + setHttpPayload(schema, value); + } + } + + @Override + public void writeEventStream(Schema schema, EventStream value) { + if (activeBindings[schema.memberIndex()] == HttpBindingSchemaExtensions.Binding.PAYLOAD) { + setEventStream(value); + } + } + + @Override + public void writeNull(Schema schema) { + // Nulls are dropped from the wire for every binding kind; nothing to do. + } + + @Override + public void writeDocument(Schema schema, software.amazon.smithy.java.core.serde.document.Document value) { + if (activeBindings[schema.memberIndex()] == HttpBindingSchemaExtensions.Binding.PAYLOAD) { + payload(schema).writeDocument(schema, value); + } + } + + private void writeHeader(HttpBindingSchemaExtensions.MemberBinding binding, String value) { + if (value != null) { + headers.addHeader(binding.headerName(), value); + } + } + + private void writeQuery(HttpBindingSchemaExtensions.MemberBinding binding, String value) { + queryStringParams().add(binding.wireName(), value); + } + + /** + * Lazy-allocate the payload buffer the first time we hit a non-streaming PAYLOAD member. + * The buffered bytes are flushed and attached to the request/response in {@link #flushPayload()}. + */ + private PayloadSerializer payload(Schema schema) { + if (payloadSerializer == null) { + payloadSerializer = new PayloadSerializer(HttpBindingSerializer.this, payloadCodec); + payloadSchema = schema; + } + return payloadSerializer; + } + + /** + * If a PAYLOAD member was buffered, attach the bytes to the request/response. + * Called once after {@code serializeMembers}. + */ + void flushPayload() { if (payloadSerializer != null && !payloadSerializer.isPayloadWritten()) { payloadSerializer.flush(); - serializer.setHttpPayload( - schema, - DataStream.ofBytes(payloadSerializer.toByteArray())); + setHttpPayload(payloadSchema, DataStream.ofByteBuffer(payloadSerializer.toByteBuffer())); + } + } + + /** + * Per-element serializer for list-typed {@code @httpHeader} members. + * Each element becomes a separate header value carrying the parent member's canonicalized header name. + */ + private final class HeaderListElementSerializer extends SpecificShapeSerializer { + private final HttpBindingSchemaExtensions.MemberBinding binding; + + HeaderListElementSerializer(HttpBindingSchemaExtensions.MemberBinding binding) { + this.binding = binding; + } + + @Override + public void writeBoolean(Schema schema, boolean value) { + writeHeader(binding, Boolean.toString(value)); + } + + @Override + public void writeShort(Schema schema, short value) { + writeHeader(binding, Short.toString(value)); + } + + @Override + public void writeByte(Schema schema, byte value) { + writeHeader(binding, Byte.toString(value)); + } + + @Override + public void writeInteger(Schema schema, int value) { + writeHeader(binding, Integer.toString(value)); + } + + @Override + public void writeLong(Schema schema, long value) { + writeHeader(binding, Long.toString(value)); + } + + @Override + public void writeFloat(Schema schema, float value) { + writeHeader(binding, Float.toString(value)); + } + + @Override + public void writeDouble(Schema schema, double value) { + writeHeader(binding, Double.toString(value)); + } + + @Override + public void writeBigInteger(Schema schema, BigInteger value) { + writeHeader(binding, value.toString()); + } + + @Override + public void writeBigDecimal(Schema schema, BigDecimal value) { + writeHeader(binding, value.toString()); + } + + @Override + public void writeString(Schema schema, String value) { + var v = binding.hasMediaType() + ? Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)) + : value; + writeHeader(binding, v); + } + + @Override + public void writeBlob(Schema schema, ByteBuffer value) { + writeHeader(binding, ByteBufferUtils.base64Encode(value)); + } + + @Override + public void writeTimestamp(Schema schema, Instant value) { + writeHeader(binding, binding.timestampFormatter().writeString(value)); + } + } + + /** + * Per-element serializer for list-typed {@code @httpQuery} members. + */ + private final class QueryListElementSerializer extends SpecificShapeSerializer { + private final HttpBindingSchemaExtensions.MemberBinding binding; + + QueryListElementSerializer(HttpBindingSchemaExtensions.MemberBinding binding) { + this.binding = binding; + } + + @Override + public void writeBoolean(Schema schema, boolean value) { + writeQuery(binding, Boolean.toString(value)); + } + + @Override + public void writeShort(Schema schema, short value) { + writeQuery(binding, Short.toString(value)); + } + + @Override + public void writeByte(Schema schema, byte value) { + writeQuery(binding, Byte.toString(value)); + } + + @Override + public void writeInteger(Schema schema, int value) { + writeQuery(binding, Integer.toString(value)); + } + + @Override + public void writeLong(Schema schema, long value) { + writeQuery(binding, Long.toString(value)); + } + + @Override + public void writeFloat(Schema schema, float value) { + writeQuery(binding, Float.toString(value)); + } + + @Override + public void writeDouble(Schema schema, double value) { + writeQuery(binding, Double.toString(value)); + } + + @Override + public void writeBigInteger(Schema schema, BigInteger value) { + writeQuery(binding, value.toString()); + } + + @Override + public void writeBigDecimal(Schema schema, BigDecimal value) { + writeQuery(binding, value.toString()); + } + + @Override + public void writeString(Schema schema, String value) { + writeQuery(binding, value); + } + + @Override + public void writeTimestamp(Schema schema, Instant value) { + writeQuery(binding, binding.timestampFormatter().writeString(value)); } } } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpHeaderSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpHeaderSerializer.java deleted file mode 100644 index ea70dd424..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpHeaderSerializer.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import static software.amazon.smithy.java.io.ByteBufferUtils.base64Encode; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Base64; -import java.util.function.BiConsumer; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.schema.TraitKey; -import software.amazon.smithy.java.core.serde.ListSerializer; -import software.amazon.smithy.java.core.serde.ShapeSerializer; -import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; -import software.amazon.smithy.java.core.serde.TimestampFormatter; - -final class HttpHeaderSerializer extends SpecificShapeSerializer { - - private final BiConsumer headerWriter; - - public HttpHeaderSerializer(BiConsumer headerWriter) { - this.headerWriter = headerWriter; - } - - @Override - public void writeList(Schema schema, T listState, int size, BiConsumer consumer) { - // Consumer is generally going to be something generic - like a shared serializer for iterating - // lists of strings and writing them back out to the delegate serializer (this). However, this - // means writeHeader, below, will receive something like the schema for smithy.api#String - // which does not have a httpHeader trait. So we wrap this in SpecificHttpHeaderSerializer, - // which will use the header member's schema, and not the schema of the type we're writing - // to call writeHeader - consumer.accept( - listState, - new ListSerializer(new SpecificHttpHeaderSerializer(schema, this), HttpHeaderSerializer::noOpPosition)); - } - - private static void noOpPosition(int position) {} - - private void writeHeader(Schema schema, String value) { - if (value != null) { - var headerTrait = schema.getTrait(TraitKey.HTTP_HEADER_TRAIT); - var field = headerTrait != null ? headerTrait.getValue() : schema.memberName(); - headerWriter.accept(field, value); - } - } - - @Override - public void writeBoolean(Schema schema, boolean value) { - writeHeader(schema, Boolean.toString(value)); - } - - @Override - public void writeShort(Schema schema, short value) { - writeHeader(schema, Short.toString(value)); - } - - @Override - public void writeByte(Schema schema, byte value) { - writeHeader(schema, Byte.toString(value)); - } - - @Override - public void writeInteger(Schema schema, int value) { - writeHeader(schema, Integer.toString(value)); - } - - @Override - public void writeLong(Schema schema, long value) { - writeHeader(schema, Long.toString(value)); - } - - @Override - public void writeFloat(Schema schema, float value) { - writeHeader(schema, Float.toString(value)); - } - - @Override - public void writeDouble(Schema schema, double value) { - writeHeader(schema, Double.toString(value)); - } - - @Override - public void writeBigInteger(Schema schema, BigInteger value) { - writeHeader(schema, value.toString()); - } - - @Override - public void writeBigDecimal(Schema schema, BigDecimal value) { - writeHeader(schema, value.toString()); - } - - @Override - public void writeString(Schema schema, String value) { - if (schema.hasTrait(TraitKey.MEDIA_TYPE_TRAIT)) { - writeHeader(schema, Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8))); - } else { - writeHeader(schema, value); - } - } - - @Override - public void writeBlob(Schema schema, ByteBuffer value) { - writeHeader(schema, base64Encode(value)); - } - - @Override - public void writeTimestamp(Schema schema, Instant value) { - var trait = schema.getTrait(TraitKey.TIMESTAMP_FORMAT_TRAIT); - TimestampFormatter formatter = trait != null - ? TimestampFormatter.of(trait) - : TimestampFormatter.Prelude.HTTP_DATE; - writeHeader(schema, formatter.writeString(value)); - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpLabelSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpLabelSerializer.java deleted file mode 100644 index 41ef172ba..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpLabelSerializer.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.time.Instant; -import java.util.Objects; -import java.util.function.BiConsumer; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.schema.TraitKey; -import software.amazon.smithy.java.core.serde.SerializationException; -import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; -import software.amazon.smithy.java.core.serde.TimestampFormatter; - -final class HttpLabelSerializer extends SpecificShapeSerializer { - - private final BiConsumer labelReceiver; - - HttpLabelSerializer(BiConsumer labelReceiver) { - this.labelReceiver = Objects.requireNonNull(labelReceiver); - } - - @Override - public void writeBoolean(Schema schema, boolean value) { - labelReceiver.accept(schema.memberName(), Boolean.toString(value)); - } - - @Override - public void writeByte(Schema schema, byte value) { - labelReceiver.accept(schema.memberName(), Byte.toString(value)); - } - - @Override - public void writeShort(Schema schema, short value) { - labelReceiver.accept(schema.memberName(), Short.toString(value)); - } - - @Override - public void writeInteger(Schema schema, int value) { - labelReceiver.accept(schema.memberName(), Integer.toString(value)); - } - - @Override - public void writeLong(Schema schema, long value) { - labelReceiver.accept(schema.memberName(), Long.toString(value)); - } - - @Override - public void writeFloat(Schema schema, float value) { - labelReceiver.accept(schema.memberName(), Float.toString(value)); - } - - @Override - public void writeDouble(Schema schema, double value) { - labelReceiver.accept(schema.memberName(), Double.toString(value)); - } - - @Override - public void writeBigInteger(Schema schema, BigInteger value) { - labelReceiver.accept(schema.memberName(), value.toString()); - } - - @Override - public void writeBigDecimal(Schema schema, BigDecimal value) { - labelReceiver.accept(schema.memberName(), value.toString()); - } - - @Override - public void writeString(Schema schema, String value) { - if (value.isEmpty()) { - throw new SerializationException("HTTP label for `" + schema.id() + "` cannot be empty"); - } - labelReceiver.accept(schema.memberName(), value); - } - - @Override - public void writeTimestamp(Schema schema, Instant value) { - var trait = schema.getTrait(TraitKey.TIMESTAMP_FORMAT_TRAIT); - TimestampFormatter formatter = trait != null - ? TimestampFormatter.of(trait) - : TimestampFormatter.Prelude.DATE_TIME; - labelReceiver.accept(schema.memberName(), formatter.writeString(value)); - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpQuerySerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpQuerySerializer.java deleted file mode 100644 index f03020fa0..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpQuerySerializer.java +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.time.Instant; -import java.util.function.BiConsumer; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.schema.TraitKey; -import software.amazon.smithy.java.core.serde.ShapeSerializer; -import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; -import software.amazon.smithy.java.core.serde.TimestampFormatter; -import software.amazon.smithy.model.traits.HttpQueryTrait; - -final class HttpQuerySerializer extends SpecificShapeSerializer { - - private final BiConsumer queryWriter; - - public HttpQuerySerializer(BiConsumer queryWriter) { - this.queryWriter = queryWriter; - } - - @Override - public void writeList(Schema schema, T listState, int size, BiConsumer consumer) { - consumer.accept(listState, new ListElementSerializer(schema.getTrait(TraitKey.HTTP_QUERY_TRAIT))); - } - - private void writeQuery(HttpQueryTrait trait, String value) { - queryWriter.accept(trait.getValue(), value); - } - - @Override - public void writeBoolean(Schema schema, boolean value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Boolean.toString(value)); - } - } - - @Override - public void writeShort(Schema schema, short value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Short.toString(value)); - } - } - - @Override - public void writeByte(Schema schema, byte value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Byte.toString(value)); - } - } - - @Override - public void writeInteger(Schema schema, int value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Integer.toString(value)); - } - } - - @Override - public void writeLong(Schema schema, long value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Long.toString(value)); - } - } - - @Override - public void writeFloat(Schema schema, float value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Float.toString(value)); - } - } - - @Override - public void writeDouble(Schema schema, double value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, Double.toString(value)); - } - } - - @Override - public void writeBigInteger(Schema schema, BigInteger value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, value.toString()); - } - } - - @Override - public void writeBigDecimal(Schema schema, BigDecimal value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, value.toString()); - } - } - - @Override - public void writeString(Schema schema, String value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - writeQuery(queryTrait, value); - } - } - - @Override - public void writeTimestamp(Schema schema, Instant value) { - var queryTrait = schema.getTrait(TraitKey.HTTP_QUERY_TRAIT); - if (queryTrait != null) { - var trait = schema.getTrait(TraitKey.TIMESTAMP_FORMAT_TRAIT); - TimestampFormatter formatter = trait != null - ? TimestampFormatter.of(trait) - : TimestampFormatter.Prelude.DATE_TIME; - writeQuery(queryTrait, formatter.writeString(value)); - } - } - - private class ListElementSerializer extends SpecificShapeSerializer { - private final HttpQueryTrait parentTrait; - - ListElementSerializer(HttpQueryTrait trait) { - this.parentTrait = trait; - } - - @Override - public void writeBoolean(Schema schema, boolean value) { - writeQuery(parentTrait, Boolean.toString(value)); - } - - @Override - public void writeShort(Schema schema, short value) { - writeQuery(parentTrait, Short.toString(value)); - } - - @Override - public void writeByte(Schema schema, byte value) { - writeQuery(parentTrait, Byte.toString(value)); - } - - @Override - public void writeInteger(Schema schema, int value) { - writeQuery(parentTrait, Integer.toString(value)); - } - - @Override - public void writeLong(Schema schema, long value) { - writeQuery(parentTrait, Long.toString(value)); - } - - @Override - public void writeFloat(Schema schema, float value) { - writeQuery(parentTrait, Float.toString(value)); - } - - @Override - public void writeDouble(Schema schema, double value) { - writeQuery(parentTrait, Double.toString(value)); - } - - @Override - public void writeBigInteger(Schema schema, BigInteger value) { - writeQuery(parentTrait, value.toString()); - } - - @Override - public void writeBigDecimal(Schema schema, BigDecimal value) { - writeQuery(parentTrait, value.toString()); - } - - @Override - public void writeString(Schema schema, String value) { - writeQuery(parentTrait, value); - } - - @Override - public void writeTimestamp(Schema schema, Instant value) { - var timestampFormatTrait = schema.getTrait(TraitKey.TIMESTAMP_FORMAT_TRAIT); - TimestampFormatter formatter = timestampFormatTrait != null - ? TimestampFormatter.of(timestampFormatTrait) - : TimestampFormatter.Prelude.DATE_TIME; - writeQuery(parentTrait, formatter.writeString(value)); - } - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PathSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PathSerializer.java new file mode 100644 index 000000000..ae7316156 --- /dev/null +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PathSerializer.java @@ -0,0 +1,176 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.http.binding; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.SerializationException; +import software.amazon.smithy.java.io.uri.URLEncoding; +import software.amazon.smithy.model.traits.HttpTrait; + +/** + * Compiles an HTTP URI template into a compact, tape for serializing the path of a single operation. + * + *

The tape is a {@code byte[]} of {@code (opcode, operand)} pairs: + *

    + *
  • {@code op=1} — emit literal at {@code literals[operand]}
  • + *
  • {@code op=2} — emit regular label via {@code schema.member(members[operand])}
  • + *
  • {@code op=3} — emit greedy label via {@code schema.member(operand)}. Operand is the schema member index + * directly: a URI has at most one greedy label, so there's no benefit to indirecting through a side array.
  • + *
+ * + *

Operands are read as unsigned bytes ({@code & 0xFF}). Supports up to 256 members in the input struct, + * which more than exceeds anything we've seen in the wild. + */ +final class PathSerializer { + + private final Schema schema; + // Tape of (opcode, operand) pairs. See class doc for opcode meanings. + private final byte[] tape; + // Regular-label member indices in input schema, in URI order. Excludes greedy. + private final byte[] members; + // Literal segments in URI order. + private final String[] literals; + // Non-null if the path has no labels and is static. + private final String fastPath; + + /** + * Compile a {@code PathSerializer} for one operation. + * + * @param httpTrait operation's @http trait, source of the URI template + * @param inputSchema input-struct Schema providing member resolution + */ + PathSerializer(HttpTrait httpTrait, Schema inputSchema) { + this.schema = inputSchema; + var segments = httpTrait.getUri().getSegments(); + + List memberList = new ArrayList<>(); + List literalList = new ArrayList<>(); + List tapeList = new ArrayList<>(); + + // Smithy URI templates start with "/" and segments are slash-separated. + // We accumulate literal text into `current` until we hit a label, then + // flush as one literal entry. Adjacent literals collapse for free. + StringBuilder current = new StringBuilder("/"); + boolean firstSegment = true; + for (var seg : segments) { + if (!firstSegment) { + current.append('/'); + } + firstSegment = false; + if (seg.isLabel() || seg.isGreedyLabel()) { + if (!current.isEmpty()) { + tapeList.add((byte) 1); + tapeList.add((byte) literalList.size()); + literalList.add(current.toString()); + current.setLength(0); + } + String name = seg.getContent(); + Schema labelMember = inputSchema.member(name); + if (labelMember == null) { + throw new IllegalStateException("URI label `" + name + "` not set for " + inputSchema.id()); + } + int memberIdx = labelMember.memberIndex(); + if (seg.isGreedyLabel()) { + tapeList.add((byte) 3); + tapeList.add((byte) memberIdx); + } else { + tapeList.add((byte) 2); + tapeList.add((byte) memberList.size()); + memberList.add((byte) memberIdx); + } + } else { + current.append(seg.getContent()); + } + } + + // Trailing literal: only emit if non-empty so paths ending in a label don't pay an empty append at runtime. + if (!current.isEmpty()) { + tapeList.add((byte) 1); + tapeList.add((byte) literalList.size()); + literalList.add(current.toString()); + } + + this.members = toByteArray(memberList); + this.literals = literalList.toArray(new String[0]); + this.tape = toByteArray(tapeList); + // Fast path: a single literal opcode with no labels means the path is fully static. + this.fastPath = (tape.length == 2 && tape[0] == 1) ? literals[0] : null; + } + + String serialize(SerializableStruct struct) { + if (fastPath != null) { + return fastPath; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < tape.length; i += 2) { + int op = tape[i]; + int operand = tape[i + 1] & 0xFF; + switch (op) { + case 1 -> sb.append(literals[operand]); + case 2 -> { + var labelSchema = schema.member(members[operand] & 0xFF); + var value = formatLabelValue(struct, labelSchema); + URLEncoding.encodeUnreserved(value, sb, false); + } + case 3 -> { + var labelSchema = schema.member(operand); + var value = formatLabelValue(struct, labelSchema); + URLEncoding.encodeUnreserved(value, sb, true); + } + default -> throw new IllegalStateException("Unknown path-tape opcode: " + op); + } + } + + return sb.toString(); + } + + private static String formatLabelValue(SerializableStruct struct, Schema labelSchema) { + Object value = struct.getMemberValue(labelSchema); + if (value == null) { + throw emptyLabel(labelSchema); + } + + return switch (labelSchema.type()) { + case STRING, ENUM -> { + var s = (String) value; + if (s.isEmpty()) { + throw emptyLabel(labelSchema); + } + yield s; + } + case BOOLEAN -> Boolean.toString((boolean) value); + case BYTE -> Byte.toString((byte) value); + case SHORT -> Short.toString((short) value); + case INTEGER, INT_ENUM -> Integer.toString((int) value); + case LONG -> Long.toString((long) value); + case FLOAT -> Float.toString((float) value); + case DOUBLE -> Double.toString((double) value); + case BIG_INTEGER, BIG_DECIMAL -> value.toString(); + case TIMESTAMP -> HttpBindingSchemaExtensions.memberBindingOf(labelSchema) + .timestampFormatter() + .writeString((Instant) value); + default -> throw new SerializationException( + "Unsupported HTTP label type " + labelSchema.type() + " for `" + labelSchema.id() + "`"); + }; + } + + private static SerializationException emptyLabel(Schema labelSchema) { + throw new SerializationException("HTTP label for `" + labelSchema.id() + "` cannot be empty"); + } + + private static byte[] toByteArray(List list) { + byte[] out = new byte[list.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = list.get(i); + } + return out; + } +} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PayloadSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PayloadSerializer.java index 9f3d7d76f..5e89f3373 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PayloadSerializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/PayloadSerializer.java @@ -5,7 +5,6 @@ package software.amazon.smithy.java.http.binding; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; @@ -23,6 +22,7 @@ import software.amazon.smithy.java.core.serde.TimestampFormatter; import software.amazon.smithy.java.core.serde.document.Document; import software.amazon.smithy.java.core.serde.event.EventStream; +import software.amazon.smithy.java.io.ByteBufferOutputStream; import software.amazon.smithy.java.io.datastream.DataStream; final class PayloadSerializer implements ShapeSerializer { @@ -31,12 +31,12 @@ final class PayloadSerializer implements ShapeSerializer { private static final byte[] FALSE_BYTES = "false".getBytes(StandardCharsets.UTF_8); private final HttpBindingSerializer serializer; private final ShapeSerializer structSerializer; - private final ByteArrayOutputStream outputStream; + private final ByteBufferOutputStream outputStream; private boolean payloadWritten = false; PayloadSerializer(HttpBindingSerializer serializer, Codec codec) { this.serializer = serializer; - this.outputStream = new ByteArrayOutputStream(); + this.outputStream = new ByteBufferOutputStream(); this.structSerializer = codec.createSerializer(outputStream); } @@ -189,7 +189,7 @@ public boolean isPayloadWritten() { return payloadWritten; } - byte[] toByteArray() { - return outputStream.toByteArray(); + ByteBuffer toByteBuffer() { + return outputStream.toByteBuffer(); } } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestDeserializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestDeserializer.java index 7aa7e4b78..6b4b25e1b 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestDeserializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestDeserializer.java @@ -6,8 +6,6 @@ package software.amazon.smithy.java.http.binding; import java.util.Map; -import java.util.concurrent.ConcurrentMap; -import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.ShapeBuilder; import software.amazon.smithy.java.core.serde.Codec; import software.amazon.smithy.java.core.serde.event.EventDecoderFactory; @@ -22,11 +20,8 @@ public final class RequestDeserializer { private final HttpBindingDeserializer.Builder deserBuilder = HttpBindingDeserializer.builder(); private ShapeBuilder inputShapeBuilder; - private final ConcurrentMap bindingCache; - RequestDeserializer(ConcurrentMap bindingCache) { - this.bindingCache = bindingCache; - } + RequestDeserializer() {} /** * Codec to use in the payload of requests. @@ -103,8 +98,7 @@ public void deserialize() { throw new IllegalStateException("inputShapeBuilder must be set"); } - var matcher = bindingCache.computeIfAbsent(inputShapeBuilder.schema(), BindingMatcher::requestMatcher); - deserBuilder.bindingMatcher(matcher); + deserBuilder.isResponse(false); HttpBindingDeserializer deserializer = deserBuilder.build(); inputShapeBuilder.deserialize(deserializer); diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestSerializer.java index 7d0f2d886..9a4ea2bb3 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestSerializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/RequestSerializer.java @@ -6,13 +6,10 @@ package software.amazon.smithy.java.http.binding; import java.util.Objects; -import java.util.concurrent.ConcurrentMap; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.schema.ApiOperation; -import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.SerializableShape; import software.amazon.smithy.java.core.schema.SerializableStruct; -import software.amazon.smithy.java.core.schema.TraitKey; import software.amazon.smithy.java.core.serde.Codec; import software.amazon.smithy.java.core.serde.event.EventEncoderFactory; import software.amazon.smithy.java.core.serde.event.Frame; @@ -33,11 +30,8 @@ public final class RequestSerializer { private EventEncoderFactory eventStreamEncodingFactory; private boolean omitEmptyPayload = false; private boolean allowEmptyStructPayload = false; - private final ConcurrentMap bindingCache; - RequestSerializer(ConcurrentMap bindingCache) { - this.bindingCache = bindingCache; - } + RequestSerializer() {} /** * Schema of the operation to serialize. @@ -136,13 +130,12 @@ public HttpRequest serializeRequest() { Objects.requireNonNull(endpoint, "endpoint is not set"); Objects.requireNonNull(payloadMediaType, "payloadMediaType is not set"); - var matcher = bindingCache.computeIfAbsent(operation.inputSchema(), BindingMatcher::requestMatcher); - var httpTrait = operation.schema().expectTrait(TraitKey.HTTP_TRAIT); + var operationBinding = HttpBindingSchemaExtensions.operationBindingOf(operation.schema()); var serializer = new HttpBindingSerializer( - httpTrait, + operationBinding, payloadCodec, payloadMediaType, - matcher, + false, // isResponse omitEmptyPayload, false, allowEmptyStructPayload, @@ -158,7 +151,7 @@ public HttpRequest serializeRequest() { } var builder = HttpRequest.create() - .setMethod(httpTrait.getMethod()) + .setMethod(operationBinding.httpTrait().getMethod()) .setUri(resolvedUri); var eventStream = serializer.getEventStream(); @@ -172,6 +165,6 @@ public HttpRequest serializeRequest() { builder.setBody(serializer.getBody()); } - return builder.setHeaders(serializer.getHeaders()).toUnmodifiable(); + return builder.setHeaders(serializer.getHeaders()); } } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseDeserializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseDeserializer.java index 1e4beacd5..2bb33b331 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseDeserializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseDeserializer.java @@ -5,9 +5,7 @@ package software.amazon.smithy.java.http.binding; -import java.util.concurrent.ConcurrentMap; import software.amazon.smithy.java.core.error.ModeledException; -import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.ShapeBuilder; import software.amazon.smithy.java.core.serde.Codec; import software.amazon.smithy.java.core.serde.event.EventDecoderFactory; @@ -23,11 +21,8 @@ public final class ResponseDeserializer { private final HttpBindingDeserializer.Builder deserBuilder = HttpBindingDeserializer.builder(); private ShapeBuilder outputShapeBuilder; private ShapeBuilder errorShapeBuilder; - private final ConcurrentMap bindingCache; - ResponseDeserializer(ConcurrentMap bindingCache) { - this.bindingCache = bindingCache; - } + ResponseDeserializer() {} /** * Codec to use in the payload of responses. @@ -114,17 +109,11 @@ public ResponseDeserializer errorShapeBuilder(ShapeBuilder bindingCache; - ResponseSerializer(ConcurrentMap bindingCache) { - this.bindingCache = bindingCache; - } + ResponseSerializer() {} /** * Schema of the operation response to serialize. @@ -147,27 +142,20 @@ public ResponseSerializer errorSchema(Schema errorSchema) { * * @return Returns the created response. */ - @SuppressWarnings("unchecked") public HttpResponse serializeResponse() { Objects.requireNonNull(shapeValue, "shapeValue is not set"); Objects.requireNonNull(operation, "operation is not set"); Objects.requireNonNull(payloadCodec, "payloadCodec is not set"); Objects.requireNonNull(payloadMediaType, "payloadMediaType is not set"); - Schema schema; var isFailure = errorSchema != null; - if (isFailure) { - schema = errorSchema; - } else { - schema = operation.outputSchema(); - } - var httpTrait = operation.schema().expectTrait(TraitKey.HTTP_TRAIT); + var operationBinding = HttpBindingSchemaExtensions.operationBindingOf(operation.schema()); var serializer = new HttpBindingSerializer( - httpTrait, + operationBinding, payloadCodec, payloadMediaType, - bindingCache.computeIfAbsent(schema, BindingMatcher::responseMatcher), + true, // isResponse omitEmptyPayload, isFailure, false, @@ -176,8 +164,7 @@ public HttpResponse serializeResponse() { shapeValue.serialize(serializer); serializer.flush(); - var builder = HttpResponse.create() - .setStatusCode(serializer.getResponseStatus()); + var builder = HttpResponse.create().setStatusCode(serializer.getResponseStatus()); var eventStream = serializer.getEventStream(); if (eventStream != null && operation.outputEventBuilderSupplier() != null) { @@ -191,6 +178,6 @@ public HttpResponse serializeResponse() { builder.setBody(serializer.getBody()); } - return builder.setHeaders(serializer.getHeaders()).toUnmodifiable(); + return builder.setHeaders(serializer.getHeaders()); } } diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseStatusSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseStatusSerializer.java deleted file mode 100644 index c7a0d35c1..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/ResponseStatusSerializer.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import java.util.function.IntConsumer; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; - -final class ResponseStatusSerializer extends SpecificShapeSerializer { - - private final IntConsumer consumer; - - ResponseStatusSerializer(IntConsumer consumer) { - this.consumer = consumer; - } - - @Override - public void writeInteger(Schema schema, int value) { - consumer.accept(value); - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/SpecificHttpHeaderSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/SpecificHttpHeaderSerializer.java deleted file mode 100644 index 8d4aa79f3..000000000 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/SpecificHttpHeaderSerializer.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.ByteBuffer; -import java.time.Instant; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.core.serde.SpecificShapeSerializer; - -final class SpecificHttpHeaderSerializer extends SpecificShapeSerializer { - - private final Schema headerSchema; - private final HttpHeaderSerializer delegate; - - public SpecificHttpHeaderSerializer(Schema headerSchema, HttpHeaderSerializer delegate) { - this.headerSchema = headerSchema; - this.delegate = delegate; - } - - @Override - public void writeBoolean(Schema schema, boolean value) { - delegate.writeBoolean(headerSchema, value); - } - - @Override - public void writeShort(Schema schema, short value) { - delegate.writeShort(headerSchema, value); - } - - @Override - public void writeByte(Schema schema, byte value) { - delegate.writeByte(headerSchema, value); - } - - @Override - public void writeInteger(Schema schema, int value) { - delegate.writeInteger(headerSchema, value); - } - - @Override - public void writeLong(Schema schema, long value) { - delegate.writeLong(headerSchema, value); - } - - @Override - public void writeFloat(Schema schema, float value) { - delegate.writeFloat(headerSchema, value); - } - - @Override - public void writeDouble(Schema schema, double value) { - delegate.writeDouble(headerSchema, value); - } - - @Override - public void writeBigInteger(Schema schema, BigInteger value) { - delegate.writeBigInteger(headerSchema, value); - } - - @Override - public void writeBigDecimal(Schema schema, BigDecimal value) { - delegate.writeBigDecimal(headerSchema, value); - } - - @Override - public void writeString(Schema schema, String value) { - delegate.writeString(headerSchema, value); - } - - @Override - public void writeBlob(Schema schema, ByteBuffer value) { - delegate.writeBlob(headerSchema, value); - } - - @Override - public void writeTimestamp(Schema schema, Instant value) { - delegate.writeTimestamp(headerSchema, value); - } -} diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/StructBodyProxy.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/StructBodyProxy.java new file mode 100644 index 000000000..fe23972af --- /dev/null +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/StructBodyProxy.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.http.binding; + +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.InterceptingSerializer; +import software.amazon.smithy.java.core.serde.ShapeSerializer; + +/** + * A proxy over a delegate structure that serializes on members with a BODY binding to a target serializer. + * + * @param delegate Input/Output/Error structure to proxy. + * @param bindings Direction-specific per-member binding kinds (from RequestBinding or ResponseBinding). + */ +record StructBodyProxy(SerializableStruct delegate, HttpBindingSchemaExtensions.Binding[] bindings) + implements SerializableStruct { + @Override + public Schema schema() { + return delegate.schema(); + } + + @Override + public void serializeMembers(ShapeSerializer codecMemberSerializer) { + delegate.serializeMembers(new BodyMemberSerializer(codecMemberSerializer)); + } + + @Override + public T getMemberValue(Schema member) { + return delegate.getMemberValue(member); + } + + private final class BodyMemberSerializer extends InterceptingSerializer { + private final ShapeSerializer inner; + + BodyMemberSerializer(ShapeSerializer inner) { + this.inner = inner; + } + + @Override + protected ShapeSerializer before(Schema schema) { + return bindings[schema.memberIndex()] == HttpBindingSchemaExtensions.Binding.BODY + ? inner + : ShapeSerializer.nullSerializer(); + } + } +} diff --git a/http/http-binding/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider b/http/http-binding/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider new file mode 100644 index 000000000..748a1d3da --- /dev/null +++ b/http/http-binding/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.http.binding.HttpBindingSchemaExtensions diff --git a/http/http-binding/src/test/java/software/amazon/smithy/java/http/binding/HttpLabelSerializerTest.java b/http/http-binding/src/test/java/software/amazon/smithy/java/http/binding/HttpLabelSerializerTest.java deleted file mode 100644 index 3d73ea05d..000000000 --- a/http/http-binding/src/test/java/software/amazon/smithy/java/http/binding/HttpLabelSerializerTest.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.http.binding; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; - -import java.util.HashMap; -import java.util.Map; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import software.amazon.smithy.java.core.schema.PreludeSchemas; -import software.amazon.smithy.java.core.serde.SerializationException; - -public class HttpLabelSerializerTest { - @Test - public void doesNotAllowEmptyLabels() { - Map labels = new HashMap<>(); - HttpLabelSerializer labelSerializer = new HttpLabelSerializer(labels::put); - - var e = Assertions.assertThrows(SerializationException.class, () -> { - labelSerializer.writeString(PreludeSchemas.DOCUMENT, ""); - }); - - assertThat(e.getMessage(), containsString("HTTP label for `smithy.api#Document` cannot be empty")); - } -} From e4c951eb15222c602e4bc2740fc02b1233358444 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 18 May 2026 21:25:06 -0500 Subject: [PATCH 2/2] Pool HTTP codec path and cache empty-body output --- .../http/binding/HttpBindingDeserializer.java | 14 +-- .../binding/HttpBindingSchemaExtensions.java | 105 +++++++++++++++--- .../http/binding/HttpBindingSerializer.java | 48 ++++---- 3 files changed, 124 insertions(+), 43 deletions(-) diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java index d80c6ce71..28ae5217c 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingDeserializer.java @@ -129,16 +129,16 @@ private void readResponseStruct( readHeaderBindings( state, structMemberConsumer, - rb.listHeaderMembers(), - rb.listHeaderNames(), - rb.scalarHeadersByName(), - rb.prefixHeadersMembers()); + rb.listHeaderMembers, + rb.listHeaderNames, + rb.scalarHeadersByName, + rb.prefixHeadersMembers); - if (rb.statusMember() != null) { - structMemberConsumer.accept(state, rb.statusMember(), new ResponseStatusDeserializer(responseStatus)); + if (rb.statusMember != null) { + structMemberConsumer.accept(state, rb.statusMember, new ResponseStatusDeserializer(responseStatus)); } - readPayloadAndBody(schema, state, structMemberConsumer, rb.payloadMember(), rb.hasBody(), rb.bindings()); + readPayloadAndBody(schema, state, structMemberConsumer, rb.payloadMember, rb.hasBody, rb.bindings); } /** diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java index fa7a95609..afea230cd 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSchemaExtensions.java @@ -5,6 +5,7 @@ package software.amazon.smithy.java.http.binding; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -14,7 +15,10 @@ import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.schema.SchemaExtensionKey; import software.amazon.smithy.java.core.schema.SchemaExtensionProvider; +import software.amazon.smithy.java.core.schema.SerializableStruct; import software.amazon.smithy.java.core.schema.TraitKey; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.core.serde.ShapeSerializer; import software.amazon.smithy.java.core.serde.TimestampFormatter; import software.amazon.smithy.java.http.api.HeaderName; import software.amazon.smithy.model.shapes.ShapeType; @@ -246,29 +250,102 @@ PathSerializer pathSerializer(HttpTrait httpTrait, Schema inputSchema) { boolean writeBody(boolean omitEmptyPayload) { return hasBody || (!omitEmptyPayload && !hasPayload); } + + // Lazy cache for the codec output of an empty struct (no body members + no payload + force-write-empty). + // Bytes are determined by codec + schema, so caching once and duplicating views per request is safe. + private volatile ByteBuffer cachedEmptyBody; + + ByteBuffer emptyBody(Codec codec, Schema schema) { + var b = cachedEmptyBody; + if (b == null) { + b = codec.serialize(new EmptyStruct(schema)); + cachedEmptyBody = b; + } + return b.duplicate(); + } + } + + /** + * A no-member {@link SerializableStruct} used to seed the codec when caching the empty-body + * representation for {@link RequestBinding#emptyBody} / {@link ResponseBinding#emptyBody}. + */ + private record EmptyStruct(Schema schema) implements SerializableStruct { + @Override + public void serializeMembers(ShapeSerializer serializer) { + // no members + } + + @Override + public T getMemberValue(Schema member) { + return null; + } } /** * Pre-computed response-direction binding data for one structure / union schema. Built lazily * by {@link StructBindings#response()} on first response-side access. */ - record ResponseBinding( - Binding[] bindings, - MemberBinding[] memberBindings, - Map scalarHeadersByName, - Schema[] listHeaderMembers, - HeaderName[] listHeaderNames, - Schema[] prefixHeadersMembers, - Schema payloadMember, - Schema statusMember, - boolean hasBody, - boolean hasPayload, - Set headerWireNames, - int headerCount, - int defaultStatus) { + static final class ResponseBinding { + final Binding[] bindings; + final MemberBinding[] memberBindings; + final Map scalarHeadersByName; + final Schema[] listHeaderMembers; + final HeaderName[] listHeaderNames; + final Schema[] prefixHeadersMembers; + final Schema payloadMember; + final Schema statusMember; + final boolean hasBody; + final boolean hasPayload; + final Set headerWireNames; + final int headerCount; + // Default response status from @httpError or @error trait, or -1 when neither is present. + final int defaultStatus; + // Lazy cache for the codec output of an empty struct (no body members + no payload + force-write-empty). + // Bytes are determined by codec + schema, so caching once and duplicating views per request is safe. + private volatile ByteBuffer cachedEmptyBody; + + ResponseBinding( + Binding[] bindings, + MemberBinding[] memberBindings, + Map scalarHeadersByName, + Schema[] listHeaderMembers, + HeaderName[] listHeaderNames, + Schema[] prefixHeadersMembers, + Schema payloadMember, + Schema statusMember, + boolean hasBody, + boolean hasPayload, + Set headerWireNames, + int headerCount, + int defaultStatus + ) { + this.bindings = bindings; + this.memberBindings = memberBindings; + this.scalarHeadersByName = scalarHeadersByName; + this.listHeaderMembers = listHeaderMembers; + this.listHeaderNames = listHeaderNames; + this.prefixHeadersMembers = prefixHeadersMembers; + this.payloadMember = payloadMember; + this.statusMember = statusMember; + this.hasBody = hasBody; + this.hasPayload = hasPayload; + this.headerWireNames = headerWireNames; + this.headerCount = headerCount; + this.defaultStatus = defaultStatus; + } + boolean writeBody(boolean omitEmptyPayload) { return hasBody || (!omitEmptyPayload && !hasPayload); } + + ByteBuffer emptyBody(Codec codec, Schema schema) { + var b = cachedEmptyBody; + if (b == null) { + b = codec.serialize(new EmptyStruct(schema)); + cachedEmptyBody = b; + } + return b.duplicate(); + } } /** diff --git a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java index f7f9eafa1..14f5d3561 100644 --- a/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java +++ b/http/http-binding/src/main/java/software/amazon/smithy/java/http/binding/HttpBindingSerializer.java @@ -30,7 +30,6 @@ import software.amazon.smithy.java.http.api.HeaderName; import software.amazon.smithy.java.http.api.HttpHeaders; import software.amazon.smithy.java.http.api.ModifiableHttpHeaders; -import software.amazon.smithy.java.io.ByteBufferOutputStream; import software.amazon.smithy.java.io.ByteBufferUtils; import software.amazon.smithy.java.io.datastream.DataStream; import software.amazon.smithy.java.io.uri.QueryStringBuilder; @@ -72,8 +71,8 @@ final class HttpBindingSerializer extends SpecificShapeSerializer implements Sha // Initialized in writeStruct. Empty until writeStruct is called. private Set namesFromHttpHeader = Set.of(); - private ShapeSerializer shapeBodySerializer; - private ByteBufferOutputStream shapeBodyOutput; + // Body bytes from the pooled codec.serialize() path. + private ByteBuffer shapeBodyBuffer; private DataStream httpPayload; private EventStream eventStream; private int responseStatus; @@ -111,26 +110,36 @@ public void writeStruct(Schema schema, SerializableStruct struct) { outerStruct = struct; boolean writeBody; + boolean hasBodyMembers; int headerCount; if (isResponse) { var resp = bindings.response(); - activeBindings = resp.bindings(); - memberBindings = resp.memberBindings(); - namesFromHttpHeader = resp.headerWireNames(); - headerCount = resp.headerCount(); - if (resp.defaultStatus() != -1) { - responseStatus = resp.defaultStatus(); + activeBindings = resp.bindings; + memberBindings = resp.memberBindings; + namesFromHttpHeader = resp.headerWireNames; + headerCount = resp.headerCount; + hasBodyMembers = resp.hasBody; + if (resp.defaultStatus != -1) { + responseStatus = resp.defaultStatus; } writeBody = allowEmptyStructPayload || resp.writeBody(omitEmptyPayload); + if (writeBody && !hasBodyMembers) { + // No body members: fixed empty struct bytes, cached on the binding. + shapeBodyBuffer = resp.emptyBody(payloadCodec, schema); + } } else { var req = bindings.request(); activeBindings = req.bindings; memberBindings = req.memberBindings; namesFromHttpHeader = req.headerWireNames; headerCount = req.headerCount; + hasBodyMembers = req.hasBody; contentTypeHeaderInInput = req.inputContentTypeHeader; pathSerializer = req.pathSerializer(operationBinding.httpTrait(), schema); writeBody = allowEmptyStructPayload || req.writeBody(omitEmptyPayload); + if (writeBody && !hasBodyMembers) { + shapeBodyBuffer = req.emptyBody(payloadCodec, schema); + } } headers = HttpHeaders.ofModifiable(headerCount); @@ -145,13 +154,10 @@ public void writeStruct(Schema schema, SerializableStruct struct) { } if (writeBody) { - shapeBodyOutput = new ByteBufferOutputStream(); - shapeBodySerializer = payloadCodec.createSerializer(shapeBodyOutput); - // Serialize only the body members to the codec, going through writeStruct so the codec emits the - // struct envelope (e.g. JSON {...}). The BodyMemberSerializer routes each member to the codec's - // per-member serializer (passed inside writeStruct) when its binding is BODY, and to a null serializer - // otherwise so non-body members (headers, labels, etc.) don't leak into the body. - shapeBodySerializer.writeStruct(schema, new StructBodyProxy(struct, activeBindings)); + if (hasBodyMembers) { + shapeBodyBuffer = payloadCodec.serialize(new StructBodyProxy(struct, activeBindings)); + } + // Empty-body case (shapeBodyBuffer was set above from the cached empty bytes). headers.setHeader(HeaderName.CONTENT_TYPE, payloadMediaType); } @@ -179,9 +185,7 @@ private void prepareError(Schema schema) { @Override public void flush() { - if (shapeBodySerializer != null) { - shapeBodySerializer.flush(); - } + // Body bytes are produced eagerly by payloadCodec.serialize(); nothing to flush here. } void setHttpPayload(Schema schema, DataStream value) { @@ -226,14 +230,14 @@ private QueryStringBuilder queryStringParams() { } boolean hasBody() { - return shapeBodyOutput != null || httpPayload != null; + return shapeBodyBuffer != null || httpPayload != null; } DataStream getBody() { if (httpPayload != null) { return httpPayload; - } else if (shapeBodyOutput != null) { - return DataStream.ofByteBuffer(shapeBodyOutput.toByteBuffer(), payloadMediaType); + } else if (shapeBodyBuffer != null) { + return DataStream.ofByteBuffer(shapeBodyBuffer, payloadMediaType); } else { return DataStream.ofEmpty(); }