com.wl4g.infra.common.remoting.parse.FormHttpMessageParser Maven / Gradle / Ivy
/*
* Copyright 2002-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
*
* https://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 com.wl4g.infra.common.remoting.parse;
import static com.wl4g.infra.common.lang.Assert2.*;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.mail.internet.MimeUtility;
import javax.annotation.Nullable;
import com.wl4g.infra.common.collection.CollectionUtils2;
import com.wl4g.infra.common.collection.multimap.LinkedMultiValueMap;
import com.wl4g.infra.common.collection.multimap.MultiValueMap;
import com.wl4g.infra.common.io.ByteStreamUtils;
import com.wl4g.infra.common.lang.StringUtils2;
import com.wl4g.infra.common.remoting.HttpEntity;
import com.wl4g.infra.common.remoting.standard.HttpHeaders;
import com.wl4g.infra.common.remoting.standard.HttpMediaType;
import com.wl4g.infra.common.remoting.standard.HttpMimeType.MimeTypeUtils;
import com.wl4g.infra.common.resource.StreamResource;
/**
* Implementation of {@link HttpMessageParser} to read and write 'normal' HTML
* forms and also to write (but not read) multipart data (e.g. file uploads).
*
*
* In other words, this converter can read and write the
* {@code "application/x-www-form-urlencoded"} media type as
* {@link MultiValueMap MultiValueMap<String, String>}, and it can also
* write (but not read) the {@code "multipart/form-data"} and
* {@code "multipart/mixed"} media types as {@link MultiValueMap
* MultiValueMap<String, Object>}.
*
*
Multipart Data
*
*
* By default, {@code "multipart/form-data"} is used as the content type when
* {@linkplain #write writing} multipart data. As it is also possible to write
* multipart data using other multipart subtypes such as
* {@code "multipart/mixed"} and {@code "multipart/related"}, as long as the
* multipart subtype is registered as a {@linkplain #getSupportedHttpMediaTypes
* supported media type} and the desired multipart subtype is specified
* as the content type when {@linkplain #write writing} the multipart data. Note
* that {@code "multipart/mixed"} is registered as a supported media type by
* default.
*
*
* When writing multipart data, this converter uses other
* {@link HttpMessageParser HttpMessageParsers} to write the respective MIME
* parts. By default, basic converters are registered for byte array,
* {@code String}, and {@code Resource}. These can be overridden via
* {@link #setPartParsers} or augmented via {@link #addPartParser}.
*
*
Examples
*
*
* The following snippet shows how to submit an HTML form using the
* {@code "multipart/form-data"} content type.
*
*
* RestClient restClient = new RestClient();
* // AllEncompassingFormHttpMessageParser is configured by default
*
* MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
* form.add("field 1", "value 1");
* form.add("field 2", "value 2");
* form.add("field 2", "value 3");
* form.add("field 3", 4); // non-String form values supported as of 5.1.4
*
* restClient.postForLocation("https://example.com/myForm", form);
*
*
*
* The following snippet shows how to do a file upload using the
* {@code "multipart/form-data"} content type.
*
*
* MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* restClient.postForLocation("https://example.com/myFileUpload", parts);
*
*
*
* The following snippet shows how to do a file upload using the
* {@code "multipart/mixed"} content type.
*
*
* MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* HttpHeaders requestHeaders = new HttpHeaders();
* requestHeaders.setContentType(HttpMediaType.MULTIPART_MIXED);
*
* restClient.postForLocation("https://example.com/myFileUpload", new HttpEntity<>(parts, requestHeaders));
*
*
*
* The following snippet shows how to do a file upload using the
* {@code "multipart/related"} content type.
*
*
* HttpMediaType multipartRelated = new HttpMediaType("multipart", "related");
*
* restClient.getMessageParsers()
* .stream()
* .filter(FormHttpMessageParser.class::isInstance)
* .map(FormHttpMessageParser.class::cast)
* .findFirst()
* .orElseThrow(() -> new IllegalStateException("Failed to find FormHttpMessageParser"))
* .addSupportedHttpMediaTypes(multipartRelated);
*
* MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* HttpHeaders requestHeaders = new HttpHeaders();
* requestHeaders.setContentType(multipartRelated);
*
* restClient.postForLocation("https://example.com/myFileUpload", new HttpEntity<>(parts, requestHeaders));
*
*
* Miscellaneous
*
*
* Some methods in this class were inspired by
* {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}.
*
* @see {@link AllEncompassingFormHttpMessageParser}
* @see {@link MultiValueMap}
*/
public class FormHttpMessageParser implements HttpMessageParser> {
/**
* The default charset used by the converter.
*/
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
static final HttpMediaType MULTIPART_ALL = new HttpMediaType("multipart", "*");
private static final HttpMediaType DEFAULT_FORM_DATA_MEDIA_TYPE = new HttpMediaType(HttpMediaType.APPLICATION_FORM_URLENCODED,
DEFAULT_CHARSET);
private List supportedHttpMediaTypes = new ArrayList<>();
private List> partParsers = new ArrayList<>();
private Charset charset = DEFAULT_CHARSET;
@Nullable
private Charset multipartCharset;
public FormHttpMessageParser() {
this.supportedHttpMediaTypes.add(HttpMediaType.APPLICATION_FORM_URLENCODED);
this.supportedHttpMediaTypes.add(HttpMediaType.MULTIPART_FORM_DATA);
this.supportedHttpMediaTypes.add(HttpMediaType.MULTIPART_MIXED);
this.partParsers.add(new ByteArrayHttpMessageParser());
this.partParsers.add(new StringHttpMessageParser());
this.partParsers.add(new ResourceHttpMessageParser());
applyDefaultCharset();
}
/**
* Set the list of {@link HttpMediaType} objects supported by this
* converter.
*
* @see #addSupportedHttpMediaTypes(HttpMediaType...)
* @see #getSupportedHttpMediaTypes()
*/
public void setSupportedHttpMediaTypes(List supportedHttpMediaTypes) {
notNull(supportedHttpMediaTypes, "'supportedHttpMediaTypes' must not be null");
// Ensure internal list is mutable.
this.supportedHttpMediaTypes = new ArrayList<>(supportedHttpMediaTypes);
}
/**
* Add {@link HttpMediaType} objects to be supported by this converter.
*
* The supplied {@code HttpMediaType} objects will be appended to the list
* of {@linkplain #getSupportedHttpMediaTypes() supported HttpMediaType
* objects}.
*
* @param supportedHttpMediaTypes
* a var-args list of {@code HttpMediaType} objects to add
* @since 5.2
* @see #setSupportedHttpMediaTypes(List)
*/
public void addSupportedHttpMediaTypes(HttpMediaType... supportedHttpMediaTypes) {
notNull(supportedHttpMediaTypes, "'supportedHttpMediaTypes' must not be null");
noNullElements(supportedHttpMediaTypes, "'supportedHttpMediaTypes' must not contain null elements");
Collections.addAll(this.supportedHttpMediaTypes, supportedHttpMediaTypes);
}
/**
* {@inheritDoc}
*
* @see #setSupportedHttpMediaTypes(List)
* @see #addSupportedHttpMediaTypes(HttpMediaType...)
*/
@Override
public List getSupportedMediaTypes() {
return Collections.unmodifiableList(this.supportedHttpMediaTypes);
}
/**
* Set the message body converters to use. These converters are used to
* convert objects to MIME parts.
*/
public void setPartParsers(List> partParsers) {
notEmpty(partParsers, "'partParsers' must not be empty");
this.partParsers = partParsers;
}
/**
* Add a message body converter. Such a converter is used to convert objects
* to MIME parts.
*/
public void addPartParser(HttpMessageParser> partParser) {
notNull(partParser, "'partParser' must not be null");
this.partParsers.add(partParser);
}
/**
* Set the default character set to use for reading and writing form data
* when the request or response {@code Content-Type} header does not
* explicitly specify it.
*
* As of 4.3, this is also used as the default charset for the conversion of
* text bodies in a multipart request.
*
* As of 5.0, this is also used for part headers including
* {@code Content-Disposition} (and its filename parameter) unless (the
* mutually exclusive) {@link #setMultipartCharset multipartCharset} is also
* set, in which case part headers are encoded as ASCII and filename
* is encoded with the {@code encoded-word} syntax from RFC 2047.
*
* By default this is set to "UTF-8".
*/
public void setCharset(@Nullable Charset charset) {
if (charset != this.charset) {
this.charset = (charset != null ? charset : DEFAULT_CHARSET);
applyDefaultCharset();
}
}
/**
* Apply the configured charset as a default to registered part converters.
*/
private void applyDefaultCharset() {
for (HttpMessageParser> candidate : this.partParsers) {
if (candidate instanceof AbstractHttpMessageParser) {
AbstractHttpMessageParser> converter = (AbstractHttpMessageParser>) candidate;
// Only override default charset if the converter operates with
// a charset to begin with...
if (converter.getDefaultCharset() != null) {
converter.setDefaultCharset(this.charset);
}
}
}
}
/**
* Set the character set to use when writing multipart data to encode file
* names. Encoding is based on the {@code encoded-word} syntax defined in
* RFC 2047 and relies on {@code MimeUtility} from {@code javax.mail}.
*
* As of 5.0 by default part headers, including {@code Content-Disposition}
* (and its filename parameter) will be encoded based on the setting of
* {@link #setCharset(Charset)} or {@code UTF-8} by default.
*
* @since 4.1.1
* @see Encoded-Word
*/
public void setMultipartCharset(Charset charset) {
this.multipartCharset = charset;
}
@Override
public boolean canRead(Class> clazz, @Nullable HttpMediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null) {
return true;
}
for (HttpMediaType supportedHttpMediaType : getSupportedMediaTypes()) {
if (MULTIPART_ALL.includes(supportedHttpMediaType)) {
// We can't read multipart, so skip this supported media type.
continue;
}
if (supportedHttpMediaType.includes(mediaType)) {
return true;
}
}
return false;
}
@Override
public boolean canWrite(Class> clazz, @Nullable HttpMediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null || HttpMediaType.ALL.equals(mediaType)) {
return true;
}
for (HttpMediaType supportedHttpMediaType : getSupportedMediaTypes()) {
if (supportedHttpMediaType.isCompatibleWith(mediaType)) {
return true;
}
}
return false;
}
@Override
public MultiValueMap read(
@Nullable Class extends MultiValueMap> clazz,
HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
HttpMediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = (contentType != null && contentType.getCharset() != null ? contentType.getCharset() : this.charset);
String body = ByteStreamUtils.copyToString(inputMessage.getBody(), charset);
String[] pairs = StringUtils2.tokenizeToStringArray(body, "&");
MultiValueMap result = new LinkedMultiValueMap<>(pairs.length);
for (String pair : pairs) {
int idx = pair.indexOf('=');
if (idx == -1) {
result.add(URLDecoder.decode(pair, charset.name()), null);
} else {
String name = URLDecoder.decode(pair.substring(0, idx), charset.name());
String value = URLDecoder.decode(pair.substring(idx + 1), charset.name());
result.add(name, value);
}
}
return result;
}
@Override
@SuppressWarnings("unchecked")
public void write(MultiValueMap map, @Nullable HttpMediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
if (isMultipart(map, contentType)) {
writeMultipart((MultiValueMap) map, contentType, outputMessage);
} else {
writeForm((MultiValueMap) map, contentType, outputMessage);
}
}
private boolean isMultipart(MultiValueMap map, @Nullable HttpMediaType contentType) {
if (contentType != null) {
return MULTIPART_ALL.includes(contentType);
}
for (List> values : map.values()) {
for (Object value : values) {
if (value != null && !(value instanceof String)) {
return true;
}
}
}
return false;
}
private void writeForm(
MultiValueMap formData,
@Nullable HttpMediaType contentType,
HttpOutputMessage outputMessage) throws IOException {
contentType = getFormContentType(contentType);
outputMessage.getHeaders().setContentType(contentType);
Charset charset = contentType.getCharset();
notNull(charset, "No charset"); // should never occur
byte[] bytes = serializeForm(formData, charset).getBytes(charset);
outputMessage.getHeaders().setContentLength(bytes.length);
if (outputMessage instanceof StreamingHttpOutputMessage) {
StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
streamingOutputMessage.setBody(outputStream -> ByteStreamUtils.copy(bytes, outputStream));
} else {
ByteStreamUtils.copy(bytes, outputMessage.getBody());
}
}
/**
* Return the content type used to write forms,notNull given the preferred
* content type. By default, this method returns the given content type, but
* adds the {@linkplain #setCharset(Charset) charset} if it does not have
* one. If {@code contentType} is {@code null},
* {@code application/x-www-form-urlencoded; charset=UTF-8} is returned.
*
* Subclasses can override this method to change this behavior.
*
* @param contentType
* the preferred content type (can be {@code null})
* @return the content type to be used
* @since 5.2.2
*/
protected HttpMediaType getFormContentType(@Nullable HttpMediaType contentType) {
if (contentType == null) {
return DEFAULT_FORM_DATA_MEDIA_TYPE;
} else if (contentType.getCharset() == null) {
return new HttpMediaType(contentType, this.charset);
} else {
return contentType;
}
}
protected String serializeForm(MultiValueMap formData, Charset charset) {
StringBuilder builder = new StringBuilder();
formData.forEach((name, values) -> {
if (name == null) {
isTrue(CollectionUtils2.isEmpty(values), "Null name in form data: " + formData);
return;
}
values.forEach(value -> {
try {
if (builder.length() != 0) {
builder.append('&');
}
builder.append(URLEncoder.encode(name, charset.name()));
if (value != null) {
builder.append('=');
builder.append(URLEncoder.encode(String.valueOf(value), charset.name()));
}
} catch (UnsupportedEncodingException ex) {
throw new IllegalStateException(ex);
}
});
});
return builder.toString();
}
private void writeMultipart(
MultiValueMap parts,
@Nullable HttpMediaType contentType,
HttpOutputMessage outputMessage) throws IOException {
// If the supplied content type is null, fall back to
// multipart/form-data.
// Otherwise rely on the fact that isMultipart() already verified the
// supplied content type is multipart.
if (contentType == null) {
contentType = HttpMediaType.MULTIPART_FORM_DATA;
}
byte[] boundary = generateMultipartBoundary();
Map parameters = new LinkedHashMap<>(2);
if (!isFilenameCharsetSet()) {
parameters.put("charset", this.charset.name());
}
parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
// Add parameters to output content type
contentType = new HttpMediaType(contentType, parameters);
outputMessage.getHeaders().setContentType(contentType);
if (outputMessage instanceof StreamingHttpOutputMessage) {
StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
streamingOutputMessage.setBody(outputStream -> {
writeParts(outputStream, parts, boundary);
writeEnd(outputStream, boundary);
});
} else {
writeParts(outputMessage.getBody(), parts, boundary);
writeEnd(outputMessage.getBody(), boundary);
}
}
/**
* When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047,
* {@code encoded-word} syntax) we need to use ASCII for part headers, or
* otherwise we encode directly using the configured
* {@link #setCharset(Charset)}.
*/
private boolean isFilenameCharsetSet() {
return (this.multipartCharset != null);
}
private void writeParts(OutputStream os, MultiValueMap parts, byte[] boundary) throws IOException {
for (Map.Entry> entry : parts.entrySet()) {
String name = entry.getKey();
for (Object part : entry.getValue()) {
if (part != null) {
writeBoundary(os, boundary);
writePart(name, getHttpEntity(part), os);
writeNewLine(os);
}
}
}
}
@SuppressWarnings("unchecked")
private void writePart(String name, HttpEntity> partEntity, OutputStream os) throws IOException {
Object partBody = partEntity.getBody();
if (partBody == null) {
throw new IllegalStateException("Empty body for part '" + name + "': " + partEntity);
}
Class> partType = partBody.getClass();
HttpHeaders partHeaders = partEntity.getHeaders();
HttpMediaType partContentType = partHeaders.getContentType();
for (HttpMessageParser> messageParser : this.partParsers) {
if (messageParser.canWrite(partType, partContentType)) {
Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
multipartMessage.getHeaders().setContentDispositionFormData(name, getFilename(partBody));
if (!partHeaders.isEmpty()) {
multipartMessage.getHeaders().putAll(partHeaders);
}
((HttpMessageParser