From 1656bbcb38a5286a33ff97f44defcac6cfcdb5a1 Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Wed, 2 May 2018 09:03:55 -0400 Subject: [PATCH] Add ProxyExchange feature for Webflux --- .../main/asciidoc/spring-cloud-gateway.adoc | 30 +- pom.xml | 1 + spring-cloud-gateway-webflux/pom.xml | 38 ++ .../cloud/gateway/webflux/ProxyExchange.java | 446 ++++++++++++++++++ .../config/ProxyExchangeArgumentResolver.java | 88 ++++ .../webflux/config/ProxyProperties.java | 70 +++ .../ProxyResponseAutoConfiguration.java | 69 +++ .../main/resources/META-INF/spring.factories | 5 + .../webflux/ProductionConfigurationTests.java | 355 ++++++++++++++ .../cloud/gateway/webflux/ReactiveTests.java | 230 +++++++++ .../src/test/resources/application.properties | 1 + .../src/test/resources/static/test.html | 4 + 12 files changed, 1331 insertions(+), 6 deletions(-) create mode 100644 spring-cloud-gateway-webflux/pom.xml create mode 100644 spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java create mode 100644 spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyExchangeArgumentResolver.java create mode 100644 spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyProperties.java create mode 100644 spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyResponseAutoConfiguration.java create mode 100644 spring-cloud-gateway-webflux/src/main/resources/META-INF/spring.factories create mode 100644 spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ProductionConfigurationTests.java create mode 100644 spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ReactiveTests.java create mode 100644 spring-cloud-gateway-webflux/src/test/resources/application.properties create mode 100644 spring-cloud-gateway-webflux/src/test/resources/static/test.html diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 2623af33..ff9e61dd 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -844,11 +844,11 @@ TODO: document writing Custom Global Filters TODO: document writing Custom Route Locators and Writers -== Building a Simple Gateway Using Spring MVC +== Building a Simple Gateway Using Spring MVC or Webflux -Spring Cloud Gateway provides a utility object called `ProxyExchange` which you can use inside a regular Spring MVC handler as a method parameter. It supports basic downstream HTTP exchanges via methods that mirror the HTTP verbs, or forwarding to a local handler via the `forward()` method. +Spring Cloud Gateway provides a utility object called `ProxyExchange` which you can use inside a regular Spring web handler as a method parameter. It supports basic downstream HTTP exchanges via methods that mirror the HTTP verbs. With MVC it also supports forwarding to a local handler via the `forward()` method. To use the `ProxyExchange` just include the right module in your classpath (either `spring-cloud-gateway-mvc` or `spring-cloud-gateway-webflux`). -Example (proxying a request to "/test" downstream to a remote server): +MVC example (proxying a request to "/test" downstream to a remote server): ```java @RestController @@ -859,7 +859,25 @@ public class GatewaySampleApplication { private URI home; @GetMapping("/test") - public ResponseEntity proxy(ProxyExchange proxy) throws Exception { + public ResponseEntity proxy(ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/image/png").get(); + } + +} +``` + +The same thing with Webflux: + +```java +@RestController +@SpringBootApplication +public class GatewaySampleApplication { + + @Value("${remote.home}") + private URI home; + + @GetMapping("/test") + public Mono> proxy(ProxyExchange proxy) throws Exception { return proxy.uri(home.toString() + "/image/png").get(); } @@ -870,13 +888,13 @@ There are convenience methods on the `ProxyExchange` to enable the handler metho ```java @GetMapping("/proxy/path/**") -public ResponseEntity proxyPath(ProxyExchange proxy) throws Exception { +public ResponseEntity proxyPath(ProxyExchange proxy) throws Exception { String path = proxy.path("/proxy/path/"); return proxy.uri(home.toString() + "/foos/" + path).get(); } ``` -All the features of Spring MVC are available to Gateway handler methods. So you can inject request headers and query parameters, for instance, and you can constrain the incoming requests with declarations in the mapping annotation. See the documentation for `@RequestMapping` in Spring MVC for more details of those features. +All the features of Spring MVC or Webflux are available to Gateway handler methods. So you can inject request headers and query parameters, for instance, and you can constrain the incoming requests with declarations in the mapping annotation. See the documentation for `@RequestMapping` in Spring MVC for more details of those features. Headers can be added to the downstream response using the `header()` methods on `ProxyExchange`. diff --git a/pom.xml b/pom.xml index be06729a..f398aa59 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,7 @@ spring-cloud-gateway-dependencies spring-cloud-gateway-mvc + spring-cloud-gateway-webflux spring-cloud-gateway-core spring-cloud-starter-gateway spring-cloud-gateway-sample diff --git a/spring-cloud-gateway-webflux/pom.xml b/spring-cloud-gateway-webflux/pom.xml new file mode 100644 index 00000000..ffd88ae0 --- /dev/null +++ b/spring-cloud-gateway-webflux/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + spring-cloud-gateway-webflux + spring-cloud-gateway-webflux + Spring Cloud Gateway Webflux + + + org.springframework.cloud + spring-cloud-gateway + 2.0.0.BUILD-SNAPSHOT + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-actuator + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + diff --git a/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java new file mode 100644 index 00000000..c5c06364 --- /dev/null +++ b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java @@ -0,0 +1,446 @@ +/* + * 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.gateway.webflux; + +import java.lang.reflect.Type; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; + +import org.reactivestreams.Publisher; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.buffer.DataBuffer; +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.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * 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 Mono<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[] (Object probably won't work unless you provide a converter). + * 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 Mono<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 WebClient} that is used in the backend calls (see the + * {@link ProxyExchange#ProxyExchange(WebClient, ServerWebExchange, BindingContext, Type) + * constructor} for details). + *

+ * + * @author Dave Syer + * + */ +public class ProxyExchange { + + public static Set DEFAULT_SENSITIVE = new HashSet<>( + Arrays.asList("cookie", "authorization")); + + private URI uri; + + private WebClient rest; + + private Publisher body; + + private boolean hasBody = false; + + private ServerWebExchange exchange; + private BindingContext bindingContext; + + private Set sensitive; + + private HttpHeaders headers = new HttpHeaders(); + + private Type responseType; + + public ProxyExchange(WebClient rest, ServerWebExchange exchange, + BindingContext bindingContext, Type type) { + this.exchange = exchange; + this.bindingContext = bindingContext; + this.responseType = type; + this.rest = rest; + } + + /** + * 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 = Mono.just(body); + return this; + } + + /** + * 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 + */ + @SuppressWarnings("unchecked") + public ProxyExchange body(Publisher body) { + this.body = (Publisher) 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 exchange.getRequest().getPath().pathWithinApplication().value(); + } + + 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 Mono> get() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.get(uri)) + .build(); + return exchange(requestEntity); + } + + public Mono> get( + Function, ResponseEntity> converter) { + return get().map(converter::apply); + } + + public Mono> head() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.head(uri)) + .build(); + return exchange(requestEntity); + } + + public Mono> head( + Function, ResponseEntity> converter) { + return head().map(converter::apply); + } + + public Mono> options() { + RequestEntity requestEntity = headers((BodyBuilder) RequestEntity.options(uri)) + .build(); + return exchange(requestEntity); + } + + public Mono> options( + Function, ResponseEntity> converter) { + return options().map(converter::apply); + } + + public Mono> post() { + RequestEntity requestEntity = headers(RequestEntity.post(uri)) + .body(body()); + return exchange(requestEntity); + } + + public Mono> post( + Function, ResponseEntity> converter) { + return post().map(converter::apply); + } + + public Mono> delete() { + RequestEntity requestEntity = headers( + (BodyBuilder) RequestEntity.delete(uri)).build(); + return exchange(requestEntity); + } + + public Mono> delete( + Function, ResponseEntity> converter) { + return delete().map(converter::apply); + } + + public Mono> put() { + RequestEntity requestEntity = headers(RequestEntity.put(uri)) + .body(body()); + return exchange(requestEntity); + } + + public Mono> put( + Function, ResponseEntity> converter) { + return put().map(converter::apply); + } + + public Mono> patch() { + RequestEntity requestEntity = headers(RequestEntity.patch(uri)) + .body(body()); + return exchange(requestEntity); + } + + public Mono> patch( + Function, ResponseEntity> converter) { + return patch().map(converter::apply); + } + + private Mono> exchange(RequestEntity requestEntity) { + Type type = this.responseType; + RequestBodySpec builder = rest.method(requestEntity.getMethod()) + .uri(requestEntity.getUrl()) + .headers(headers -> headers.addAll(requestEntity.getHeaders())); + Mono result; + if (requestEntity.getBody() instanceof Publisher) { + @SuppressWarnings("unchecked") + Publisher publisher = (Publisher) requestEntity.getBody(); + result = builder.body(publisher, Object.class).exchange(); + } + else if (requestEntity.getBody() != null) { + result = builder.body(BodyInserters.fromObject(requestEntity.getBody())) + .exchange(); + } + else { + if (hasBody) { + result = builder.headers( + headers -> headers.addAll(exchange.getRequest().getHeaders())) + .body(exchange.getRequest().getBody(), DataBuffer.class) + .exchange(); + } + else { + result = builder.headers( + headers -> headers.addAll(exchange.getRequest().getHeaders())) + .exchange(); + } + } + return result.flatMap(response -> response.toEntity(ParameterizedTypeReference.forType(type))); + } + + 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() { + URI uri = exchange.getRequest().getURI(); + appendForwarded(uri); + appendXForwarded(uri); + } + + 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 Publisher body() { + Publisher body = this.body; + if (body != null) { + return body; + } + body = getRequestBody(); + hasBody = true; // even if it's null + 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 Mono getRequestBody() { + for (String key : bindingContext.getModel().asMap().keySet()) { + if (key.startsWith(BindingResult.MODEL_KEY_PREFIX)) { + BindingResult result = (BindingResult) bindingContext.getModel().asMap() + .get(key); + return Mono.just(result.getTarget()); + } + } + return null; + } + + protected static class BodyGrabber { + public Publisher body(@RequestBody Publisher body) { + return body; + } + } + + protected static class BodySender { + @ResponseBody + public Publisher body() { + return null; + } + } + +} diff --git a/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyExchangeArgumentResolver.java b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyExchangeArgumentResolver.java new file mode 100644 index 00000000..973c2897 --- /dev/null +++ b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyExchangeArgumentResolver.java @@ -0,0 +1,88 @@ +/* + * 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.gateway.webflux.config; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.Set; + +import org.springframework.cloud.gateway.webflux.ProxyExchange; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Dave Syer + * + */ +public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResolver { + + private WebClient rest; + + private HttpHeaders headers; + + private Set sensitive; + + public ProxyExchangeArgumentResolver(WebClient 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()); + } + + private Type type(MethodParameter parameter) { + Type type = parameter.getGenericParameterType(); + if (type instanceof ParameterizedType) { + ParameterizedType param = (ParameterizedType) type; + type = param.getActualTypeArguments()[0]; + } + if (type instanceof TypeVariable || type instanceof WildcardType) { + type = Object.class; + } + return type; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, + BindingContext bindingContext, ServerWebExchange exchange) { + ProxyExchange proxy = new ProxyExchange<>(rest, exchange, bindingContext, + type(parameter)); + proxy.headers(headers); + if (sensitive != null) { + proxy.sensitive(sensitive.toArray(new String[0])); + } + return Mono.just(proxy); + } + +} diff --git a/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyProperties.java b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyProperties.java new file mode 100644 index 00000000..d9575bd4 --- /dev/null +++ b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/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.gateway.webflux.config; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.gateway.webflux.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-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyResponseAutoConfiguration.java b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyResponseAutoConfiguration.java new file mode 100644 index 00000000..420b5eed --- /dev/null +++ b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/config/ProxyResponseAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * 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.gateway.webflux.config; + +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.cloud.gateway.webflux.ProxyExchange; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; + +/** + * Autoconfiguration for the {@link ProxyExchange} argument handler in Spring Webflux + * @RequestMapping methods. + * + * @author Dave Syer + */ +@Configuration +@ConditionalOnWebApplication +@ConditionalOnClass({ HandlerMethodReturnValueHandler.class }) +@EnableConfigurationProperties(ProxyProperties.class) +public class ProxyResponseAutoConfiguration implements WebFluxConfigurer { + + @Autowired + private ApplicationContext context; + + @Bean + @ConditionalOnMissingBean + public ProxyExchangeArgumentResolver proxyExchangeArgumentResolver( + Optional optional, ProxyProperties proxy) { + WebClient.Builder builder = optional.orElse(WebClient.builder()); + WebClient template = builder.build(); + ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver( + template); + resolver.setHeaders(proxy.convertHeaders()); + resolver.setSensitive(proxy.getSensitive()); // can be null + return resolver; + } + + @Override + public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { + WebFluxConfigurer.super.configureArgumentResolvers(configurer); + configurer + .addCustomResolver(context.getBean(ProxyExchangeArgumentResolver.class)); + } +} diff --git a/spring-cloud-gateway-webflux/src/main/resources/META-INF/spring.factories b/spring-cloud-gateway-webflux/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..2611f70c --- /dev/null +++ b/spring-cloud-gateway-webflux/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.cloud.gateway.webflux.config.ProxyResponseAutoConfiguration + +org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebFlux=\ +org.springframework.cloud.gateway.webflux.config.ProxyResponseAutoConfiguration diff --git a/spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ProductionConfigurationTests.java b/spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ProductionConfigurationTests.java new file mode 100644 index 00000000..9d1040a8 --- /dev/null +++ b/spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ProductionConfigurationTests.java @@ -0,0 +1,355 @@ +/* + * 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.gateway.webflux; + +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.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.cloud.gateway.webflux.ProductionConfigurationTests.TestApplication; +import org.springframework.cloud.gateway.webflux.ProductionConfigurationTests.TestApplication.Bar; +import org.springframework.cloud.gateway.webflux.ProductionConfigurationTests.TestApplication.Foo; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +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; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ContextConfiguration(classes = TestApplication.class) +@DirtiesContext +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 list() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler() + .expand("/proxy")) + .contentType(MediaType.APPLICATION_JSON) + .body(Collections + .singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }); + assertThat(result.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 Mono> proxyFoos(@PathVariable Integer id, ProxyExchange proxy) + throws Exception { + return proxy.uri(home.toString() + "/foos/" + id).get(); + } + + @GetMapping("/proxy/path/**") + public Mono> 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 Mono> proxyHtml(ProxyExchange proxy, + UriComponentsBuilder uri) throws Exception { + String path = proxy.path("/proxy/html"); + return proxy.uri(home.toString() + path).get(); + } + + @GetMapping("/proxy/typeless/**") + public Mono> 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 Mono> proxyMissing(@PathVariable Integer id, ProxyExchange proxy) + throws Exception { + return proxy.uri(home.toString() + "/missing/" + id).get(); + } + + @GetMapping("/proxy") + public Mono> proxyUri(ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/foos").get(); + } + + @PostMapping("/proxy/{id}") + public Mono> 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 Mono>> barsWithNoBody(ProxyExchange> proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").post(); + } + + @PostMapping("/proxy/entity") + public Mono> explicitEntity(@RequestBody Mono foo, + ProxyExchange proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Flux.from(foo)).post(); + } + + @PostMapping("/proxy/type") + public Mono>> explicitEntityWithType( + @RequestBody Mono foo, ProxyExchange> proxy) + throws Exception { + return proxy.uri(home.toString() + "/bars").body(Flux.from(foo)).post(); + } + + @PostMapping("/proxy/single") + public Mono> implicitEntity(@RequestBody Mono foo, + ProxyExchange> proxy) throws Exception { + return proxy.uri(home.toString() + "/bars").body(Flux.from(foo)) + .post(this::first); + } + + @PostMapping("/proxy/converter") + public Mono> 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())); + } + + 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-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ReactiveTests.java b/spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ReactiveTests.java new file mode 100644 index 00000000..3f0e795b --- /dev/null +++ b/spring-cloud-gateway-webflux/src/test/java/org/springframework/cloud/gateway/webflux/ReactiveTests.java @@ -0,0 +1,230 @@ +/* + * 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.gateway.webflux; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +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.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.cloud.gateway.webflux.ReactiveTests.TestApplication; +import org.springframework.cloud.gateway.webflux.ReactiveTests.TestApplication.Bar; +import org.springframework.cloud.gateway.webflux.ReactiveTests.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.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.GetMapping; +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.reactive.DispatcherHandler; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ContextConfiguration(classes = TestApplication.class) +public class ReactiveTests { + + @Autowired + private TestRestTemplate rest; + + @LocalServerPort + private int port; + + @Test + public void postBytes() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity.post( + rest.getRestTemplate().getUriTemplateHandler().expand("/bytes")) + .body("hello foo".getBytes()), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("hello foo"); + } + + @Test + public void post() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler() + .expand("/bars")) + .body(Collections + .singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("hello foo"); + } + + @Test + public void postFlux() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity + .post(rest.getRestTemplate().getUriTemplateHandler() + .expand("/flux/bars")) + .body(Collections + .singletonList(Collections.singletonMap("name", "foo"))), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("hello foo"); + } + + @Test + public void get() throws Exception { + ResponseEntity> result = rest.exchange(RequestEntity + .get(rest.getRestTemplate().getUriTemplateHandler().expand("/foos")) + .build(), new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("hello"); + } + + @Test + public void forward() throws Exception { + ResponseEntity> result = rest.exchange( + RequestEntity.get(rest.getRestTemplate().getUriTemplateHandler() + .expand("/forward/foos")).build(), + new ParameterizedTypeReference>() { + }); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody().iterator().next().getName()).isEqualTo("hello"); + } + + @SpringBootApplication + static class TestApplication { + + @RestController + static class TestController { + + @Autowired + private DispatcherHandler handler; + + @PostMapping("/bars") + public List bars(@RequestBody List foos, + @RequestHeader HttpHeaders headers) { + String custom = "hello "; + return foos.stream().map(foo -> new Bar(custom + foo.getName())) + .collect(Collectors.toList()); + } + + @PostMapping("/flux/bars") + public Flux fluxbars(@RequestBody Flux foos, + @RequestHeader HttpHeaders headers) { + String custom = "hello "; + return foos.map(foo -> new Bar(custom + foo.getName())); + } + + @GetMapping("/foos") + public Flux foos() { + return Flux.just(new Foo("hello")); + } + + @GetMapping("/forward/foos") + public Mono forwardFoos(ServerWebExchange exchange) { + return handler.handle(exchange.mutate() + .request(request -> request.path("/foos").build()).build()); + } + + @PostMapping("/bytes") + public Flux forwardBars(@RequestBody Flux body) { + return Flux.from(body.reduce(this::concatenate) + .map(value -> new Foo(new String(value)))); + } + + byte[] concatenate(@Nullable byte[] array1, @Nullable byte[] array2) { + if (ObjectUtils.isEmpty(array1)) { + return array2; + } + if (ObjectUtils.isEmpty(array2)) { + return array1; + } + + byte[] newArr = new byte[array1.length + array2.length]; + System.arraycopy(array1, 0, newArr, 0, array1.length); + System.arraycopy(array2, 0, newArr, array1.length, array2.length); + return newArr; + } + + } + + @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-webflux/src/test/resources/application.properties b/spring-cloud-gateway-webflux/src/test/resources/application.properties new file mode 100644 index 00000000..d443a79a --- /dev/null +++ b/spring-cloud-gateway-webflux/src/test/resources/application.properties @@ -0,0 +1 @@ +logging.level.org.springframework.web.reactive=DEBUG \ No newline at end of file diff --git a/spring-cloud-gateway-webflux/src/test/resources/static/test.html b/spring-cloud-gateway-webflux/src/test/resources/static/test.html new file mode 100644 index 00000000..0cfed139 --- /dev/null +++ b/spring-cloud-gateway-webflux/src/test/resources/static/test.html @@ -0,0 +1,4 @@ + +Test + + \ No newline at end of file