diff --git a/spring-web-reactive/build.gradle b/spring-web-reactive/build.gradle index 2d359c0d66..a6d4226ad6 100644 --- a/spring-web-reactive/build.gradle +++ b/spring-web-reactive/build.gradle @@ -84,6 +84,7 @@ dependencies { compile "io.projectreactor:reactor-core:${reactorVersion}" compile "commons-logging:commons-logging:1.2" + optional "org.springframework:spring-context-support:${springVersion}" // for FreeMarker optional 'io.reactivex:rxjava:1.1.0' optional "io.reactivex:rxnetty-http:0.5.0-SNAPSHOT" optional "com.fasterxml.jackson.core:jackson-databind:2.6.2" @@ -95,6 +96,7 @@ dependencies { optional 'io.undertow:undertow-core:1.3.5.Final' optional "org.eclipse.jetty:jetty-server:${jettyVersion}" optional "org.eclipse.jetty:jetty-servlet:${jettyVersion}" + optional("org.freemarker:freemarker:2.3.23") provided "javax.servlet:javax.servlet-api:3.1.0" diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandler.java index a27b5ce82f..8c0b57d969 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandler.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandler.java @@ -66,7 +66,7 @@ public class ResponseBodyResultHandler implements HandlerResultHandler, Ordered private final Map, List> mediaTypesByEncoder; - private int order = 0; + private int order = 0; // TODO: should be MAX_VALUE public ResponseBodyResultHandler(List> encoders, ConversionService service) { diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractUrlBasedView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractUrlBasedView.java new file mode 100644 index 0000000000..50ffac0b3c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractUrlBasedView.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view; + +import java.util.Locale; + +import org.springframework.beans.factory.InitializingBean; + +/** + * Abstract base class for URL-based views. Provides a consistent way of + * holding the URL that a View wraps, in the form of a "url" bean property. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractUrlBasedView extends AbstractView implements InitializingBean { + + private String url; + + + /** + * Constructor for use as a bean. + */ + protected AbstractUrlBasedView() { + } + + /** + * Create a new AbstractUrlBasedView with the given URL. + */ + protected AbstractUrlBasedView(String url) { + this.url = url; + } + + + /** + * Set the URL of the resource that this view wraps. + * The URL must be appropriate for the concrete View implementation. + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * Return the URL of the resource that this view wraps. + */ + public String getUrl() { + return this.url; + } + + + @Override + public void afterPropertiesSet() throws Exception { + if (getUrl() == null) { + throw new IllegalArgumentException("Property 'url' is required"); + } + } + + /** + * Check whether the resource for the configured URL actually exists. + * @param locale the desired Locale that we're looking for + * @return {@code false} if the resource exists + * {@code false} if we know that it does not exist + * @throws Exception if the resource exists but is invalid (e.g. could not be parsed) + */ + public abstract boolean checkResourceExists(Locale locale) throws Exception; + + + @Override + public String toString() { + return super.toString() + "; URL [" + getUrl() + "]"; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractView.java new file mode 100644 index 0000000000..436bb63fe7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/AbstractView.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferAllocator; +import org.springframework.core.io.buffer.DefaultDataBufferAllocator; +import org.springframework.http.MediaType; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.View; +import org.springframework.web.server.ServerWebExchange; + +/** + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractView implements View, ApplicationContextAware { + + /** Logger that is available to subclasses */ + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List mediaTypes = new ArrayList<>(4); + + private DataBufferAllocator bufferAllocator = new DefaultDataBufferAllocator(); + + private ApplicationContext applicationContext; + + + public AbstractView() { + this.mediaTypes.add(ViewResolverSupport.DEFAULT_CONTENT_TYPE); + } + + + /** + * Set the supported media types for this view. + * Default is "text/html;charset=UTF-8". + */ + public void setSupportedMediaTypes(List supportedMediaTypes) { + Assert.notEmpty(supportedMediaTypes, "'supportedMediaTypes' is required."); + this.mediaTypes.clear(); + if (supportedMediaTypes != null) { + this.mediaTypes.addAll(supportedMediaTypes); + } + } + + /** + * Return the configured media types supported by this view. + */ + @Override + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + /** + * Configure the {@link DataBufferAllocator} to use for write I/O. + *

By default this is set to {@link DefaultDataBufferAllocator}. + * @param bufferAllocator the allocator to use + */ + public void setBufferAllocator(DataBufferAllocator bufferAllocator) { + Assert.notNull(bufferAllocator, "'bufferAllocator' is required."); + this.bufferAllocator = bufferAllocator; + } + + /** + * Return the configured buffer allocator, never {@code null}. + */ + public DataBufferAllocator getBufferAllocator() { + return this.bufferAllocator; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + public ApplicationContext getApplicationContext() { + return applicationContext; + } + + + /** + * Prepare the model to render. + * @param result the result from handler execution + * @param contentType the content type selected to render with which should + * match one of the {@link #getSupportedMediaTypes() supported media types}. + * @param exchange the current exchange + * @return + */ + @Override + public Flux render(HandlerResult result, Optional contentType, + ServerWebExchange exchange) { + + if (logger.isTraceEnabled()) { + logger.trace("Rendering view with model " + result.getModel()); + } + + if (contentType.isPresent()) { + exchange.getResponse().getHeaders().setContentType(contentType.get()); + } + + Map mergedModel = getModelAttributes(result, exchange); + return renderInternal(mergedModel, exchange); + } + + /** + * Prepare the model to use for rendering. + *

The default implementation creates a combined output Map that includes + * model as well as static attributes with the former taking precedence. + */ + protected Map getModelAttributes(HandlerResult result, ServerWebExchange exchange) { + ModelMap model = result.getModel(); + int size = (model != null ? model.size() : 0); + + Map attributes = new LinkedHashMap<>(size); + if (model != null) { + attributes.putAll(model); + } + + return attributes; + } + + /** + * Subclasses must implement this method to actually render the view. + * @param renderAttributes combined output Map (never {@code null}), + * with dynamic values taking precedence over static attributes + * @param exchange current exchange + */ + protected abstract Flux renderInternal(Map renderAttributes, + ServerWebExchange exchange); + + + @Override + public String toString() { + return getClass().getName(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/UrlBasedViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/UrlBasedViewResolver.java new file mode 100644 index 0000000000..5449493789 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/UrlBasedViewResolver.java @@ -0,0 +1,168 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view; + +import java.util.Locale; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.web.reactive.View; + + +/** + * A {@link org.springframework.web.reactive.ViewResolver ViewResolver} that + * allow direct resolution of symbolic view names to URLs without explicit + * mapping definition. This is useful if symbolic names match the names of view + * resources in a straightforward manner (i.e. the symbolic name is the unique + * part of the resource's filename), without the need for a dedicated mapping + * to be defined for each view. + * + *

Supports {@link AbstractUrlBasedView} subclasses like + * {@link org.springframework.web.reactive.view.freemarker.FreeMarkerView}. + * The view class for all views generated by this resolver can be specified + * via the "viewClass" property. + * + *

View names can either be resource URLs themselves, or get augmented by a + * specified prefix and/or suffix. Exporting an attribute that holds the + * RequestContext to all views is explicitly supported. + * + *

Example: prefix="templates/", suffix=".ftl", viewname="test" -> + * "templates/test.ftl" + * + *

As a special feature, redirect URLs can be specified via the "redirect:" + * prefix. E.g.: "redirect:myAction" will trigger a redirect to the given + * URL, rather than resolution as standard view name. This is typically used + * for redirecting to a controller URL after finishing a form workflow. + * + *

Note: This class does not support localized resolution, i.e. resolving + * a symbolic view name to different resources depending on the current locale. + * * @author Rossen Stoyanchev + */ +public class UrlBasedViewResolver extends ViewResolverSupport implements InitializingBean { + + private Class viewClass; + + private String prefix = ""; + + private String suffix = ""; + + + /** + * Set the view class to instantiate through {@link #createUrlBasedView(String)}. + * @param viewClass a class that is assignable to the required view class + * which by default is AbstractUrlBasedView. + */ + public void setViewClass(Class viewClass) { + if (viewClass == null || !requiredViewClass().isAssignableFrom(viewClass)) { + String name = (viewClass != null ? viewClass.getName() : null); + throw new IllegalArgumentException("Given view class [" + name + "] " + + "is not of type [" + requiredViewClass().getName() + "]"); + } + this.viewClass = viewClass; + } + + /** + * Return the view class to be used to create views. + */ + protected Class getViewClass() { + return this.viewClass; + } + + /** + * Return the required type of view for this resolver. + * This implementation returns {@link AbstractUrlBasedView}. + * @see AbstractUrlBasedView + */ + protected Class requiredViewClass() { + return AbstractUrlBasedView.class; + } + + /** + * Set the prefix that gets prepended to view names when building a URL. + */ + public void setPrefix(String prefix) { + this.prefix = (prefix != null ? prefix : ""); + } + + /** + * Return the prefix that gets prepended to view names when building a URL. + */ + protected String getPrefix() { + return this.prefix; + } + + /** + * Set the suffix that gets appended to view names when building a URL. + */ + public void setSuffix(String suffix) { + this.suffix = (suffix != null ? suffix : ""); + } + + /** + * Return the suffix that gets appended to view names when building a URL. + */ + protected String getSuffix() { + return this.suffix; + } + + + @Override + public void afterPropertiesSet() throws Exception { + if (getViewClass() == null) { + throw new IllegalArgumentException("Property 'viewClass' is required"); + } + } + + + @Override + public Mono resolveViewName(String viewName, Locale locale) { + AbstractUrlBasedView urlBasedView = createUrlBasedView(viewName); + View view = applyLifecycleMethods(viewName, urlBasedView); + try { + return (urlBasedView.checkResourceExists(locale) ? Mono.just(view) : Mono.empty()); + } + catch (Exception ex) { + return Mono.error(ex); + } + } + + /** + * Creates a new View instance of the specified view class and configures it. + * Does not perform any lookup for pre-defined View instances. + *

Spring lifecycle methods as defined by the bean container do not have to + * be called here; those will be applied by the {@code loadView} method + * after this method returns. + *

Subclasses will typically call {@code super.buildView(viewName)} + * first, before setting further properties themselves. {@code loadView} + * will then apply Spring lifecycle methods at the end of this process. + * @param viewName the name of the view to build + * @return the View instance + */ + protected AbstractUrlBasedView createUrlBasedView(String viewName) { + AbstractUrlBasedView view = (AbstractUrlBasedView) BeanUtils.instantiateClass(getViewClass()); + view.setSupportedMediaTypes(getSupportedMediaTypes()); + view.setBufferAllocator(getBufferAllocator()); + view.setUrl(getPrefix() + viewName + getSuffix()); + return view; + } + + private View applyLifecycleMethods(String viewName, AbstractView view) { + return (View) getApplicationContext().getAutowireCapableBeanFactory().initializeBean(view, viewName); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java index 76c69134f1..317e23e2a5 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java @@ -24,6 +24,7 @@ import java.util.Optional; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.Ordered; import org.springframework.core.convert.ConversionService; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.util.Assert; @@ -45,12 +46,14 @@ import org.springframework.web.server.ServerWebExchange; * * @author Rossen Stoyanchev */ -public class ViewResolverResultHandler implements HandlerResultHandler { +public class ViewResolverResultHandler implements HandlerResultHandler, Ordered { private final List viewResolvers = new ArrayList<>(4); private final ConversionService conversionService; + private int order = Integer.MAX_VALUE; + public ViewResolverResultHandler(List resolvers, ConversionService service) { Assert.notEmpty(resolvers, "At least one ViewResolver is required."); @@ -67,6 +70,15 @@ public class ViewResolverResultHandler implements HandlerResultHandler { return Collections.unmodifiableList(this.viewResolvers); } + public void setOrder(int order) { + this.order = order; + } + + @Override + public int getOrder() { + return this.order; + } + // TODO: @ModelAttribute return value, declared Object return value (either String or View) diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverSupport.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverSupport.java new file mode 100644 index 0000000000..7bafe058ec --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverSupport.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBufferAllocator; +import org.springframework.core.io.buffer.DefaultDataBufferAllocator; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.reactive.ViewResolver; + +/** + * Base class for {@code ViewResolver} implementations with shared properties. + * + * @author Rossen Stoyanchev + * @since 4.3 + */ +public abstract class ViewResolverSupport implements ViewResolver, ApplicationContextAware, Ordered { + + public static final MediaType DEFAULT_CONTENT_TYPE = MediaType.parseMediaType("text/html;charset=UTF-8"); + + + private List mediaTypes = new ArrayList<>(4); + + private DataBufferAllocator bufferAllocator = new DefaultDataBufferAllocator(); + + private ApplicationContext applicationContext; + + private int order = Integer.MAX_VALUE; + + + public ViewResolverSupport() { + this.mediaTypes.add(DEFAULT_CONTENT_TYPE); + } + + + /** + * Set the supported media types for this view. + * Default is "text/html;charset=UTF-8". + */ + public void setSupportedMediaTypes(List supportedMediaTypes) { + Assert.notEmpty(supportedMediaTypes, "'supportedMediaTypes' is required."); + this.mediaTypes.clear(); + if (supportedMediaTypes != null) { + this.mediaTypes.addAll(supportedMediaTypes); + } + } + + /** + * Return the configured media types supported by this view. + */ + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + /** + * Configure the {@link DataBufferAllocator} to use for write I/O. + *

By default this is set to {@link DefaultDataBufferAllocator}. + * @param bufferAllocator the allocator to use + */ + public void setBufferAllocator(DataBufferAllocator bufferAllocator) { + Assert.notNull(bufferAllocator, "'bufferAllocator' is required."); + this.bufferAllocator = bufferAllocator; + } + + /** + * Return the configured buffer allocator, never {@code null}. + */ + public DataBufferAllocator getBufferAllocator() { + return this.bufferAllocator; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + public ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + /** + * Set the order in which this {@link ViewResolver} + * is evaluated. + */ + public void setOrder(int order) { + this.order = order; + } + + /** + * Return the order in which this {@link ViewResolver} is evaluated. + */ + @Override + public int getOrder() { + return this.order; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfig.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfig.java new file mode 100644 index 0000000000..6502f3e182 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.view.freemarker; + +import freemarker.template.Configuration; + +/** + * Interface to be implemented by objects that configure and manage a + * FreeMarker Configuration object in a web environment. Detected and + * used by {@link FreeMarkerView}. + * + * @author Rossen Stoyanchev + */ +public interface FreeMarkerConfig { + + /** + * Return the FreeMarker Configuration object for the current + * web application context. + *

A FreeMarker Configuration object may be used to set FreeMarker + * properties and shared objects, and allows to retrieve templates. + * @return the FreeMarker Configuration + */ + Configuration getConfiguration(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfigurer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfigurer.java new file mode 100644 index 0000000000..5dc3901ea5 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerConfigurer.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.view.freemarker; + +import java.io.IOException; +import java.util.List; + +import freemarker.cache.ClassTemplateLoader; +import freemarker.cache.TemplateLoader; +import freemarker.template.Configuration; +import freemarker.template.TemplateException; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.ui.freemarker.FreeMarkerConfigurationFactory; + +/** + * Configures FreeMarker for web usage via the "configLocation" and/or + * "freemarkerSettings" and/or "templateLoaderPath" properties. + * The simplest way to use this class is to specify just a "templateLoaderPath" + * (e.g. "classpath:templates"); you do not need any further configuration then. + * + *

This bean must be included in the application context of any application + * using {@link FreeMarkerView}. It exists purely to configure FreeMarker. + * It is not meant to be referenced by application components but just internally + * by {@code FreeMarkerView}. Implements {@link FreeMarkerConfig} to be found by + * {@code FreeMarkerView} without depending on the bean name the configurer. + * + *

Note that you can also refer to a pre-configured FreeMarker Configuration + * instance via the "configuration" property. This allows to share a FreeMarker + * Configuration for web and email usage for example. + * + *

TODO: macros + * + *

This configurer registers a template loader for this package, allowing to + * reference the "spring.ftl" macro library contained in this package: + * + *

+ * <#import "/spring.ftl" as spring/>
+ * <@spring.bind "person.age"/>
+ * age is ${spring.status.value}
+ * + * Note: Spring's FreeMarker support requires FreeMarker 2.3 or higher. + * + * @author Rossen Stoyanchev + */ +public class FreeMarkerConfigurer extends FreeMarkerConfigurationFactory + implements FreeMarkerConfig, InitializingBean, ResourceLoaderAware { + + private Configuration configuration; + + + public FreeMarkerConfigurer() { + setDefaultEncoding("UTF-8"); + } + + + /** + * Set a pre-configured Configuration to use for the FreeMarker web config, + * e.g. a shared one for web and email usage. If this is not set, + * FreeMarkerConfigurationFactory's properties (inherited by this class) + * have to be specified. + */ + public void setConfiguration(Configuration configuration) { + this.configuration = configuration; + } + + + /** + * Initialize FreeMarkerConfigurationFactory's Configuration + * if not overridden by a pre-configured FreeMarker Configuation. + *

Sets up a ClassTemplateLoader to use for loading Spring macros. + * @see #createConfiguration + * @see #setConfiguration + */ + @Override + public void afterPropertiesSet() throws IOException, TemplateException { + if (this.configuration == null) { + this.configuration = createConfiguration(); + } + } + + /** + * This implementation registers an additional ClassTemplateLoader + * for the Spring-provided macros, added to the end of the list. + */ + @Override + protected void postProcessTemplateLoaders(List templateLoaders) { + templateLoaders.add(new ClassTemplateLoader(FreeMarkerConfigurer.class, "")); + logger.info("ClassTemplateLoader for Spring macros added to FreeMarker configuration"); + } + + + /** + * Return the Configuration object wrapped by this bean. + */ + @Override + public Configuration getConfiguration() { + return this.configuration; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerView.java new file mode 100644 index 0000000000..9efd05265b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerView.java @@ -0,0 +1,219 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view.freemarker; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.Locale; +import java.util.Map; + +import freemarker.core.ParseException; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.ObjectWrapper; +import freemarker.template.SimpleHash; +import freemarker.template.Template; +import freemarker.template.Version; +import reactor.core.publisher.Flux; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContextException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.web.reactive.view.AbstractUrlBasedView; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@code View} implementation that uses the FreeMarker template engine. + * + *

Depends on a single {@link FreeMarkerConfig} object such as + * {@link FreeMarkerConfigurer} being accessible in the application context. + * Alternatively set the FreeMarker configuration can be set directly on this + * class via {@link #setConfiguration}. + * + *

The {@link #setUrl(String) url} property is the location of the FreeMarker + * template relative to the FreeMarkerConfigurer's + * {@link FreeMarkerConfigurer#setTemplateLoaderPath templateLoaderPath}. + * + *

Note: Spring's FreeMarker support requires FreeMarker 2.3 or higher. + * + * @author Rossen Stoyanchev + */ +public class FreeMarkerView extends AbstractUrlBasedView { + + private Configuration configuration; + + private String encoding; + + + /** + * Set the FreeMarker Configuration to be used by this view. + *

Typically this property is not set directly. Instead a single + * {@link FreeMarkerConfig} is expected in the Spring application context + * which is used to obtain the FreeMarker configuration. + */ + public void setConfiguration(Configuration configuration) { + this.configuration = configuration; + } + + /** + * Return the FreeMarker configuration used by this view. + */ + protected Configuration getConfiguration() { + return this.configuration; + } + + /** + * Set the encoding of the FreeMarker template file. + *

By default {@link FreeMarkerConfigurer} sets the default encoding in + * the FreeMarker configuration to "UTF-8". It's recommended to specify the + * encoding in the FreeMarker Configuration rather than per template if all + * your templates share a common encoding. + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * Return the encoding for the FreeMarker template. + */ + protected String getEncoding() { + return this.encoding; + } + + + @Override + public void afterPropertiesSet() throws Exception { + super.afterPropertiesSet(); + if (getConfiguration() == null) { + FreeMarkerConfig config = autodetectConfiguration(); + setConfiguration(config.getConfiguration()); + } + } + + /** + * Autodetect a {@link FreeMarkerConfig} object via the ApplicationContext. + * @return the Configuration instance to use for FreeMarkerViews + * @throws BeansException if no Configuration instance could be found + * @see #setConfiguration + */ + protected FreeMarkerConfig autodetectConfiguration() throws BeansException { + try { + return BeanFactoryUtils.beanOfTypeIncludingAncestors( + getApplicationContext(), FreeMarkerConfig.class, true, false); + } + catch (NoSuchBeanDefinitionException ex) { + throw new ApplicationContextException( + "Must define a single FreeMarkerConfig bean in this web application context " + + "(may be inherited): FreeMarkerConfigurer is the usual implementation. " + + "This bean may be given any name.", ex); + } + } + + + /** + * Check that the FreeMarker template used for this view exists and is valid. + *

Can be overridden to customize the behavior, for example in case of + * multiple templates to be rendered into a single view. + */ + @Override + public boolean checkResourceExists(Locale locale) throws Exception { + try { + // Check that we can get the template, even if we might subsequently get it again. + getTemplate(locale); + return true; + } + catch (FileNotFoundException ex) { + if (logger.isDebugEnabled()) { + logger.debug("No FreeMarker view found for URL: " + getUrl()); + } + return false; + } + catch (ParseException ex) { + throw new ApplicationContextException( + "Failed to parse FreeMarker template for URL [" + getUrl() + "]", ex); + } + catch (IOException ex) { + throw new ApplicationContextException( + "Could not load FreeMarker template for URL [" + getUrl() + "]", ex); + } + } + + @Override + protected Flux renderInternal(Map renderAttributes, ServerWebExchange exchange) { + // Expose all standard FreeMarker hash models. + SimpleHash freeMarkerModel = getTemplateModel(renderAttributes, exchange); + if (logger.isDebugEnabled()) { + logger.debug("Rendering FreeMarker template [" + getUrl() + "]."); + } + Locale locale = Locale.getDefault(); // TODO + DataBuffer dataBuffer = getBufferAllocator().allocateBuffer(); + try { + Writer writer = new OutputStreamWriter(dataBuffer.asOutputStream()); + getTemplate(locale).process(freeMarkerModel, writer); + } + catch (IOException ex) { + String message = "Could not load FreeMarker template for URL [" + getUrl() + "]"; + return Flux.error(new IllegalStateException(message, ex)); + } + catch (Throwable ex) { + return Flux.error(ex); + } + return Flux.just(dataBuffer); + } + + /** + * Build a FreeMarker template model for the given model Map. + *

The default implementation builds a {@link SimpleHash}. + * @param model the model to use for rendering + * @param exchange current exchange + * @return the FreeMarker template model, as a {@link SimpleHash} or subclass thereof + */ + protected SimpleHash getTemplateModel(Map model, ServerWebExchange exchange) { + SimpleHash fmModel = new SimpleHash(getObjectWrapper()); + fmModel.putAll(model); + return fmModel; + } + + /** + * Return the configured FreeMarker {@link ObjectWrapper}, or the + * {@link ObjectWrapper#DEFAULT_WRAPPER default wrapper} if none specified. + * @see freemarker.template.Configuration#getObjectWrapper() + */ + protected ObjectWrapper getObjectWrapper() { + ObjectWrapper ow = getConfiguration().getObjectWrapper(); + Version version = Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS; + return (ow != null ? ow : new DefaultObjectWrapperBuilder(version).build()); + } + + /** + * Retrieve the FreeMarker template for the given locale, + * to be rendering by this view. + *

By default, the template specified by the "url" bean property + * will be retrieved. + * @param locale the current locale + * @return the FreeMarker template to render + */ + protected Template getTemplate(Locale locale) throws IOException { + return (getEncoding() != null ? + getConfiguration().getTemplate(getUrl(), locale, getEncoding()) : + getConfiguration().getTemplate(getUrl(), locale)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewResolver.java new file mode 100644 index 0000000000..631d6fbcd7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewResolver.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view.freemarker; + +import org.springframework.web.reactive.view.UrlBasedViewResolver; + +/** + * A {@code ViewResolver} for resolving {@link FreeMarkerView} instances, i.e. + * FreeMarker templates and custom subclasses of it. + * + *

The view class for all views generated by this resolver can be specified + * via the "viewClass" property. See {@link UrlBasedViewResolver} for details. + * + * @author Rossen Stoyanchev + */public class FreeMarkerViewResolver extends UrlBasedViewResolver { + + + /** + * Simple constructor. + */ + public FreeMarkerViewResolver() { + setViewClass(requiredViewClass()); + } + + /** + * Convenience constructor with a prefix and suffix. + * @param suffix the suffix to prepend view names with + * @param prefix the prefix to prepend view names with + */ + public FreeMarkerViewResolver(String prefix, String suffix) { + setViewClass(requiredViewClass()); + setPrefix(prefix); + setSuffix(suffix); + } + + + /** + * Requires {@link FreeMarkerView}. + */ + @Override + protected Class requiredViewClass() { + return FreeMarkerView.class; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewTests.java new file mode 100644 index 0000000000..7123553d47 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/freemarker/FreeMarkerViewTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.view.freemarker; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Locale; +import java.util.Optional; + +import freemarker.template.Configuration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.ApplicationContextException; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * @author Rossen Stoyanchev + */ +public class FreeMarkerViewTests { + + public static final String TEMPLATE_PATH = "classpath*:org/springframework/web/reactive/view/freemarker/"; + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + + private ServerWebExchange exchange; + + private GenericApplicationContext context; + + private Configuration freeMarkerConfig; + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + + @Before + public void setUp() throws Exception { + this.context = new GenericApplicationContext(); + this.context.refresh(); + + FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); + configurer.setPreferFileSystemAccess(false); + configurer.setTemplateLoaderPath(TEMPLATE_PATH); + configurer.setResourceLoader(this.context); + this.freeMarkerConfig = configurer.createConfiguration(); + + FreeMarkerView fv = new FreeMarkerView(); + fv.setApplicationContext(this.context); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager manager = new DefaultWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, response, manager); + } + + + @Test + public void noFreeMarkerConfig() throws Exception { + this.exception.expect(ApplicationContextException.class); + this.exception.expectMessage("Must define a single FreeMarkerConfig bean"); + + FreeMarkerView view = new FreeMarkerView(); + view.setApplicationContext(this.context); + view.setUrl("anythingButNull"); + view.afterPropertiesSet(); + } + + @Test + public void noTemplateName() throws Exception { + this.exception.expect(IllegalArgumentException.class); + this.exception.expectMessage("Property 'url' is required"); + + FreeMarkerView freeMarkerView = new FreeMarkerView(); + freeMarkerView.afterPropertiesSet(); + } + + @Test + public void checkResourceExists() throws Exception { + FreeMarkerView view = new FreeMarkerView(); + view.setConfiguration(this.freeMarkerConfig); + view.setUrl("test.ftl"); + + assertTrue(view.checkResourceExists(Locale.US)); + } + + @Test + public void render() throws Exception { + FreeMarkerView view = new FreeMarkerView(); + view.setConfiguration(this.freeMarkerConfig); + view.setUrl("test.ftl"); + + ModelMap model = new ExtendedModelMap(); + model.addAttribute("hello", "hi FreeMarker"); + HandlerResult result = new HandlerResult(new Object(), "", ResolvableType.NONE, model); + Flux flux = view.render(result, Optional.empty(), this.exchange); + + TestSubscriber subscriber = new TestSubscriber<>(); + subscriber.bindTo(flux).assertValuesWith(dataBuffer -> + assertEquals("hi FreeMarker", asString(dataBuffer))); + } + + + + private static String asString(DataBuffer dataBuffer) { + ByteBuffer byteBuffer = dataBuffer.asByteBuffer(); + final byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return new String(bytes, UTF_8); + } + +} diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/view/freemarker/test.ftl b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/view/freemarker/test.ftl new file mode 100644 index 0000000000..f9ad1fdc6e --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/view/freemarker/test.ftl @@ -0,0 +1 @@ +${hello} \ No newline at end of file