001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.http.converter; 018 019import java.io.IOException; 020import java.io.OutputStream; 021import java.io.UnsupportedEncodingException; 022import java.net.URLDecoder; 023import java.net.URLEncoder; 024import java.nio.charset.Charset; 025import java.util.ArrayList; 026import java.util.Collections; 027import java.util.Iterator; 028import java.util.List; 029import java.util.Map; 030import javax.mail.internet.MimeUtility; 031 032import org.springframework.core.io.Resource; 033import org.springframework.http.HttpEntity; 034import org.springframework.http.HttpHeaders; 035import org.springframework.http.HttpInputMessage; 036import org.springframework.http.HttpOutputMessage; 037import org.springframework.http.MediaType; 038import org.springframework.http.StreamingHttpOutputMessage; 039import org.springframework.util.Assert; 040import org.springframework.util.LinkedMultiValueMap; 041import org.springframework.util.MimeTypeUtils; 042import org.springframework.util.MultiValueMap; 043import org.springframework.util.StreamUtils; 044import org.springframework.util.StringUtils; 045 046/** 047 * Implementation of {@link HttpMessageConverter} to read and write 'normal' HTML 048 * forms and also to write (but not read) multipart data (e.g. file uploads). 049 * 050 * <p>In other words, this converter can read and write the 051 * {@code "application/x-www-form-urlencoded"} media type as 052 * {@link MultiValueMap MultiValueMap<String, String>} and it can also 053 * write (but not read) the {@code "multipart/form-data"} media type as 054 * {@link MultiValueMap MultiValueMap<String, Object>}. 055 * 056 * <p>When writing multipart data, this converter uses other 057 * {@link HttpMessageConverter HttpMessageConverters} to write the respective 058 * MIME parts. By default, basic converters are registered (for {@code Strings} 059 * and {@code Resources}). These can be overridden through the 060 * {@link #setPartConverters partConverters} property. 061 * 062 * <p>For example, the following snippet shows how to submit an HTML form: 063 * <pre class="code"> 064 * RestTemplate template = new RestTemplate(); 065 * // AllEncompassingFormHttpMessageConverter is configured by default 066 * 067 * MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 068 * form.add("field 1", "value 1"); 069 * form.add("field 2", "value 2"); 070 * form.add("field 2", "value 3"); 071 * template.postForLocation("https://example.com/myForm", form); 072 * </pre> 073 * 074 * <p>The following snippet shows how to do a file upload: 075 * <pre class="code"> 076 * MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); 077 * parts.add("field 1", "value 1"); 078 * parts.add("file", new ClassPathResource("myFile.jpg")); 079 * template.postForLocation("https://example.com/myFileUpload", parts); 080 * </pre> 081 * 082 * <p>Some methods in this class were inspired by 083 * {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}. 084 * 085 * @author Arjen Poutsma 086 * @author Rossen Stoyanchev 087 * @author Juergen Hoeller 088 * @since 3.0 089 * @see org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter 090 * @see org.springframework.util.MultiValueMap 091 */ 092public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> { 093 094 public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); 095 096 097 private List<MediaType> supportedMediaTypes = new ArrayList<MediaType>(); 098 099 private List<HttpMessageConverter<?>> partConverters = new ArrayList<HttpMessageConverter<?>>(); 100 101 private Charset charset = DEFAULT_CHARSET; 102 103 private Charset multipartCharset; 104 105 106 public FormHttpMessageConverter() { 107 this.supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED); 108 this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA); 109 110 StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter(); 111 stringHttpMessageConverter.setWriteAcceptCharset(false); // see SPR-7316 112 113 this.partConverters.add(new ByteArrayHttpMessageConverter()); 114 this.partConverters.add(stringHttpMessageConverter); 115 this.partConverters.add(new ResourceHttpMessageConverter()); 116 117 applyDefaultCharset(); 118 } 119 120 121 /** 122 * Set the list of {@link MediaType} objects supported by this converter. 123 */ 124 public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) { 125 this.supportedMediaTypes = supportedMediaTypes; 126 } 127 128 @Override 129 public List<MediaType> getSupportedMediaTypes() { 130 return Collections.unmodifiableList(this.supportedMediaTypes); 131 } 132 133 /** 134 * Set the message body converters to use. These converters are used to 135 * convert objects to MIME parts. 136 */ 137 public void setPartConverters(List<HttpMessageConverter<?>> partConverters) { 138 Assert.notEmpty(partConverters, "'partConverters' must not be empty"); 139 this.partConverters = partConverters; 140 } 141 142 /** 143 * Add a message body converter. Such a converter is used to convert objects 144 * to MIME parts. 145 */ 146 public void addPartConverter(HttpMessageConverter<?> partConverter) { 147 Assert.notNull(partConverter, "'partConverter' must not be null"); 148 this.partConverters.add(partConverter); 149 } 150 151 /** 152 * Set the default character set to use for reading and writing form data when 153 * the request or response Content-Type header does not explicitly specify it. 154 * <p>By default this is set to "UTF-8". As of 4.3, it will also be used as 155 * the default charset for the conversion of text bodies in a multipart request. 156 * In contrast to this, {@link #setMultipartCharset} only affects the encoding of 157 * <i>file names</i> in a multipart request according to the encoded-word syntax. 158 */ 159 public void setCharset(Charset charset) { 160 if (charset != this.charset) { 161 this.charset = (charset != null ? charset : DEFAULT_CHARSET); 162 applyDefaultCharset(); 163 } 164 } 165 166 /** 167 * Apply the configured charset as a default to registered part converters. 168 */ 169 private void applyDefaultCharset() { 170 for (HttpMessageConverter<?> candidate : this.partConverters) { 171 if (candidate instanceof AbstractHttpMessageConverter) { 172 AbstractHttpMessageConverter<?> converter = (AbstractHttpMessageConverter<?>) candidate; 173 // Only override default charset if the converter operates with a charset to begin with... 174 if (converter.getDefaultCharset() != null) { 175 converter.setDefaultCharset(this.charset); 176 } 177 } 178 } 179 } 180 181 /** 182 * Set the character set to use when writing multipart data to encode file 183 * names. Encoding is based on the encoded-word syntax defined in RFC 2047 184 * and relies on {@code MimeUtility} from "javax.mail". 185 * <p>If not set file names will be encoded as US-ASCII. 186 * @since 4.1.1 187 * @see <a href="https://en.wikipedia.org/wiki/MIME#Encoded-Word">Encoded-Word</a> 188 */ 189 public void setMultipartCharset(Charset charset) { 190 this.multipartCharset = charset; 191 } 192 193 194 @Override 195 public boolean canRead(Class<?> clazz, MediaType mediaType) { 196 if (!MultiValueMap.class.isAssignableFrom(clazz)) { 197 return false; 198 } 199 if (mediaType == null) { 200 return true; 201 } 202 for (MediaType supportedMediaType : getSupportedMediaTypes()) { 203 // We can't read multipart.... 204 if (!supportedMediaType.equals(MediaType.MULTIPART_FORM_DATA) && supportedMediaType.includes(mediaType)) { 205 return true; 206 } 207 } 208 return false; 209 } 210 211 @Override 212 public boolean canWrite(Class<?> clazz, MediaType mediaType) { 213 if (!MultiValueMap.class.isAssignableFrom(clazz)) { 214 return false; 215 } 216 if (mediaType == null || MediaType.ALL.equals(mediaType)) { 217 return true; 218 } 219 for (MediaType supportedMediaType : getSupportedMediaTypes()) { 220 if (supportedMediaType.isCompatibleWith(mediaType)) { 221 return true; 222 } 223 } 224 return false; 225 } 226 227 @Override 228 public MultiValueMap<String, String> read(Class<? extends MultiValueMap<String, ?>> clazz, 229 HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { 230 231 MediaType contentType = inputMessage.getHeaders().getContentType(); 232 Charset charset = (contentType.getCharset() != null ? contentType.getCharset() : this.charset); 233 String body = StreamUtils.copyToString(inputMessage.getBody(), charset); 234 235 String[] pairs = StringUtils.tokenizeToStringArray(body, "&"); 236 MultiValueMap<String, String> result = new LinkedMultiValueMap<String, String>(pairs.length); 237 for (String pair : pairs) { 238 int idx = pair.indexOf('='); 239 if (idx == -1) { 240 result.add(URLDecoder.decode(pair, charset.name()), null); 241 } 242 else { 243 String name = URLDecoder.decode(pair.substring(0, idx), charset.name()); 244 String value = URLDecoder.decode(pair.substring(idx + 1), charset.name()); 245 result.add(name, value); 246 } 247 } 248 return result; 249 } 250 251 @Override 252 @SuppressWarnings("unchecked") 253 public void write(MultiValueMap<String, ?> map, MediaType contentType, HttpOutputMessage outputMessage) 254 throws IOException, HttpMessageNotWritableException { 255 256 if (!isMultipart(map, contentType)) { 257 writeForm((MultiValueMap<String, String>) map, contentType, outputMessage); 258 } 259 else { 260 writeMultipart((MultiValueMap<String, Object>) map, outputMessage); 261 } 262 } 263 264 265 private boolean isMultipart(MultiValueMap<String, ?> map, MediaType contentType) { 266 if (contentType != null) { 267 return MediaType.MULTIPART_FORM_DATA.includes(contentType); 268 } 269 for (List<?> values : map.values()) { 270 for (Object value : values) { 271 if (value != null && !(value instanceof String)) { 272 return true; 273 } 274 } 275 } 276 return false; 277 } 278 279 private void writeForm(MultiValueMap<String, String> form, MediaType contentType, 280 HttpOutputMessage outputMessage) throws IOException { 281 282 Charset charset; 283 if (contentType != null) { 284 outputMessage.getHeaders().setContentType(contentType); 285 charset = (contentType.getCharset() != null ? contentType.getCharset() : this.charset); 286 } 287 else { 288 outputMessage.getHeaders().setContentType(MediaType.APPLICATION_FORM_URLENCODED); 289 charset = this.charset; 290 } 291 StringBuilder builder = new StringBuilder(); 292 for (Iterator<String> nameIterator = form.keySet().iterator(); nameIterator.hasNext();) { 293 String name = nameIterator.next(); 294 for (Iterator<String> valueIterator = form.get(name).iterator(); valueIterator.hasNext();) { 295 String value = valueIterator.next(); 296 builder.append(URLEncoder.encode(name, charset.name())); 297 if (value != null) { 298 builder.append('='); 299 builder.append(URLEncoder.encode(value, charset.name())); 300 if (valueIterator.hasNext()) { 301 builder.append('&'); 302 } 303 } 304 } 305 if (nameIterator.hasNext()) { 306 builder.append('&'); 307 } 308 } 309 final byte[] bytes = builder.toString().getBytes(charset.name()); 310 outputMessage.getHeaders().setContentLength(bytes.length); 311 312 if (outputMessage instanceof StreamingHttpOutputMessage) { 313 StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage; 314 streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() { 315 @Override 316 public void writeTo(OutputStream outputStream) throws IOException { 317 StreamUtils.copy(bytes, outputStream); 318 } 319 }); 320 } 321 else { 322 StreamUtils.copy(bytes, outputMessage.getBody()); 323 } 324 } 325 326 private void writeMultipart(final MultiValueMap<String, Object> parts, HttpOutputMessage outputMessage) 327 throws IOException { 328 329 final byte[] boundary = generateMultipartBoundary(); 330 Map<String, String> parameters = Collections.singletonMap("boundary", new String(boundary, "US-ASCII")); 331 332 MediaType contentType = new MediaType(MediaType.MULTIPART_FORM_DATA, parameters); 333 HttpHeaders headers = outputMessage.getHeaders(); 334 headers.setContentType(contentType); 335 336 if (outputMessage instanceof StreamingHttpOutputMessage) { 337 StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage; 338 streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() { 339 @Override 340 public void writeTo(OutputStream outputStream) throws IOException { 341 writeParts(outputStream, parts, boundary); 342 writeEnd(outputStream, boundary); 343 } 344 }); 345 } 346 else { 347 writeParts(outputMessage.getBody(), parts, boundary); 348 writeEnd(outputMessage.getBody(), boundary); 349 } 350 } 351 352 private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException { 353 for (Map.Entry<String, List<Object>> entry : parts.entrySet()) { 354 String name = entry.getKey(); 355 for (Object part : entry.getValue()) { 356 if (part != null) { 357 writeBoundary(os, boundary); 358 writePart(name, getHttpEntity(part), os); 359 writeNewLine(os); 360 } 361 } 362 } 363 } 364 365 @SuppressWarnings("unchecked") 366 private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException { 367 Object partBody = partEntity.getBody(); 368 Class<?> partType = partBody.getClass(); 369 HttpHeaders partHeaders = partEntity.getHeaders(); 370 MediaType partContentType = partHeaders.getContentType(); 371 for (HttpMessageConverter<?> messageConverter : this.partConverters) { 372 if (messageConverter.canWrite(partType, partContentType)) { 373 HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os); 374 multipartMessage.getHeaders().setContentDispositionFormData(name, getFilename(partBody)); 375 if (!partHeaders.isEmpty()) { 376 multipartMessage.getHeaders().putAll(partHeaders); 377 } 378 ((HttpMessageConverter<Object>) messageConverter).write(partBody, partContentType, multipartMessage); 379 return; 380 } 381 } 382 throw new HttpMessageNotWritableException("Could not write request: no suitable HttpMessageConverter " + 383 "found for request type [" + partType.getName() + "]"); 384 } 385 386 /** 387 * Generate a multipart boundary. 388 * <p>This implementation delegates to 389 * {@link MimeTypeUtils#generateMultipartBoundary()}. 390 */ 391 protected byte[] generateMultipartBoundary() { 392 return MimeTypeUtils.generateMultipartBoundary(); 393 } 394 395 /** 396 * Return an {@link HttpEntity} for the given part Object. 397 * @param part the part to return an {@link HttpEntity} for 398 * @return the part Object itself it is an {@link HttpEntity}, 399 * or a newly built {@link HttpEntity} wrapper for that part 400 */ 401 protected HttpEntity<?> getHttpEntity(Object part) { 402 return (part instanceof HttpEntity ? (HttpEntity<?>) part : new HttpEntity<Object>(part)); 403 } 404 405 /** 406 * Return the filename of the given multipart part. This value will be used for the 407 * {@code Content-Disposition} header. 408 * <p>The default implementation returns {@link Resource#getFilename()} if the part is a 409 * {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses. 410 * @param part the part to determine the file name for 411 * @return the filename, or {@code null} if not known 412 */ 413 protected String getFilename(Object part) { 414 if (part instanceof Resource) { 415 Resource resource = (Resource) part; 416 String filename = resource.getFilename(); 417 if (filename != null && this.multipartCharset != null) { 418 filename = MimeDelegate.encode(filename, this.multipartCharset.name()); 419 } 420 return filename; 421 } 422 else { 423 return null; 424 } 425 } 426 427 428 private void writeBoundary(OutputStream os, byte[] boundary) throws IOException { 429 os.write('-'); 430 os.write('-'); 431 os.write(boundary); 432 writeNewLine(os); 433 } 434 435 private static void writeEnd(OutputStream os, byte[] boundary) throws IOException { 436 os.write('-'); 437 os.write('-'); 438 os.write(boundary); 439 os.write('-'); 440 os.write('-'); 441 writeNewLine(os); 442 } 443 444 private static void writeNewLine(OutputStream os) throws IOException { 445 os.write('\r'); 446 os.write('\n'); 447 } 448 449 450 /** 451 * Implementation of {@link org.springframework.http.HttpOutputMessage} used 452 * to write a MIME multipart. 453 */ 454 private static class MultipartHttpOutputMessage implements HttpOutputMessage { 455 456 private final OutputStream outputStream; 457 458 private final HttpHeaders headers = new HttpHeaders(); 459 460 private boolean headersWritten = false; 461 462 public MultipartHttpOutputMessage(OutputStream outputStream) { 463 this.outputStream = outputStream; 464 } 465 466 @Override 467 public HttpHeaders getHeaders() { 468 return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers); 469 } 470 471 @Override 472 public OutputStream getBody() throws IOException { 473 writeHeaders(); 474 return this.outputStream; 475 } 476 477 private void writeHeaders() throws IOException { 478 if (!this.headersWritten) { 479 for (Map.Entry<String, List<String>> entry : this.headers.entrySet()) { 480 byte[] headerName = getAsciiBytes(entry.getKey()); 481 for (String headerValueString : entry.getValue()) { 482 byte[] headerValue = getAsciiBytes(headerValueString); 483 this.outputStream.write(headerName); 484 this.outputStream.write(':'); 485 this.outputStream.write(' '); 486 this.outputStream.write(headerValue); 487 writeNewLine(this.outputStream); 488 } 489 } 490 writeNewLine(this.outputStream); 491 this.headersWritten = true; 492 } 493 } 494 495 private byte[] getAsciiBytes(String name) { 496 try { 497 return name.getBytes("US-ASCII"); 498 } 499 catch (UnsupportedEncodingException ex) { 500 // Should not happen - US-ASCII is always supported. 501 throw new IllegalStateException(ex); 502 } 503 } 504 } 505 506 507 /** 508 * Inner class to avoid a hard dependency on the JavaMail API. 509 */ 510 private static class MimeDelegate { 511 512 public static String encode(String value, String charset) { 513 try { 514 return MimeUtility.encodeText(value, charset, null); 515 } 516 catch (UnsupportedEncodingException ex) { 517 throw new IllegalStateException(ex); 518 } 519 } 520 } 521 522}