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
This commit is contained in:
Stéphane Nicoll
2024-07-03 15:51:48 +02:00
parent 206d81ee08
commit 4bdb772d39
14 changed files with 506 additions and 108 deletions

View File

@@ -0,0 +1,273 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.http;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.SmartHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.MockHttpInputMessage;
import org.springframework.mock.http.MockHttpOutputMessage;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link HttpMessageContentConverter}.
*
* @author Stephane Nicoll
*/
class HttpMessageContentConverterTests {
private static final MediaType JSON = MediaType.APPLICATION_JSON;
private static final ResolvableType listOfIntegers = ResolvableType.forClassWithGenerics(List.class, Integer.class);
private static final MappingJackson2HttpMessageConverter jacksonMessageConverter =
new MappingJackson2HttpMessageConverter(new ObjectMapper());
@Test
void createInstanceWithEmptyIterable() {
assertThatIllegalArgumentException()
.isThrownBy(() -> HttpMessageContentConverter.of(List.of()))
.withMessage("At least one message converter needs to be specified");
}
@Test
void createInstanceWithEmptyVarArg() {
assertThatIllegalArgumentException()
.isThrownBy(HttpMessageContentConverter::of)
.withMessage("At least one message converter needs to be specified");
}
@Test
void convertInvokesFirstMatchingConverter() throws IOException {
HttpInputMessage message = createMessage("1,2,3");
SmartHttpMessageConverter<?> firstConverter = mockSmartConverterForRead(
listOfIntegers, JSON, message, List.of(1, 2, 3));
SmartHttpMessageConverter<?> secondConverter = mockSmartConverterForRead(
listOfIntegers, JSON, message, List.of(3, 2, 1));
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(firstConverter, secondConverter));
List<Integer> data = contentConverter.convert(message, JSON, listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(firstConverter).canRead(listOfIntegers, JSON);
verifyNoInteractions(secondConverter);
}
@Test
void convertInvokesGenericHttpMessageConverter() throws IOException {
GenericHttpMessageConverter<?> firstConverter = mock(GenericHttpMessageConverter.class);
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(firstConverter, jacksonMessageConverter));
List<Integer> data = contentConverter.convert(createMessage("[2,3,4]"), JSON, listOfIntegers);
assertThat(data).containsExactly(2, 3, 4);
verify(firstConverter).canRead(listOfIntegers.getType(), List.class, JSON);
}
@Test
void convertInvokesSmartHttpMessageConverter() throws IOException {
HttpInputMessage message = createMessage("dummy");
GenericHttpMessageConverter<?> firstConverter = mock(GenericHttpMessageConverter.class);
SmartHttpMessageConverter<?> smartConverter = mockSmartConverterForRead(
listOfIntegers, JSON, message, List.of(1, 2, 3));
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(firstConverter, smartConverter));
List<Integer> data = contentConverter.convert(message, JSON, listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(smartConverter).canRead(listOfIntegers, JSON);
}
@Test
void convertInvokesHttpMessageConverter() throws IOException {
HttpInputMessage message = createMessage("1,2,3");
SmartHttpMessageConverter<?> secondConverter = mockSmartConverterForRead(
listOfIntegers, JSON, message, List.of(1, 2, 3));
HttpMessageConverter<?> thirdConverter = mockSimpleConverterForRead(
List.class, MediaType.TEXT_PLAIN, message, List.of(1, 2, 3));
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(jacksonMessageConverter, secondConverter, thirdConverter));
List<Integer> data = contentConverter.convert(message, MediaType.TEXT_PLAIN, listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(secondConverter).canRead(listOfIntegers, MediaType.TEXT_PLAIN);
verify(thirdConverter).canRead(List.class, MediaType.TEXT_PLAIN);
}
@Test
void convertFailsIfNoMatchingConverterIsFound() throws IOException {
HttpInputMessage message = createMessage("[1,2,3]");
SmartHttpMessageConverter<?> textConverter = mockSmartConverterForRead(
listOfIntegers, MediaType.TEXT_PLAIN, message, List.of(1, 2, 3));
SmartHttpMessageConverter<?> htmlConverter = mockSmartConverterForRead(
listOfIntegers, MediaType.TEXT_HTML, message, List.of(3, 2, 1));
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(textConverter, htmlConverter));
assertThatIllegalStateException()
.isThrownBy(() -> contentConverter.convert(message, JSON, listOfIntegers))
.withMessage("No converter found to read [application/json] to [java.util.List<java.lang.Integer>]");
verify(textConverter).canRead(listOfIntegers, JSON);
verify(htmlConverter).canRead(listOfIntegers, JSON);
}
@Test
void convertViaJsonInvokesFirstMatchingConverter() throws IOException {
String value = "1,2,3";
ResolvableType valueType = ResolvableType.forInstance(value);
SmartHttpMessageConverter<?> readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3));
SmartHttpMessageConverter<?> firstWriteJsonConverter = mockSmartConverterForWritingJson(value, valueType, "[1,2,3]");
SmartHttpMessageConverter<?> secondWriteJsonConverter = mockSmartConverterForWritingJson(value, valueType, "[3,2,1]");
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(readConverter, firstWriteJsonConverter, secondWriteJsonConverter));
List<Integer> data = contentConverter.convertViaJson(value, listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(readConverter).canRead(listOfIntegers, JSON);
verify(firstWriteJsonConverter).canWrite(valueType, String.class, JSON);
verifyNoInteractions(secondWriteJsonConverter);
}
@Test
void convertViaJsonInvokesGenericHttpMessageConverter() throws IOException {
String value = "1,2,3";
ResolvableType valueType = ResolvableType.forInstance(value);
SmartHttpMessageConverter<?> readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3));
GenericHttpMessageConverter<?> writeConverter = mockGenericConverterForWritingJson(value, valueType, "[3,2,1]");
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(readConverter, writeConverter, jacksonMessageConverter));
List<Integer> data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(readConverter).canRead(listOfIntegers, JSON);
verify(writeConverter).canWrite(valueType.getType(), value.getClass(), JSON);
}
@Test
void convertViaJsonInvokesSmartHttpMessageConverter() throws IOException {
String value = "1,2,3";
ResolvableType valueType = ResolvableType.forInstance(value);
SmartHttpMessageConverter<?> readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3));
SmartHttpMessageConverter<?> writeConverter = mockSmartConverterForWritingJson(value, valueType, "[3,2,1]");
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(readConverter, writeConverter, jacksonMessageConverter));
List<Integer> data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(readConverter).canRead(listOfIntegers, JSON);
verify(writeConverter).canWrite(valueType, value.getClass(), JSON);
}
@Test
void convertViaJsonInvokesHttpMessageConverter() throws IOException {
String value = "1,2,3";
SmartHttpMessageConverter<?> readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3));
HttpMessageConverter<?> writeConverter = mockSimpleConverterForWritingJson(value, "[3,2,1]");
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(
List.of(readConverter, writeConverter, jacksonMessageConverter));
List<Integer> data = contentConverter.convertViaJson("[1, 2, 3]", listOfIntegers);
assertThat(data).containsExactly(1, 2, 3);
verify(readConverter).canRead(listOfIntegers, JSON);
verify(writeConverter).canWrite(value.getClass(), JSON);
}
@Test
void convertViaJsonFailsIfNoMatchingConverterIsFound() throws IOException {
String value = "1,2,3";
ResolvableType valueType = ResolvableType.forInstance(value);
SmartHttpMessageConverter<?> readConverter = mockSmartConverterForRead(listOfIntegers, JSON, null, List.of(1, 2, 3));
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(List.of(readConverter));
assertThatIllegalStateException()
.isThrownBy(() -> contentConverter.convertViaJson(value, listOfIntegers))
.withMessage("No converter found to convert [java.lang.String] to JSON");
verify(readConverter).canWrite(valueType, value.getClass(), JSON);
}
@SuppressWarnings("unchecked")
private static SmartHttpMessageConverter<?> mockSmartConverterForRead(
ResolvableType type, MediaType mediaType, @Nullable HttpInputMessage message, Object value) throws IOException {
SmartHttpMessageConverter<Object> converter = mock(SmartHttpMessageConverter.class);
given(converter.canRead(type, mediaType)).willReturn(true);
given(converter.read(eq(type), (message != null ? eq(message) : any()), any())).willReturn(value);
return converter;
}
@SuppressWarnings("unchecked")
private static SmartHttpMessageConverter<?> mockSmartConverterForWritingJson(Object value, ResolvableType valueType, String json) throws IOException {
SmartHttpMessageConverter<Object> converter = mock(SmartHttpMessageConverter.class);
given(converter.canWrite(valueType, value.getClass(), JSON)).willReturn(true);
willAnswer(invocation -> {
MockHttpOutputMessage out = invocation.getArgument(3, MockHttpOutputMessage.class);
StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody());
return null;
}).given(converter).write(eq(value), eq(valueType), eq(JSON), any(), any());
return converter;
}
@SuppressWarnings("unchecked")
private static GenericHttpMessageConverter<?> mockGenericConverterForWritingJson(Object value, ResolvableType valueType, String json) throws IOException {
GenericHttpMessageConverter<Object> converter = mock(GenericHttpMessageConverter.class);
given(converter.canWrite(valueType.getType(), value.getClass(), JSON)).willReturn(true);
willAnswer(invocation -> {
MockHttpOutputMessage out = invocation.getArgument(4, MockHttpOutputMessage.class);
StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody());
return null;
}).given(converter).write(eq(value), eq(valueType.getType()), eq(JSON), any());
return converter;
}
@SuppressWarnings("unchecked")
private static HttpMessageConverter<?> mockSimpleConverterForRead(
Class<?> rawType, MediaType mediaType, HttpInputMessage message, Object value) throws IOException {
HttpMessageConverter<Object> converter = mock(HttpMessageConverter.class);
given(converter.canRead(rawType, mediaType)).willReturn(true);
given(converter.read(rawType, message)).willReturn(value);
return converter;
}
@SuppressWarnings("unchecked")
private static HttpMessageConverter<?> mockSimpleConverterForWritingJson(Object value, String json) throws IOException {
HttpMessageConverter<Object> converter = mock(HttpMessageConverter.class);
given(converter.canWrite(value.getClass(), JSON)).willReturn(true);
willAnswer(invocation -> {
MockHttpOutputMessage out = invocation.getArgument(2, MockHttpOutputMessage.class);
StreamUtils.copy(json, StandardCharsets.UTF_8, out.getBody());
return null;
}).given(converter).write(eq(value), eq(JSON), any());
return converter;
}
private static HttpInputMessage createMessage(String content) {
return new MockHttpInputMessage(content.getBytes(StandardCharsets.UTF_8));
}
}

