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.codec;
018
019import java.io.UnsupportedEncodingException;
020import java.net.URLEncoder;
021import java.nio.ByteBuffer;
022import java.nio.charset.Charset;
023import java.nio.charset.StandardCharsets;
024import java.util.Collections;
025import java.util.List;
026import java.util.Map;
027
028import org.reactivestreams.Publisher;
029import reactor.core.publisher.Mono;
030
031import org.springframework.core.ResolvableType;
032import org.springframework.core.codec.Hints;
033import org.springframework.core.io.buffer.DataBuffer;
034import org.springframework.core.log.LogFormatUtils;
035import org.springframework.http.MediaType;
036import org.springframework.http.ReactiveHttpOutputMessage;
037import org.springframework.lang.Nullable;
038import org.springframework.util.Assert;
039import org.springframework.util.MultiValueMap;
040
041/**
042 * {@link HttpMessageWriter} for writing a {@code MultiValueMap<String, String>}
043 * as HTML form data, i.e. {@code "application/x-www-form-urlencoded"}, to the
044 * body of a request.
045 *
046 * <p>Note that unless the media type is explicitly set to
047 * {@link MediaType#APPLICATION_FORM_URLENCODED}, the {@link #canWrite} method
048 * will need generic type information to confirm the target map has String values.
049 * This is because a MultiValueMap with non-String values can be used to write
050 * multipart requests.
051 *
052 * <p>To support both form data and multipart requests, consider using
053 * {@link org.springframework.http.codec.multipart.MultipartHttpMessageWriter}
054 * configured with this writer as the fallback for writing plain form data.
055 *
056 * @author Sebastien Deleuze
057 * @author Rossen Stoyanchev
058 * @since 5.0
059 * @see org.springframework.http.codec.multipart.MultipartHttpMessageWriter
060 */
061public class FormHttpMessageWriter extends LoggingCodecSupport
062                implements HttpMessageWriter<MultiValueMap<String, String>> {
063
064        /**
065         * The default charset used by the writer.
066         */
067        public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
068
069        private static final MediaType DEFAULT_FORM_DATA_MEDIA_TYPE =
070                        new MediaType(MediaType.APPLICATION_FORM_URLENCODED, DEFAULT_CHARSET);
071
072        private static final List<MediaType> MEDIA_TYPES =
073                        Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED);
074
075        private static final ResolvableType MULTIVALUE_TYPE =
076                        ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class);
077
078
079        private Charset defaultCharset = DEFAULT_CHARSET;
080
081
082        /**
083         * Set the default character set to use for writing form data when the response
084         * Content-Type header does not explicitly specify it.
085         * <p>By default this is set to "UTF-8".
086         */
087        public void setDefaultCharset(Charset charset) {
088                Assert.notNull(charset, "Charset must not be null");
089                this.defaultCharset = charset;
090        }
091
092        /**
093         * Return the configured default charset.
094         */
095        public Charset getDefaultCharset() {
096                return this.defaultCharset;
097        }
098
099
100        @Override
101        public List<MediaType> getWritableMediaTypes() {
102                return MEDIA_TYPES;
103        }
104
105
106        @Override
107        public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) {
108                if (!MultiValueMap.class.isAssignableFrom(elementType.toClass())) {
109                        return false;
110                }
111                if (MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)) {
112                        // Optimistically, any MultiValueMap with or without generics
113                        return true;
114                }
115                if (mediaType == null) {
116                        // Only String-based MultiValueMap
117                        return MULTIVALUE_TYPE.isAssignableFrom(elementType);
118                }
119                return false;
120        }
121
122        @Override
123        public Mono<Void> write(Publisher<? extends MultiValueMap<String, String>> inputStream,
124                        ResolvableType elementType, @Nullable MediaType mediaType, ReactiveHttpOutputMessage message,
125                        Map<String, Object> hints) {
126
127                mediaType = getMediaType(mediaType);
128                message.getHeaders().setContentType(mediaType);
129
130                Charset charset = mediaType.getCharset() != null ? mediaType.getCharset() : getDefaultCharset();
131
132                return Mono.from(inputStream).flatMap(form -> {
133                        logFormData(form, hints);
134                        String value = serializeForm(form, charset);
135                        ByteBuffer byteBuffer = charset.encode(value);
136                        DataBuffer buffer = message.bufferFactory().wrap(byteBuffer); // wrapping only, no allocation
137                        message.getHeaders().setContentLength(byteBuffer.remaining());
138                        return message.writeWith(Mono.just(buffer));
139                });
140        }
141
142        protected MediaType getMediaType(@Nullable MediaType mediaType) {
143                if (mediaType == null) {
144                        return DEFAULT_FORM_DATA_MEDIA_TYPE;
145                }
146                else if (mediaType.getCharset() == null) {
147                        return new MediaType(mediaType, getDefaultCharset());
148                }
149                else {
150                        return mediaType;
151                }
152        }
153
154        private void logFormData(MultiValueMap<String, String> form, Map<String, Object> hints) {
155                LogFormatUtils.traceDebug(logger, traceOn -> Hints.getLogPrefix(hints) + "Writing " +
156                                (isEnableLoggingRequestDetails() ?
157                                                LogFormatUtils.formatValue(form, !traceOn) :
158                                                "form fields " + form.keySet() + " (content masked)"));
159        }
160
161        protected String serializeForm(MultiValueMap<String, String> formData, Charset charset) {
162                StringBuilder builder = new StringBuilder();
163                formData.forEach((name, values) ->
164                                values.forEach(value -> {
165                                        try {
166                                                if (builder.length() != 0) {
167                                                        builder.append('&');
168                                                }
169                                                builder.append(URLEncoder.encode(name, charset.name()));
170                                                if (value != null) {
171                                                        builder.append('=');
172                                                        builder.append(URLEncoder.encode(value, charset.name()));
173                                                }
174                                        }
175                                        catch (UnsupportedEncodingException ex) {
176                                                throw new IllegalStateException(ex);
177                                        }
178                                }));
179                return builder.toString();
180        }
181
182}