Skip to content

Commit 4bdb772

Browse files
committed
Introduce HttpMessageContentConverter
This commit introduces an abstraction that allows to convert HTTP inputs to a data type based on a set of HttpMessageConverter. Previously, the AssertJ integration was finding the first converter that is able to convert JSON to a Map (and vice-versa) and used that in its API. With the introduction of SmartHttpMessageConverter, exposing a specific converter is fragile. The added abstraction allows for converting other kind of input than JSON if we need to do that in the future. Closes gh-33148
1 parent 206d81e commit 4bdb772

14 files changed

+506
-108
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2002-2024 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.test.http;
18+
19+
import java.io.IOException;
20+
import java.lang.reflect.Type;
21+
import java.util.Arrays;
22+
import java.util.List;
23+
import java.util.stream.StreamSupport;
24+
25+
import org.springframework.core.ResolvableType;
26+
import org.springframework.http.HttpInputMessage;
27+
import org.springframework.http.MediaType;
28+
import org.springframework.http.converter.GenericHttpMessageConverter;
29+
import org.springframework.http.converter.HttpMessageConverter;
30+
import org.springframework.http.converter.HttpMessageNotReadableException;
31+
import org.springframework.http.converter.SmartHttpMessageConverter;
32+
import org.springframework.mock.http.MockHttpInputMessage;
33+
import org.springframework.mock.http.MockHttpOutputMessage;
34+
import org.springframework.util.Assert;
35+
import org.springframework.util.function.SingletonSupplier;
36+
37+
/**
38+
* Convert HTTP message content for testing purposes.
39+
*
40+
* @author Stephane Nicoll
41+
* @since 6.2
42+
*/
43+
public class HttpMessageContentConverter {
44+
45+
private static final MediaType JSON = MediaType.APPLICATION_JSON;
46+
47+
private final List<HttpMessageConverter<?>> messageConverters;
48+
49+
HttpMessageContentConverter(Iterable<HttpMessageConverter<?>> messageConverters) {
50+
this.messageConverters = StreamSupport.stream(messageConverters.spliterator(), false).toList();
51+
Assert.notEmpty(this.messageConverters, "At least one message converter needs to be specified");
52+
}
53+
54+
55+
/**
56+
* Create an instance with an iterable of the candidates to use.
57+
* @param candidates the candidates
58+
*/
59+
public static HttpMessageContentConverter of(Iterable<HttpMessageConverter<?>> candidates) {
60+
return new HttpMessageContentConverter(candidates);
61+
}
62+
63+
/**
64+
* Create an instance with a vararg of the candidates to use.
65+
* @param candidates the candidates
66+
*/
67+
public static HttpMessageContentConverter of(HttpMessageConverter<?>... candidates) {
68+
return new HttpMessageContentConverter(Arrays.asList(candidates));
69+
}
70+
71+
72+
/**
73+
* Convert the given {@link HttpInputMessage} whose content must match the
74+
* given {@link MediaType} to the requested {@code targetType}.
75+
* @param message an input message
76+
* @param mediaType the media type of the input
77+
* @param targetType the target type
78+
* @param <T> the converted object type
79+
* @return a value of the given {@code targetType}
80+
*/
81+
@SuppressWarnings("unchecked")
82+
public <T> T convert(HttpInputMessage message, MediaType mediaType, ResolvableType targetType)
83+
throws IOException, HttpMessageNotReadableException {
84+
Class<?> contextClass = targetType.getRawClass();
85+
SingletonSupplier<Type> javaType = SingletonSupplier.of(targetType::getType);
86+
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
87+
if (messageConverter instanceof GenericHttpMessageConverter<?> genericMessageConverter) {
88+
Type type = javaType.obtain();
89+
if (genericMessageConverter.canRead(type, contextClass, mediaType)) {
90+
return (T) genericMessageConverter.read(type, contextClass, message);
91+
}
92+
}
93+
else if (messageConverter instanceof SmartHttpMessageConverter<?> smartMessageConverter) {
94+
if (smartMessageConverter.canRead(targetType, mediaType)) {
95+
return (T) smartMessageConverter.read(targetType, message, null);
96+
}
97+
}
98+
else {
99+
Class<?> targetClass = (contextClass != null ? contextClass : Object.class);
100+
if (messageConverter.canRead(targetClass, mediaType)) {
101+
HttpMessageConverter<T> simpleMessageConverter = (HttpMessageConverter<T>) messageConverter;
102+
Class<? extends T> clazz = (Class<? extends T>) targetClass;
103+
return simpleMessageConverter.read(clazz, message);
104+
}
105+
}
106+
}
107+
throw new IllegalStateException("No converter found to read [%s] to [%s]".formatted(mediaType, targetType));
108+
}
109+
110+
/**
111+
* Convert the given raw value to the given {@code targetType} by writing
112+
* it first to JSON and reading it back.
113+
* @param value the value to convert
114+
* @param targetType the target type
115+
* @param <T> the converted object type
116+
* @return a value of the given {@code targetType}
117+
*/
118+
public <T> T convertViaJson(Object value, ResolvableType targetType) throws IOException {
119+
MockHttpOutputMessage outputMessage = convertToJson(value, ResolvableType.forInstance(value));
120+
return convert(fromHttpOutputMessage(outputMessage), JSON, targetType);
121+
}
122+
123+
@SuppressWarnings({ "rawtypes", "unchecked" })
124+
private MockHttpOutputMessage convertToJson(Object value, ResolvableType valueType) throws IOException {
125+
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
126+
Class<?> valueClass = value.getClass();
127+
SingletonSupplier<Type> javaType = SingletonSupplier.of(valueType::getType);
128+
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
129+
if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) {
130+
Type type = javaType.obtain();
131+
if (genericMessageConverter.canWrite(type, valueClass, JSON)) {
132+
genericMessageConverter.write(value, type, JSON, outputMessage);
133+
return outputMessage;
134+
}
135+
}
136+
else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) {
137+
if (smartMessageConverter.canWrite(valueType, valueClass, JSON)) {
138+
smartMessageConverter.write(value, valueType, JSON, outputMessage, null);
139+
return outputMessage;
140+
}
141+
}
142+
else if (messageConverter.canWrite(valueClass, JSON)) {
143+
((HttpMessageConverter<Object>) messageConverter).write(value, JSON, outputMessage);
144+
return outputMessage;
145+
}
146+
}
147+
throw new IllegalStateException("No converter found to convert [%s] to JSON".formatted(valueType));
148+
}
149+
150+
private static HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
151+
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
152+
inputMessage.getHeaders().addAll(message.getHeaders());
153+
return inputMessage;
154+
}
155+
156+
}

