INT-3714: Add HTTP CORS Support

JIRA: https://jira.spring.io/browse/INT-3714

Docs and change Reactor dependency to `RELEASE`

AMQP -> `1.5.0.M1` and rebase

Doc Polishing
This commit is contained in:
Artem Bilan
2015-05-06 00:38:16 +03:00
committed by Gary Russell
parent 5065a5a1d5
commit 580ecddcc0
10 changed files with 561 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 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.
@@ -29,6 +29,7 @@ import org.springframework.beans.factory.support.ManagedMap;
import org.springframework.beans.factory.xml.AbstractSingleBeanDefinitionParser;
import org.springframework.beans.factory.xml.ParserContext;
import org.springframework.integration.config.xml.IntegrationNamespaceUtils;
import org.springframework.integration.http.inbound.CrossOrigin;
import org.springframework.integration.http.inbound.HttpRequestHandlingController;
import org.springframework.integration.http.inbound.HttpRequestHandlingMessagingGateway;
import org.springframework.integration.http.inbound.RequestMapping;
@@ -172,9 +173,24 @@ public class HttpInboundEndpointParser extends AbstractSingleBeanDefinitionParse
builder.addPropertyValue("headerMapper", headerMapperBuilder.getBeanDefinition());
}
BeanDefinition requestMappingDef = this.createRequestMapping(element);
BeanDefinition requestMappingDef = createRequestMapping(element);
builder.addPropertyValue("requestMapping", requestMappingDef);
Element crossOriginElement = DomUtils.getChildElementByTagName(element, "cross-origin");
if (crossOriginElement != null) {
BeanDefinitionBuilder crossOriginBuilder =
BeanDefinitionBuilder.genericBeanDefinition(CrossOrigin.class);
String[] attributes = {"origin", "allowed-headers", "exposed-headers", "max-age", "method"};
for (String crossOriginAttribute : attributes) {
IntegrationNamespaceUtils.setValueIfAttributeDefined(crossOriginBuilder, crossOriginElement,
crossOriginAttribute);
}
IntegrationNamespaceUtils.setValueIfAttributeDefined(crossOriginBuilder, crossOriginElement,
"allow-credentials", true);
builder.addPropertyValue("crossOrigin", crossOriginBuilder.getBeanDefinition());
}
IntegrationNamespaceUtils.setValueIfAttributeDefined(builder, element, "request-payload-type", "requestPayloadType");
BeanDefinition statusCodeExpressionDef =

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2015 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.inbound;
import org.springframework.web.bind.annotation.RequestMethod;
/**
* The mapping to permit cross origin requests (CORS) for {@link HttpRequestHandlingEndpointSupport}.
* Provides direct mapping in terms of functionality compared to
* {@link org.springframework.web.bind.annotation.CrossOrigin}.
*
* @author Artem Bilan
* @since 4.2
* @see org.springframework.web.bind.annotation.CrossOrigin
* @see IntegrationRequestMappingHandlerMapping
*/
public class CrossOrigin {
private String[] origin = {"*"};
private String[] allowedHeaders = {"*"};
private String[] exposedHeaders = {};
private RequestMethod[] method = {};
private Boolean allowCredentials = true;
private long maxAge = 1800;
public void setOrigin(String... origin) {
this.origin = origin;
}
public String[] getOrigin() {
return origin;
}
public void setAllowedHeaders(String... allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}
public String[] getAllowedHeaders() {
return allowedHeaders;
}
public void setExposedHeaders(String... exposedHeaders) {
this.exposedHeaders = exposedHeaders;
}
public String[] getExposedHeaders() {
return exposedHeaders;
}
public void setMethod(RequestMethod... method) {
this.method = method;
}
public RequestMethod[] getMethod() {
return method;
}
public void setAllowCredentials(Boolean allowCredentials) {
this.allowCredentials = allowCredentials;
}
public Boolean getAllowCredentials() {
return allowCredentials;
}
public void setMaxAge(long maxAge) {
this.maxAge = maxAge;
}
public long getMaxAge() {
return maxAge;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 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.
@@ -126,6 +126,8 @@ public abstract class HttpRequestHandlingEndpointSupport extends MessagingGatewa
private volatile RequestMapping requestMapping = new RequestMapping();
private volatile CrossOrigin crossOrigin;
private volatile Class<?> requestPayloadType = null;
private volatile boolean convertersMerged;
@@ -283,6 +285,20 @@ public abstract class HttpRequestHandlingEndpointSupport extends MessagingGatewa
return requestMapping;
}
/**
* Set the {@link CrossOrigin} to permit cross origin requests for this endpoint.
* @param crossOrigin the CrossOrigin config.
* @since 4.2
*/
public void setCrossOrigin(CrossOrigin crossOrigin) {
this.crossOrigin = crossOrigin;
}
public CrossOrigin getCrossOrigin() {
return crossOrigin;
}
/**
* Specify the type of payload to be generated when the inbound HTTP request content is read by the
* {@link HttpMessageConverter}s. By default this value is null which means at runtime any "text" Content-Type will

View File

@@ -27,11 +27,15 @@ import javax.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.condition.NameValueExpression;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@@ -98,10 +102,50 @@ public final class IntegrationRequestMappingHandlerMapping extends RequestMappin
}
RequestMappingInfo mapping = this.getMappingForEndpoint((HttpRequestHandlingEndpointSupport) handler);
if (mapping != null) {
this.registerMapping(mapping, handler, HANDLE_REQUEST_METHOD);
registerMapping(mapping, handler, HANDLE_REQUEST_METHOD);
}
}
@Override
protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) {
CrossOrigin crossOrigin = ((HttpRequestHandlingEndpointSupport) handler).getCrossOrigin();
if (crossOrigin != null) {
CorsConfiguration config = new CorsConfiguration();
for (String origin : crossOrigin.getOrigin()) {
config.addAllowedOrigin(origin);
}
for (RequestMethod requestMethod : crossOrigin.getMethod()) {
config.addAllowedMethod(requestMethod.name());
}
for (String header : crossOrigin.getAllowedHeaders()) {
config.addAllowedHeader(header);
}
for (String header : crossOrigin.getExposedHeaders()) {
config.addExposedHeader(header);
}
if (crossOrigin.getAllowCredentials() != null) {
config.setAllowCredentials(crossOrigin.getAllowCredentials());
}
if (crossOrigin.getMaxAge() != -1) {
config.setMaxAge(crossOrigin.getMaxAge());
}
if (CollectionUtils.isEmpty(config.getAllowedMethods())) {
for (RequestMethod allowedMethod : mappingInfo.getMethodsCondition().getMethods()) {
config.addAllowedMethod(allowedMethod.name());
}
}
if (CollectionUtils.isEmpty(config.getAllowedHeaders())) {
for (NameValueExpression<String> headerExpression : mappingInfo.getHeadersCondition().getExpressions()) {
if (!headerExpression.isNegated()) {
config.addAllowedHeader(headerExpression.getName());
}
}
}
return config;
}
return null;
}
/**
* Created a {@link RequestMappingInfo} from a
* 'Spring Integration HTTP Inbound Endpoint' {@link RequestMapping}.

View File

@@ -32,6 +32,13 @@
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="cross-origin" type="crossOriginType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Marks this endpoint as permitting cross origin requests (CORS).
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="header" type="headerType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
@@ -108,6 +115,13 @@
</xsd:complexContent>
</xsd:complexType>
</xsd:element>
<xsd:element name="cross-origin" type="crossOriginType" minOccurs="0">
<xsd:annotation>
<xsd:documentation>
Marks this endpoint as permitting cross origin requests (CORS).
</xsd:documentation>
</xsd:annotation>
</xsd:element>
<xsd:element name="header" type="headerType" minOccurs="0" maxOccurs="unbounded">
<xsd:annotation>
<xsd:documentation>
@@ -589,6 +603,79 @@
</xsd:attribute>
</xsd:complexType>
<xsd:complexType name="crossOriginType">
<xsd:annotation>
<xsd:documentation>
Defines configuration for org.springframework.web.cors.CorsConfiguration.
</xsd:documentation>
</xsd:annotation>
<xsd:attribute name="origin" type="xsd:string" default="*">
<xsd:annotation>
<xsd:documentation>
List of allowed origins. "*" means that all origins are allowed. These values
are placed in the 'Access-Control-Allow-Origin' header of both the pre-flight
and actual responses. Default value is "*".
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="allowed-headers" type="xsd:string" default="*">
<xsd:annotation>
<xsd:documentation>
Indicates which request headers can be used during the actual request. "*" means
that all headers asked by the client are allowed. This property controls the value of
pre-flight response's 'Access-Control-Allow-Headers' header.
Default value is "*".
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="exposed-headers" type="xsd:string">
<xsd:annotation>
<xsd:documentation>
List of response headers that the user-agent will allow the client to access. This property
controls the value of actual response's 'Access-Control-Expose-Headers' header.
</xsd:documentation>
</xsd:annotation>
</xsd:attribute>
<xsd:attribute name="method">
<xsd:annotation>
<xsd:documentation>
The HTTP request methods to allow: GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE, TRACE.
Methods specified here overrides 'supported-methods' ones.
</xsd:documentation>
</xsd:annotation>
<xsd:simpleType>
<xsd:union memberTypes="httpMethodEnumeration xsd:string" />
</xsd:simpleType>
</xsd:attribute>
<xsd:attribute name="allow-credentials" default="true">
<xsd:annotation>
<xsd:documentation>
Set to "true" if the the browser should include any cookies associated to the domain
of the request being annotated, or "false" if it should not. Empty string "" means undefined.
If true, the pre-flight response will include the header
'Access-Control-Allow-Credentials=true'. Default value is "true".
</xsd:documentation>
</xsd:annotation>
<xsd:simpleType>
<xsd:union memberTypes="xsd:boolean xsd:string" />
</xsd:simpleType>
</xsd:attribute>
<xsd:attribute name="max-age" default="1800">
<xsd:annotation>
<xsd:documentation>
Controls the cache duration for pre-flight responses. Setting this to a reasonable
value can reduce the number of pre-flight request/response interaction required by
the browser. This property controls the value of the 'Access-Control-Max-Age' header
in the pre-flight response. Value set to '-1' means undefined.
Default value is 1800 seconds, or 30 minutes.
</xsd:documentation>
</xsd:annotation>
<xsd:simpleType>
<xsd:union memberTypes="xsd:long xsd:string" />
</xsd:simpleType>
</xsd:attribute>
</xsd:complexType>
<xsd:attributeGroup name="httpOutboundCommonAttributes">
<xsd:attribute name="url" type="xsd:string" use="optional">
<xsd:annotation>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns:int-http="http://www.springframework.org/schema/integration/http"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:beans="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/integration/http
http://www.springframework.org/schema/integration/http/spring-integration-http.xsd">
<int-http:inbound-channel-adapter path="/no" supported-methods="GET" channel="nullChannel"/>
<int-http:inbound-channel-adapter path="/no" supported-methods="POST" channel="nullChannel"/>
<int-http:inbound-channel-adapter path="/default" supported-methods="GET" channel="nullChannel">
<int-http:cross-origin/>
</int-http:inbound-channel-adapter>
<int-http:inbound-channel-adapter path="/default" supported-methods="GET" channel="nullChannel">
<int-http:request-mapping params="q"/>
<int-http:cross-origin/>
</int-http:inbound-channel-adapter>
<int-http:inbound-channel-adapter path="/ambiguous-header" supported-methods="GET" channel="nullChannel">
<int-http:request-mapping headers="header1=a, header2=foo"/>
<int-http:cross-origin/>
</int-http:inbound-channel-adapter>
<int-http:inbound-channel-adapter path="/ambiguous-header" supported-methods="GET" channel="nullChannel">
<int-http:request-mapping headers="header1=b"/>
<int-http:cross-origin/>
</int-http:inbound-channel-adapter>
<int-http:inbound-channel-adapter path="/customized" supported-methods="GET,POST" channel="nullChannel">
<int-http:cross-origin origin="http://site1.com,http://site2.com"
allowed-headers="header1, header2"
exposed-headers="header3, header4"
method="DELETE"
max-age="123"
allow-credentials="false"/>
</int-http:inbound-channel-adapter>
<int-http:inbound-gateway path="/ambiguous-produces" supported-methods="GET" request-channel="nullChannel">
<int-http:request-mapping produces="application/xml"/>
<int-http:cross-origin/>
</int-http:inbound-gateway>
<int-http:inbound-gateway path="/ambiguous-produces" supported-methods="GET" request-channel="nullChannel">
<int-http:request-mapping produces="application/json"/>
<int-http:cross-origin/>
</int-http:inbound-gateway>
</beans:beans>

View File

@@ -0,0 +1,190 @@
/*
* Copyright 2015 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.inbound;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.integration.test.util.TestUtils;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* @author Artem Bilan
* @since 4.2
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@DirtiesContext
public class CrossOriginTests {
@Autowired
private IntegrationRequestMappingHandlerMapping handlerMapping;
private MockHttpServletRequest request;
@Before
public void setUp() {
this.request = new MockHttpServletRequest();
this.request.setMethod("GET");
this.request.addHeader(HttpHeaders.ORIGIN, "http://domain.com/");
}
@Test
public void noEndpointWithoutOriginHeader() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/no");
HandlerExecutionChain chain = this.handlerMapping.getHandler(request);
CorsConfiguration config = getCorsConfiguration(chain, false);
assertNull(config);
}
@Test
public void noEndpointWithOriginHeader() throws Exception {
this.request.setRequestURI("/no");
HandlerExecutionChain chain = this.handlerMapping.getHandler(request);
CorsConfiguration config = getCorsConfiguration(chain, false);
assertNull(config);
}
@Test
public void noEndpointPostWithOriginHeader() throws Exception {
this.request.setMethod("POST");
this.request.setRequestURI("/no");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, false);
assertNull(config);
}
@Test
public void defaultEndpointWithCrossOrigin() throws Exception {
this.request.setRequestURI("/default");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, false);
assertNotNull(config);
assertArrayEquals(new String[]{"GET"}, config.getAllowedMethods().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedOrigins().toArray());
assertTrue(config.getAllowCredentials());
assertArrayEquals(new String[]{"*"}, config.getAllowedHeaders().toArray());
assertNull(config.getExposedHeaders());
assertEquals(new Long(1800), config.getMaxAge());
}
@Test
public void customized() throws Exception {
this.request.setRequestURI("/customized");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, false);
assertNotNull(config);
assertArrayEquals(new String[]{"DELETE"}, config.getAllowedMethods().toArray());
assertArrayEquals(new String[]{"http://site1.com", "http://site2.com"}, config.getAllowedOrigins().toArray());
assertArrayEquals(new String[]{"header1", "header2"}, config.getAllowedHeaders().toArray());
assertArrayEquals(new String[]{"header3", "header4"}, config.getExposedHeaders().toArray());
assertEquals(new Long(123), config.getMaxAge());
assertEquals(false, config.getAllowCredentials());
}
@Test
public void preFlightRequest() throws Exception {
this.request.setMethod("OPTIONS");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.setRequestURI("/default");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, true);
assertNotNull(config);
assertArrayEquals(new String[]{"GET"}, config.getAllowedMethods().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedOrigins().toArray());
assertTrue(config.getAllowCredentials());
assertArrayEquals(new String[]{"*"}, config.getAllowedHeaders().toArray());
assertNull(config.getExposedHeaders());
assertEquals(new Long(1800), config.getMaxAge());
}
@Test
public void ambiguousHeaderPreFlightRequest() throws Exception {
this.request.setMethod("OPTIONS");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "header1");
this.request.setRequestURI("/ambiguous-header");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, true);
assertNotNull(config);
assertArrayEquals(new String[]{"*"}, config.getAllowedMethods().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedOrigins().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedHeaders().toArray());
assertTrue(config.getAllowCredentials());
assertNull(config.getExposedHeaders());
assertNull(config.getMaxAge());
}
@Test
public void ambiguousProducesPreFlightRequest() throws Exception {
this.request.setMethod("OPTIONS");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.setRequestURI("/ambiguous-produces");
HandlerExecutionChain chain = this.handlerMapping.getHandler(this.request);
CorsConfiguration config = getCorsConfiguration(chain, true);
assertNotNull(config);
assertArrayEquals(new String[]{"*"}, config.getAllowedMethods().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedOrigins().toArray());
assertArrayEquals(new String[]{"*"}, config.getAllowedHeaders().toArray());
assertTrue(config.getAllowCredentials());
assertNull(config.getExposedHeaders());
assertNull(config.getMaxAge());
}
@Test(expected = HttpRequestMethodNotSupportedException.class)
public void preFlightRequestWithoutRequestMethodHeader() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/default");
request.addHeader(HttpHeaders.ORIGIN, "http://domain2.com");
this.handlerMapping.getHandler(request);
}
private CorsConfiguration getCorsConfiguration(HandlerExecutionChain chain, boolean isPreFlightRequest) {
if (isPreFlightRequest) {
Object handler = chain.getHandler();
assertTrue(handler.getClass().getSimpleName().equals("PreFlightHandler"));
return TestUtils.getPropertyValue(handler, "config", CorsConfiguration.class);
}
else {
HandlerInterceptor[] interceptors = chain.getInterceptors();
if (interceptors != null) {
for (HandlerInterceptor interceptor : interceptors) {
if (interceptor.getClass().getSimpleName().equals("CorsInterceptor")) {
return TestUtils.getPropertyValue(interceptor, "config", CorsConfiguration.class);
}
}
}
}
return null;
}
}