GH-2806: Add generics support to HTTP Inbound

Fixes https://github.com/spring-projects/spring-integration/issues/2806

**Cherry-pick to 5.1.x**
This commit is contained in:
Artem Bilan
2019-03-13 18:54:10 -04:00
committed by Gary Russell
parent 8d9903981e
commit df697c53e2
4 changed files with 137 additions and 73 deletions

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2017-2018 the original author or authors. * Copyright 2017-2019 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -66,23 +66,23 @@ public class BaseHttpInboundEndpoint extends MessagingGatewaySupport implements
protected final AtomicInteger activeCount = new AtomicInteger(); protected final AtomicInteger activeCount = new AtomicInteger();
private volatile ResolvableType requestPayloadType = null; private ResolvableType requestPayloadType = null;
private volatile HeaderMapper<HttpHeaders> headerMapper = DefaultHttpHeaderMapper.inboundMapper(); private HeaderMapper<HttpHeaders> headerMapper = DefaultHttpHeaderMapper.inboundMapper();
private volatile boolean extractReplyPayload = true; private boolean extractReplyPayload = true;
private volatile Expression statusCodeExpression; private Expression statusCodeExpression;
private volatile EvaluationContext evaluationContext; private EvaluationContext evaluationContext;
private volatile RequestMapping requestMapping = new RequestMapping(); private RequestMapping requestMapping = new RequestMapping();
private volatile Expression payloadExpression; private Expression payloadExpression;
private volatile Map<String, Expression> headerExpressions; private Map<String, Expression> headerExpressions;
private volatile CrossOrigin crossOrigin; private CrossOrigin crossOrigin;
public BaseHttpInboundEndpoint(boolean expectReply) { public BaseHttpInboundEndpoint(boolean expectReply) {
super(expectReply); super(expectReply);
@@ -279,8 +279,13 @@ public class BaseHttpInboundEndpoint extends MessagingGatewaySupport implements
} }
protected HttpStatus evaluateHttpStatus(HttpEntity<?> httpEntity) { protected HttpStatus evaluateHttpStatus(HttpEntity<?> httpEntity) {
Object value = this.statusCodeExpression.getValue(this.evaluationContext, httpEntity); if (this.statusCodeExpression != null) {
return buildHttpStatus(value); Object value = this.statusCodeExpression.getValue(this.evaluationContext, httpEntity);
return buildHttpStatus(value);
}
else {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
} }
protected HttpStatus resolveHttpStatusFromHeaders(MessageHeaders headers) { protected HttpStatus resolveHttpStatusFromHeaders(MessageHeaders headers) {
@@ -328,8 +333,7 @@ public class BaseHttpInboundEndpoint extends MessagingGatewaySupport implements
* @return true or false if HTTP request can contain the body * @return true or false if HTTP request can contain the body
*/ */
protected boolean isReadable(HttpRequest request) { protected boolean isReadable(HttpRequest request) {
HttpMethod method = request.getMethod(); return !(CollectionUtils.containsInstance(nonReadableBodyHttpMethods, request.getMethod()));
return method == null ? false : !(CollectionUtils.containsInstance(nonReadableBodyHttpMethods, method));
} }
} }

View File

@@ -17,6 +17,7 @@
package org.springframework.integration.http.inbound; package org.springframework.integration.http.inbound;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -39,6 +40,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity; import org.springframework.http.RequestEntity;
import org.springframework.http.converter.ByteArrayHttpMessageConverter; import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter;
@@ -93,7 +95,7 @@ import org.springframework.web.servlet.HandlerMapping;
* In a request-reply scenario, the reply Message's payload will be extracted prior * In a request-reply scenario, the reply Message's payload will be extracted prior
* to generating a response by default. * to generating a response by default.
* To have the entire serialized Message available for the response, switch the * To have the entire serialized Message available for the response, switch the
* {@link #extractReplyPayload} value to {@code false}. * {@code extractReplyPayload} value to {@code false}.
* *
* @author Mark Fisher * @author Mark Fisher
* @author Oleg Zhurakousky * @author Oleg Zhurakousky
@@ -146,27 +148,21 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
stringHttpMessageConverter.setWriteAcceptCharset(false); stringHttpMessageConverter.setWriteAcceptCharset(false);
this.defaultMessageConverters.add(stringHttpMessageConverter); this.defaultMessageConverters.add(stringHttpMessageConverter);
this.defaultMessageConverters.add(new ResourceHttpMessageConverter()); this.defaultMessageConverters.add(new ResourceHttpMessageConverter());
SourceHttpMessageConverter<Source> sourceConverter = new SourceHttpMessageConverter<Source>(); SourceHttpMessageConverter<Source> sourceConverter = new SourceHttpMessageConverter<>();
this.defaultMessageConverters.add(sourceConverter); this.defaultMessageConverters.add(sourceConverter);
if (jaxb2Present) { if (jaxb2Present) {
this.defaultMessageConverters.add(new Jaxb2RootElementHttpMessageConverter()); this.defaultMessageConverters.add(new Jaxb2RootElementHttpMessageConverter());
if (logger.isDebugEnabled()) { logger.debug("'Jaxb2RootElementHttpMessageConverter' was added to the 'defaultMessageConverters'.");
logger.debug("'Jaxb2RootElementHttpMessageConverter' was added to the 'defaultMessageConverters'.");
}
} }
if (JacksonPresent.isJackson2Present()) { if (JacksonPresent.isJackson2Present()) {
this.defaultMessageConverters.add(new MappingJackson2HttpMessageConverter()); this.defaultMessageConverters.add(new MappingJackson2HttpMessageConverter());
if (logger.isDebugEnabled()) { logger.debug("'MappingJackson2HttpMessageConverter' was added to the 'defaultMessageConverters'.");
logger.debug("'MappingJackson2HttpMessageConverter' was added to the 'defaultMessageConverters'.");
}
} }
if (romeToolsPresent) { if (romeToolsPresent) {
this.defaultMessageConverters.add(new AtomFeedHttpMessageConverter()); this.defaultMessageConverters.add(new AtomFeedHttpMessageConverter());
this.defaultMessageConverters.add(new RssChannelHttpMessageConverter()); this.defaultMessageConverters.add(new RssChannelHttpMessageConverter());
if (logger.isDebugEnabled()) { logger.debug("'AtomFeedHttpMessageConverter' was added to the 'defaultMessageConverters'.");
logger.debug("'AtomFeedHttpMessageConverter' was added to the 'defaultMessageConverters'."); logger.debug("'RssChannelHttpMessageConverter' was added to the 'defaultMessageConverters'.");
logger.debug("'RssChannelHttpMessageConverter' was added to the 'defaultMessageConverters'.");
}
} }
} }
@@ -176,8 +172,9 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
* @param messageConverters The message converters. * @param messageConverters The message converters.
*/ */
public void setMessageConverters(List<HttpMessageConverter<?>> messageConverters) { public void setMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
Assert.notNull(messageConverters, "'messageConverters' must not be null");
Assert.noNullElements(messageConverters.toArray(), "'messageConverters' must not contain null entries"); Assert.noNullElements(messageConverters.toArray(), "'messageConverters' must not contain null entries");
List<HttpMessageConverter<?>> localConverters = new ArrayList<HttpMessageConverter<?>>(messageConverters); List<HttpMessageConverter<?>> localConverters = new ArrayList<>(messageConverters);
if (this.mergeWithDefaultConverters) { if (this.mergeWithDefaultConverters) {
localConverters.addAll(this.defaultMessageConverters); localConverters.addAll(this.defaultMessageConverters);
this.convertersMerged = true; this.convertersMerged = true;
@@ -266,14 +263,13 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
this.activeCount.incrementAndGet(); this.activeCount.incrementAndGet();
try { try {
StandardEvaluationContext evaluationContext = this.createEvaluationContext(); StandardEvaluationContext evaluationContext = createEvaluationContext();
evaluationContext.setRootObject(httpEntity); evaluationContext.setRootObject(httpEntity);
evaluationContext.setVariable("requestAttributes", RequestContextHolder.currentRequestAttributes()); evaluationContext.setVariable("requestAttributes", RequestContextHolder.currentRequestAttributes());
MultiValueMap<String, String> requestParams = this.convertParameterMap(servletRequest.getParameterMap()); MultiValueMap<String, String> requestParams = convertParameterMap(servletRequest.getParameterMap());
evaluationContext.setVariable("requestParams", requestParams); evaluationContext.setVariable("requestParams", requestParams);
evaluationContext.setVariable("requestHeaders", new ServletServerHttpRequest(servletRequest).getHeaders()); evaluationContext.setVariable("requestHeaders", new ServletServerHttpRequest(servletRequest).getHeaders());
Cookie[] requestCookies = servletRequest.getCookies(); Cookie[] requestCookies = servletRequest.getCookies();
@@ -335,12 +331,16 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
AbstractIntegrationMessageBuilder<?> messageBuilder = null; AbstractIntegrationMessageBuilder<?> messageBuilder = null;
if (payload instanceof Message<?>) { if (payload instanceof Message<?>) {
messageBuilder = this.getMessageBuilderFactory().fromMessage((Message<?>) payload) messageBuilder =
.copyHeadersIfAbsent(headers); getMessageBuilderFactory()
.fromMessage((Message<?>) payload)
.copyHeadersIfAbsent(headers);
} }
else { else {
Assert.state(payload != null, "payload cannot be null"); Assert.state(payload != null, "payload cannot be null");
messageBuilder = this.getMessageBuilderFactory().withPayload(payload).copyHeaders(headers); messageBuilder = getMessageBuilderFactory()
.withPayload(payload)
.copyHeaders(headers);
} }
HttpMethod method = httpEntity.getMethod(); HttpMethod method = httpEntity.getMethod();
@@ -359,30 +359,24 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
Message<?> reply = null; Message<?> reply = null;
if (this.expectReply) { if (this.expectReply) {
try { try {
reply = this.sendAndReceiveMessage(message); reply = sendAndReceiveMessage(message);
} }
catch (MessageTimeoutException e) { catch (MessageTimeoutException e) {
if (getStatusCodeExpression() != null) { reply =
reply = getMessageBuilderFactory().withPayload(e.getMessage()) getMessageBuilderFactory()
.setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE, .withPayload(e.getMessage())
evaluateHttpStatus(httpEntity)) .setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE,
.build(); evaluateHttpStatus(httpEntity))
} .build();
else {
reply = getMessageBuilderFactory().withPayload(e.getMessage())
.setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE,
HttpStatus.INTERNAL_SERVER_ERROR)
.build();
}
} }
} }
else { else {
this.send(message); send(message);
} }
return reply; return reply;
} }
finally { finally {
this.postProcessRequest(servletRequest); postProcessRequest(servletRequest);
this.activeCount.decrementAndGet(); this.activeCount.decrementAndGet();
} }
} }
@@ -391,7 +385,8 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("Endpoint is stopped; returning status " + HttpStatus.SERVICE_UNAVAILABLE); logger.debug("Endpoint is stopped; returning status " + HttpStatus.SERVICE_UNAVAILABLE);
} }
return this.getMessageBuilderFactory().withPayload("Endpoint is stopped") return getMessageBuilderFactory()
.withPayload("Endpoint is stopped")
.setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE, HttpStatus.SERVICE_UNAVAILABLE) .setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE, HttpStatus.SERVICE_UNAVAILABLE)
.build(); .build();
} }
@@ -401,7 +396,7 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
* sets up the {@link ServletServerHttpResponse}. * sets up the {@link ServletServerHttpResponse}.
* @param response The ServletServerHttpResponse. * @param response The ServletServerHttpResponse.
* @param replyMessage The reply message. * @param replyMessage The reply message.
* @return The message payload (if {@link #extractReplyPayload}) otherwise the message. * @return The message payload (if {@code extractReplyPayload}) otherwise the message.
*/ */
protected final Object setupResponseAndConvertReply(ServletServerHttpResponse response, Message<?> replyMessage) { protected final Object setupResponseAndConvertReply(ServletServerHttpResponse response, Message<?> replyMessage) {
getHeaderMapper().fromHeaders(replyMessage.getHeaders(), response.getHeaders()); getHeaderMapper().fromHeaders(replyMessage.getHeaders(), response.getHeaders());
@@ -461,7 +456,7 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
* Converts a servlet request's parameterMap to a {@link MultiValueMap}. * Converts a servlet request's parameterMap to a {@link MultiValueMap}.
*/ */
private MultiValueMap<String, String> convertParameterMap(Map<String, String[]> parameterMap) { private MultiValueMap<String, String> convertParameterMap(Map<String, String[]> parameterMap) {
MultiValueMap<String, String> convertedMap = new LinkedMultiValueMap<String, String>(parameterMap.size()); MultiValueMap<String, String> convertedMap = new LinkedMultiValueMap<>(parameterMap.size());
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) { for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String[] values = entry.getValue(); String[] values = entry.getValue();
for (String value : values) { for (String value : values) {
@@ -487,29 +482,37 @@ public abstract class HttpRequestHandlingEndpointSupport extends BaseHttpInbound
contentType = MediaType.APPLICATION_OCTET_STREAM; contentType = MediaType.APPLICATION_OCTET_STREAM;
} }
ResolvableType requestPayloadType = getRequestPayloadType(); ResolvableType requestPayloadType = getRequestPayloadType();
Class<?> expectedType;
if (requestPayloadType == null) { if (requestPayloadType == null) {
expectedType = "text".equals(contentType.getType()) ? String.class : byte[].class; requestPayloadType =
} ResolvableType.forClass(
else { "text".equals(contentType.getType())
expectedType = requestPayloadType.resolve(); ? String.class
: byte[].class);
} }
/* Type targetType = requestPayloadType.getType();
* TODO: resolve() can return null, which is not valid for canRead(). Class<?> targetClass = requestPayloadType.toClass();
* Perhaps we should coerce to String/byte[] instead of attempting
* to convert. However this might be a breaking change - 5.2?
* Hence NOSONAR below.
*/
for (HttpMessageConverter<?> converter : this.messageConverters) { for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter.canRead(expectedType, contentType)) { GenericHttpMessageConverter<?> genericConverter =
return converter.read((Class) expectedType, request); converter instanceof GenericHttpMessageConverter
? (GenericHttpMessageConverter<?>) converter
: null;
if (genericConverter != null
? genericConverter.canRead(targetType, null, contentType) :
(converter.canRead(targetClass, contentType))) {
if (genericConverter != null) {
return genericConverter.read(targetType, null, request);
}
else {
return converter.read((Class) targetClass, request);
}
} }
} }
throw new MessagingException(// NOSONAR might be null; see comment above. throw new MessagingException(
"Could not convert request: no suitable HttpMessageConverter found for expected type [" "Could not convert request: no suitable HttpMessageConverter found for expected type ["
+ expectedType != null ? expectedType.getName() : "null" + requestPayloadType + "] and content type [" + contentType + "]");
+ "] and content type [" + contentType + "]");
} }
} }

