diff --git a/README.md b/README.md index 7c283b9..012596c 100644 --- a/README.md +++ b/README.md @@ -256,3 +256,10 @@ public interface DownloadClient { } } ``` +### multipart/mixed +Replace `SpringManyMultipartFilesReader` in the `DownloadClient` example above with `SpringMultipartMixedReader`. + +#### TODO +- update the maven dependency versions, they are out of date +- refactor a common SpringMultiXXX base class with the common parts of the two Spring readers +- increment the versions -> 3.8.1 diff --git a/feign-form-spring/src/main/java/feign/form/spring/converter/SpringMultipartMixedReader.java b/feign-form-spring/src/main/java/feign/form/spring/converter/SpringMultipartMixedReader.java new file mode 100644 index 0000000..4d9f4ea --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/converter/SpringMultipartMixedReader.java @@ -0,0 +1,182 @@ +/* + * Copyright 2019 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 feign.form.spring.converter; + +import static feign.form.util.CharsetUtil.UTF_8; +import static lombok.AccessLevel.PRIVATE; +import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.LinkedList; +import java.util.Map; +import java.util.regex.Pattern; + +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.apache.commons.fileupload.MultipartStream; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +/** + * Implementation of {@link HttpMessageConverter} that can read multipart/form-data HTTP bodies + * (writing is not handled because that is already supported by {@link FormHttpMessageConverter}). + *

+ * This reader supports an array of {@link MultipartFile} as the mapping return class type - each + * multipart body is read into an underlying byte array (in memory) implemented via + * {@link ByteArrayMultipartFile}. + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class SpringMultipartMixedReader extends AbstractHttpMessageConverter { + + private static final Pattern NEWLINES_PATTERN = Pattern.compile("\\R"); + + private static final Pattern COLON_PATTERN = Pattern.compile(":"); + + private static final Pattern SEMICOLON_PATTERN = Pattern.compile(";"); + + private static final Pattern EQUALITY_SIGN_PATTERN = Pattern.compile("="); + + int bufSize; + + /** + * Construct an {@code AbstractHttpMessageConverter} that can read mulitpart/form-data. + * + * @param bufSize The size of the buffer (in bytes) to read the HTTP multipart body. + */ + public SpringMultipartMixedReader (int bufSize) { + super(new MediaType("multipart", "mixed")); // TODO later version of Spring MVC MediaType has this + this.bufSize = bufSize; + } + + @Override + protected boolean canWrite (MediaType mediaType) { + return false; // Class NOT meant for writing multipart/form-data HTTP bodies + } + + @Override + protected boolean supports (Class clazz) { + return MultipartFile[].class == clazz; + } + + @Override + protected MultipartFile[] readInternal (Class clazz, HttpInputMessage inputMessage + ) throws IOException { + val headers = inputMessage.getHeaders(); + + MediaType contentType = headers.getContentType(); + if (contentType == null) { + throw new HttpMessageNotReadableException("Content-Type is missing.", inputMessage); + } + + val boundaryBytes = getMultiPartBoundary(contentType); + MultipartStream multipartStream = new MultipartStream(inputMessage.getBody(), boundaryBytes, bufSize, null); + + val multiparts = new LinkedList(); + for (boolean nextPart = multipartStream.skipPreamble(); nextPart; nextPart = multipartStream.readBoundary()) { + ByteArrayMultipartFile multiPart; + try { + multiPart = readMultiPart(multipartStream); + } catch (Exception e) { + throw new HttpMessageNotReadableException("Multipart body could not be read.", e, inputMessage); + } + multiparts.add(multiPart); + } + return multiparts.toArray(new ByteArrayMultipartFile[0]); + } + + @Override + protected void writeInternal (MultipartFile[] byteArrayMultipartFiles, HttpOutputMessage outputMessage) { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not support writing to HTTP body."); + } + + private byte[] getMultiPartBoundary (MediaType contentType) { + val boundaryString = unquote(contentType.getParameter("boundary")); + if (StringUtils.isEmpty(boundaryString)) { + throw new HttpMessageConversionException("Content-Type missing boundary information."); + } + return boundaryString.getBytes(UTF_8); + } + + private ByteArrayMultipartFile readMultiPart (MultipartStream multipartStream) throws IOException { + val multiPartHeaders = splitIntoKeyValuePairs( + multipartStream.readHeaders(), + NEWLINES_PATTERN, + COLON_PATTERN, + false + ); + + val contentDisposition = splitIntoKeyValuePairs( + multiPartHeaders.get(CONTENT_DISPOSITION), + SEMICOLON_PATTERN, + EQUALITY_SIGN_PATTERN, + true + ); + + val bodyStream = new ByteArrayOutputStream(); + multipartStream.readBodyData(bodyStream); + return new ByteArrayMultipartFile( + contentDisposition.get("name"), + contentDisposition.get("filename"), + multiPartHeaders.get(CONTENT_TYPE), + bodyStream.toByteArray() + ); + } + + private Map splitIntoKeyValuePairs (String str, Pattern entriesSeparatorPattern, + Pattern keyValueSeparatorPattern, boolean unquoteValue + ) { + val keyValuePairs = new IgnoreKeyCaseMap(); + if (!StringUtils.isEmpty(str)) { + val tokens = entriesSeparatorPattern.split(str); + for (val token : tokens) { + val pair = keyValueSeparatorPattern.split(token.trim(), 2); + val key = pair[0].trim(); + val value = pair.length > 1 + ? pair[1].trim() + : ""; + + keyValuePairs.put(key, unquoteValue + ? unquote(value) + : value); + } + } + return keyValuePairs; + } + + private String unquote (String value) { + if (value == null) { + return null; + } + return isSurroundedBy(value, "\"") || isSurroundedBy(value, "'") + ? value.substring(1, value.length() - 1) + : value; + } + + private boolean isSurroundedBy (String value, String preSuffix) { + return value.length() > 1 && value.startsWith(preSuffix) && value.endsWith(preSuffix); + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java b/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java index 4d1be65..45207d2 100644 --- a/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java @@ -35,7 +35,7 @@ @FeignClient( name = "multipart-download-support-service", - url = "http://localhost:8081", + url = "http://localhost:8082", configuration = DownloadClient.ClientConfiguration.class ) public interface DownloadClient { diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java index b474f7d..95776f6 100644 --- a/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java @@ -33,7 +33,7 @@ webEnvironment = DEFINED_PORT, classes = Server.class, properties = { - "server.port=8081", + "server.port=8082", "feign.hystrix.enabled=false" } )