spring-cloud-gateway
spring-cloud-gateway copied to clipboard
Route multi part request with JSON body and multiple attachments
Dear Spring Cloud Gateway team / users,
We have a question regarding the Spring Cloud Gateway MVC implementation used as a Backend-for-Frontend (BFF). Our goal is to send a POST request from our frontend with a JSON body and zero, one, or multiple files to the Gateway, which should then route the request to a downstream Spring Boot backend service.
When sending this request directly to the downstream Spring Boot backend service, everything works as expected. However, when sending the same request to the Gateway, it does not work. During debugging, we observed that the body appears to be empty when the RestClient sends it to the backend service.
We discovered the GatewayMvcMultipartResolver class, which is autowired by the GatewayMultipartAutoConfiguration class. The JavaDoc of the GatewayMvcMultipartResolver mentions, "A MultipartResolver that does not resolve if the current request is a Gateway request."
Therefore, we have the following questions:
- Is the routing of a POST request with a JSON body and zero, one, or multiple files supported by the Spring Cloud Gateway MVC project?
- If supported, can you provide pointers or guidance on resolving our issue?
- If not supported, could you explain why? Additionally, what is the recommended implementation for routing attachments from our frontend through the BFF (Spring Cloud Gateway MVC) to our backend? Thank you in advance for your assistance.
Best regards, Marinus
By default, spring mvc parses the multipart body and therefor it will not be sent downstream. I had to work around that.
There are multipart integration tests that pass, if you could supply a test that fails, it would be helpful
https://github.com/spring-cloud/spring-cloud-gateway/blob/2295b9aaab18f72b78453d108a466a0d1ff9f494/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java#L590-L640
Hello @spencergibb , I encounter the same issue here. The body of a multipart request is empty at the output of my spring cloud gateway mvc app. I'm not sure if I understood well your previous comment. You said the multipart body will not be sent downstream so it could explain why the body is empty in our case. But you have integration tests which prove that the body is correctly sent to downstream.
So my questions are :
- is it a bug in Spring Cloud Gateway MVC ? it seems that it's not a recent problem after do some researchs in github issues.
- did multipart support by Spring Cloud Gateway MVC ? If not, could you purpose a workaround ?
BTW, to demonstrate this in my side, I create a http server who log request body. And I do a multipart request with postman. My Spring boot app with Spring Cloud Gateway mvc will just route the request. Everythinh works fine except with multipart request.
import com.sun.net.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
// http server who logs request body
public class HttpServerMain {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpServerMain.class);
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 9008), 0);
server.createContext("/", exchange -> {
LOGGER.info("request body : {}", log(exchange.getRequestBody()));
exchange.sendResponseHeaders(200, "OK".length());
OutputStream responseBody = exchange.getResponseBody();
responseBody.write("OK".getBytes(StandardCharsets.UTF_8));
responseBody.flush();
responseBody.close();
});
server.start();
LOGGER.info(" Server started on port 9008");
}
private static String log(InputStream is) throws IOException {
var isr = new InputStreamReader(is);
var br = new BufferedReader(isr);
int b;
var buf = new StringBuilder();
while ((b = br.read()) != -1) {
buf.append((char) b);
}
return buf.toString();
}
}
when I send a multipart request directly to my sample http server, the body is correct (not empty)
08:10:42.825 [HTTP-Dispatcher] INFO com.demo.httpserver.HttpServerMain -- request body : ----------------------------382294335276443370046525
Content-Disposition: form-data; name="File"; filename="test-multipart-spring-cloud-gateway.txt"
Content-Type: text/plain
hello
----------------------------382294335276443370046525
Content-Disposition: form-data; name="filename"
test-multipart-spring-cloud-gateway.txt
----------------------------382294335276443370046525--
when I send a multipart request throug my spring cloud gateway mvc app, the body is empty
08:12:44.430 [HTTP-Dispatcher] INFO com.demo.httpserver.HttpServerMain -- request body :
Versions : Spring Boot 3.3.0 Spring Cloud Gateway MVC 4.1.5 Java 21
I share my workaround so that spring gateway mvc is able to process multipart requests waiting to have a fix.
The workaround is to replace the RestClientProxyExchange class by a custom class that is able to process multipart requests.
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse;
import org.springframework.cloud.gateway.server.mvc.handler.RestClientProxyExchange;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.function.ServerResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class MultipartRestClientProxyExchange extends RestClientProxyExchange {
private final RestClient restClient;
public MultipartRestClientProxyExchange(RestClient restClient) {
super(restClient);
this.restClient = restClient;
}
@Override
public ServerResponse exchange(Request request) {
var servletRequest = request.getServerRequest().servletRequest();
if (servletRequest instanceof MultipartHttpServletRequest) {
return exchangeMultiPart(request, (CustomGatewayMvcMultipartResolver.CustomGatewayMultipartHttpServletRequest) servletRequest);
}
return restClient.method(request.getMethod()).uri(request.getUri())
.headers(httpHeaders -> httpHeaders.putAll(request.getHeaders()))
.body(outputStream -> copyBody(request, outputStream))
.exchange((clientRequest, clientResponse) -> doExchange(request, clientResponse), false);
}
/** custom code : process multipart request
*/
private ServerResponse exchangeMultiPart(Request request, CustomGatewayMvcMultipartResolver.CustomGatewayMultipartHttpServletRequest servletRequest) {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
var multipartFiles = servletRequest.getMultipartFiles();
for (var part : multipartFiles.entrySet()) {
for (var value : part.getValue()){
try {
// Rebuild Content-Disposition
MultiValueMap<String, String> contentDispositionMap = new LinkedMultiValueMap<>();
ContentDisposition contentDisposition = ContentDisposition.builder("form-data").name(part.getKey()).filename(value.getOriginalFilename()).build();
contentDispositionMap.add(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString());
// Create new HttpEntity with body + content-disposition
HttpEntity<byte[]> fileEntity = new HttpEntity(value.getBytes(), contentDispositionMap);
parts.add(part.getKey(), fileEntity);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
var parameterMap = servletRequest.getParameterMap();
for (var parameterMapKey : parameterMap.entrySet()) {
String[] parameterValues = parameterMapKey.getValue();
for (String parameterValue : parameterValues) {
parts.add(parameterMapKey.getKey(), parameterValue);
}
}
return restClient.method(request.getMethod()).uri(request.getUri()).headers(httpHeaders -> httpHeaders.putAll(request.getHeaders())).body(parts)
.exchange((clientRequest, clientResponse) -> doExchange(request, clientResponse), false);
}
private static int copyBody(Request request, OutputStream outputStream) throws IOException {
// same code that the parent class
}
private static ServerResponse doExchange(Request request, ClientHttpResponse clientResponse) throws IOException {
// same code that the parent class
}
}
Then, we create a bean as :
@Bean
public RestClientProxyExchange restClientProxyExchange(){
// init your RestTemplate here
RestTemplate restTemplate = new RestTemplate(httpComponentsClientHttpRequestFactory);
// return our custom instance of RestClientProxyExchange that is able to process multipart request
return new MultipartRestClientProxyExchange(RestClient.create(restTemplate));
}
For it to work, we need to duplicate some spring gateway mvc classes because there are not public.
CustomGatewayMvcMultipartResolver extends org.springframework.web.multipart.support.StandardServletMultipartResolver {
//copy paste the code from parent class
public static class CustomGatewayMultipartHttpServletRequest extends org.springframework.web.multipart.support.StandardMultipartHttpServletRequest {
//copy paste the code from parent class
// add the public getter to have access to this Map in the MultipartRestClientProxyExchange class
public MultiValueMap<String, MultipartFile> getMultipartFiles() {
return super.getMultipartFiles();
}
}
}
public class MyClientHttpResponseAdapter implements org.springframework.cloud.gateway.server.mvc.handler.ProxyExchange.Response {
//copy paste the code from parent class
}
// Declare it in a @Configuration class.
@Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
public CustomGatewayMvcMultipartResolver gatewayMvcMultipartResolver(MultipartProperties properties) {
CustomGatewayMvcMultipartResolver multipartResolver = new CustomGatewayMvcMultipartResolver();
multipartResolver.setResolveLazily(properties.isResolveLazily());
return multipartResolver;
}
Dear @spencergibb,
@RomainPro76 demostrated the issue and also showed the code to work around the problem.
What are the next steps to resolve this issue in Spring Cloud Gateway MVC. Would be nice if it works out-of-the-box. Can I help?
Best regards, Marinus
Hi, i'm facing the same issue. I've tried to implement the work around but for me the servletRequest.getMultipartFiles() is always empty.
Do you have any idea why ? I'm i missing a property ?
Best Regards,
Victor
Ok nevermind, i enabled the multipart using the spring.servlet.multipart.enabled property and it seems to work now.
Hi, I think I found the cause of the empty body in the multipart request. In our route we use a 'BeforeFilterFunctions.rewritePath()' to rewrite the output url. Without the filter, the output multipart request is OK. When I add the rewritepath filter, the body is empty.
I reproduced the case with this simple code.
@Configuration
public class RouterConfiguration {
private static final String WILDCARD = "/**";
private static final String SEGMENT_TO_INCLUDE_REGEX = "(?<segment>.*)";
private static final String SEGMENT_TO_ADD_REGEX = "${segment}";
@Bean
public RouterFunctions.Builder routerFunctionsBuilder() {
return RouterFunctions.route();
}
@Bean
public RouterFunction<ServerResponse> buildRoutes(RouterFunctions.Builder routerFunctionsBuilder) {
var destinationHost = URI.create("http://localhost:9008/");
routerFunctionsBuilder
.POST("multipart" + WILDCARD, HandlerFunctions.http(destinationHost)) // multipart works with this line only
// multipart don't work anymore in adding the filter (body is empty)
.before(BeforeFilterFunctions.rewritePath("multipart" + SEGMENT_TO_INCLUDE_REGEX, "/api/multipart" + SEGMENT_TO_ADD_REGEX));
return routerFunctionsBuilder.build();
}
}
@spencergibb With this example, could you explain the problem and solve it please ? @marinusgeuze do you use also this filter ?
Hi,
We use the BeforeFilterFunctions.setPath() to rewrite the output url.
I hope that we can solve this issue so that we can start routing multi-part messages.
Marinus
I have the same issue but I don't explicitly use BeforeFilterFunctions. I feel like there are multiple bugs in this code, such as incorrect detection if request is multipart (RestClientProxyExchange.isBodyPresent looks fishy and StandardMultipartHttpServletRequest.parseRequest is not being called sometimes etc) so the bug can be triggered in a multiple different ways. I didn't dig too deep but it seems that multipart functionality is overall broken so no wonder there is over a dozen open issues related to it. Some of those reports are somewhat old so I guess this project is no longer maintained? Luckily the workaround provided in this thread works well for me and also addresses the issue with missing query parameters as a bonus (#3485). @RomainPro76 do you mind opening a PR incorporating your change into the codebase? Not sure if there are any active developers to approve and merge it though
We're still here. We'll work on getting things wiring these first few months of the year
Hi everyone,
Just wanted to share an update from our side—we managed to resolve the issue by following the approach outlined in https://github.com/spring-cloud/spring-cloud-gateway/issues/3527.
We implemented a custom ContextAttributesMapper to prevent the multi-part form data from being consumed from the InputStream, which was causing the problem. Here's a simplified version of what we used:
static class CustomContextAttributesMapper
implements Function<OAuth2AuthorizeRequest, Map<String, Object>> {
@Override
public Map<String, Object> apply(OAuth2AuthorizeRequest authorizeRequest) {
return Collections.emptyMap();
}
}
Additionally, we made sure to set spring.servlet.multipart.enabled: true in our configuration.
Hopefully this helps others facing the same issue! It looks like the upcoming Spring Cloud Gateway release will also address this problem.