Skip to content

Commit

Permalink
Introduce HttpMessageContentConverter
Browse files Browse the repository at this point in the history
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
  • Loading branch information
snicoll committed Jul 4, 2024
1 parent 206d81e commit 4bdb772
Show file tree
Hide file tree
Showing 14 changed files with 506 additions and 108 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* 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.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;
import java.util.stream.StreamSupport;

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.HttpMessageNotReadableException;
import org.springframework.http.converter.SmartHttpMessageConverter;
import org.springframework.mock.http.MockHttpInputMessage;
import org.springframework.mock.http.MockHttpOutputMessage;
import org.springframework.util.Assert;
import org.springframework.util.function.SingletonSupplier;

/**
* Convert HTTP message content for testing purposes.
*
* @author Stephane Nicoll
* @since 6.2
*/
public class HttpMessageContentConverter {

private static final MediaType JSON = MediaType.APPLICATION_JSON;

private final List<HttpMessageConverter<?>> messageConverters;

HttpMessageContentConverter(Iterable<HttpMessageConverter<?>> messageConverters) {
this.messageConverters = StreamSupport.stream(messageConverters.spliterator(), false).toList();
Assert.notEmpty(this.messageConverters, "At least one message converter needs to be specified");
}


/**
* Create an instance with an iterable of the candidates to use.
* @param candidates the candidates
*/
public static HttpMessageContentConverter of(Iterable<HttpMessageConverter<?>> candidates) {
return new HttpMessageContentConverter(candidates);
}

/**
* Create an instance with a vararg of the candidates to use.
* @param candidates the candidates
*/
public static HttpMessageContentConverter of(HttpMessageConverter<?>... candidates) {
return new HttpMessageContentConverter(Arrays.asList(candidates));
}


/**
* Convert the given {@link HttpInputMessage} whose content must match the
* given {@link MediaType} to the requested {@code targetType}.
* @param message an input message
* @param mediaType the media type of the input
* @param targetType the target type
* @param <T> the converted object type
* @return a value of the given {@code targetType}
*/
@SuppressWarnings("unchecked")
public <T> T convert(HttpInputMessage message, MediaType mediaType, ResolvableType targetType)
throws IOException, HttpMessageNotReadableException {
Class<?> contextClass = targetType.getRawClass();
SingletonSupplier<Type> javaType = SingletonSupplier.of(targetType::getType);
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter<?> genericMessageConverter) {
Type type = javaType.obtain();
if (genericMessageConverter.canRead(type, contextClass, mediaType)) {
return (T) genericMessageConverter.read(type, contextClass, message);
}
}
else if (messageConverter instanceof SmartHttpMessageConverter<?> smartMessageConverter) {
if (smartMessageConverter.canRead(targetType, mediaType)) {
return (T) smartMessageConverter.read(targetType, message, null);
}
}
else {
Class<?> targetClass = (contextClass != null ? contextClass : Object.class);
if (messageConverter.canRead(targetClass, mediaType)) {
HttpMessageConverter<T> simpleMessageConverter = (HttpMessageConverter<T>) messageConverter;
Class<? extends T> clazz = (Class<? extends T>) targetClass;
return simpleMessageConverter.read(clazz, message);
}
}
}
throw new IllegalStateException("No converter found to read [%s] to [%s]".formatted(mediaType, targetType));
}

/**
* Convert the given raw value to the given {@code targetType} by writing
* it first to JSON and reading it back.
* @param value the value to convert
* @param targetType the target type
* @param <T> the converted object type
* @return a value of the given {@code targetType}
*/
public <T> T convertViaJson(Object value, ResolvableType targetType) throws IOException {
MockHttpOutputMessage outputMessage = convertToJson(value, ResolvableType.forInstance(value));
return convert(fromHttpOutputMessage(outputMessage), JSON, targetType);
}

@SuppressWarnings({ "rawtypes", "unchecked" })
private MockHttpOutputMessage convertToJson(Object value, ResolvableType valueType) throws IOException {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Class<?> valueClass = value.getClass();
SingletonSupplier<Type> javaType = SingletonSupplier.of(valueType::getType);
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) {
Type type = javaType.obtain();
if (genericMessageConverter.canWrite(type, valueClass, JSON)) {
genericMessageConverter.write(value, type, JSON, outputMessage);
return outputMessage;
}
}
else if (messageConverter instanceof SmartHttpMessageConverter smartMessageConverter) {
if (smartMessageConverter.canWrite(valueType, valueClass, JSON)) {
smartMessageConverter.write(value, valueType, JSON, outputMessage, null);
return outputMessage;
}
}
else if (messageConverter.canWrite(valueClass, JSON)) {
((HttpMessageConverter<Object>) messageConverter).write(value, JSON, outputMessage);
return outputMessage;
}
}
throw new IllegalStateException("No converter found to convert [%s] to JSON".formatted(valueType));
}

private static HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
inputMessage.getHeaders().addAll(message.getHeaders());
return inputMessage;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.assertj.core.error.BasicErrorMessageFactory;
import org.assertj.core.internal.Failures;

import org.springframework.core.ResolvableType;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
Expand All @@ -43,9 +44,9 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.MockHttpInputMessage;
import org.springframework.test.http.HttpMessageContentConverter;
import org.springframework.util.Assert;

