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..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
@@ -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 extends SerializableStruct> 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 extends SerializableStruct> 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