spring-test/src/main/java/org/springframework/test/json/AbstractJsonContentAssert.java

+9-8
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.assertj.core.error.BasicErrorMessageFactory;
3636
import org.assertj.core.internal.Failures;
3737

38+
import org.springframework.core.ResolvableType;
3839
import org.springframework.core.io.ByteArrayResource;
3940
import org.springframework.core.io.ClassPathResource;
4041
import org.springframework.core.io.FileSystemResource;
@@ -43,9 +44,9 @@
4344
import org.springframework.http.HttpHeaders;
4445
import org.springframework.http.HttpInputMessage;
4546
import org.springframework.http.MediaType;
46-
import org.springframework.http.converter.GenericHttpMessageConverter;
4747
import org.springframework.lang.Nullable;
4848
import org.springframework.mock.http.MockHttpInputMessage;
49+
import org.springframework.test.http.HttpMessageContentConverter;
4950
import org.springframework.util.Assert;
5051

5152
/**
@@ -77,7 +78,7 @@ public abstract class AbstractJsonContentAssert<SELF extends AbstractJsonContent
7778

7879

7980
@Nullable
80-
private final GenericHttpMessageConverter<Object> jsonMessageConverter;
81+
private final HttpMessageContentConverter contentConverter;
8182

8283
@Nullable
8384
private Class<?> resourceLoadClass;
@@ -94,7 +95,7 @@ public abstract class AbstractJsonContentAssert<SELF extends AbstractJsonContent
9495
*/
9596
protected AbstractJsonContentAssert(@Nullable JsonContent actual, Class<?> selfType) {
9697
super(actual, selfType);
97-
this.jsonMessageConverter = (actual != null ? actual.getJsonMessageConverter() : null);
98+
this.contentConverter = (actual != null ? actual.getContentConverter() : null);
9899
this.jsonLoader = new JsonLoader(null, null);
99100
as("JSON content");
100101
}
@@ -131,15 +132,15 @@ public <T> AbstractObjectAssert<?, T> convertTo(Class<T> target) {
131132
return assertFactory.createAssert(this::convertToTargetType);
132133
}
133134

