Skip to content

Commit 3ab30a2

Browse files
committed
Add unboxing of java.util.Optional to GenericHttpMessageConverter #24498
--
1 parent 93b340e commit 3ab30a2

File tree

5 files changed

+189
-7
lines changed

5 files changed

+189
-7
lines changed

spring-web/src/main/java/org/springframework/http/converter/AbstractGenericHttpMessageConverter.java

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -31,6 +31,7 @@
3131
*
3232
* @author Sebastien Deleuze
3333
* @author Juergen Hoeller
34+
* @author Vladislav Kisel
3435
* @since 4.2
3536
* @param <T> the converted object type
3637
*/
@@ -84,8 +85,9 @@ public boolean canWrite(@Nullable Type type, Class<?> clazz, @Nullable MediaType
8485
public final void write(final T t, @Nullable final Type type, @Nullable MediaType contentType,
8586
HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
8687

88+
final T unboxed = unboxIfNeeded(t);
8789
final HttpHeaders headers = outputMessage.getHeaders();
88-
addDefaultHeaders(headers, t, contentType);
90+
addDefaultHeaders(headers, unboxed, contentType);
8991

9092
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
9193
streamingOutputMessage.setBody(outputStream -> writeInternal(t, type, new HttpOutputMessage() {
@@ -100,7 +102,7 @@ public HttpHeaders getHeaders() {
100102
}));
101103
}
102104
else {
103-
writeInternal(t, type, outputMessage);
105+
writeInternal(unboxed, type, outputMessage);
104106
outputMessage.getBody().flush();
105107
}
106108
}

spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java

+38-3
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
import java.util.Arrays;
2424
import java.util.Collections;
2525
import java.util.List;
26+
import java.util.Optional;
2627

2728
import org.apache.commons.logging.Log;
2829

30+
import org.springframework.core.GenericTypeResolver;
2931
import org.springframework.http.HttpHeaders;
3032
import org.springframework.http.HttpInputMessage;
3133
import org.springframework.http.HttpLogging;
@@ -45,6 +47,7 @@
4547
* @author Arjen Poutsma
4648
* @author Juergen Hoeller
4749
* @author Sebastien Deleuze
50+
* @author Vladislav Kisel
4851
* @since 3.0
4952
* @param <T> the converted object type
5053
*/
@@ -53,17 +56,23 @@ public abstract class AbstractHttpMessageConverter<T> implements HttpMessageConv
5356
/** Logger available to subclasses. */
5457
protected final Log logger = HttpLogging.forLogName(getClass());
5558

59+
/**
60+
* This converter type argument
61+
*/
62+
@Nullable
63+
private Class<?> typeArgument;
64+
5665
private List<MediaType> supportedMediaTypes = Collections.emptyList();
5766

5867
@Nullable
5968
private Charset defaultCharset;
6069

61-
6270
/**
6371
* Construct an {@code AbstractHttpMessageConverter} with no supported media types.
6472
* @see #setSupportedMediaTypes
6573
*/
6674
protected AbstractHttpMessageConverter() {
75+
resolveTypeArgument();
6776
}
6877

6978
/**
@@ -72,6 +81,7 @@ protected AbstractHttpMessageConverter() {
7281
*/
7382
protected AbstractHttpMessageConverter(MediaType supportedMediaType) {
7483
setSupportedMediaTypes(Collections.singletonList(supportedMediaType));
84+
resolveTypeArgument();
7585
}
7686

7787
/**
@@ -80,6 +90,7 @@ protected AbstractHttpMessageConverter(MediaType supportedMediaType) {
8090
*/
8191
protected AbstractHttpMessageConverter(MediaType... supportedMediaTypes) {
8292
setSupportedMediaTypes(Arrays.asList(supportedMediaTypes));
93+
resolveTypeArgument();
8394
}
8495

8596
/**
@@ -92,6 +103,7 @@ protected AbstractHttpMessageConverter(MediaType... supportedMediaTypes) {
92103
protected AbstractHttpMessageConverter(Charset defaultCharset, MediaType... supportedMediaTypes) {
93104
this.defaultCharset = defaultCharset;
94105
setSupportedMediaTypes(Arrays.asList(supportedMediaTypes));
106+
resolveTypeArgument();
95107
}
96108

97109

@@ -207,8 +219,9 @@ public final T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
207219
public final void write(final T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
208220
throws IOException, HttpMessageNotWritableException {
209221

222+
final T unboxed = unboxIfNeeded(t);
210223
final HttpHeaders headers = outputMessage.getHeaders();
211-
addDefaultHeaders(headers, t, contentType);
224+
addDefaultHeaders(headers, unboxed, contentType);
212225

213226
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
214227
streamingOutputMessage.setBody(outputStream -> writeInternal(t, new HttpOutputMessage() {
@@ -223,11 +236,26 @@ public HttpHeaders getHeaders() {
223236
}));
224237
}
225238
else {
226-
writeInternal(t, outputMessage);
239+
writeInternal(unboxed, outputMessage);
227240
outputMessage.getBody().flush();
228241
}
229242
}
230243

244+
/**
245+
* Unbox the given object if it is stored in a container (like {@link java.util.Optional}.
246+
*/
247+
@SuppressWarnings("unchecked")
248+
protected T unboxIfNeeded(T value) {
249+
// Skip unboxing if the type is unknown to ensure type safety
250+
if (typeArgument != null) {
251+
if (value instanceof Optional && !typeArgument.equals(Optional.class)) {
252+
return ((Optional<T>) value).orElse(value);
253+
}
254+
}
255+
256+
return value;
257+
}
258+
231259
/**
232260
* Add default headers to the output message.
233261
* <p>This implementation delegates to {@link #getDefaultContentType(Object)} if a
@@ -319,4 +347,11 @@ protected abstract T readInternal(Class<? extends T> clazz, HttpInputMessage inp
319347
protected abstract void writeInternal(T t, HttpOutputMessage outputMessage)
320348
throws IOException, HttpMessageNotWritableException;
321349

350+
/**
351+
* Resolve generic type of this converter and set it to {@link this#typeArgument}
352+
*/
353+
private void resolveTypeArgument() {
354+
this.typeArgument = GenericTypeResolver.resolveTypeArgument(this.getClass(), AbstractHttpMessageConverter.class);
355+
}
356+
322357
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2002-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http.converter;
18+
19+
import org.junit.jupiter.api.Assertions;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.http.HttpInputMessage;
22+
import org.springframework.http.HttpOutputMessage;
23+
import org.springframework.http.MediaType;
24+
import org.springframework.http.MockHttpOutputMessage;
25+
26+
import java.io.IOException;
27+
import java.util.Optional;
28+
29+
/**
30+
* Test cases for {@link AbstractHttpMessageConverter} class.
31+
*
32+
* @author Vladislav Kisel
33+
*/
34+
public class AbstractHttpMessageConverterTests {
35+
36+
@Test
37+
public void testValueUnboxingWhenTypeIsObject() throws IOException {
38+
TestConverterWithObjectType converter = new TestConverterWithObjectType();
39+
converter.write(Optional.of("123"), MediaType.ALL, new MockHttpOutputMessage());
40+
41+
Assertions.assertEquals("123", converter.t);
42+
}
43+
44+
@Test
45+
public void testValueUnboxingWhenValueIsNull() throws IOException {
46+
TestConverterWithObjectType converter = new TestConverterWithObjectType();
47+
converter.write(Optional.empty(), MediaType.ALL, new MockHttpOutputMessage());
48+
49+
Assertions.assertEquals(Optional.empty(), converter.t);
50+
}
51+
52+
@Test
53+
public void testValueUnboxingWhenTypeIsOptional() throws IOException {
54+
TestConverterWithOptionalType converter = new TestConverterWithOptionalType();
55+
converter.write(Optional.of("123"), MediaType.ALL, new MockHttpOutputMessage());
56+
57+
Assertions.assertEquals(Optional.of("123"), converter.t);
58+
}
59+
60+
@Test
61+
public void testValueUnboxingWhenTypeIsUnknown() throws IOException {
62+
GenericTestConverter<Optional<String>> converter = new GenericTestConverter<>();
63+
converter.write(Optional.of("123"), MediaType.ALL, new MockHttpOutputMessage());
64+
65+
Assertions.assertEquals(Optional.of("123"), converter.t);
66+
}
67+
68+
private static class GenericTestConverter<T> extends AbstractHttpMessageConverter<T> {
69+
70+
T t;
71+
72+
@Override
73+
protected boolean supports(Class<?> clazz) {
74+
return true;
75+
}
76+
77+
@Override
78+
protected T readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage) throws HttpMessageNotReadableException {
79+
return t;
80+
}
81+
82+
@Override
83+
protected void writeInternal(T t, HttpOutputMessage outputMessage) throws HttpMessageNotWritableException {
84+
this.t = t;
85+
}
86+
}
87+
88+
private static class TestConverterWithOptionalType extends GenericTestConverter<Optional<String>> {
89+
}
90+
91+
private static class TestConverterWithObjectType extends GenericTestConverter<Object> {
92+
}
93+
94+
}

