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.web.reactive.result.view.freemarker; 018 019import java.io.FileNotFoundException; 020import java.io.IOException; 021import java.io.OutputStreamWriter; 022import java.io.Writer; 023import java.nio.charset.Charset; 024import java.util.HashMap; 025import java.util.Locale; 026import java.util.Map; 027import java.util.Optional; 028 029import freemarker.core.ParseException; 030import freemarker.template.Configuration; 031import freemarker.template.DefaultObjectWrapperBuilder; 032import freemarker.template.ObjectWrapper; 033import freemarker.template.SimpleHash; 034import freemarker.template.Template; 035import freemarker.template.Version; 036import reactor.core.publisher.Mono; 037 038import org.springframework.beans.BeansException; 039import org.springframework.beans.factory.BeanFactoryUtils; 040import org.springframework.beans.factory.NoSuchBeanDefinitionException; 041import org.springframework.context.ApplicationContextException; 042import org.springframework.context.i18n.LocaleContextHolder; 043import org.springframework.core.io.buffer.DataBuffer; 044import org.springframework.core.io.buffer.DataBufferUtils; 045import org.springframework.core.io.buffer.PooledDataBuffer; 046import org.springframework.http.MediaType; 047import org.springframework.lang.Nullable; 048import org.springframework.util.Assert; 049import org.springframework.util.MimeType; 050import org.springframework.web.reactive.result.view.AbstractUrlBasedView; 051import org.springframework.web.reactive.result.view.RequestContext; 052import org.springframework.web.server.ServerWebExchange; 053 054/** 055 * A {@code View} implementation that uses the FreeMarker template engine. 056 * 057 * <p>Depends on a single {@link FreeMarkerConfig} object such as 058 * {@link FreeMarkerConfigurer} being accessible in the application context. 059 * Alternatively the FreeMarker {@link Configuration} can be set directly on this 060 * class via {@link #setConfiguration}. 061 * 062 * <p>The {@link #setUrl(String) url} property is the location of the FreeMarker 063 * template relative to the FreeMarkerConfigurer's 064 * {@link FreeMarkerConfigurer#setTemplateLoaderPath templateLoaderPath}. 065 * 066 * <p>Note: Spring's FreeMarker support requires FreeMarker 2.3 or higher. 067 * 068 * @author Rossen Stoyanchev 069 * @author Sam Brannen 070 * @since 5.0 071 */ 072public class FreeMarkerView extends AbstractUrlBasedView { 073 074 /** 075 * Attribute name of the {@link RequestContext} instance in the template model, 076 * available to Spring's macros — for example, for creating 077 * {@link org.springframework.web.reactive.result.view.BindStatus BindStatus} 078 * objects. 079 * @since 5.2 080 * @see #setExposeSpringMacroHelpers(boolean) 081 */ 082 public static final String SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE = "springMacroRequestContext"; 083 084 085 @Nullable 086 private Configuration configuration; 087 088 @Nullable 089 private String encoding; 090 091 private boolean exposeSpringMacroHelpers = true; 092 093 094 /** 095 * Set the FreeMarker {@link Configuration} to be used by this view. 096 * <p>Typically this property is not set directly. Instead a single 097 * {@link FreeMarkerConfig} is expected in the Spring application context 098 * which is used to obtain the FreeMarker configuration. 099 */ 100 public void setConfiguration(@Nullable Configuration configuration) { 101 this.configuration = configuration; 102 } 103 104 /** 105 * Get the FreeMarker {@link Configuration} used by this view. 106 */ 107 @Nullable 108 protected Configuration getConfiguration() { 109 return this.configuration; 110 } 111 112 /** 113 * Obtain the FreeMarker {@link Configuration} for actual use. 114 * @return the FreeMarker configuration (never {@code null}) 115 * @throws IllegalStateException in case of no {@code Configuration} object set 116 * @see #getConfiguration() 117 */ 118 protected Configuration obtainConfiguration() { 119 Configuration configuration = getConfiguration(); 120 Assert.state(configuration != null, "No Configuration set"); 121 return configuration; 122 } 123 124 /** 125 * Set the encoding of the FreeMarker template file. 126 * <p>By default {@link FreeMarkerConfigurer} sets the default encoding in 127 * the FreeMarker configuration to "UTF-8". It's recommended to specify the 128 * encoding in the FreeMarker {@link Configuration} rather than per template 129 * if all your templates share a common encoding. 130 */ 131 public void setEncoding(@Nullable String encoding) { 132 this.encoding = encoding; 133 } 134 135 /** 136 * Get the encoding for the FreeMarker template. 137 */ 138 @Nullable 139 protected String getEncoding() { 140 return this.encoding; 141 } 142 143 /** 144 * Set whether to expose a {@link RequestContext} for use by Spring's macro 145 * library, under the name {@value #SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE}. 146 * <p>Default is {@code true}. 147 * <p>Needed for Spring's FreeMarker default macros. Note that this is 148 * <i>not</i> required for templates that use HTML forms <i>unless</i> you 149 * wish to take advantage of the Spring helper macros. 150 * @since 5.2 151 * @see #SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE 152 */ 153 public void setExposeSpringMacroHelpers(boolean exposeSpringMacroHelpers) { 154 this.exposeSpringMacroHelpers = exposeSpringMacroHelpers; 155 } 156 157 158 @Override 159 public void afterPropertiesSet() throws Exception { 160 super.afterPropertiesSet(); 161 if (getConfiguration() == null) { 162 FreeMarkerConfig config = autodetectConfiguration(); 163 setConfiguration(config.getConfiguration()); 164 } 165 } 166 167 /** 168 * Autodetect a {@link FreeMarkerConfig} object in the {@code ApplicationContext}. 169 * @return the {@code FreeMarkerConfig} instance to use for this view 170 * @throws BeansException if no {@code FreeMarkerConfig} instance could be found 171 * @see #setConfiguration 172 */ 173 protected FreeMarkerConfig autodetectConfiguration() throws BeansException { 174 try { 175 return BeanFactoryUtils.beanOfTypeIncludingAncestors( 176 obtainApplicationContext(), FreeMarkerConfig.class, true, false); 177 } 178 catch (NoSuchBeanDefinitionException ex) { 179 throw new ApplicationContextException( 180 "Must define a single FreeMarkerConfig bean in this application context " + 181 "(may be inherited): FreeMarkerConfigurer is the usual implementation. " + 182 "This bean may be given any name.", ex); 183 } 184 } 185 186 187 /** 188 * Check that the FreeMarker template used for this view exists and is valid. 189 * <p>Can be overridden to customize the behavior, for example in case of 190 * multiple templates to be rendered into a single view. 191 */ 192 @Override 193 public boolean checkResourceExists(Locale locale) throws Exception { 194 try { 195 // Check that we can get the template, even if we might subsequently get it again. 196 getTemplate(locale); 197 return true; 198 } 199 catch (FileNotFoundException ex) { 200 // Allow for ViewResolver chaining... 201 return false; 202 } 203 catch (ParseException ex) { 204 throw new ApplicationContextException( 205 "Failed to parse FreeMarker template for URL [" + getUrl() + "]", ex); 206 } 207 catch (IOException ex) { 208 throw new ApplicationContextException( 209 "Could not load FreeMarker template for URL [" + getUrl() + "]", ex); 210 } 211 } 212 213 /** 214 * Prepare the model to use for rendering by potentially exposing a 215 * {@link RequestContext} for use in Spring FreeMarker macros and then 216 * delegating to the inherited implementation of this method. 217 * @since 5.2 218 * @see #setExposeSpringMacroHelpers(boolean) 219 * @see org.springframework.web.reactive.result.view.AbstractView#getModelAttributes(Map, ServerWebExchange) 220 */ 221 @Override 222 protected Mono<Map<String, Object>> getModelAttributes( 223 @Nullable Map<String, ?> model, ServerWebExchange exchange) { 224 225 if (this.exposeSpringMacroHelpers) { 226 if (model != null && model.containsKey(SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE)) { 227 throw new IllegalStateException( 228 "Cannot expose bind macro helper '" + SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE + 229 "' because of an existing model object of the same name"); 230 } 231 // Make a defensive copy of the model. 232 Map<String, Object> attributes = (model != null ? new HashMap<>(model) : new HashMap<>()); 233 // Expose RequestContext instance for Spring macros. 234 attributes.put(SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE, new RequestContext( 235 exchange, attributes, obtainApplicationContext(), getRequestDataValueProcessor())); 236 return super.getModelAttributes(attributes, exchange); 237 } 238 return super.getModelAttributes(model, exchange); 239 } 240 241 @Override 242 protected Mono<Void> renderInternal(Map<String, Object> renderAttributes, 243 @Nullable MediaType contentType, ServerWebExchange exchange) { 244 245 return exchange.getResponse().writeWith(Mono 246 .fromCallable(() -> { 247 // Expose all standard FreeMarker hash models. 248 SimpleHash freeMarkerModel = getTemplateModel(renderAttributes, exchange); 249 250 if (logger.isDebugEnabled()) { 251 logger.debug(exchange.getLogPrefix() + "Rendering [" + getUrl() + "]"); 252 } 253 254 Locale locale = LocaleContextHolder.getLocale(exchange.getLocaleContext()); 255 DataBuffer dataBuffer = exchange.getResponse().bufferFactory().allocateBuffer(); 256 try { 257 Charset charset = getCharset(contentType); 258 Writer writer = new OutputStreamWriter(dataBuffer.asOutputStream(), charset); 259 getTemplate(locale).process(freeMarkerModel, writer); 260 return dataBuffer; 261 } 262 catch (IOException ex) { 263 DataBufferUtils.release(dataBuffer); 264 String message = "Could not load FreeMarker template for URL [" + getUrl() + "]"; 265 throw new IllegalStateException(message, ex); 266 } 267 catch (Throwable ex) { 268 DataBufferUtils.release(dataBuffer); 269 throw ex; 270 } 271 }) 272 .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release)); 273 } 274 275 private Charset getCharset(@Nullable MediaType mediaType) { 276 return Optional.ofNullable(mediaType).map(MimeType::getCharset).orElse(getDefaultCharset()); 277 } 278 279 /** 280 * Build a FreeMarker template model for the given model map. 281 * <p>The default implementation builds a {@link SimpleHash}. 282 * @param model the model to use for rendering 283 * @param exchange current exchange 284 * @return the FreeMarker template model, as a {@link SimpleHash} or subclass thereof 285 */ 286 protected SimpleHash getTemplateModel(Map<String, Object> model, ServerWebExchange exchange) { 287 SimpleHash fmModel = new SimpleHash(getObjectWrapper()); 288 fmModel.putAll(model); 289 return fmModel; 290 } 291 292 /** 293 * Get the configured FreeMarker {@link ObjectWrapper}, or the 294 * {@linkplain ObjectWrapper#DEFAULT_WRAPPER default wrapper} if none specified. 295 * @see freemarker.template.Configuration#getObjectWrapper() 296 */ 297 protected ObjectWrapper getObjectWrapper() { 298 ObjectWrapper ow = obtainConfiguration().getObjectWrapper(); 299 Version version = Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS; 300 return (ow != null ? ow : new DefaultObjectWrapperBuilder(version).build()); 301 } 302 303 /** 304 * Get the FreeMarker template for the given locale, to be rendered by this view. 305 * <p>By default, the template specified by the "url" bean property will be retrieved. 306 * @param locale the current locale 307 * @return the FreeMarker template to render 308 */ 309 protected Template getTemplate(Locale locale) throws IOException { 310 return (getEncoding() != null ? 311 obtainConfiguration().getTemplate(getUrl(), locale, getEncoding()) : 312 obtainConfiguration().getTemplate(getUrl(), locale)); 313 } 314 315}