View File

@@ -44,10 +44,11 @@ import org.springframework.web.HttpRequestHandler;
* (e.g. 200 OK). * (e.g. 200 OK).
* <p> * <p>
* The default supported request methods are GET and POST, but the list of values can be configured with the * The default supported request methods are GET and POST, but the list of values can be configured with the
* {@link RequestMapping#methods} property. The payload generated from a GET request (or HEAD or OPTIONS if supported) will * {@link RequestMapping#getMethods()} property.
* be a {@link org.springframework.util.MultiValueMap} containing the parameter values. For a request containing a body * The payload generated from a GET request (or HEAD or OPTIONS if supported) will
* (e.g. a POST), the type * be a {@link org.springframework.util.MultiValueMap} containing the parameter values.
* of the payload is determined by the {@link #setRequestPayloadTypeClass(Class)} request payload type}. * For a request containing a body (e.g. a POST), the type of the payload is determined
* by the {@link #setRequestPayloadTypeClass(Class)} request payload type}.
* <p> * <p>
* If the HTTP request is a multipart and a "multipartResolver" bean has been defined in the context, then it will be * If the HTTP request is a multipart and a "multipartResolver" bean has been defined in the context, then it will be
* converted by the * converted by the

View File

@@ -16,6 +16,7 @@
package org.springframework.integration.http.inbound; package org.springframework.integration.http.inbound;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
@@ -38,6 +39,8 @@ import org.hamcrest.Matchers;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ResolvableType;
import org.springframework.expression.common.LiteralExpression; import org.springframework.expression.common.LiteralExpression;
import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage; import org.springframework.http.HttpOutputMessage;
@@ -64,6 +67,8 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.SerializationUtils; import org.springframework.util.SerializationUtils;
import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.MultipartResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
/** /**
* @author Mark Fisher * @author Mark Fisher
* @author Gary Russell * @author Gary Russell
@@ -274,9 +279,52 @@ public class HttpRequestHandlingMessagingGatewayTests extends AbstractHttpInboun
assertEquals(TestBean.class, message.getPayload().getClass()); assertEquals(TestBean.class, message.getPayload().getClass());
TestBean result = (TestBean) message.getPayload(); TestBean result = (TestBean) message.getPayload();
assertEquals("T. Bean", result.name); assertEquals("T. Bean", result.name);
assertEquals(84, result.age); assertEquals(42, result.age);
} }
@Test
public void testJsonRequestBody() throws Exception {
QueueChannel channel = new QueueChannel();
HttpRequestHandlingMessagingGateway gateway = new HttpRequestHandlingMessagingGateway(false);
gateway.setBeanFactory(mock(BeanFactory.class));
ParameterizedTypeReference<List<TestBean>> parameterizedTypeReference =
new ParameterizedTypeReference<List<TestBean>>() {
};
gateway.setRequestPayloadType(ResolvableType.forType(parameterizedTypeReference));
gateway.setRequestChannel(channel);
gateway.afterPropertiesSet();
gateway.start();
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/test");
request.setContentType("application/json");
TestBean testBean = new TestBean();
testBean.setName("T. Bean");
testBean.setAge(42);
request.setContent(new ObjectMapper().writeValueAsBytes(new TestBean[] { testBean }));
MockHttpServletResponse response = new MockHttpServletResponse();
gateway.handleRequest(request, response);
byte[] bytes = response.getContentAsByteArray();
assertThat(bytes).isNotNull();
Message<?> message = channel.receive(0);
assertThat(message).isNotNull()
.extracting(Message::getPayload)
.isInstanceOf(List.class)
.asList()
.hasSize(1)
.element(0)
.isInstanceOf(TestBean.class)
.satisfies((actual) -> {
TestBean bean = (TestBean) actual;
assertThat(bean).extracting(TestBean::getName).isEqualTo("T. Bean");
assertThat(bean).extracting(TestBean::getAge).isEqualTo(42);
});
}
@Test @Test
public void INT2680DuplicateContentTypeHeader() throws Exception { public void INT2680DuplicateContentTypeHeader() throws Exception {
@@ -496,7 +544,15 @@ public class HttpRequestHandlingMessagingGatewayTests extends AbstractHttpInboun
} }
public void setAge(int age) { public void setAge(int age) {
this.age = age * 2; this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
} }
} }