From 73eea9d8c09a410cf35ff4563298d91df6695b3a Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Fri, 30 Jun 2017 14:24:46 +0100 Subject: [PATCH] Migrate MVC module over from spring-cloud-function --- pom.xml | 1 + spring-cloud-gateway-dependencies/pom.xml | 5 + spring-cloud-gateway-mvc/.jdk8 | 0 spring-cloud-gateway-mvc/pom.xml | 33 + .../cloud/function/gateway/ProxyExchange.java | 644 ++++++++++++++++++ .../config/ProxyExchangeArgumentResolver.java | 83 +++ .../gateway/config/ProxyProperties.java | 70 ++ .../ProxyResponseAutoConfiguration.java | 89 +++ .../main/resources/META-INF/spring.factories | 5 + .../gateway/ProductionConfigurationTests.java | 445 ++++++++++++ .../src/test/resources/static/test.html | 4 + 11 files changed, 1379 insertions(+) create mode 100644 spring-cloud-gateway-mvc/.jdk8 create mode 100644 spring-cloud-gateway-mvc/pom.xml create mode 100644 spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/ProxyExchange.java create mode 100644 spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyExchangeArgumentResolver.java create mode 100644 spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyProperties.java create mode 100644 spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyResponseAutoConfiguration.java create mode 100644 spring-cloud-gateway-mvc/src/main/resources/META-INF/spring.factories create mode 100644 spring-cloud-gateway-mvc/src/test/java/org/springframework/cloud/function/web/gateway/ProductionConfigurationTests.java create mode 100644 spring-cloud-gateway-mvc/src/test/resources/static/test.html diff --git a/pom.xml b/pom.xml index 6fe0389ce..816ba3a4c 100644 --- a/pom.xml +++ b/pom.xml @@ -140,6 +140,7 @@ spring-cloud-gateway-dependencies + spring-cloud-gateway-mvc spring-cloud-gateway-core spring-cloud-starter-gateway spring-cloud-gateway-sample diff --git a/spring-cloud-gateway-dependencies/pom.xml b/spring-cloud-gateway-dependencies/pom.xml index 0b5de9081..3cad857e4 100644 --- a/spring-cloud-gateway-dependencies/pom.xml +++ b/spring-cloud-gateway-dependencies/pom.xml @@ -21,6 +21,11 @@ + + org.springframework.cloud + spring-cloud-gateway-mvc + ${project.version} + org.springframework.cloud spring-cloud-gateway-core diff --git a/spring-cloud-gateway-mvc/.jdk8 b/spring-cloud-gateway-mvc/.jdk8 new file mode 100644 index 000000000..e69de29bb diff --git a/spring-cloud-gateway-mvc/pom.xml b/spring-cloud-gateway-mvc/pom.xml new file mode 100644 index 000000000..35a74ed2c --- /dev/null +++ b/spring-cloud-gateway-mvc/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + spring-cloud-gateway-mvc + jar + spring-cloud-gateway-mvc + Spring Cloud Gateway MVC + + + org.springframework.cloud + spring-cloud-function-parent + 1.0.0.BUILD-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/ProxyExchange.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/ProxyExchange.java new file mode 100644 index 000000000..a8764f7e9 --- /dev/null +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/ProxyExchange.java @@ -0,0 +1,644 @@ +/* + * Copyright 2016-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.cloud.function.gateway; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.Vector; +import java.util.function.Function; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + +import org.springframework.core.Conventions; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.RequestEntity.BodyBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.util.ClassUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseExtractor; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor; +import org.springframework.web.util.AbstractUriTemplateHandler; + +/** + * A @RequestMapping argument type that can proxy the request to a backend. + * Spring will inject one of these into your MVC handler method, and you get return a + * ResponseEntity that you get from one of the HTTP methods {@link #get()}, + * {@link #post()}, {@link #put()}, {@link #patch()}, {@link #delete()} etc. Example: + * + *
+ * @GetMapping("/proxy/{id}")
+ * public ResponseEntity<?> proxy(@PathVariable Integer id, ProxyExchange<?> proxy)
+ * 		throws Exception {
+ * 	return proxy.uri("http://localhost:9000/foos/" + id).get();
+ * }
+ * 
+ * + *

+ * By default the incoming request body and headers are sent intact to the downstream + * service (with the exception of "sensitive" headers). To manipulate the downstream + * request there are "builder" style methods in {@link ProxyExchange}, but only the + * {@link #uri(String)} is mandatory. You can change the sensitive headers by calling the + * {@link #sensitive(String...)} method (Authorization and Cookie are sensitive by + * default). + *

+ *

+ * The type parameter T in ProxyExchange<T> is the type of + * the response body, so it comes out in the {@link ResponseEntity} that you return from + * your @RequestMapping. If you don't care about the type of the request and + * response body (e.g. if it's just a passthru) then use a wildcard, or + * byte[] or Object. Use a concrete type if you want to + * transform or manipulate the response, or if you want to assert that it is convertible + * to the type you declare. + *

+ *

+ * To manipulate the response use the overloaded HTTP methods with a Function + * argument and pass in code to transform the response. E.g. + * + *

+ * @PostMapping("/proxy")
+ * public ResponseEntity<Foo> proxy(ProxyExchange<Foo> proxy) throws Exception {
+ * 	return proxy.uri("http://localhost:9000/foos/") //
+ * 			.post(response -> ResponseEntity.status(response.getStatusCode()) //
+ * 					.headers(response.getHeaders()) //
+ * 					.header("X-Custom", "MyCustomHeader") //
+ * 					.body(response.getBody()) //
+ * 	);
+ * }
+ * 
+ * 
+ * + *

+ *

+ * The full machinery of Spring {@link HttpMessageConverter message converters} is applied + * to the incoming request and response and also to the backend request. If you need + * additional converters then they need to be added upstream in the MVC configuration and + * also to the {@link RestTemplate} that is used in the backend calls (see the + * {@link ProxyExchange#ProxyExchange(RestTemplate, NativeWebRequest, ModelAndViewContainer, WebDataBinderFactory, Type) + * constructor} for details). + *

+ *

+ * As well as the HTTP methods for a backend call you can also use + * {@link #forward(String)} for a local in-container dispatch. + *

+ * + * @author Dave Syer + * + */ +public class ProxyExchange { + + public static Set DEFAULT_SENSITIVE = new HashSet<>( + Arrays.asList("cookie", "authorization")); + + private URI uri; + + private NestedTemplate rest; + + private Object body; + + private RequestResponseBodyMethodProcessor delegate; + + private NativeWebRequest webRequest; + + private ModelAndViewContainer mavContainer; + + private WebDataBinderFactory binderFactory; + + private Set sensitive; + + private HttpHeaders headers = new HttpHeaders(); + + private Type responseType; + + public ProxyExchange(RestTemplate rest, NativeWebRequest webRequest, + ModelAndViewContainer mavContainer, WebDataBinderFactory binderFactory, + Type type) { + this.responseType = type; + this.rest = createTemplate(rest); + this.webRequest = webRequest; + this.mavContainer = mavContainer; + this.binderFactory = binderFactory; + this.delegate = new RequestResponseBodyMethodProcessor( + rest.getMessageConverters()); + } + + /** + * Sets the body for the downstream request (if using {@link #post()}, {@link #put()} + * or {@link #patch()}). The body can be omitted if you just want to pass the incoming + * request downstream without changing it. If you want to transform the incoming + * request you can declare it as a @RequestBody in your + * @RequestMapping in the usual Spring MVC way. + * + * @param body the request body to send downstream + * @return this for convenience + */ + public ProxyExchange body(Object body) { + this.body = body; + return this; + } + + /** + * Sets a header for the downstream call. + * + * @param name + * @param value + * @return this for convenience + */ + public ProxyExchange header(String name, String... value) { + this.headers.put(name, Arrays.asList(value)); + return this; + } + + /** + * Additional headers, or overrides of the incoming ones, to be used in the downstream + * call. + * + * @param headers the http headers to use in the downstream call + * @return this for convenience + */ + public ProxyExchange headers(HttpHeaders headers) { + this.headers.putAll(headers); + return this; + } + + /** + * Sets the names of sensitive headers that are not passed downstream to the backend + * service. + * + * @param names the names of sensitive headers + * @return this for convenience + */ + public ProxyExchange sensitive(String... names) { + if (this.sensitive == null) { + this.sensitive = new HashSet<>(); + } + for (String name : names) { + this.sensitive.add(name.toLowerCase()); + } + return this; + } + + /** + * Sets the uri for the backend call when triggered by the HTTP methods. + * + * @param uri the backend uri to send the request to + * @return this for convenience + */ + public ProxyExchange uri(String uri) { + try { + this.uri = new URI(uri); + } + catch (URISyntaxException e) { + throw new IllegalStateException("Cannot create URI", e); + } + return this; + } + + public String path() { + return (String) this.webRequest.getAttribute( + HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, + WebRequest.SCOPE_REQUEST); + } + + public String path(String prefix) { + String path = path(); + if (!path.startsWith(prefix)) { + throw new IllegalArgumentException( + "Path does not start with prefix (" + prefix + "): " + path); + } + return path.substring(prefix.length()); + } + + public void forward(String path) { + HttpServletRequest request = this.webRequest + .getNativeRequest(HttpServletRequest.class); + HttpServletResponse response = this.webRequest + .getNativeResponse(HttpServletResponse.class); + try { + request.getRequestDispatcher(path).forward( + new BodyForwardingHttpServletRequest(request, response), response); + } + catch (Exception e) { + throw new IllegalStateException("Cannot forward request", e); + } + } + + public ResponseEntity get() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.get(uri)) + .build(); + return exchange(requestEntity); + } + + public ResponseEntity get( + Function, ResponseEntity> converter) { + return converter.apply(get()); + } + + public ResponseEntity head() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.head(uri)) + .build(); + return exchange(requestEntity); + } + + public ResponseEntity head( + Function, ResponseEntity> converter) { + return converter.apply(head()); + } + + public ResponseEntity options() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.options(uri)) + .build(); + return exchange(requestEntity); + } + + public ResponseEntity options( + Function, ResponseEntity> converter) { + return converter.apply(options()); + } + + public ResponseEntity post() { + RequestEntity requestEntity = headers(RequestEntity.post(uri)) + .body(body()); + return exchange(requestEntity); + } + + public ResponseEntity post( + Function, ResponseEntity> converter) { + return converter.apply(post()); + } + + public ResponseEntity delete() { + RequestEntity requestEntity = headers( + (BodyBuilder) RequestEntity.delete(uri)).build(); + return exchange(requestEntity); + } + + public ResponseEntity delete( + Function, ResponseEntity> converter) { + return converter.apply(delete()); + } + + public ResponseEntity put() { + RequestEntity requestEntity = headers(RequestEntity.put(uri)) + .body(body()); + return exchange(requestEntity); + } + + public ResponseEntity put( + Function, ResponseEntity> converter) { + return converter.apply(put()); + } + + public ResponseEntity patch() { + RequestEntity requestEntity = headers(RequestEntity.patch(uri)) + .body(body()); + return exchange(requestEntity); + } + + public ResponseEntity patch( + Function, ResponseEntity> converter) { + return converter.apply(patch()); + } + + private ResponseEntity exchange(RequestEntity requestEntity) { + Type type = this.responseType; + if (type instanceof TypeVariable || type instanceof WildcardType) { + type = Object.class; + } + RequestCallback requestCallback = rest.httpEntityCallback((Object) requestEntity, + type); + ResponseExtractor> responseExtractor = rest + .responseEntityExtractor(type); + return rest.execute(requestEntity.getUrl(), requestEntity.getMethod(), + requestCallback, responseExtractor); + } + + private BodyBuilder headers(BodyBuilder builder) { + Set sensitive = this.sensitive; + if (sensitive == null) { + sensitive = DEFAULT_SENSITIVE; + } + proxy(); + for (String name : headers.keySet()) { + if (sensitive.contains(name.toLowerCase())) { + continue; + } + builder.header(name, headers.get(name).toArray(new String[0])); + } + return builder; + } + + private void proxy() { + try { + URI uri = new URI(webRequest.getNativeRequest(HttpServletRequest.class) + .getRequestURL().toString()); + appendForwarded(uri); + appendXForwarded(uri); + } + catch (URISyntaxException e) { + throw new IllegalStateException("Cannot create URI for request: " + webRequest + .getNativeRequest(HttpServletRequest.class).getRequestURL()); + } + } + + private void appendXForwarded(URI uri) { + // Append the legacy headers if they were already added upstream + String host = headers.getFirst("x-forwarded-host"); + if (host == null) { + return; + } + host = host + "," + uri.getHost(); + headers.set("x-forwarded-host", host); + String proto = headers.getFirst("x-forwarded-proto"); + if (proto == null) { + return; + } + proto = proto + "," + uri.getScheme(); + headers.set("x-forwarded-proto", proto); + } + + private void appendForwarded(URI uri) { + String forwarded = headers.getFirst("forwarded"); + if (forwarded != null) { + forwarded = forwarded + ","; + } else { + forwarded = ""; + } + forwarded = forwarded + forwarded(uri); + headers.set("forwarded", forwarded); + } + + private String forwarded(URI uri) { + if ("http".equals(uri.getScheme())) { + return "host=" + uri.getHost(); + } + return String.format("host=%s;proto=%s", uri.getHost(), uri.getScheme()); + } + + private Object body() { + if (body != null) { + return body; + } + body = getRequestBody(); + return body; + } + + /** + * Search for the request body if it was already deserialized using + * @RequestBody. If it is not found then deserialize it in the same way + * that it would have been for a @RequestBody. + * + * @return the request body + */ + private Object getRequestBody() { + for (String key : mavContainer.getModel().keySet()) { + if (key.startsWith(BindingResult.MODEL_KEY_PREFIX)) { + BindingResult result = (BindingResult) mavContainer.getModel().get(key); + return result.getTarget(); + } + } + MethodParameter input = new MethodParameter( + ClassUtils.getMethod(BodyGrabber.class, "body", Object.class), 0); + try { + delegate.resolveArgument(input, mavContainer, webRequest, binderFactory); + } + catch (Exception e) { + throw new IllegalStateException("Cannot resolve body", e); + } + String name = Conventions.getVariableNameForParameter(input); + BindingResult result = (BindingResult) mavContainer.getModel() + .get(BindingResult.MODEL_KEY_PREFIX + name); + return result.getTarget(); + } + + private NestedTemplate createTemplate(RestTemplate input) { + NestedTemplate rest = new NestedTemplate(); + rest.setMessageConverters(input.getMessageConverters()); + rest.setErrorHandler(input.getErrorHandler()); + rest.setDefaultUriVariables( + ((AbstractUriTemplateHandler) input.getUriTemplateHandler()) + .getDefaultUriVariables()); + rest.setRequestFactory(input.getRequestFactory()); + rest.setInterceptors(input.getInterceptors()); + return rest; + } + + /** + * A special {@link RestTemplate} that knows about the {@link Type} of its response + * body explicitly (rather than through a {@link ParameterizedTypeReference}, which is + * the only way to access this feature in a regular template). + * + */ + class NestedTemplate extends RestTemplate { + @Override + protected RequestCallback httpEntityCallback(Object requestBody, + Type responseType) { + return super.httpEntityCallback(requestBody, responseType); + } + + @Override + protected ResponseExtractor> responseEntityExtractor( + Type responseType) { + return super.responseEntityExtractor(responseType); + } + } + + /** + * A servlet request wrapper that can be safely passed downstream to an internal + * forward dispatch, caching its body, and making it available in converted form using + * Spring message converters. + * + */ + class BodyForwardingHttpServletRequest extends HttpServletRequestWrapper { + private HttpServletRequest request; + private HttpServletResponse response; + + BodyForwardingHttpServletRequest(HttpServletRequest request, + HttpServletResponse response) { + super(request); + this.request = request; + this.response = response; + } + + private List header(String name) { + List list = headers.get(name); + return list; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + Object body = body(); + MethodParameter output = new MethodParameter( + ClassUtils.getMethod(BodySender.class, "body"), -1); + ServletOutputToInputConverter response = new ServletOutputToInputConverter( + this.response); + ServletWebRequest webRequest = new ServletWebRequest(this.request, response); + try { + delegate.handleReturnValue(body, output, mavContainer, webRequest); + } + catch (HttpMessageNotWritableException + | HttpMediaTypeNotAcceptableException e) { + throw new IllegalStateException("Cannot convert body", e); + } + return response.getInputStream(); + } + + @Override + public Enumeration getHeaderNames() { + Set names = headers.keySet(); + if (names.isEmpty()) { + return super.getHeaderNames(); + } + Set result = new LinkedHashSet<>(names); + result.addAll(Collections.list(super.getHeaderNames())); + return new Vector(result).elements(); + } + + @Override + public Enumeration getHeaders(String name) { + List list = header(name); + if (list != null) { + return new Vector(list).elements(); + } + return super.getHeaders(name); + } + + @Override + public String getHeader(String name) { + List list = header(name); + if (list != null && !list.isEmpty()) { + return list.iterator().next(); + } + return super.getHeader(name); + } + } + + protected static class BodyGrabber { + public Object body(@RequestBody Object body) { + return body; + } + } + + protected static class BodySender { + @ResponseBody + public Object body() { + return null; + } + } + +} + +/** + * Convenience class that converts an incoming request input stream into a form that can + * be easily deserialized to a Java object using Spring message converters. It is only + * used in a local forward dispatch, in which case there is a danger that the request body + * will need to be read and analysed more than once. Apart from using the message + * converters the other main feature of this class is that the request body is cached and + * can be read repeatedly as necessary. + * + * @author Dave Syer + * + */ +class ServletOutputToInputConverter extends HttpServletResponseWrapper { + + private StringBuilder builder = new StringBuilder(); + + public ServletOutputToInputConverter(HttpServletResponse response) { + super(response); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + return new ServletOutputStream() { + + @Override + public void write(int b) throws IOException { + builder.append(new Character((char) b)); + } + + @Override + public void setWriteListener(WriteListener listener) { + } + + @Override + public boolean isReady() { + return true; + } + }; + } + + public ServletInputStream getInputStream() { + ByteArrayInputStream body = new ByteArrayInputStream( + builder.toString().getBytes()); + return new ServletInputStream() { + + @Override + public int read() throws IOException { + return body.read(); + } + + @Override + public void setReadListener(ReadListener listener) { + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public boolean isFinished() { + return body.available() <= 0; + } + }; + } + +} diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyExchangeArgumentResolver.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyExchangeArgumentResolver.java new file mode 100644 index 000000000..bc9e143be --- /dev/null +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyExchangeArgumentResolver.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016-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.cloud.function.gateway.config; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Set; + +import org.springframework.cloud.function.gateway.ProxyExchange; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * @author Dave Syer + * + */ +public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResolver { + + private RestTemplate rest; + + private HttpHeaders headers; + + private Set sensitive; + + public ProxyExchangeArgumentResolver(RestTemplate builder) { + this.rest = builder; + } + + public void setHeaders(HttpHeaders headers) { + this.headers = headers; + } + + public void setSensitive(Set sensitive) { + this.sensitive = sensitive; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return ProxyExchange.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + ProxyExchange proxy = new ProxyExchange<>(rest, webRequest, mavContainer, + binderFactory, type(parameter)); + proxy.headers(headers); + if (sensitive != null) { + proxy.sensitive(sensitive.toArray(new String[0])); + } + return proxy; + } + + private Type type(MethodParameter parameter) { + Type type = parameter.getGenericParameterType(); + if (type instanceof ParameterizedType) { + ParameterizedType param = (ParameterizedType) type; + type = param.getActualTypeArguments()[0]; + } + return type; + } + +} diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyProperties.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyProperties.java new file mode 100644 index 000000000..01419f35d --- /dev/null +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyProperties.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016-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.cloud.function.gateway.config; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.function.gateway.ProxyExchange; +import org.springframework.http.HttpHeaders; + +/** + * Configuration properties for the {@link ProxyExchange} argument handler in + * @RequestMapping methods. + * @author Dave Syer + * + */ +@ConfigurationProperties("spring.cloud.gateway.proxy") +public class ProxyProperties { + + /** + * Fixed header values that will be added to all downstream requests. + */ + private Map headers = new LinkedHashMap<>(); + + /** + * A set of sensitive header names that will not be sent downstream by default. + */ + private Set sensitive = null; + + public Map getHeaders() { + return headers; + } + + public void setHeaders(Map headers) { + this.headers = headers; + } + + public Set getSensitive() { + return sensitive; + } + + public void setSensitive(Set sensitive) { + this.sensitive = sensitive; + } + + public HttpHeaders convertHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (String key : this.headers.keySet()) { + headers.set(key, this.headers.get(key)); + } + return headers; + } + +} diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyResponseAutoConfiguration.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyResponseAutoConfiguration.java new file mode 100644 index 000000000..ddfd07676 --- /dev/null +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/function/gateway/config/ProxyResponseAutoConfiguration.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016-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.cloud.function.gateway.config; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cloud.function.gateway.ProxyExchange; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +/** + * Autoconfiguration for the {@link ProxyExchange} argument handler in Spring MVC + * @RequestMapping methods. + * + * @author Dave Syer + */ +@Configuration +@ConditionalOnWebApplication +@ConditionalOnClass({ HandlerMethodReturnValueHandler.class }) +@EnableConfigurationProperties(ProxyProperties.class) +public class ProxyResponseAutoConfiguration extends WebMvcConfigurerAdapter { + + @Autowired + private ApplicationContext context; + + @Bean + @ConditionalOnMissingBean + public ProxyExchangeArgumentResolver proxyExchangeArgumentResolver( + Optional optional, ProxyProperties proxy) { + RestTemplateBuilder builder = optional.orElse(new RestTemplateBuilder()); + RestTemplate template = builder.build(); + template.setErrorHandler(new NoOpResponseErrorHandler()); + template.getMessageConverters().add(new ByteArrayHttpMessageConverter() { + @Override + public boolean supports(Class clazz) { + return true; + } + }); + ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver( + template); + resolver.setHeaders(proxy.convertHeaders()); + resolver.setSensitive(proxy.getSensitive()); // can be null + return resolver; + } + + @Override + public void addArgumentResolvers( + List argumentResolvers) { + argumentResolvers.add(context.getBean(ProxyExchangeArgumentResolver.class)); + } + + private static class NoOpResponseErrorHandler extends DefaultResponseErrorHandler { + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + } + + } +} diff --git a/spring-cloud-gateway-mvc/src/main/resources/META-INF/spring.factories b/spring-cloud-gateway-mvc/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..8a98f9330 --- /dev/null +++ b/spring-cloud-gateway-mvc/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.function.gateway.config.ProxyResponseAutoConfiguration + +org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc=\ +org.springframework.cloud.function.gateway.config.ProxyResponseAutoConfiguration diff --git a/spring-cloud-gateway-mvc/src/test/java/org/springframework/cloud/function/web/gateway/ProductionConfigurationTests.java b/spring-cloud-gateway-mvc/src/test/java/org/springframework/cloud/function/web/gateway/ProductionConfigurationTests.java new file mode 100644 index 000000000..ec2772920 --- /dev/null +++ b/spring-cloud-gateway-mvc/src/test/java/org/springframework/cloud/function/web/gateway/ProductionConfigurationTests.java @@ -0,0 +1,445 @@ +/* + * Copyright 2016-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.cloud.function.web.gateway; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.embedded.LocalServerPort; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.function.gateway.ProxyExchange; +import org.springframework.cloud.function.web.gateway.ProductionConfigurationTests.TestApplication; +import org.springframework.cloud.function.web.gateway.ProductionConfigurationTests.TestApplication.Bar; +import org.springframework.cloud.function.web.gateway.ProductionConfigurationTests.TestApplication.Foo; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ContextConfiguration(classes = TestApplication.class) +public class ProductionConfigurationTests { + + @Autowired + private TestRestTemplate rest; + + @Autowired + private TestApplication application; + + @LocalServerPort + private int port; + + @Before + public void init() throws Exception { + application.setHome(new URI("http://localhost:" + port)); + } + + @Test + public void get() throws Exception { + assertThat(rest.getForObject("/proxy/0", Foo.class).getName()).isEqualTo("bye"); + } + + @Test + public void path() throws Exception { + assertThat(rest.getForObject("/proxy/path/1", Foo.class).getName()) + .isEqualTo("foo"); + } + + @Test + public void resource() throws Exception { + assertThat(rest.getForObject("/proxy/html/test.html", String.class)) + .contains("Test"); + } + + @Test + public void resourceWithNoType() throws Exception { + assertThat(rest.getForObject("/proxy/typeless/test.html", String.class)) + .contains("Test"); + } + + @Test + public void missing() throws Exception { + assertThat(rest.getForEntity("/proxy/missing/0", Foo.class).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void uri() throws Exception { + assertThat(rest.getForObject("/proxy/0", Foo.class).getName()).isEqualTo("bye"); + } + + @Test + public void post() throws Exception { + assertThat(rest.postForObject("/proxy/0", Collections.singletonMap("name", "foo"), + Bar.class).getName()).isEqualTo("host=localhost;foo"); + } + + @Test + public void forward() throws Exception { + assertThat(rest.getForObject("/forward/foos/0", Foo.class).getName()) + .isEqualTo("bye"); + } + + @Test + public void forwardHeader() throws Exception { + ResponseEntity result = rest.getForEntity("/forward/special/foos/0", + Foo.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().getName()).isEqualTo("FOO"); + } + + @Test + public void postForwardHeader() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler().expand( + "/forward/special/bars")) + .body(Collections.singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("FOOfoo"); + } + + @Test + public void postForwardBody() throws Exception { + ResponseEntity result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler().expand( + "/forward/body/bars")) + .body(Collections.singletonList(Collections.singletonMap("name", "foo"))), + String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).contains("foo"); + } + + @Test + public void postForwardForgetBody() throws Exception { + ResponseEntity result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler().expand( + "/forward/forget/bars")) + .body(Collections.singletonList(Collections.singletonMap("name", "foo"))), + String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).contains("foo"); + } + + @Test + public void postForwardBodyFoo() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler().expand( + "/forward/body/bars")) + .body(Collections.singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("foo"); + } + + @Test + public void list() throws Exception { + assertThat(rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler().expand( + "/proxy")) + .body(Collections.singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }).getBody().iterator().next().getName()).isEqualTo("host=localhost;foo"); + } + + @Test + public void bodyless() throws Exception { + assertThat(rest.postForObject("/proxy/0", Collections.singletonMap("name", "foo"), + Bar.class).getName()).isEqualTo("host=localhost;foo"); + } + + @Test + public void entity() throws Exception { + assertThat(rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler() + .expand("/proxy/entity")) + .body(Collections.singletonMap("name", "foo")), + new ParameterizedTypeReference>() { + }).getBody().iterator().next().getName()).isEqualTo("host=localhost;foo"); + } + + @Test + public void entityWithType() throws Exception { + assertThat(rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler() + .expand("/proxy/type")) + .body(Collections.singletonMap("name", "foo")), + new ParameterizedTypeReference>() { + }).getBody().iterator().next().getName()).isEqualTo("host=localhost;foo"); + } + + @Test + public void single() throws Exception { + assertThat(rest.postForObject("/proxy/single", + Collections.singletonMap("name", "foobar"), Bar.class).getName()) + .isEqualTo("host=localhost;foobar"); + } + + @Test + public void converter() throws Exception { + assertThat(rest.postForObject("/proxy/converter", + Collections.singletonMap("name", "foobar"), Bar.class).getName()) + .isEqualTo("host=localhost;foobar"); + } + + @SpringBootApplication + static class TestApplication { + + @RestController + static class ProxyController { + + private URI home; + + public void setHome(URI home) { + this.home = home; + } + + @GetMapping("/proxy/{id}") + public ResponseEntity proxyFoos(@PathVariable Integer id, + ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/foos/" + id).get(); + } + + @GetMapping("/proxy/path/**") + public ResponseEntity proxyPath(ProxyExchange proxy, + UriComponentsBuilder uri) throws Exception { + String path = proxy.path("/proxy/path/"); + return proxy.uri(home.toString() + "/foos/" + path).get(); + } + + @GetMapping("/proxy/html/**") + public ResponseEntity proxyHtml(ProxyExchange proxy, + UriComponentsBuilder uri) throws Exception { + String path = proxy.path("/proxy/html"); + return proxy.uri(home.toString() + path).get(); + } + + @GetMapping("/proxy/typeless/**") + public ResponseEntity proxyTypeless(ProxyExchange proxy, + UriComponentsBuilder uri) throws Exception { + String path = proxy.path("/proxy/typeless"); + return proxy.uri(home.toString() + path).get(); + } + + @GetMapping("/proxy/missing/{id}") + public ResponseEntity proxyMissing(@PathVariable Integer id, + ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/missing/" + id).get(); + } + + @GetMapping("/proxy") + public ResponseEntity proxyUri(ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/foos").get(); + } + + @PostMapping("/proxy/{id}") + public ResponseEntity proxyBars(@PathVariable Integer id, + @RequestBody Map body, + ProxyExchange> proxy) throws Exception { + body.put("id", id); + return proxy.uri(home.toString() + "/bars").body(Arrays.asList(body)) + .post(this::first); + } + + @PostMapping("/proxy") + public ResponseEntity barsWithNoBody(ProxyExchange proxy) + throws Exception { + return proxy.uri(home.toString() + "/bars").post(); + } + + @PostMapping("/proxy/entity") + public ResponseEntity explicitEntity(@RequestBody Foo foo, + ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Arrays.asList(foo)) + .post(); + } + + @PostMapping("/proxy/type") + public ResponseEntity> explicitEntityWithType(@RequestBody Foo foo, + ProxyExchange> proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Arrays.asList(foo)) + .post(); + } + + @PostMapping("/proxy/single") + public ResponseEntity implicitEntity(@RequestBody Foo foo, + ProxyExchange> proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Arrays.asList(foo)) + .post(this::first); + } + + @PostMapping("/proxy/converter") + public ResponseEntity implicitEntityWithConverter(@RequestBody Foo foo, + ProxyExchange> proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Arrays.asList(foo)) + .post(response -> ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody().iterator().next())); + } + + @GetMapping("/forward/**") + public void forward(ProxyExchange proxy) throws Exception { + String path = proxy.path("/forward"); + if (path.startsWith("/special")) { + proxy.header("X-Custom", "FOO"); + path = proxy.path("/forward/special"); + } + proxy.forward(path); + } + + @PostMapping("/forward/**") + public void postForward(ProxyExchange proxy) throws Exception { + String path = proxy.path("/forward"); + if (path.startsWith("/special")) { + proxy.header("X-Custom", "FOO"); + path = proxy.path("/forward/special"); + } + proxy.forward(path); + } + + @PostMapping("/forward/body/**") + public void postForwardBody(@RequestBody byte[] body, ProxyExchange proxy) + throws Exception { + String path = proxy.path("/forward/body"); + proxy.body(body).forward(path); + } + + @PostMapping("/forward/forget/**") + public void postForwardForgetBody(@RequestBody byte[] body, + ProxyExchange proxy) throws Exception { + String path = proxy.path("/forward/forget"); + proxy.forward(path); + } + + private ResponseEntity first(ResponseEntity> response) { + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(response.getBody().iterator().next()); + } + + } + + @Autowired + private ProxyController controller; + + public void setHome(URI home) { + controller.setHome(home); + } + + @RestController + static class TestController { + + @GetMapping("/foos") + public List foos() { + return Arrays.asList(new Foo("hello")); + } + + @GetMapping("/foos/{id}") + public Foo foo(@PathVariable Integer id, @RequestHeader HttpHeaders headers) { + String custom = headers.getFirst("X-Custom"); + return new Foo(id == 1 ? "foo" : custom != null ? custom : "bye"); + } + + @PostMapping("/bars") + public List bars(@RequestBody List foos, + @RequestHeader HttpHeaders headers) { + String custom = headers.getFirst("X-Custom"); + custom = custom == null ? "" : custom; + custom = headers.getFirst("forwarded")==null ? custom : headers.getFirst("forwarded") + ";" + custom; + return Arrays.asList(new Bar(custom + foos.iterator().next().getName())); + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static class Foo { + private String name; + + public Foo() { + } + + public Foo(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static class Bar { + private String name; + + public Bar() { + } + + public Bar(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + } + +} \ No newline at end of file diff --git a/spring-cloud-gateway-mvc/src/test/resources/static/test.html b/spring-cloud-gateway-mvc/src/test/resources/static/test.html new file mode 100644 index 000000000..0cfed139f --- /dev/null +++ b/spring-cloud-gateway-mvc/src/test/resources/static/test.html @@ -0,0 +1,4 @@ + +Test + + \ No newline at end of file