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}