Skip to content

Commit 3d38a08

Browse files
Add support for marshalling lists of strings in HTTP headers... (#2588)
* Add support for marshalling lists of strings in HTTP headers... ...for JSON/XML protocols. ## Motivation and Context We currently lack support for marshalling lists of strings (and enums) in HTTP headers. Other IDLs and modeling languages, e.g., Smithy, do support such bindings and services may come to expect such support: https://awslabs.github.io/smithy/1.0/spec/core/http-traits.html#httpheader-trait ## Description * Add support for marshalling a list of strings to relevant JSON and XML marshalling classes * Consistent with the existing XML HeaderUnmarshaller map implementation, unmarshalling only supports string value types (this can be extended in the future if needed) * Marshalling implementations are changed to use appendHeader rather than putHeader * Add convenience method SdkField#getRequiredTrait(..) to support the common use case of ensuring a trait is present; update other existing use cases to utilize this method * Update relevant test classes to correctly recognize headers as a Map<String, List<String>> * Only wrap marshalling assertion exceptions as runtime when needed, minimizing stack trace noise ## Testing New tests added to: * rest-json-input.json * rest-json-output.json * rest-xml-input.json * rest-xml-output.json
1 parent ef15b89 commit 3d38a08

File tree

22 files changed

+414
-20
lines changed

22 files changed

+414
-20
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"category": "AWS SDK for Java v2",
3+
"contributor": "",
4+
"type": "feature",
5+
"description": "Add support for marshalling lists of strings in HTTP headers"
6+
}

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/HeaderMarshaller.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515

1616
package software.amazon.awssdk.protocols.json.internal.marshall;
1717

18+
import static software.amazon.awssdk.utils.CollectionUtils.isNullOrEmpty;
19+
1820
import java.nio.charset.StandardCharsets;
1921
import java.time.Instant;
22+
import java.util.List;
2023
import software.amazon.awssdk.annotations.SdkInternalApi;
2124
import software.amazon.awssdk.core.SdkField;
25+
import software.amazon.awssdk.core.protocol.MarshallLocation;
2226
import software.amazon.awssdk.core.traits.JsonValueTrait;
27+
import software.amazon.awssdk.core.traits.ListTrait;
2328
import software.amazon.awssdk.protocols.core.ValueToStringConverter;
2429
import software.amazon.awssdk.utils.BinaryUtils;
2530