/**
Expand Down Expand Up @@ -77,7 +78,7 @@ public abstract class AbstractJsonContentAssert<SELF extends AbstractJsonContent


@Nullable
private final GenericHttpMessageConverter<Object> jsonMessageConverter;
private final HttpMessageContentConverter contentConverter;

@Nullable
private Class<?> resourceLoadClass;
Expand All @@ -94,7 +95,7 @@ public abstract class AbstractJsonContentAssert<SELF extends AbstractJsonContent
*/
protected AbstractJsonContentAssert(@Nullable JsonContent actual, Class<?> selfType) {
super(actual, selfType);
this.jsonMessageConverter = (actual != null ? actual.getJsonMessageConverter() : null);
this.contentConverter = (actual != null ? actual.getContentConverter() : null);
this.jsonLoader = new JsonLoader(null, null);
as("JSON content");
}
Expand Down Expand Up @@ -131,15 +132,15 @@ public <T> AbstractObjectAssert<?, T> convertTo(Class<T> target) {
return assertFactory.createAssert(this::convertToTargetType);
}

@SuppressWarnings("unchecked")
private <T> T convertToTargetType(Type targetType) {
String json = this.actual.getJson();
if (this.jsonMessageConverter == null) {
if (this.contentConverter == null) {
throw new IllegalStateException(
"No JSON message converter available to convert %s".formatted(json));
}
try {
return (T) this.jsonMessageConverter.read(targetType, getClass(), fromJson(json));
return this.contentConverter.convert(fromJson(json), MediaType.APPLICATION_JSON,
ResolvableType.forType(targetType));
}
catch (Exception ex) {
throw failure(new ValueProcessingFailed(json,
Expand All @@ -165,7 +166,7 @@ private HttpInputMessage fromJson(String json) {
*/
public JsonPathValueAssert extractingPath(String path) {
Object value = new JsonPathValue(path).getValue();
return new JsonPathValueAssert(value, path, this.jsonMessageConverter);
return new JsonPathValueAssert(value, path, this.contentConverter);
}

/**
Expand All @@ -176,7 +177,7 @@ public JsonPathValueAssert extractingPath(String path) {
*/
public SELF hasPathSatisfying(String path, Consumer<AssertProvider<JsonPathValueAssert>> valueRequirements) {
Object value = new JsonPathValue(path).assertHasPath();
JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.jsonMessageConverter);
JsonPathValueAssert valueAssert = new JsonPathValueAssert(value, path, this.contentConverter);
valueRequirements.accept(() -> valueAssert);
return this.myself;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,9 @@
import org.assertj.core.internal.Failures;

import org.springframework.core.ResolvableType;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.MockHttpInputMessage;
import org.springframework.mock.http.MockHttpOutputMessage;
import org.springframework.test.http.HttpMessageContentConverter;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

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

@Nullable
private final GenericHttpMessageConverter<Object> httpMessageConverter;
private final HttpMessageContentConverter contentConverter;


protected AbstractJsonValueAssert(@Nullable Object actual, Class<?> selfType,
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
@Nullable HttpMessageContentConverter contentConverter) {

super(actual, selfType);
this.httpMessageConverter = httpMessageConverter;
this.contentConverter = contentConverter;
}


Expand Down Expand Up @@ -199,32 +196,20 @@ public SELF isNotEmpty() {
return this.myself;
}


@SuppressWarnings("unchecked")
private <T> T convertToTargetType(Type targetType) {
if (this.httpMessageConverter == null) {
if (this.contentConverter == null) {
throw new IllegalStateException(
"No JSON message converter available to convert %s".formatted(actualToString()));
}
try {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
this.httpMessageConverter.write(this.actual, ResolvableType.forInstance(this.actual).getType(),
MediaType.APPLICATION_JSON, outputMessage);
return (T) this.httpMessageConverter.read(targetType, getClass(),
fromHttpOutputMessage(outputMessage));
return this.contentConverter.convertViaJson(this.actual, ResolvableType.forType(targetType));
}
catch (Exception ex) {
throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n"
.formatted(targetType.getTypeName(), ex.getMessage()));
}
}

private HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
inputMessage.getHeaders().addAll(message.getHeaders());
return inputMessage;
}

protected String getExpectedErrorMessagePrefix() {
return "Expected:";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@

import org.assertj.core.api.AssertProvider;

import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.test.http.HttpMessageContentConverter;
import org.springframework.util.Assert;

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

@Nullable
private final GenericHttpMessageConverter<Object> jsonMessageConverter;
private final HttpMessageContentConverter contentConverter;


/**
* Create a new {@code JsonContent} instance with the message converter to
* use to deserialize content.
* @param json the actual JSON content
* @param jsonMessageConverter the message converter to use
* @param contentConverter the content converter to use
*/
public JsonContent(String json, @Nullable GenericHttpMessageConverter<Object> jsonMessageConverter) {
public JsonContent(String json, @Nullable HttpMessageContentConverter contentConverter) {
Assert.notNull(json, "JSON must not be null");
this.json = json;
this.jsonMessageConverter = jsonMessageConverter;
this.contentConverter = contentConverter;
}


/**
* Create a new {@code JsonContent} instance.
* @param json the actual JSON content
Expand All @@ -59,6 +58,7 @@ public JsonContent(String json) {
this(json, null);
}


/**
* Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat}
* instead.
Expand All @@ -76,11 +76,11 @@ public String getJson() {
}

/**
* Return the message converter to use to deserialize content.
* Return the {@link HttpMessageContentConverter} to use to deserialize content.
*/
@Nullable
GenericHttpMessageConverter<Object> getJsonMessageConverter() {
return this.jsonMessageConverter;
HttpMessageContentConverter getContentConverter() {
return this.contentConverter;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@

import com.jayway.jsonpath.JsonPath;

import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.test.http.HttpMessageContentConverter;

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


JsonPathValueAssert(@Nullable Object actual, String expression,
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
@Nullable HttpMessageContentConverter contentConverter) {

super(actual, JsonPathValueAssert.class, httpMessageConverter);
super(actual, JsonPathValueAssert.class, contentConverter);
this.expression = expression;
}

Expand Down
Loading

0 comments on commit 4bdb772

Please sign in to comment.