134-
@SuppressWarnings("unchecked")
135135
private <T> T convertToTargetType(Type targetType) {
136136
String json = this.actual.getJson();
137-
if (this.jsonMessageConverter == null) {
137+
if (this.contentConverter == null) {
138138
throw new IllegalStateException(
139139
"No JSON message converter available to convert %s".formatted(json));
140140
}
141141
try {
142-
return (T) this.jsonMessageConverter.read(targetType, getClass(), fromJson(json));
142+
return this.contentConverter.convert(fromJson(json), MediaType.APPLICATION_JSON,
143+
ResolvableType.forType(targetType));
143144
}
144145
catch (Exception ex) {
145146
throw failure(new ValueProcessingFailed(json,
@@ -165,7 +166,7 @@ private HttpInputMessage fromJson(String json) {
165166
*/
166167
public JsonPathValueAssert extractingPath(String path) {
167168
Object value = new JsonPathValue(path).getValue();
168-
return new JsonPathValueAssert(value, path, this.jsonMessageConverter);
169+
return new JsonPathValueAssert(value, path, this.contentConverter);
169170
}
170171

171172
/**
@@ -176,7 +177,7 @@ public JsonPathValueAssert extractingPath(String path) {
176177
*/
177178
public SELF hasPathSatisfying(String path, Consumer<AssertProvider<JsonPathValueAssert>> valueRequirements) {
178179
Object value = new JsonPathValue(path).assertHasPath();
179-
JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.jsonMessageConverter);
180+
JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.contentConverter);
180181
valueRequirements.accept(() -> valueAssert);
181182
return this.myself;
182183
}

spring-test/src/main/java/org/springframework/test/json/AbstractJsonValueAssert.java

+6-21
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,9 @@
3333
import org.assertj.core.internal.Failures;
3434

3535
import org.springframework.core.ResolvableType;
36-
import org.springframework.http.HttpInputMessage;
37-
import org.springframework.http.MediaType;
3836
import org.springframework.http.converter.GenericHttpMessageConverter;
3937
import org.springframework.lang.Nullable;
40-
import org.springframework.mock.http.MockHttpInputMessage;
41-
import org.springframework.mock.http.MockHttpOutputMessage;
38+
import org.springframework.test.http.HttpMessageContentConverter;
4239
import org.springframework.util.ObjectUtils;
4340
import org.springframework.util.StringUtils;
4441

@@ -68,14 +65,14 @@ public abstract class AbstractJsonValueAssert<SELF extends AbstractJsonValueAsse
6865
private final Failures failures = Failures.instance();
6966

7067
@Nullable
71-
private final GenericHttpMessageConverter<Object> httpMessageConverter;
68+
private final HttpMessageContentConverter contentConverter;
7269

7370

7471
protected AbstractJsonValueAssert(@Nullable Object actual, Class<?> selfType,
75-
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
72+
@Nullable HttpMessageContentConverter contentConverter) {
7673

7774
super(actual, selfType);
78-
this.httpMessageConverter = httpMessageConverter;
75+
this.contentConverter = contentConverter;
7976
}
8077

8178

@@ -199,32 +196,20 @@ public SELF isNotEmpty() {
199196
return this.myself;
200197
}
201198

202-
203-
@SuppressWarnings("unchecked")
204199
private <T> T convertToTargetType(Type targetType) {
205-
if (this.httpMessageConverter == null) {
200+
if (this.contentConverter == null) {
206201
throw new IllegalStateException(
207202
"No JSON message converter available to convert %s".formatted(actualToString()));
208203
}
209204
try {
210-
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
211-
this.httpMessageConverter.write(this.actual, ResolvableType.forInstance(this.actual).getType(),
212-
MediaType.APPLICATION_JSON, outputMessage);
213-
return (T) this.httpMessageConverter.read(targetType, getClass(),
214-
fromHttpOutputMessage(outputMessage));
205+
return this.contentConverter.convertViaJson(this.actual, ResolvableType.forType(targetType));
215206
}
216207
catch (Exception ex) {
217208
throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n"
218209
.formatted(targetType.getTypeName(), ex.getMessage()));
219210
}
220211
}
221212

222-
private HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
223-
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
224-
inputMessage.getHeaders().addAll(message.getHeaders());
225-
return inputMessage;
226-
}
227-
228213
protected String getExpectedErrorMessagePrefix() {
229214
return "Expected:";
230215
}

spring-test/src/main/java/org/springframework/test/json/JsonContent.java

+9-9
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
import org.assertj.core.api.AssertProvider;
2020

21-
import org.springframework.http.converter.GenericHttpMessageConverter;
2221
import org.springframework.lang.Nullable;
22+
import org.springframework.test.http.HttpMessageContentConverter;
2323
import org.springframework.util.Assert;
2424

2525
/**
@@ -35,22 +35,21 @@ public final class JsonContent implements AssertProvider<JsonContentAssert> {
3535
private final String json;
3636

3737
@Nullable
38-
private final GenericHttpMessageConverter<Object> jsonMessageConverter;
38+
private final HttpMessageContentConverter contentConverter;
3939

4040

4141
/**
4242
* Create a new {@code JsonContent} instance with the message converter to
4343
* use to deserialize content.
4444
* @param json the actual JSON content
45-
* @param jsonMessageConverter the message converter to use
45+
* @param contentConverter the content converter to use
4646
*/
47-
public JsonContent(String json, @Nullable GenericHttpMessageConverter<Object> jsonMessageConverter) {
47+
public JsonContent(String json, @Nullable HttpMessageContentConverter contentConverter) {
4848
Assert.notNull(json, "JSON must not be null");
4949
this.json = json;
50-
this.jsonMessageConverter = jsonMessageConverter;
50+
this.contentConverter = contentConverter;
5151
}
5252

53-
5453
/**
5554
* Create a new {@code JsonContent} instance.
5655
* @param json the actual JSON content
@@ -59,6 +58,7 @@ public JsonContent(String json) {
5958
this(json, null);
6059
}
6160

61+
6262
/**
6363
* Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat}
6464
* instead.
@@ -76,11 +76,11 @@ public String getJson() {
7676
}
7777

7878
/**
79-
* Return the message converter to use to deserialize content.
79+
* Return the {@link HttpMessageContentConverter} to use to deserialize content.
8080
*/
8181
@Nullable
82-
GenericHttpMessageConverter<Object> getJsonMessageConverter() {
83-
return this.jsonMessageConverter;
82+
HttpMessageContentConverter getContentConverter() {
83+
return this.contentConverter;
8484
}
8585

8686
@Override

spring-test/src/main/java/org/springframework/test/json/JsonPathValueAssert.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
import com.jayway.jsonpath.JsonPath;
2020

21-
import org.springframework.http.converter.GenericHttpMessageConverter;
2221
import org.springframework.lang.Nullable;
22+
import org.springframework.test.http.HttpMessageContentConverter;
2323

2424
/**
2525
* AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied
@@ -35,9 +35,9 @@ public class JsonPathValueAssert extends AbstractJsonValueAssert<JsonPathValueAs
3535

3636

3737
JsonPathValueAssert(@Nullable Object actual, String expression,
38-
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
38+
@Nullable HttpMessageContentConverter contentConverter) {
3939

40-
super(actual, JsonPathValueAssert.class, httpMessageConverter);
40+
super(actual, JsonPathValueAssert.class, contentConverter);
4141
this.expression = expression;
4242
}
4343

0 commit comments

Comments
 (0)