/*
* Copyright 2017 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
*
* http://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.integration.http.outbound;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Supplier;
import javax.xml.transform.Source;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.integration.expression.ExpressionEvalMap;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.expression.ValueExpression;
import org.springframework.integration.handler.AbstractReplyProducingMessageHandler;
import org.springframework.integration.http.support.DefaultHttpHeaderMapper;
import org.springframework.integration.mapping.HeaderMapper;
import org.springframework.integration.support.AbstractIntegrationMessageBuilder;
import org.springframework.integration.support.MessageBuilderFactory;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandlingException;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Base class for http outbound adapter/gateway.
*
* @author Mark Fisher
* @author Oleg Zhurakousky
* @author Gary Russell
* @author Gunnar Hillert
* @author Artem Bilan
* @author Wallace Wadge
* @author Shiliang Li
* @since 5.0
*/
public abstract class AbstractHttpRequestExecutingMessageHandler extends AbstractReplyProducingMessageHandler {
private static final List<HttpMethod> noBodyHttpMethods =
Arrays.asList(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.TRACE);
private final Map<String, Expression> uriVariableExpressions = new HashMap<>();
private volatile StandardEvaluationContext evaluationContext;
private final Expression uriExpression;
private volatile boolean encodeUri = true;
private volatile Expression httpMethodExpression = new ValueExpression<>(HttpMethod.POST);
private volatile boolean expectReply = true;
private volatile Expression expectedResponseTypeExpression;
private volatile boolean extractPayload = true;
private volatile boolean extractPayloadExplicitlySet = false;
private volatile Charset charset = Charset.forName("UTF-8");
private volatile boolean transferCookies = false;
private volatile HeaderMapper<HttpHeaders> headerMapper = DefaultHttpHeaderMapper.outboundMapper();
private volatile Expression uriVariablesExpression;
public AbstractHttpRequestExecutingMessageHandler(Expression uriExpression) {
Assert.notNull(uriExpression, "URI Expression is required");
this.uriExpression = uriExpression;
}
/**
* Specify whether the real URI should be encoded after <code>uriVariables</code>
* expanding and before send request via {@link RestTemplate}. The default value is <code>true</code>.
*
* @param encodeUri true if the URI should be encoded.
*
* @see UriComponentsBuilder
*/
public void setEncodeUri(boolean encodeUri) {
this.encodeUri = encodeUri;
}
/**
* Specify the SpEL {@link Expression} to determine {@link HttpMethod} at runtime.
* @param httpMethodExpression The method expression.
*/
public void setHttpMethodExpression(Expression httpMethodExpression) {
Assert.notNull(httpMethodExpression, "'httpMethodExpression' must not be null");
this.httpMethodExpression = httpMethodExpression;
}
/**
* Specify the {@link HttpMethod} for requests.
* The default method is {@code POST}.
* @param httpMethod The method.
*/
public void setHttpMethod(HttpMethod httpMethod) {
Assert.notNull(httpMethod, "'httpMethod' must not be null");
this.httpMethodExpression = new ValueExpression<HttpMethod>(httpMethod);
}
/**
* Specify whether the outbound message's payload should be extracted
* when preparing the request body.
* Otherwise the Message instance itself is serialized.
* The default value is {@code true}.
* @param extractPayload true if the payload should be extracted.
*/
public void setExtractPayload(boolean extractPayload) {
this.extractPayload = extractPayload;
this.extractPayloadExplicitlySet = true;
}
/**
* Specify the charset name to use for converting String-typed payloads to bytes.
* The default is {@code UTF-8}.
* @param charset The charset.
*/
public void setCharset(String charset) {
Assert.isTrue(Charset.isSupported(charset), "unsupported charset '" + charset + "'");
this.charset = Charset.forName(charset);
}
/**
* @return whether a reply Message is expected.
* @see AbstractHttpRequestExecutingMessageHandler#setExpectReply(boolean)
*/
public boolean isExpectReply() {
return this.expectReply;
}
/**
* Specify whether a reply Message is expected. If not, this handler will simply return null for a
* successful response or throw an Exception for a non-successful response. The default is true.
* @param expectReply true if a reply is expected.
*/
public void setExpectReply(boolean expectReply) {
this.expectReply = expectReply;
}
/**
* Specify the expected response type for the REST request
* otherwise the default response type is {@link ResponseEntity} and will
* be returned as a payload of the reply Message.
* To take advantage of the HttpMessageConverters
* registered on this adapter, provide a different type).
*
* @param expectedResponseType The expected type.
*
* Also see {@link #setExpectedResponseTypeExpression(Expression)}
*/
public void setExpectedResponseType(Class<?> expectedResponseType) {
Assert.notNull(expectedResponseType, "'expectedResponseType' must not be null");
this.expectedResponseTypeExpression = new ValueExpression<Class<?>>(expectedResponseType);
}
/**
* Specify the {@link Expression} to determine the type for the expected response
* The returned value of the expression could be an instance of {@link Class} or
* {@link String} representing a fully qualified class name.
* @param expectedResponseTypeExpression The expected response type expression.
* Also see {@link #setExpectedResponseType}
*/
public void setExpectedResponseTypeExpression(Expression expectedResponseTypeExpression) {
this.expectedResponseTypeExpression = expectedResponseTypeExpression;
}
/**
* Set the {@link HeaderMapper} to use when mapping between HTTP headers and MessageHeaders.
* @param headerMapper The header mapper.
*/
public void setHeaderMapper(HeaderMapper<HttpHeaders> headerMapper) {
Assert.notNull(headerMapper, "headerMapper must not be null");
this.headerMapper = headerMapper;
}
/**
* Set the Map of URI variable expressions to evaluate against the outbound message
* when replacing the variable placeholders in a URI template.
*
* @param uriVariableExpressions The URI variable expressions.
*/
public void setUriVariableExpressions(Map<String, Expression> uriVariableExpressions) {
synchronized (this.uriVariableExpressions) {
this.uriVariableExpressions.clear();
this.uriVariableExpressions.putAll(uriVariableExpressions);
}
}
/**
* Set the {@link Expression} to evaluate against the outbound message; the expression
* must evaluate to a Map of URI variable expressions to evaluate against the outbound message
* when replacing the variable placeholders in a URI template.
*
* @param uriVariablesExpression The URI variables expression.
*/
public void setUriVariablesExpression(Expression uriVariablesExpression) {
this.uriVariablesExpression = uriVariablesExpression;
}
/**
* Set to true if you wish 'Set-Cookie' headers in responses to be
* transferred as 'Cookie' headers in subsequent interactions for
* a message.
*
* @param transferCookies the transferCookies to set.
*/
public void setTransferCookies(boolean transferCookies) {
this.transferCookies = transferCookies;
}
@Override
protected void doInit() {
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(this.getBeanFactory());
}
@Override
protected Object handleRequestMessage(Message<?> requestMessage) {
HttpMethod httpMethod = determineHttpMethod(requestMessage);
if (!shouldIncludeRequestBody(httpMethod) && this.extractPayloadExplicitlySet) {
if (logger.isWarnEnabled()) {
logger.warn("The 'extractPayload' attribute has no relevance for the current request " +
"since the HTTP Method is '" + httpMethod +
"', and no request body will be sent for that method.");
}
}
Object expectedResponseType = determineExpectedResponseType(requestMessage);
HttpEntity<?> httpRequest = generateHttpRequest(requestMessage, httpMethod);
return exchange(() -> generateUri(requestMessage), httpMethod, httpRequest, expectedResponseType,
requestMessage);
}
protected abstract Object exchange(Supplier<URI> uriSupplier, HttpMethod httpMethod, HttpEntity<?> httpRequest,
Object expectedResponseType, Message<?> requestMessage);
private URI generateUri(Message<?> requestMessage) {
Object uri = this.uriExpression.getValue(this.evaluationContext, requestMessage);
Assert.state(uri instanceof String || uri instanceof URI,
"'uriExpression' evaluation must result in a 'String' or 'URI' instance, not: "
+ (uri == null ? "null" : uri.getClass()));
Map<String, ?> uriVariables = determineUriVariables(requestMessage);
UriComponentsBuilder uriComponentsBuilder = uri instanceof String
? UriComponentsBuilder.fromUriString((String) uri)
: UriComponentsBuilder.fromUri((URI) uri);
UriComponents uriComponents = uriComponentsBuilder.buildAndExpand(uriVariables);
try {
return this.encodeUri ? uriComponents.toUri() : new URI(uriComponents.toUriString());
}
catch (URISyntaxException e) {
throw new MessageHandlingException(requestMessage, "Invalid URI [" + uri + "]", e);
}
}
protected Object getReply(ResponseEntity<?> httpResponse) {
if (this.expectReply) {
HttpHeaders httpHeaders = httpResponse.getHeaders();
Map<String, Object> headers = this.headerMapper.toHeaders(httpHeaders);
if (this.transferCookies) {
this.doConvertSetCookie(headers);
}
AbstractIntegrationMessageBuilder<?> replyBuilder = null;
MessageBuilderFactory messageBuilderFactory = getMessageBuilderFactory();
if (httpResponse.hasBody()) {
Object responseBody = httpResponse.getBody();
replyBuilder = (responseBody instanceof Message<?>)
? messageBuilderFactory.fromMessage((Message<?>) responseBody)
: messageBuilderFactory.withPayload(responseBody);
}
else {
replyBuilder = messageBuilderFactory.withPayload(httpResponse);
}
replyBuilder.setHeader(org.springframework.integration.http.HttpHeaders.STATUS_CODE,
httpResponse.getStatusCode());
return replyBuilder.copyHeaders(headers);
}
return null;
}
/**
* Convert Set-Cookie to Cookie
*/
private void doConvertSetCookie(Map<String, Object> headers) {
String keyName = null;
for (String key : headers.keySet()) {
if (key.equalsIgnoreCase(DefaultHttpHeaderMapper.SET_COOKIE)) {
keyName = key;
break;
}
}
if (keyName != null) {
Object cookies = headers.remove(keyName);
headers.put(DefaultHttpHeaderMapper.COOKIE, cookies);
if (logger.isDebugEnabled()) {
logger.debug("Converted Set-Cookie header to Cookie for: "
+ cookies);
}
}
}
private HttpEntity<?> generateHttpRequest(Message<?> message, HttpMethod httpMethod) {
Assert.notNull(message, "message must not be null");
return (this.extractPayload) ? this.createHttpEntityFromPayload(message, httpMethod)
: this.createHttpEntityFromMessage(message, httpMethod);
}
private HttpEntity<?> createHttpEntityFromPayload(Message<?> message, HttpMethod httpMethod) {
Object payload = message.getPayload();
if (payload instanceof HttpEntity<?>) {
// payload is already an HttpEntity, just return it as-is
return (HttpEntity<?>) payload;
}
HttpHeaders httpHeaders = this.mapHeaders(message);
if (!shouldIncludeRequestBody(httpMethod)) {
return new HttpEntity<>(httpHeaders);
}
// otherwise, we are creating a request with a body and need to deal with the content-type header as well
if (httpHeaders.getContentType() == null) {
MediaType contentType = (payload instanceof String)
? resolveContentType((String) payload, this.charset)
: resolveContentType(payload);
httpHeaders.setContentType(contentType);
}
if (MediaType.APPLICATION_FORM_URLENCODED.equals(httpHeaders.getContentType()) ||
MediaType.MULTIPART_FORM_DATA.equals(httpHeaders.getContentType())) {
if (!(payload instanceof MultiValueMap)) {
payload = this.convertToMultiValueMap((Map<?, ?>) payload);
}
}
return new HttpEntity<>(payload, httpHeaders);
}
private HttpEntity<?> createHttpEntityFromMessage(Message<?> message, HttpMethod httpMethod) {
HttpHeaders httpHeaders = mapHeaders(message);
if (shouldIncludeRequestBody(httpMethod)) {
httpHeaders.setContentType(new MediaType("application", "x-java-serialized-object"));
return new HttpEntity<Object>(message, httpHeaders);
}
return new HttpEntity<>(httpHeaders);
}
protected HttpHeaders mapHeaders(Message<?> message) {
HttpHeaders httpHeaders = new HttpHeaders();
this.headerMapper.fromHeaders(message.getHeaders(), httpHeaders);
return httpHeaders;
}
@SuppressWarnings("unchecked")
private MediaType resolveContentType(Object content) {
MediaType contentType = null;
if (content instanceof byte[]) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
else if (content instanceof Source) {
contentType = MediaType.TEXT_XML;
}
else if (content instanceof Map) {
// We need to check separately for MULTIPART as well as URLENCODED simply because
// MultiValueMap<Object, Object> is actually valid content for serialization
if (this.isFormData((Map<Object, ?>) content)) {
if (this.isMultipart((Map<String, ?>) content)) {
contentType = MediaType.MULTIPART_FORM_DATA;
}
else {
contentType = MediaType.APPLICATION_FORM_URLENCODED;
}
}
}
if (contentType == null) {
contentType = new MediaType("application", "x-java-serialized-object");
}
return contentType;
}
private boolean shouldIncludeRequestBody(HttpMethod httpMethod) {
return !(CollectionUtils.containsInstance(noBodyHttpMethods, httpMethod));
}
private MediaType resolveContentType(String content, Charset charset) {
return new MediaType("text", "plain", charset);
}
private MultiValueMap<Object, Object> convertToMultiValueMap(Map<?, ?> simpleMap) {
LinkedMultiValueMap<Object, Object> multipartValueMap = new LinkedMultiValueMap<Object, Object>();
for (Entry<?, ?> entry : simpleMap.entrySet()) {
Object key = entry.getKey();
Object value = entry.getValue();
if (value instanceof Object[]) {
value = Arrays.asList((Object[]) value);
}
if (value instanceof Collection) {
multipartValueMap.put(key, new ArrayList<Object>((Collection<?>) value));
}
else {
multipartValueMap.add(key, value);
}
}
return multipartValueMap;
}
/**
* If all keys are Strings, and some values are not Strings we'll consider
* the Map to be multipart/form-data
*/
private boolean isMultipart(Map<String, ?> map) {
for (Object value : map.values()) {
if (value != null) {
if (value.getClass().isArray()) {
value = CollectionUtils.arrayToList(value);
}
if (value instanceof Collection) {
Collection<?> cValues = (Collection<?>) value;
for (Object cValue : cValues) {
if (cValue != null && !(cValue instanceof String)) {
return true;
}
}
}
else if (!(value instanceof String)) {
return true;
}
}
}
return false;
}
/**
* If all keys and values are Strings, we'll consider the Map to be form data.
*/
private boolean isFormData(Map<Object, ?> map) {
for (Object key : map.keySet()) {
if (!(key instanceof String)) {
return false;
}
}
return true;
}
private HttpMethod determineHttpMethod(Message<?> requestMessage) {
Object httpMethod = this.httpMethodExpression.getValue(this.evaluationContext, requestMessage);
Assert.state(httpMethod != null && (httpMethod instanceof String || httpMethod instanceof HttpMethod),
"'httpMethodExpression' evaluation must result in an 'HttpMethod' enum or its String representation, " +
"not: " + (httpMethod == null ? "null" : httpMethod.getClass()));
if (httpMethod instanceof HttpMethod) {
return (HttpMethod) httpMethod;
}
else {
try {
return HttpMethod.valueOf((String) httpMethod);
}
catch (Exception e) {
throw new IllegalStateException("The 'httpMethodExpression' returned an invalid HTTP Method value: "
+ httpMethod);
}
}
}
private Object determineExpectedResponseType(Message<?> requestMessage) {
Object expectedResponseType = null;
if (this.expectedResponseTypeExpression != null) {
expectedResponseType = this.expectedResponseTypeExpression.getValue(this.evaluationContext, requestMessage);
}
if (expectedResponseType != null) {
Assert.state(expectedResponseType instanceof Class<?>
|| expectedResponseType instanceof String
|| expectedResponseType instanceof ParameterizedTypeReference,
"'expectedResponseType' can be an instance of 'Class<?>', 'String' " +
"or 'ParameterizedTypeReference<?>'; " +
"evaluation resulted in a" + expectedResponseType.getClass() + ".");
if (expectedResponseType instanceof String && StringUtils.hasText((String) expectedResponseType)) {
try {
expectedResponseType = ClassUtils.forName((String) expectedResponseType,
getApplicationContext().getClassLoader());
}
catch (ClassNotFoundException e) {
throw new IllegalStateException("Cannot load class for name: " + expectedResponseType, e);
}
}
}
return expectedResponseType;
}
@SuppressWarnings("unchecked")
private Map<String, ?> determineUriVariables(Message<?> requestMessage) {
Map<String, ?> expressions;
if (this.uriVariablesExpression != null) {
Object expressionsObject = this.uriVariablesExpression.getValue(this.evaluationContext, requestMessage);
Assert.state(expressionsObject instanceof Map,
"The 'uriVariablesExpression' evaluation must result in a 'Map'.");
expressions = (Map<String, ?>) expressionsObject;
}
else {
expressions = this.uriVariableExpressions;
}
return ExpressionEvalMap.from(expressions)
.usingEvaluationContext(this.evaluationContext)
.withRoot(requestMessage)
.build();
}
}