spring-web/src/test/java/org/springframework/http/converter/ObjectToStringHttpMessageConverterTests.java

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@
2121
import java.nio.charset.StandardCharsets;
2222
import java.util.Date;
2323
import java.util.Locale;
24+
import java.util.Optional;
2425

2526
import org.junit.jupiter.api.BeforeEach;
2627
import org.junit.jupiter.api.Test;
@@ -42,6 +43,7 @@
4243
*
4344
* @author <a href="mailto:[email protected]">Dmitry Katsubo</a>
4445
* @author Rossen Stoyanchev
46+
* @author Vladislav Kisel
4547
*/
4648
public class ObjectToStringHttpMessageConverterTests {
4749

@@ -173,4 +175,20 @@ public void testConversionServiceRequired() {
173175
new ObjectToStringHttpMessageConverter(null));
174176
}
175177

178+
@Test
179+
public void testWriteAndReadOptional() throws IOException {
180+
MediaType contentType = new MediaType("text", "plain");
181+
String value = "stringValue";
182+
this.converter.write(Optional.of(value), contentType, this.response);
183+
184+
assertThat(this.servletResponse.getContentAsString()).isEqualTo(value);
185+
186+
MockHttpServletRequest request = new MockHttpServletRequest();
187+
request.setContentType(MediaType.TEXT_PLAIN_VALUE);
188+
request.setCharacterEncoding("UTF-8");
189+
request.setContent(value.getBytes());
190+
191+
assertThat(this.converter.read(String.class, new ServletServerHttpRequest(request))).isEqualTo(value);
192+
}
193+
176194
}

spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java

+33
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@
2424
import java.util.HashMap;
2525
import java.util.List;
2626
import java.util.Map;
27+
import java.util.Optional;
2728

2829
import com.fasterxml.jackson.annotation.JsonFilter;
30+
import com.fasterxml.jackson.annotation.JsonProperty;
31+
import com.fasterxml.jackson.annotation.JsonSubTypes;
32+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
2933
import com.fasterxml.jackson.annotation.JsonView;
3034
import com.fasterxml.jackson.databind.JavaType;
3135
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -54,6 +58,7 @@
5458
* @author Rossen Stoyanchev
5559
* @author Sebastien Deleuze
5660
* @author Juergen Hoeller
61+
* @author Vladislav Kisel
5762
*/
5863
public class MappingJackson2HttpMessageConverterTests {
5964

@@ -528,6 +533,22 @@ public void writeAscii() throws Exception {
528533
assertThat(outputMessage.getHeaders().getContentType()).as("Invalid content-type").isEqualTo(contentType);
529534
}
530535

536+
@Test // issue #24498
537+
public void writeAndReadWithOptionalAndBeanInheritance() throws IOException {
538+
JacksonChildBean bean = new JacksonChildBean();
539+
bean.firstName = "optionalTest";
540+
541+
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
542+
543+
converter.write(Optional.of(bean), null, outputMessage);
544+
String json = outputMessage.getBodyAsString(StandardCharsets.UTF_8);
545+
assertThat(json).isEqualTo("{\"@type\":\"MappingJackson2HttpMessageConverterTests$JacksonChildBean\",\"firstName\":\"optionalTest\"}");
546+
assertThat(outputMessage.getHeaders().getContentType()).as("Invalid content-type").isEqualTo(MediaType.APPLICATION_JSON);
547+
548+
MockHttpInputMessage inputMessage = new MockHttpInputMessage(json.getBytes());
549+
JacksonChildBean read = (JacksonChildBean) converter.read(JacksonChildBean.class, inputMessage);
550+
assertThat(read.firstName).isEqualTo("optionalTest");
551+
}
531552

532553
interface MyInterface {
533554

@@ -713,4 +734,16 @@ public String getProperty2() {
713734
}
714735
}
715736

737+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
738+
@JsonSubTypes({@JsonSubTypes.Type(JacksonChildBean.class)})
739+
public interface JacksonParentBean {
740+
}
741+
742+
public static class JacksonChildBean implements JacksonParentBean {
743+
744+
@JsonProperty
745+
String firstName;
746+
747+
}
748+
716749
}

0 commit comments

Comments
 (0)