@@ -45,6 +50,19 @@ public final class HeaderMarshaller {
4550
public static final JsonMarshaller<Instant> INSTANT
4651
= new SimpleHeaderMarshaller<>(JsonProtocolMarshaller.INSTANT_VALUE_TO_STRING);
4752

53+
public static final JsonMarshaller<List<?>> LIST = (list, context, paramName, sdkField) -> {
54+
// Null or empty lists cannot be meaningfully (or safely) represented in an HTTP header message since header-fields must
55+
// typically have a non-empty field-value. https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
56+
if (isNullOrEmpty(list)) {
57+
return;
58+
}
59+
SdkField memberFieldInfo = sdkField.getRequiredTrait(ListTrait.class).memberFieldInfo();
60+
for (Object listValue : list) {
61+
JsonMarshaller marshaller = context.marshallerRegistry().getMarshaller(MarshallLocation.HEADER, listValue);
62+
marshaller.marshall(listValue, context, paramName, memberFieldInfo);
63+
}
64+
};
65+
4866
private HeaderMarshaller() {
4967
}
5068

@@ -58,8 +76,7 @@ private SimpleHeaderMarshaller(ValueToStringConverter.ValueToString<T> converter
5876

5977
@Override
6078
public void marshall(T val, JsonMarshallerContext context, String paramName, SdkField<T> sdkField) {
61-
context.request().putHeader(paramName, converter.convert(val, sdkField));
79+
context.request().appendHeader(paramName, converter.convert(val, sdkField));
6280
}
6381
}
64-
6582
}

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ private static JsonMarshallerRegistry createMarshallerRegistry() {
110110
.headerMarshaller(MarshallingType.FLOAT, HeaderMarshaller.FLOAT)
111111
.headerMarshaller(MarshallingType.BOOLEAN, HeaderMarshaller.BOOLEAN)
112112
.headerMarshaller(MarshallingType.INSTANT, HeaderMarshaller.INSTANT)
113+
.headerMarshaller(MarshallingType.LIST, HeaderMarshaller.LIST)
113114
.headerMarshaller(MarshallingType.NULL, JsonMarshaller.NULL)
114115

115116
.queryParamMarshaller(MarshallingType.STRING, QueryParamMarshaller.STRING)

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/unmarshall/HeaderUnmarshaller.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@
1515

1616
package software.amazon.awssdk.protocols.json.internal.unmarshall;
1717

18+
import static java.util.stream.Collectors.toList;
19+
1820
import java.nio.charset.StandardCharsets;
1921
import java.time.Instant;
22+
import java.util.List;
2023
import software.amazon.awssdk.annotations.SdkInternalApi;
2124
import software.amazon.awssdk.core.SdkField;
2225
import software.amazon.awssdk.core.traits.JsonValueTrait;
2326
import software.amazon.awssdk.protocols.core.StringToValueConverter;
2427
import software.amazon.awssdk.protocols.json.internal.dom.SdkJsonNode;
2528
import software.amazon.awssdk.utils.BinaryUtils;
29+
import software.amazon.awssdk.utils.http.SdkHttpUtils;
2630

2731
/**
2832
* Header unmarshallers for all the simple types we support.
@@ -39,6 +43,11 @@ final class HeaderUnmarshaller {
3943
public static final JsonUnmarshaller<Boolean> BOOLEAN = new SimpleHeaderUnmarshaller<>(StringToValueConverter.TO_BOOLEAN);
4044
public static final JsonUnmarshaller<Float> FLOAT = new SimpleHeaderUnmarshaller<>(StringToValueConverter.TO_FLOAT);
4145

46+
// Only supports string value type
47+
public static final JsonUnmarshaller<List<?>> LIST = (context, jsonContent, field) -> {
48+
return SdkHttpUtils.allMatchingHeaders(context.response().headers(), field.locationName()).collect(toList());
49+
};
50+
4251
private HeaderUnmarshaller() {
4352
}
4453

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/unmarshall/JsonProtocolUnmarshaller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ private static JsonUnmarshallerRegistry createUnmarshallerRegistry(
8282
.headerUnmarshaller(MarshallingType.BOOLEAN, HeaderUnmarshaller.BOOLEAN)
8383
.headerUnmarshaller(MarshallingType.INSTANT, HeaderUnmarshaller.createInstantHeaderUnmarshaller(instantStringToValue))
8484
.headerUnmarshaller(MarshallingType.FLOAT, HeaderUnmarshaller.FLOAT)
85+
.headerUnmarshaller(MarshallingType.LIST, HeaderUnmarshaller.LIST)
8586

8687
.payloadUnmarshaller(MarshallingType.STRING, new SimpleTypeJsonUnmarshaller<>(StringToValueConverter.TO_STRING))
8788
.payloadUnmarshaller(MarshallingType.INTEGER, new SimpleTypeJsonUnmarshaller<>(StringToValueConverter.TO_INTEGER))

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/marshall/HeaderMarshaller.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515

1616
package software.amazon.awssdk.protocols.xml.internal.marshall;
1717

18+
import static software.amazon.awssdk.utils.CollectionUtils.isNullOrEmpty;
19+
1820
import java.time.Instant;
21+
import java.util.List;
1922
import java.util.Map;
2023
import software.amazon.awssdk.annotations.SdkInternalApi;
2124
import software.amazon.awssdk.core.SdkField;
2225
import software.amazon.awssdk.core.protocol.MarshallLocation;
26+
import software.amazon.awssdk.core.traits.ListTrait;
2327
import software.amazon.awssdk.protocols.core.ValueToStringConverter;
2428

2529
@SdkInternalApi
@@ -62,10 +66,30 @@ public void marshall(Map<String, ?> map, XmlMarshallerContext context, String pa
6266

6367
@Override
6468
protected boolean shouldEmit(Map map) {
65-
return map != null && !map.isEmpty();
69+
return !isNullOrEmpty(map);
6670
}
6771
};
6872

73+
public static final XmlMarshaller<List<?>> LIST = new SimpleHeaderMarshaller<List<?>>(null) {
74+
@Override
75+
public void marshall(List<?> list, XmlMarshallerContext context, String paramName, SdkField<List<?>> sdkField) {
76+
if (!shouldEmit(list)) {
77+
return;
78+
}
79+
SdkField memberFieldInfo = sdkField.getRequiredTrait(ListTrait.class).memberFieldInfo();
80+
for (Object listValue : list) {
81+
XmlMarshaller marshaller = context.marshallerRegistry().getMarshaller(MarshallLocation.HEADER, listValue);
82+
marshaller.marshall(listValue, context, paramName, memberFieldInfo);
83+
}
84+
}
85+
86+
@Override
87+
protected boolean shouldEmit(List list) {
88+
// Null or empty lists cannot be meaningfully (or safely) represented in an HTTP header message since header-fields
89+
// must typically have a non-empty field-value. https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
90+
return !isNullOrEmpty(list);
91+
}
92+
};
6993

7094
private HeaderMarshaller() {
7195
}
@@ -83,7 +107,7 @@ public void marshall(T val, XmlMarshallerContext context, String paramName, SdkF
83107
return;
84108
}
85109

86-
context.request().putHeader(paramName, converter.convert(val, sdkField));
110+
context.request().appendHeader(paramName, converter.convert(val, sdkField));
87111
}
88112

89113
protected boolean shouldEmit(T val) {

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/marshall/QueryParamMarshaller.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ public final class QueryParamMarshaller {
6060
return;
6161
}
6262

63-
MapTrait mapTrait = sdkField.getOptionalTrait(MapTrait.class)
64-
.orElseThrow(() -> new IllegalStateException("SdkField of list type is missing List trait"));
63+
MapTrait mapTrait = sdkField.getRequiredTrait(MapTrait.class);
6564
SdkField valueField = mapTrait.valueFieldInfo();
6665

6766
for (Map.Entry<String, ?> entry : map.entrySet()) {

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/marshall/XmlPayloadMarshaller.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,7 @@ public void marshall(List<?> val, XmlMarshallerContext context, String paramName
8181
@Override
8282
public void marshall(List<?> list, XmlMarshallerContext context, String paramName,
8383
SdkField<List<?>> sdkField, ValueToStringConverter.ValueToString<List<?>> converter) {
84-
ListTrait listTrait = sdkField
85-
.getOptionalTrait(ListTrait.class)
86-
.orElseThrow(() -> new IllegalStateException(paramName + " member is missing ListTrait"));
84+
ListTrait listTrait = sdkField.getRequiredTrait(ListTrait.class);
8785

8886
if (!listTrait.isFlattened()) {
8987
context.xmlGenerator().startElement(paramName);
@@ -125,8 +123,7 @@ protected boolean shouldEmit(List list, String paramName) {
125123
public void marshall(Map<String, ?> map, XmlMarshallerContext context, String paramName,
126124
SdkField<Map<String, ?>> sdkField, ValueToStringConverter.ValueToString<Map<String, ?>> converter) {
127125

128-
MapTrait mapTrait = sdkField.getOptionalTrait(MapTrait.class)
129-
.orElseThrow(() -> new IllegalStateException(paramName + " member is missing MapTrait"));
126+
MapTrait mapTrait = sdkField.getRequiredTrait(MapTrait.class);
130127

131128
for (Map.Entry<String, ?> entry : map.entrySet()) {
132129
context.xmlGenerator().startElement("entry");

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/marshall/XmlProtocolMarshaller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ private static XmlMarshallerRegistry createMarshallerRegistry() {
176176
.headerMarshaller(MarshallingType.BOOLEAN, HeaderMarshaller.BOOLEAN)
177177
.headerMarshaller(MarshallingType.INSTANT, HeaderMarshaller.INSTANT)
178178
.headerMarshaller(MarshallingType.MAP, HeaderMarshaller.MAP)
179+
.headerMarshaller(MarshallingType.LIST, HeaderMarshaller.LIST)
179180
.headerMarshaller(MarshallingType.NULL, XmlMarshaller.NULL)
180181

181182
.queryParamMarshaller(MarshallingType.STRING, QueryParamMarshaller.STRING)

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/unmarshall/HeaderUnmarshaller.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
package software.amazon.awssdk.protocols.xml.internal.unmarshall;
1717

18+
import static java.util.stream.Collectors.toList;
1819
import static software.amazon.awssdk.utils.StringUtils.replacePrefixIgnoreCase;
1920
import static software.amazon.awssdk.utils.StringUtils.startsWithIgnoreCase;
2021

@@ -26,6 +27,7 @@
2627
import software.amazon.awssdk.core.SdkField;
2728
import software.amazon.awssdk.protocols.core.StringToValueConverter;
2829
import software.amazon.awssdk.protocols.query.unmarshall.XmlElement;
30+
import software.amazon.awssdk.utils.http.SdkHttpUtils;
2931

3032
@SdkInternalApi
3133
public final class HeaderUnmarshaller {
@@ -39,6 +41,7 @@ public final class HeaderUnmarshaller {
3941
public static final XmlUnmarshaller<Instant> INSTANT =
4042
new SimpleHeaderUnmarshaller<>(XmlProtocolUnmarshaller.INSTANT_STRING_TO_VALUE);
4143

44+
// Only supports string value type
4245
public static final XmlUnmarshaller<Map<String, ?>> MAP = ((context, content, field) -> {
4346
Map<String, String> result = new HashMap<>();
4447
context.response().headers().entrySet().stream()
@@ -48,6 +51,11 @@ public final class HeaderUnmarshaller {
4851
return result;
4952
});
5053

54+
// Only supports string value type
55+
public static final XmlUnmarshaller<List<?>> LIST = (context, content, field) -> {
56+
return SdkHttpUtils.allMatchingHeaders(context.response().headers(), field.locationName()).collect(toList());
57+
};
58+
5159
private HeaderUnmarshaller() {
5260
}
5361

core/protocols/aws-xml-protocol/src/main/java/software/amazon/awssdk/protocols/xml/internal/unmarshall/XmlProtocolUnmarshaller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ private static XmlUnmarshallerRegistry createUnmarshallerRegistry() {
150150
.headerUnmarshaller(MarshallingType.INSTANT, HeaderUnmarshaller.INSTANT)
151151
.headerUnmarshaller(MarshallingType.FLOAT, HeaderUnmarshaller.FLOAT)
152152
.headerUnmarshaller(MarshallingType.MAP, HeaderUnmarshaller.MAP)
153+
.headerUnmarshaller(MarshallingType.LIST, HeaderUnmarshaller.LIST)
153154

154155
.payloadUnmarshaller(MarshallingType.STRING, XmlPayloadUnmarshaller.STRING)
155156
.payloadUnmarshaller(MarshallingType.INTEGER, XmlPayloadUnmarshaller.INTEGER)

core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkField.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ public <T extends Trait> Optional<T> getOptionalTrait(Class<T> clzz) {
122122
return Optional.ofNullable((T) traits.get(clzz));
123123
}
124124

125+
/**
126+
* Gets the trait of the specified class, or throw {@link IllegalStateException} if not available.
127+
*
128+
* @param clzz Trait class to get.
129+
* @param <T> Type of trait.
130+
* @return Trait instance.
131+
* @throws IllegalStateException if trait is not present.
132+
*/
133+
@SuppressWarnings("unchecked")
134+
public <T extends Trait> T getRequiredTrait(Class<T> clzz) throws IllegalStateException {
135+
T trait = (T) traits.get(clzz);
136+
if (trait == null) {
137+
throw new IllegalStateException(memberName + " member is missing " + clzz.getSimpleName());
138+
}
139+
return trait;
140+
}
141+
125142
/**
126143
* Checks if a given {@link Trait} is present on the field.
127144
*

test/protocol-tests-core/src/main/java/software/amazon/awssdk/protocol/asserts/marshalling/HeadersAssertion.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static org.junit.Assert.assertEquals;
1919
import static org.junit.Assert.assertFalse;
20+
import static org.junit.Assert.assertTrue;
2021

2122
import com.github.tomakehurst.wiremock.http.HttpHeaders;
2223
import com.github.tomakehurst.wiremock.verification.LoggedRequest;
@@ -28,11 +29,11 @@
2829
*/
2930
public class HeadersAssertion extends MarshallingAssertion {
3031

31-
private Map<String, String> contains;
32+
private Map<String, List<String>> contains;
3233

3334
private List<String> doesNotContain;
3435

35-
public void setContains(Map<String, String> contains) {
36+
public void setContains(Map<String, List<String>> contains) {
3637
this.contains = contains;
3738
}
3839

@@ -51,8 +52,11 @@ protected void doAssert(LoggedRequest actual) throws Exception {
5152
}
5253

5354
private void assertHeadersContains(HttpHeaders actual) {
54-
contains.entrySet().forEach(e -> {
55-
assertEquals(e.getValue(), actual.getHeader(e.getKey()).firstValue());
55+
contains.forEach((expectedKey, expectedValues) -> {
56+
assertTrue(String.format("Header '%s' was expected to be present. Actual headers: %s", expectedKey, actual),
57+
actual.getHeader(expectedKey).isPresent());
58+
List<String> actualValues = actual.getHeader(expectedKey).values();
59+
assertEquals(expectedValues, actualValues);
5660
});
5761
}
5862

test/protocol-tests-core/src/main/java/software/amazon/awssdk/protocol/asserts/marshalling/MarshallingAssertion.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ public abstract class MarshallingAssertion {
2929
* @throws AssertionError If any assertions fail
3030
*/
3131
public final void assertMatches(LoggedRequest actual) throws AssertionError {
32-
// Catches the exception to play nicer with lambda's
32+
// Wrap checked exceptions to play nicer with lambda's
3333
try {
3434
doAssert(actual);
35+
} catch (Error | RuntimeException e) {
36+
throw e;
3537
} catch (Exception e) {
3638
throw new RuntimeException(e);
3739
}

test/protocol-tests-core/src/main/java/software/amazon/awssdk/protocol/model/GivenResponse.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616
package software.amazon.awssdk.protocol.model;
1717

1818
import com.fasterxml.jackson.annotation.JsonProperty;
19+
import java.util.List;
1920
import java.util.Map;
2021

2122
public class GivenResponse {
2223

2324
@JsonProperty(value = "status_code")
2425
private Integer statusCode;
25-
private Map<String, String> headers;
26+
private Map<String, List<String>> headers;
2627
private String body;
2728

2829
public Integer getStatusCode() {
@@ -33,11 +34,11 @@ public void setStatusCode(Integer statusCode) {
3334
this.statusCode = statusCode;
3435
}
3536

36-
public Map<String, String> getHeaders() {
37+
public Map<String, List<String>> getHeaders() {
3738
return headers;
3839
}
3940

40-
public void setHeaders(Map<String, String> headers) {
41+
public void setHeaders(Map<String, List<String>> headers) {
4142
this.headers = headers;
4243
}
4344

test/protocol-tests-core/src/main/java/software/amazon/awssdk/protocol/runners/UnmarshallingTestRunner.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ private ResponseDefinitionBuilder toResponseBuilder(GivenResponse givenResponse)
9999

100100
ResponseDefinitionBuilder responseBuilder = aResponse().withStatus(200);
101101
if (givenResponse.getHeaders() != null) {
102-
givenResponse.getHeaders().forEach(responseBuilder::withHeader);
102+
givenResponse.getHeaders().forEach((key, values) -> {
103+
responseBuilder.withHeader(key, values.toArray(new String[0]));
104+
});
103105
}
104106
if (givenResponse.getStatusCode() != null) {
105107
responseBuilder.withStatus(givenResponse.getStatusCode());

0 commit comments

Comments
 (0)