View File

@@ -51,9 +51,9 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.test.http.HttpMessageContentConverter;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -85,8 +85,8 @@ class AbstractJsonContentAssertTests {
private static final String DIFFERENT = loadJson("different.json");
private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter =
new MappingJackson2HttpMessageConverter(new ObjectMapper());
private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of(
new MappingJackson2HttpMessageConverter(new ObjectMapper()));
private static final JsonComparator comparator = JsonAssert.comparator(JsonCompareMode.LENIENT);
@@ -108,14 +108,14 @@ class AbstractJsonContentAssertTests {
@Test
void convertToTargetType() {
assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
assertThat(forJson(SIMPSONS, jsonContentConverter))
.convertTo(Family.class)
.satisfies(family -> assertThat(family.familyMembers()).hasSize(5));
}
@Test
void convertToIncompatibleTargetTypeShouldFail() {
AbstractJsonContentAssert<?> jsonAssert = assertThat(forJson(SIMPSONS, jsonHttpMessageConverter));
AbstractJsonContentAssert<?> jsonAssert = assertThat(forJson(SIMPSONS, jsonContentConverter));
assertThatExceptionOfType(AssertionError.class)
.isThrownBy(() -> jsonAssert.convertTo(Member.class))
.withMessageContainingAll("To convert successfully to:",
@@ -124,15 +124,15 @@ class AbstractJsonContentAssertTests {
@Test
void convertUsingAssertFactory() {
assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
assertThat(forJson(SIMPSONS, jsonContentConverter))
.convertTo(new FamilyAssertFactory())
.hasFamilyMember("Homer");
}
private AssertProvider<AbstractJsonContentAssert<?>> forJson(@Nullable String json,
@Nullable GenericHttpMessageConverter<Object> jsonHttpMessageConverter) {
@Nullable HttpMessageContentConverter jsonContentConverter) {
return () -> new TestJsonContentAssert(json, jsonHttpMessageConverter);
return () -> new TestJsonContentAssert(json, jsonContentConverter);
}
private static class FamilyAssertFactory extends InstanceOfAssertFactory<Family, FamilyAssert> {
@@ -320,14 +320,14 @@ class AbstractJsonContentAssertTests {
@Test
void convertToTargetType() {
assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
assertThat(forJson(SIMPSONS, jsonContentConverter))
.extractingPath("$.familyMembers[0]").convertTo(Member.class)
.satisfies(member -> assertThat(member.name).isEqualTo("Homer"));
}
@Test
void convertToIncompatibleTargetTypeShouldFail() {
JsonPathValueAssert path = assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
JsonPathValueAssert path = assertThat(forJson(SIMPSONS, jsonContentConverter))
.extractingPath("$.familyMembers[0]");
assertThatExceptionOfType(AssertionError.class)
.isThrownBy(() -> path.convertTo(ExtractingPathTests.Customer.class))
@@ -337,7 +337,7 @@ class AbstractJsonContentAssertTests {
@Test
void convertArrayUsingAssertFactory() {
assertThat(forJson(SIMPSONS, jsonHttpMessageConverter))
assertThat(forJson(SIMPSONS, jsonContentConverter))
.extractingPath("$.familyMembers")
.convertTo(InstanceOfAssertFactories.list(Member.class))
.hasSize(5).element(0).isEqualTo(new Member("Homer"));
@@ -395,8 +395,8 @@ class AbstractJsonContentAssertTests {
return () -> new TestJsonContentAssert(json, null);
}
private AssertProvider<AbstractJsonContentAssert<?>> forJson(@Nullable String json, GenericHttpMessageConverter<Object> jsonHttpMessageConverter) {
return () -> new TestJsonContentAssert(json, jsonHttpMessageConverter);
private AssertProvider<AbstractJsonContentAssert<?>> forJson(@Nullable String json, HttpMessageContentConverter jsonContentConverter) {
return () -> new TestJsonContentAssert(json, jsonContentConverter);
}
}
@@ -895,8 +895,8 @@ class AbstractJsonContentAssertTests {
private static class TestJsonContentAssert extends AbstractJsonContentAssert<TestJsonContentAssert> {
public TestJsonContentAssert(@Nullable String json, @Nullable GenericHttpMessageConverter<Object> jsonMessageConverter) {
super((json != null ? new JsonContent(json, jsonMessageConverter) : null), TestJsonContentAssert.class);
public TestJsonContentAssert(@Nullable String json, @Nullable HttpMessageContentConverter jsonContentConverter) {
super((json != null ? new JsonContent(json, jsonContentConverter) : null), TestJsonContentAssert.class);
}
}

View File

@@ -18,7 +18,8 @@ package org.springframework.test.json;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.test.http.HttpMessageContentConverter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -61,10 +62,10 @@ class JsonContentTests {
}
@Test
void getJsonMessageConverterShouldReturnConverter() {
MappingJackson2HttpMessageConverter converter = mock(MappingJackson2HttpMessageConverter.class);
JsonContent content = new JsonContent(JSON, converter);
assertThat(content.getJsonMessageConverter()).isSameAs(converter);
void getJsonContentConverterShouldReturnConverter() {
HttpMessageContentConverter contentConverter = HttpMessageContentConverter.of(mock(HttpMessageConverter.class));
JsonContent content = new JsonContent(JSON, contentConverter);
assertThat(content.getContentConverter()).isSameAs(contentConverter);
}
}

View File

@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.test.http.HttpMessageContentConverter;
import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -204,8 +205,8 @@ class JsonPathValueAssertTests {
@Nested
class ConvertToTests {
private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter =
new MappingJackson2HttpMessageConverter(new ObjectMapper());
private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of(
new MappingJackson2HttpMessageConverter(new ObjectMapper()));
@Test
void convertToWithoutHttpMessageConverter() {
@@ -246,7 +247,7 @@ class JsonPathValueAssertTests {
private AssertProvider<JsonPathValueAssert> forValue(@Nullable Object actual) {
return () -> new JsonPathValueAssert(actual, "$.test", jsonHttpMessageConverter);
return () -> new JsonPathValueAssert(actual, "$.test", jsonContentConverter);
}

View File

@@ -16,8 +16,8 @@
package org.springframework.test.web.servlet.assertj;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
@@ -125,8 +125,7 @@ class MockMvcTesterTests {
}
@Test
@SuppressWarnings("unchecked")
void withHttpMessageConverterDetectsJsonConverter() {
void withHttpMessageConverterUsesConverter() {
MappingJackson2HttpMessageConverter converter = spy(jsonHttpMessageConverter);
MockMvcTester mockMvc = MockMvcTester.of(HelloController.class)
.withHttpMessageConverters(List.of(mock(), mock(), converter));
@@ -135,7 +134,7 @@ class MockMvcTesterTests {
assertThat(message.message()).isEqualTo("Hello World");
assertThat(message.counter()).isEqualTo(42);
});
verify(converter).canWrite(Map.class, MediaType.APPLICATION_JSON);
verify(converter).canWrite(LinkedHashMap.class, LinkedHashMap.class, MediaType.APPLICATION_JSON);
}
@Test