diff --git a/docs/src/main/asciidoc/spring-cloud-netflix.adoc b/docs/src/main/asciidoc/spring-cloud-netflix.adoc index 7693ba89..b2e525e6 100644 --- a/docs/src/main/asciidoc/spring-cloud-netflix.adoc +++ b/docs/src/main/asciidoc/spring-cloud-netflix.adoc @@ -860,6 +860,37 @@ An application with the `@EnableZuulProxy` could act as a standalone server if you set a default route ("/"), for example `zuul.route.home: /` would route all traffic (i.e. "/**") to the "home" service. +=== Uploading Files through Zuul + +If you `@EnableZuulProxy` you can use the proxy paths to +upload files and it should just work as long as the files +are small. For large files there is an alternative path +which bypasses the Spring `DispatcherServlet` (to +avoid multipart processing) in "/zuul/*". I.e. if +`zuul.routes.customers=/customers/**` then you can +POST large files to "/zuul/customers/*". The servlet +path is externalized via `zuul.servletPath`. Extremely +large files will also require elevated timeout settings +if the proxy route takes you through a Ribbon load +balancer, e.g. + +.application.yml +[source,yaml] +---- +hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 +ribbon: + ConnectTimeout: 3000 + ReadTimeout: 60000 +---- + +Note that for streaming to work with large files, you need to use chunked encoding in the request (which some browsers +do not do by default). E.g. on the command line: + +---- +$ curl -v -H "Transfer-Encoding: chunked" \ + -F "file=@mylarge.iso" localhost:9999/zuul/simple/file +---- + === Plain Embedded Zuul You can also run a Zuul server without the proxying, or switch on parts of the proxying platform selectively, if you diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java index 08f1d4e1..a7851d76 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java @@ -20,6 +20,7 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.embedded.ServletRegistrationBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; import org.springframework.cloud.netflix.zuul.filters.RouteLocator; @@ -73,6 +74,12 @@ public class ZuulConfiguration { return new ZuulRefreshListener(); } + @Bean + public ServletRegistrationBean zuulServlet() { + return new ServletRegistrationBean(new ZuulServlet(), + this.zuulProperties.getServletPattern()); + } + // pre filters @Bean diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java index 3f9e7ea6..c064f9ee 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java @@ -51,6 +51,8 @@ public class ZuulProperties { private List ignoredServices = new ArrayList(); + private String servletPath = "/zuul"; + @PostConstruct public void init() { for (Entry entry : this.routes.entrySet()) { @@ -132,4 +134,15 @@ public class ZuulProperties { } + public String getServletPattern() { + String path = this.servletPath; + if (!path.startsWith("/")) { + path = "/" + path; + } + if (!path.contains("*")) { + path = path.endsWith("/") ? (path + "*") : (path + "/*"); + } + return path; + } + } diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java index be238dff..eb023cef 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java @@ -37,6 +37,7 @@ import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartRequest; +import org.springframework.web.servlet.DispatcherServlet; import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; @@ -76,17 +77,23 @@ public class FormBodyWrapperFilter extends ZuulFilter { if (contentType == null) { return false; } - // Only use this filter for form data + // Only use this filter for form data and only for multipart data in a + // DispatcherServlet handler try { MediaType mediaType = MediaType.valueOf(contentType); return MediaType.APPLICATION_FORM_URLENCODED.includes(mediaType) - || MediaType.MULTIPART_FORM_DATA.includes(mediaType); + || (isDispatcherServletRequest(request) && MediaType.MULTIPART_FORM_DATA + .includes(mediaType)); } catch (InvalidMediaTypeException ex) { return false; } } + private boolean isDispatcherServletRequest(HttpServletRequest request) { + return request.getAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null; + } + @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java index 7cab1ed8..234522f2 100644 --- a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java @@ -34,6 +34,7 @@ import org.springframework.boot.test.IntegrationTest; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.boot.test.TestRestTemplate; import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.RibbonClients; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpEntity; @@ -137,7 +138,9 @@ public class FormZuulProxyApplicationTests { @EnableAutoConfiguration @RestController @EnableZuulProxy -@RibbonClient(name = "simple", configuration = FormRibbonClientConfiguration.class) +@RibbonClients({ + @RibbonClient(name = "simple", configuration = FormRibbonClientConfiguration.class), + @RibbonClient(name = "psimple", configuration = FormRibbonClientConfiguration.class) }) @Slf4j class FormZuulProxyApplication { @@ -211,8 +214,10 @@ class FormZuulProxyApplication { public static void main(String[] args) { new SpringApplicationBuilder(FormZuulProxyApplication.class).properties( - "zuul.routes.simple:/simple/**", "multipart.maxFileSize:4096MB", - "multipart.maxRequestSize:4096MB").run(args); + "zuul.routes.simple:/simple/**", + "zuul.routes.direct.url:http://localhost:9999", + "multipart.maxFileSize:4096MB", "multipart.maxRequestSize:4096MB").run( + args); } } diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java new file mode 100644 index 00000000..53855a25 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2013-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.netflix.zuul; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.trace.InMemoryTraceRepository; +import org.springframework.boot.actuate.trace.TraceRepository; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.boot.test.TestRestTemplate; +import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.RibbonClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.netflix.appinfo.EurekaInstanceConfig; +import com.netflix.loadbalancer.BaseLoadBalancer; +import com.netflix.loadbalancer.ILoadBalancer; +import com.netflix.loadbalancer.Server; +import com.netflix.zuul.ZuulFilter; + +import static org.junit.Assert.assertEquals; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = FormZuulServletProxyApplication.class) +@WebAppConfiguration +@IntegrationTest({ "server.port:0", "zuul.routes.simple:/zuul/simple/**" }) +@DirtiesContext +public class FormZuulServletProxyApplicationTests { + + @Value("${local.server.port}") + private int port; + + @Test + public void postWithForm() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } + + @Test + public void postWithMultipartForm() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } + + @Test + public void postWithMultipartFile() { + MultiValueMap form = new LinkedMultiValueMap(); + HttpHeaders part = new HttpHeaders(); + part.setContentType(MediaType.TEXT_PLAIN); + part.setContentDispositionFormData("file", "foo.txt"); + form.set("foo", new HttpEntity("bar".getBytes(), part)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/file", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! bar", result.getBody()); + } + + @Test + public void postWithUTF8Form() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType + .valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + "; charset=UTF-8")); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } +} + +// Don't use @SpringBootApplication because we don't want to component scan +@Configuration +@EnableAutoConfiguration +@RestController +@EnableZuulProxy +@RibbonClients(@RibbonClient(name = "simple", configuration = ServletFormRibbonClientConfiguration.class)) +@Slf4j +class FormZuulServletProxyApplication { + + @RequestMapping(value = "/form", method = RequestMethod.POST) + public String accept(@RequestParam MultiValueMap form) + throws IOException { + return "Posted! " + form; + } + + // TODO: Why does this not work if you add @RequestParam as above? + @RequestMapping(value = "/file", method = RequestMethod.POST) + public String file(@RequestParam(required = false) MultipartFile file) + throws IOException { + byte[] bytes = new byte[0]; + if (file != null) { + if (file.getSize() > 1024) { + bytes = new byte[1024]; + InputStream inputStream = file.getInputStream(); + inputStream.read(bytes); + byte[] buffer = new byte[1024 * 1024 * 10]; + while (inputStream.read(buffer) >= 0) { + log.info("Read more bytes"); + } + } + else { + bytes = file.getBytes(); + } + } + return "Posted! " + new String(bytes); + } + + @Bean + public ZuulFilter sampleFilter() { + return new ZuulFilter() { + + @Override + public String filterType() { + return "pre"; + } + + @Override + public boolean shouldFilter() { + return true; + } + + @Override + public Object run() { + return null; + } + + @Override + public int filterOrder() { + return 0; + } + + }; + } + + @Bean + public TraceRepository traceRepository() { + return new InMemoryTraceRepository() { + @Override + public void add(Map map) { + if (map.containsKey("body")) { + map.get("body"); + } + super.add(map); + } + }; + } + + public static void main(String[] args) { + new SpringApplicationBuilder(FormZuulProxyApplication.class).properties( + "zuul.routes.simple:/zuul/simple/**", + "zuul.routes.direct.url:http://localhost:9999", + "zuul.routes.direct.path:/zuul/direct/**", + "multipart.maxFileSize:4096MB", "multipart.maxRequestSize:4096MB").run( + args); + } + +} + +// Load balancer with fixed server list for "simple" pointing to localhost +@Configuration +class ServletFormRibbonClientConfiguration { + + @Bean + public ILoadBalancer ribbonLoadBalancer(EurekaInstanceConfig instance) { + BaseLoadBalancer balancer = new BaseLoadBalancer(); + balancer.setServersList(Arrays.asList(new Server("localhost", instance + .getNonSecurePort()))); + // balancer.setServersList(Arrays.asList(new Server("localhost", 8000))); + return balancer; + } + +} diff --git a/spring-cloud-netflix-core/src/test/resources/application.yml b/spring-cloud-netflix-core/src/test/resources/application.yml index 7c8e58f6..080dafad 100644 --- a/spring-cloud-netflix-core/src/test/resources/application.yml +++ b/spring-cloud-netflix-core/src/test/resources/application.yml @@ -14,6 +14,10 @@ eureka: fetchRegistry: false #error: # path: /myerror +hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 +ribbon: + ConnectTimeout: 3000 + ReadTimeout: 60000 management: context-path: /admin endpoints: diff --git a/spring-cloud-netflix-core/src/test/resources/static/index.html b/spring-cloud-netflix-core/src/test/resources/static/index.html index c7dbaf3c..27f58190 100644 --- a/spring-cloud-netflix-core/src/test/resources/static/index.html +++ b/spring-cloud-netflix-core/src/test/resources/static/index.html @@ -4,7 +4,19 @@ action="/simple/file"> File to upload:
Name:

Press here to upload the file via proxy! + value="Upload"> Press here to upload the file via ribbon proxy! + +
+ File to upload:
Name:

Press here to upload the file via direct proxy! +
+
+ File to upload:
Name:

Press here to upload the file via proxy servlet!