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 &mdash; 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}