TL;DR
- If you know what
HttpMessageConverters
are, then skip the "In General" part.
- If your client is reactive, than you most likely don't have a big problem. Go to the part named "Reactive Client (Spring Webflux)" for details.
- If your client is non-reactive i.e. you're using Spring MVC and
RestTemplate
then the last section is for you. In short, it is not possible out of the box and you need to write custom code.
In General
When we want to read multipart data, then we are at the serialization/marshalling layer of our application. This is basically the same layer as when we transform a JSON or an XML document to a POJO via Jackson for example. What I want to emphasize here is that the logic for parsing multipart data should not take place in a service but rather much earlier.
Our hook to transform multipart data comes as early, as to when an HTTP response enters our application in form of an HttpInputMessage
. Out of the box, Spring provides a set of HttpMessageConverters
, that are able to transform our HTTP response to an object with which we can work. For example, the MappingJackson2HttpMessageConverter is used to read and write all request/responses that have the MediaType "application/Json".
If the application is reactive, then Spring uses HttpMessageReader
and HttpMessageWriter
instead of HttpMessageConverters
. They save the same purpose.
The following two sections show how to read (download) a multipart response via the two different paradigms.
Reactive Client (Spring Webflux)
This would be the easiest use case and the only thing we need, is already available in Spring Webflux out of the box.
The class MultipartHttpMessageReader
would be doing all the heavy lifting. In case it does not behave exactly how you need it to, you can easily extend it and overwrite the methods to your liking. Your custom Reader can then be registered as a bean like so:
@Configuration
public class MultipartMessageConverterConfiguration {
@Bean
public CodecCustomizer myCustomMultipartHttpMessageWriter() {
return configurer -> configurer.customCodecs()
.register(new MyCustomMultipartHttpMessageWriter());
}
}
Non-Reactive Client (Spring MVC/RestTemplate)
If you have a "classic" application that uses RestTemplate
to communicate via HTTP, then you need to rely on the aforementioned HttpMessageConverters
. Unfortunately, the MessageConverter that is responsible for reading multipart data, does not support reading/downloading data:
Implementation of HttpMessageConverter to read and write 'normal' HTML forms and also to write (but not read) multipart data (e.g. file uploads)
Source: FormHttpMessageConverter
Documentation
So what we need to do, is write our own MessageConverter
, which is able to download multipart data. An easy way to do that, would be to make use of the DefaultPartHttpMessageReader
that is internally used by MultipartHttpMessageReader
. We don't even need Webflux for that, as it is already shipped with spring-web.
First let us define 2 classes in which we save the several parts the we read:
public class MyCustomPart {
public MyCustomPart(byte[] content, String filename, MediaType contentType) {
//assign to corresponding member variables; here omitted
}
}
/**
* Basically a container for a list of objects of the class above.
*/
public class MyCustomMultiParts {
public MyCustomMultiParts(List<MyCustomPart> parts){
//assign to corresponding member variable; here omitted
}
}
Later on, you can always take each Part and convert it to whatever is appropriate. The MyCustomPart
represents a single block of your multipart data response. The MyCustomMultiParts
represent the whole multipart data.
Now we come to the meaty stuff:
public class CustomMultipartHttpMessageConverter implements HttpMessageConverter<MyCustomMultiParts> {
private final List<MediaType> supportedMediaTypes = new ArrayList<>();
private final DefaultPartHttpMessageReader defaultPartHttpMessageReader;
public CustomMultipartHttpMessageConverter() {
this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);
this.defaultPartHttpMessageReader = new DefaultPartHttpMessageReader();
}
@Override
public boolean canRead(final Class<?> clazz, @Nullable final MediaType mediaType) {
if (!MyCustomMultiParts.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null) {
return true;
}
for (final MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.includes(mediaType) && mediaType.getParameter("boundary") != null) {
return true;
}
}
return false;
}
/**
* This wraps the input message into a "reactive" input message, that the reactive DefaultPartHttpMessageReader uses.
*/
private ReactiveHttpInputMessage wrapHttpInputMessage(final HttpInputMessage message) {
return new ReactiveHttpInputMessage() {
@Override
public HttpHeaders getHeaders() {
return message.getHeaders();
}
@SneakyThrows //Part of lombok. Just use a try catch block if you're not using it
@Override
public Flux<DataBuffer> getBody() {
final DefaultDataBuffer wrappedBody = new DefaultDataBufferFactory()
.wrap(message.getBody().readAllBytes());
return Flux.just(wrappedBody);
}
};
}
@Override
public MyCustomMultiParts read(@Nullable final Class<? extends MyCustomMultiParts> clazz,
final HttpInputMessage message) throws IOException, HttpMessageNotReadableException {
final ReactiveHttpInputMessage wrappedMessage = wrapHttpInputMessage(message);
final ResolvableType resolvableType = ResolvableType.forClass(byte[].class); //plays no role
List<Part> rawParts = defaultPartHttpMessageReader.read(resolvableType, wrappedMessage, Map.of())//
.buffer()//
.blockFirst();
//You can check here whether the result exists or just continue
final List<MyCustomPart> customParts = rawParts.stream()// Now we convert to our customPart
.map(part -> {
//Part consists of a DataBuffer, we make a byte[] so we can convert it to whatever we want later
final byte[] content = Optional.ofNullable(part.content().blockFirst())//
.map(DataBuffer::asByteBuffer)//
.map(ByteBuffer::array)//
.orElse(new byte[]{});
final HttpHeaders headers = part.headers();
final String filename = headers.getContentDisposition().getFilename();
final MediaType contentType = headers.getContentType();
return new MyCustomPart(content, filename, contentType);
}).collect(Collectors.toList());
return new MyCustomMultiParts(customParts);
}
@Override
public void write(final MyCustomMultiParts parts, final MediaType contentType,
final HttpOutputMessage outputMessage) {
// we're just interested in reading
throw new UnsupportedOperationException();
}
@Override
public boolean canWrite(final Class<?> clazz, final MediaType mediaType) {
// we're just interested in reading
return false;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return this.supportedMediaTypes;
}
}
From here on, you should know better what to do with your "CustomPart". Whether it is a JSON, a bitmap or a PDF. From the byte array you can convert it into anything.
Now if you want to test it, you only have to add your CustomConverter to a RestTemplate
and then "await" the MyCustomMultiParts
that we defined:
// This could also be inside your @Bean definition of RestTemplate of course
final RestTemplate restTemplate = new RestTemplate();
final List<HttpMessageConverter<?>> messageConverters = restTemplate.getMessageConverters();
messageConverters.add(new CustomMultipartHttpMessageConverter());
String url = "http://server.of.choice:8080/whatever-endpoint-that-sends-multiparts/";
final HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.MULTIPART_FORM_DATA));
final HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
//here we await our MyCustomMultiParts
final MyCustomMultiParts entity = restTemplate.exchange(url, GET, requestEntity, MyCustomMultiParts.class);