001/*
002 * Copyright 2002-2020 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.nio.charset.StandardCharsets;
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.LinkedHashMap;
029import java.util.List;
030import java.util.Map;
031
032import javax.mail.internet.MimeUtility;
033
034import org.springframework.core.io.Resource;
035import org.springframework.http.HttpEntity;
036import org.springframework.http.HttpHeaders;
037import org.springframework.http.HttpInputMessage;
038import org.springframework.http.HttpOutputMessage;
039import org.springframework.http.MediaType;
040import org.springframework.http.StreamingHttpOutputMessage;
041import org.springframework.lang.Nullable;
042import org.springframework.util.Assert;
043import org.springframework.util.CollectionUtils;
044import org.springframework.util.LinkedMultiValueMap;
045import org.springframework.util.MimeTypeUtils;
046import org.springframework.util.MultiValueMap;
047import org.springframework.util.StreamUtils;
048import org.springframework.util.StringUtils;
049
050/**
051 * Implementation of {@link HttpMessageConverter} to read and write 'normal' HTML
052 * forms and also to write (but not read) multipart data (e.g. file uploads).
053 *
054 * <p>In other words, this converter can read and write the
055 * {@code "application/x-www-form-urlencoded"} media type as
056 * {@link MultiValueMap MultiValueMap&lt;String, String&gt;}, and it can also
057 * write (but not read) the {@code "multipart/form-data"} and
058 * {@code "multipart/mixed"} media types as
059 * {@link MultiValueMap MultiValueMap&lt;String, Object&gt;}.
060 *
061 * <h3>Multipart Data</h3>
062 *
063 * <p>By default, {@code "multipart/form-data"} is used as the content type when
064 * {@linkplain #write writing} multipart data. As of Spring Framework 5.2 it is
065 * also possible to write multipart data using other multipart subtypes such as
066 * {@code "multipart/mixed"} and {@code "multipart/related"}, as long as the
067 * multipart subtype is registered as a {@linkplain #getSupportedMediaTypes
068 * supported media type} <em>and</em> the desired multipart subtype is specified
069 * as the content type when {@linkplain #write writing} the multipart data. Note
070 * that {@code "multipart/mixed"} is registered as a supported media type by
071 * default.
072 *
073 * <p>When writing multipart data, this converter uses other
074 * {@link HttpMessageConverter HttpMessageConverters} to write the respective
075 * MIME parts. By default, basic converters are registered for byte array,
076 * {@code String}, and {@code Resource}. These can be overridden via
077 * {@link #setPartConverters} or augmented via {@link #addPartConverter}.
078 *
079 * <h3>Examples</h3>
080 *
081 * <p>The following snippet shows how to submit an HTML form using the
082 * {@code "multipart/form-data"} content type.
083 *
084 * <pre class="code">
085 * RestTemplate restTemplate = new RestTemplate();
086 * // AllEncompassingFormHttpMessageConverter is configured by default
087 *
088 * MultiValueMap&lt;String, Object&gt; form = new LinkedMultiValueMap&lt;&gt;();
089 * form.add("field 1", "value 1");
090 * form.add("field 2", "value 2");
091 * form.add("field 2", "value 3");
092 * form.add("field 3", 4);  // non-String form values supported as of 5.1.4
093 *
094 * restTemplate.postForLocation("https://example.com/myForm", form);</pre>
095 *
096 * <p>The following snippet shows how to do a file upload using the
097 * {@code "multipart/form-data"} content type.
098 *
099 * <pre class="code">
100 * MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
101 * parts.add("field 1", "value 1");
102 * parts.add("file", new ClassPathResource("myFile.jpg"));
103 *
104 * restTemplate.postForLocation("https://example.com/myFileUpload", parts);</pre>
105 *
106 * <p>The following snippet shows how to do a file upload using the
107 * {@code "multipart/mixed"} content type.
108 *
109 * <pre class="code">
110 * MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
111 * parts.add("field 1", "value 1");
112 * parts.add("file", new ClassPathResource("myFile.jpg"));
113 *
114 * HttpHeaders requestHeaders = new HttpHeaders();
115 * requestHeaders.setContentType(MediaType.MULTIPART_MIXED);
116 *
117 * restTemplate.postForLocation("https://example.com/myFileUpload",
118 *     new HttpEntity&lt;&gt;(parts, requestHeaders));</pre>
119 *
120 * <p>The following snippet shows how to do a file upload using the
121 * {@code "multipart/related"} content type.
122 *
123 * <pre class="code">
124 * MediaType multipartRelated = new MediaType("multipart", "related");
125 *
126 * restTemplate.getMessageConverters().stream()
127 *     .filter(FormHttpMessageConverter.class::isInstance)
128 *     .map(FormHttpMessageConverter.class::cast)
129 *     .findFirst()
130 *     .orElseThrow(() -&gt; new IllegalStateException("Failed to find FormHttpMessageConverter"))
131 *     .addSupportedMediaTypes(multipartRelated);
132 *
133 * MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
134 * parts.add("field 1", "value 1");
135 * parts.add("file", new ClassPathResource("myFile.jpg"));
136 *
137 * HttpHeaders requestHeaders = new HttpHeaders();
138 * requestHeaders.setContentType(multipartRelated);
139 *
140 * restTemplate.postForLocation("https://example.com/myFileUpload",
141 *     new HttpEntity&lt;&gt;(parts, requestHeaders));</pre>
142 *
143 * <h3>Miscellaneous</h3>
144 *
145 * <p>Some methods in this class were inspired by
146 * {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}.
147 *
148 * @author Arjen Poutsma
149 * @author Rossen Stoyanchev
150 * @author Juergen Hoeller
151 * @author Sam Brannen
152 * @since 3.0
153 * @see org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
154 * @see org.springframework.util.MultiValueMap
155 */
156public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
157
158        /**
159         * The default charset used by the converter.
160         */
161        public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
162
163        private static final MediaType DEFAULT_FORM_DATA_MEDIA_TYPE =
164                        new MediaType(MediaType.APPLICATION_FORM_URLENCODED, DEFAULT_CHARSET);
165
166
167        private List<MediaType> supportedMediaTypes = new ArrayList<>();
168
169        private List<HttpMessageConverter<?>> partConverters = new ArrayList<>();
170
171        private Charset charset = DEFAULT_CHARSET;
172
173        @Nullable
174        private Charset multipartCharset;
175
176
177        public FormHttpMessageConverter() {
178                this.supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
179                this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);
180                this.supportedMediaTypes.add(MediaType.MULTIPART_MIXED);
181
182                this.partConverters.add(new ByteArrayHttpMessageConverter());
183                this.partConverters.add(new StringHttpMessageConverter());
184                this.partConverters.add(new ResourceHttpMessageConverter());
185
186                applyDefaultCharset();
187        }
188
189
190        /**
191         * Set the list of {@link MediaType} objects supported by this converter.
192         * @see #addSupportedMediaTypes(MediaType...)
193         * @see #getSupportedMediaTypes()
194         */
195        public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
196                Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
197                // Ensure internal list is mutable.
198                this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes);
199        }
200
201        /**
202         * Add {@link MediaType} objects to be supported by this converter.
203         * <p>The supplied {@code MediaType} objects will be appended to the list
204         * of {@linkplain #getSupportedMediaTypes() supported MediaType objects}.
205         * @param supportedMediaTypes a var-args list of {@code MediaType} objects to add
206         * @since 5.2
207         * @see #setSupportedMediaTypes(List)
208         */
209        public void addSupportedMediaTypes(MediaType... supportedMediaTypes) {
210                Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
211                Assert.noNullElements(supportedMediaTypes, "'supportedMediaTypes' must not contain null elements");
212                Collections.addAll(this.supportedMediaTypes, supportedMediaTypes);
213        }
214
215        /**
216         * {@inheritDoc}
217         * @see #setSupportedMediaTypes(List)
218         * @see #addSupportedMediaTypes(MediaType...)
219         */
220        @Override
221        public List<MediaType> getSupportedMediaTypes() {
222                return Collections.unmodifiableList(this.supportedMediaTypes);
223        }
224
225        /**
226         * Set the message body converters to use. These converters are used to
227         * convert objects to MIME parts.
228         */
229        public void setPartConverters(List<HttpMessageConverter<?>> partConverters) {
230                Assert.notEmpty(partConverters, "'partConverters' must not be empty");
231                this.partConverters = partConverters;
232        }
233
234        /**
235         * Add a message body converter. Such a converter is used to convert objects
236         * to MIME parts.
237         */
238        public void addPartConverter(HttpMessageConverter<?> partConverter) {
239                Assert.notNull(partConverter, "'partConverter' must not be null");
240                this.partConverters.add(partConverter);
241        }
242
243        /**
244         * Set the default character set to use for reading and writing form data when
245         * the request or response {@code Content-Type} header does not explicitly
246         * specify it.
247         * <p>As of 4.3, this is also used as the default charset for the conversion
248         * of text bodies in a multipart request.
249         * <p>As of 5.0, this is also used for part headers including
250         * {@code Content-Disposition} (and its filename parameter) unless (the mutually
251         * exclusive) {@link #setMultipartCharset multipartCharset} is also set, in
252         * which case part headers are encoded as ASCII and <i>filename</i> is encoded
253         * with the {@code encoded-word} syntax from RFC 2047.
254         * <p>By default this is set to "UTF-8".
255         */
256        public void setCharset(@Nullable Charset charset) {
257                if (charset != this.charset) {
258                        this.charset = (charset != null ? charset : DEFAULT_CHARSET);
259                        applyDefaultCharset();
260                }
261        }
262
263        /**
264         * Apply the configured charset as a default to registered part converters.
265         */
266        private void applyDefaultCharset() {
267                for (HttpMessageConverter<?> candidate : this.partConverters) {
268                        if (candidate instanceof AbstractHttpMessageConverter) {
269                                AbstractHttpMessageConverter<?> converter = (AbstractHttpMessageConverter<?>) candidate;
270                                // Only override default charset if the converter operates with a charset to begin with...
271                                if (converter.getDefaultCharset() != null) {
272                                        converter.setDefaultCharset(this.charset);
273                                }
274                        }
275                }
276        }
277
278        /**
279         * Set the character set to use when writing multipart data to encode file
280         * names. Encoding is based on the {@code encoded-word} syntax defined in
281         * RFC 2047 and relies on {@code MimeUtility} from {@code javax.mail}.
282         * <p>As of 5.0 by default part headers, including {@code Content-Disposition}
283         * (and its filename parameter) will be encoded based on the setting of
284         * {@link #setCharset(Charset)} or {@code UTF-8} by default.
285         * @since 4.1.1
286         * @see <a href="https://en.wikipedia.org/wiki/MIME#Encoded-Word">Encoded-Word</a>
287         */
288        public void setMultipartCharset(Charset charset) {
289                this.multipartCharset = charset;
290        }
291
292
293        @Override
294        public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
295                if (!MultiValueMap.class.isAssignableFrom(clazz)) {
296                        return false;
297                }
298                if (mediaType == null) {
299                        return true;
300                }
301                for (MediaType supportedMediaType : getSupportedMediaTypes()) {
302                        if (supportedMediaType.getType().equalsIgnoreCase("multipart")) {
303                                // We can't read multipart, so skip this supported media type.
304                                continue;
305                        }
306                        if (supportedMediaType.includes(mediaType)) {
307                                return true;
308                        }
309                }
310                return false;
311        }
312
313        @Override
314        public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
315                if (!MultiValueMap.class.isAssignableFrom(clazz)) {
316                        return false;
317                }
318                if (mediaType == null || MediaType.ALL.equals(mediaType)) {
319                        return true;
320                }
321                for (MediaType supportedMediaType : getSupportedMediaTypes()) {
322                        if (supportedMediaType.isCompatibleWith(mediaType)) {
323                                return true;
324                        }
325                }
326                return false;
327        }
328
329        @Override
330        public MultiValueMap<String, String> read(@Nullable Class<? extends MultiValueMap<String, ?>> clazz,
331                        HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
332
333                MediaType contentType = inputMessage.getHeaders().getContentType();
334                Charset charset = (contentType != null && contentType.getCharset() != null ?
335                                contentType.getCharset() : this.charset);
336                String body = StreamUtils.copyToString(inputMessage.getBody(), charset);
337
338                String[] pairs = StringUtils.tokenizeToStringArray(body, "&");
339                MultiValueMap<String, String> result = new LinkedMultiValueMap<>(pairs.length);
340                for (String pair : pairs) {
341                        int idx = pair.indexOf('=');
342                        if (idx == -1) {
343                                result.add(URLDecoder.decode(pair, charset.name()), null);
344                        }
345                        else {
346                                String name = URLDecoder.decode(pair.substring(0, idx), charset.name());
347                                String value = URLDecoder.decode(pair.substring(idx + 1), charset.name());
348                                result.add(name, value);
349                        }
350                }
351                return result;
352        }
353
354        @Override
355        @SuppressWarnings("unchecked")
356        public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
357                        throws IOException, HttpMessageNotWritableException {
358
359                if (isMultipart(map, contentType)) {
360                        writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
361                }
362                else {
363                        writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
364                }
365        }
366
367
368        private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
369                if (contentType != null) {
370                        return contentType.getType().equalsIgnoreCase("multipart");
371                }
372                for (List<?> values : map.values()) {
373                        for (Object value : values) {
374                                if (value != null && !(value instanceof String)) {
375                                        return true;
376                                }
377                        }
378                }
379                return false;
380        }
381
382        private void writeForm(MultiValueMap<String, Object> formData, @Nullable MediaType contentType,
383                        HttpOutputMessage outputMessage) throws IOException {
384
385                contentType = getFormContentType(contentType);
386                outputMessage.getHeaders().setContentType(contentType);
387
388                Charset charset = contentType.getCharset();
389                Assert.notNull(charset, "No charset"); // should never occur
390
391                byte[] bytes = serializeForm(formData, charset).getBytes(charset);
392                outputMessage.getHeaders().setContentLength(bytes.length);
393
394                if (outputMessage instanceof StreamingHttpOutputMessage) {
395                        StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
396                        streamingOutputMessage.setBody(outputStream -> StreamUtils.copy(bytes, outputStream));
397                }
398                else {
399                        StreamUtils.copy(bytes, outputMessage.getBody());
400                }
401        }
402
403        /**
404         * Return the content type used to write forms, given the preferred content type.
405         * By default, this method returns the given content type, but adds the
406         * {@linkplain #setCharset(Charset) charset} if it does not have one.
407         * If {@code contentType} is {@code null},
408         * {@code application/x-www-form-urlencoded; charset=UTF-8} is returned.
409         * <p>Subclasses can override this method to change this behavior.
410         * @param contentType the preferred content type (can be {@code null})
411         * @return the content type to be used
412         * @since 5.2.2
413         */
414        protected MediaType getFormContentType(@Nullable MediaType contentType) {
415                if (contentType == null) {
416                        return DEFAULT_FORM_DATA_MEDIA_TYPE;
417                }
418                else if (contentType.getCharset() == null) {
419                        return new MediaType(contentType, this.charset);
420                }
421                else {
422                        return contentType;
423                }
424        }
425
426        protected String serializeForm(MultiValueMap<String, Object> formData, Charset charset) {
427                StringBuilder builder = new StringBuilder();
428                formData.forEach((name, values) -> {
429                                if (name == null) {
430                                        Assert.isTrue(CollectionUtils.isEmpty(values), "Null name in form data: " + formData);
431                                        return;
432                                }
433                                values.forEach(value -> {
434                                        try {
435                                                if (builder.length() != 0) {
436                                                        builder.append('&');
437                                                }
438                                                builder.append(URLEncoder.encode(name, charset.name()));
439                                                if (value != null) {
440                                                        builder.append('=');
441                                                        builder.append(URLEncoder.encode(String.valueOf(value), charset.name()));
442                                                }
443                                        }
444                                        catch (UnsupportedEncodingException ex) {
445                                                throw new IllegalStateException(ex);
446                                        }
447                                });
448                });
449
450                return builder.toString();
451        }
452
453        private void writeMultipart(
454                        MultiValueMap<String, Object> parts, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
455                        throws IOException {
456
457                // If the supplied content type is null, fall back to multipart/form-data.
458                // Otherwise rely on the fact that isMultipart() already verified the
459                // supplied content type is multipart.
460                if (contentType == null) {
461                        contentType = MediaType.MULTIPART_FORM_DATA;
462                }
463
464                byte[] boundary = generateMultipartBoundary();
465                Map<String, String> parameters = new LinkedHashMap<>(2);
466                if (!isFilenameCharsetSet()) {
467                        parameters.put("charset", this.charset.name());
468                }
469                parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
470
471                // Add parameters to output content type
472                contentType = new MediaType(contentType, parameters);
473                outputMessage.getHeaders().setContentType(contentType);
474
475                if (outputMessage instanceof StreamingHttpOutputMessage) {
476                        StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
477                        streamingOutputMessage.setBody(outputStream -> {
478                                writeParts(outputStream, parts, boundary);
479                                writeEnd(outputStream, boundary);
480                        });
481                }
482                else {
483                        writeParts(outputMessage.getBody(), parts, boundary);
484                        writeEnd(outputMessage.getBody(), boundary);
485                }
486        }
487
488        /**
489         * When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047,
490         * {@code encoded-word} syntax) we need to use ASCII for part headers, or
491         * otherwise we encode directly using the configured {@link #setCharset(Charset)}.
492         */
493        private boolean isFilenameCharsetSet() {
494                return (this.multipartCharset != null);
495        }
496
497        private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException {
498                for (Map.Entry<String, List<Object>> entry : parts.entrySet()) {
499                        String name = entry.getKey();
500                        for (Object part : entry.getValue()) {
501                                if (part != null) {
502                                        writeBoundary(os, boundary);
503                                        writePart(name, getHttpEntity(part), os);
504                                        writeNewLine(os);
505                                }
506                        }
507                }
508        }
509
510        @SuppressWarnings("unchecked")
511        private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException {
512                Object partBody = partEntity.getBody();
513                if (partBody == null) {
514                        throw new IllegalStateException("Empty body for part '" + name + "': " + partEntity);
515                }
516                Class<?> partType = partBody.getClass();
517                HttpHeaders partHeaders = partEntity.getHeaders();
518                MediaType partContentType = partHeaders.getContentType();
519                for (HttpMessageConverter<?> messageConverter : this.partConverters) {
520                        if (messageConverter.canWrite(partType, partContentType)) {
521                                Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
522                                HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
523                                multipartMessage.getHeaders().setContentDispositionFormData(name, getFilename(partBody));
524                                if (!partHeaders.isEmpty()) {
525                                        multipartMessage.getHeaders().putAll(partHeaders);
526                                }
527                                ((HttpMessageConverter<Object>) messageConverter).write(partBody, partContentType, multipartMessage);
528                                return;
529                        }
530                }
531                throw new HttpMessageNotWritableException("Could not write request: no suitable HttpMessageConverter " +
532                                "found for request type [" + partType.getName() + "]");
533        }
534
535        /**
536         * Generate a multipart boundary.
537         * <p>This implementation delegates to
538         * {@link MimeTypeUtils#generateMultipartBoundary()}.
539         */
540        protected byte[] generateMultipartBoundary() {
541                return MimeTypeUtils.generateMultipartBoundary();
542        }
543
544        /**
545         * Return an {@link HttpEntity} for the given part Object.
546         * @param part the part to return an {@link HttpEntity} for
547         * @return the part Object itself it is an {@link HttpEntity},
548         * or a newly built {@link HttpEntity} wrapper for that part
549         */
550        protected HttpEntity<?> getHttpEntity(Object part) {
551                return (part instanceof HttpEntity ? (HttpEntity<?>) part : new HttpEntity<>(part));
552        }
553
554        /**
555         * Return the filename of the given multipart part. This value will be used for the
556         * {@code Content-Disposition} header.
557         * <p>The default implementation returns {@link Resource#getFilename()} if the part is a
558         * {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses.
559         * @param part the part to determine the file name for
560         * @return the filename, or {@code null} if not known
561         */
562        @Nullable
563        protected String getFilename(Object part) {
564                if (part instanceof Resource) {
565                        Resource resource = (Resource) part;
566                        String filename = resource.getFilename();
567                        if (filename != null && this.multipartCharset != null) {
568                                filename = MimeDelegate.encode(filename, this.multipartCharset.name());
569                        }
570                        return filename;
571                }
572                else {
573                        return null;
574                }
575        }
576
577
578        private void writeBoundary(OutputStream os, byte[] boundary) throws IOException {
579                os.write('-');
580                os.write('-');
581                os.write(boundary);
582                writeNewLine(os);
583        }
584
585        private static void writeEnd(OutputStream os, byte[] boundary) throws IOException {
586                os.write('-');
587                os.write('-');
588                os.write(boundary);
589                os.write('-');
590                os.write('-');
591                writeNewLine(os);
592        }
593
594        private static void writeNewLine(OutputStream os) throws IOException {
595                os.write('\r');
596                os.write('\n');
597        }
598
599
600        /**
601         * Implementation of {@link org.springframework.http.HttpOutputMessage} used
602         * to write a MIME multipart.
603         */
604        private static class MultipartHttpOutputMessage implements HttpOutputMessage {
605
606                private final OutputStream outputStream;
607
608                private final Charset charset;
609
610                private final HttpHeaders headers = new HttpHeaders();
611
612                private boolean headersWritten = false;
613
614                public MultipartHttpOutputMessage(OutputStream outputStream, Charset charset) {
615                        this.outputStream = outputStream;
616                        this.charset = charset;
617                }
618
619                @Override
620                public HttpHeaders getHeaders() {
621                        return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers);
622                }
623
624                @Override
625                public OutputStream getBody() throws IOException {
626                        writeHeaders();
627                        return this.outputStream;
628                }
629
630                private void writeHeaders() throws IOException {
631                        if (!this.headersWritten) {
632                                for (Map.Entry<String, List<String>> entry : this.headers.entrySet()) {
633                                        byte[] headerName = getBytes(entry.getKey());
634                                        for (String headerValueString : entry.getValue()) {
635                                                byte[] headerValue = getBytes(headerValueString);
636                                                this.outputStream.write(headerName);
637                                                this.outputStream.write(':');
638                                                this.outputStream.write(' ');
639                                                this.outputStream.write(headerValue);
640                                                writeNewLine(this.outputStream);
641                                        }
642                                }
643                                writeNewLine(this.outputStream);
644                                this.headersWritten = true;
645                        }
646                }
647
648                private byte[] getBytes(String name) {
649                        return name.getBytes(this.charset);
650                }
651        }
652
653
654        /**
655         * Inner class to avoid a hard dependency on the JavaMail API.
656         */
657        private static class MimeDelegate {
658
659                public static String encode(String value, String charset) {
660                        try {
661                                return MimeUtility.encodeText(value, charset, null);
662                        }
663                        catch (UnsupportedEncodingException ex) {
664                                throw new IllegalStateException(ex);
665                        }
666                }
667        }
668
669}