diff --git a/spring-web-reactive/build.gradle b/spring-web-reactive/build.gradle index 2d359c0d663..a6d4226ad69 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/DispatcherHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java index 7b37bf23a75..318f52a991d 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java @@ -146,7 +146,7 @@ public class DispatcherHandler implements WebHandler, ApplicationContextAware { return resultHandler; } } - throw new IllegalStateException("No HandlerResultHandler for " + handlerResult.getResult()); + throw new IllegalStateException("No HandlerResultHandler for " + handlerResult.getReturnValue()); } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java index 9ffae86ba46..6f4bd8220bc 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java @@ -16,11 +16,14 @@ package org.springframework.web.reactive; +import java.util.Optional; import java.util.function.Function; import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; import org.springframework.util.Assert; /** @@ -32,40 +35,67 @@ public class HandlerResult { private final Object handler; - private final Object result; + private final Optional returnValue; - private final ResolvableType resultType; + private final ResolvableType returnValueType; + + private final ModelMap model; private Function> exceptionHandler; - public HandlerResult(Object handler, Object result, ResolvableType resultType) { + /** + * Create a new {@code HandlerResult}. + * @param handler the handler that handled the request + * @param returnValue the return value from the handler possibly {@code null} + * @param returnValueType the return value type + * @param model the model used for request handling + */ + public HandlerResult(Object handler, Object returnValue, ResolvableType returnValueType, ModelMap model) { Assert.notNull(handler, "'handler' is required"); - Assert.notNull(handler, "'resultType' is required"); + Assert.notNull(returnValueType, "'returnValueType' is required"); + Assert.notNull(model, "'model' is required"); this.handler = handler; - this.result = result; - this.resultType = resultType; + this.returnValue = Optional.ofNullable(returnValue); + this.returnValueType = returnValueType; + this.model = model; } + /** + * Return the handler that handled the request. + */ public Object getHandler() { return this.handler; } - public Object getResult() { - return this.result; + /** + * Return the value returned from the handler wrapped as {@link Optional}. + */ + public Optional getReturnValue() { + return this.returnValue; } - public ResolvableType getResultType() { - return this.resultType; + /** + * Return the type of the value returned from the handler. + */ + public ResolvableType getReturnValueType() { + return this.returnValueType; } /** - * For an async result, failures may occur later during result handling. - * Use this property to configure an exception handler to be invoked if - * result handling fails. - * - * @param function a function to map the the error to an alternative result. + * Return the model used during request handling with attributes that may be + * used to render HTML templates with. + */ + public ModelMap getModel() { + return this.model; + } + + /** + * Configure an exception handler that may be used to produce an alternative + * result when result handling fails. Especially for an async return value + * errors may occur after the invocation of the handler. + * @param function the error handler * @return the current instance */ public HandlerResult setExceptionHandler(Function> function) { @@ -73,12 +103,20 @@ public class HandlerResult { return this; } + /** + * Whether there is an exception handler. + */ public boolean hasExceptionHandler() { return (this.exceptionHandler != null); } - public Mono applyExceptionHandler(Throwable ex) { - return (hasExceptionHandler() ? this.exceptionHandler.apply(ex) : Mono.error(ex)); + /** + * Apply the exception handler and return the alternative result. + * @param failure the exception + * @return the new result or the same error if there is no exception handler + */ + public Mono applyExceptionHandler(Throwable failure) { + return (hasExceptionHandler() ? this.exceptionHandler.apply(failure) : Mono.error(failure)); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java new file mode 100644 index 00000000000..91fb8c3dd01 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java @@ -0,0 +1,59 @@ +/* + * 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; + +import java.util.List; +import java.util.Optional; + +import reactor.core.publisher.Flux; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; + +/** + * Contract to render {@link HandlerResult} to the HTTP response. + * + *

In contrast to an {@link org.springframework.core.codec.Encoder Encoder} + * which is a singleton and encodes any object of a given type, a {@code View} + * is typically selected by name and resolved using a {@link ViewResolver} + * which may for example match it to an HTML template. Furthermore a {@code View} + * may render based on multiple attributes contained in the model. + * + *

A {@code View} can also choose to select an attribute from the model use + * any existing {@code Encoder} to render alternate media types. + * + * @author Rossen Stoyanchev + */ +public interface View { + + /** + * Return the list of media types this encoder supports. + */ + List getSupportedMediaTypes(); + + /** + * Render the view based on the given {@link HandlerResult}. Implementations + * can access and use the model or only a specific attribute in it. + * @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 the output stream + */ + Flux render(HandlerResult result, Optional contentType, ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java new file mode 100644 index 00000000000..942f0501937 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java @@ -0,0 +1,30 @@ +package org.springframework.web.reactive; + +import java.util.Locale; + +import reactor.core.publisher.Mono; + +/** + * Contract to resolve a view name to a {@link View} instance. The view name may + * correspond to an HTML template or be generated dynamically. + * + *

The process of view resolution is driven through a ViewResolver-based + * {@code HandlerResultHandler} implementation called + * {@link org.springframework.web.reactive.view.ViewResolverResultHandler + * ViewResolverResultHandler}. + * + * @author Rossen Stoyanchev + * @see org.springframework.web.reactive.view.ViewResolverResultHandler + + */ +public interface ViewResolver { + + /** + * Resolve the view name to a View instance. + * @param viewName the name of the view to resolve + * @param locale the locale for the request + * @return the resolved view or an empty stream + */ + Mono resolveViewName(String viewName, Locale locale); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/HttpHandlerHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/HttpHandlerHandlerAdapter.java index bfe32fae984..67cc1fc2cc8 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/HttpHandlerHandlerAdapter.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/HttpHandlerHandlerAdapter.java @@ -20,6 +20,8 @@ import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import org.springframework.core.ResolvableType; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; import org.springframework.web.reactive.DispatcherHandler; import org.springframework.web.reactive.HandlerAdapter; import org.springframework.web.reactive.HandlerResult; @@ -48,7 +50,8 @@ public class HttpHandlerHandlerAdapter implements HandlerAdapter { public Mono handle(ServerWebExchange exchange, Object handler) { WebHandler webHandler = (WebHandler) handler; Mono completion = webHandler.handle(exchange); - return Mono.just(new HandlerResult(webHandler, completion, PUBLISHER_VOID)); + ModelMap model = new ExtendedModelMap(); + return Mono.just(new HandlerResult(webHandler, completion, PUBLISHER_VOID, model)); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandler.java index ed3c513f8aa..fc46f9d5966 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandler.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandler.java @@ -16,6 +16,8 @@ package org.springframework.web.reactive.handler; +import java.util.Optional; + import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; @@ -61,7 +63,7 @@ public class SimpleHandlerResultHandler implements Ordered, HandlerResultHandler @Override public boolean supports(HandlerResult result) { - ResolvableType type = result.getResultType(); + ResolvableType type = result.getReturnValueType(); return (type != null && Void.TYPE.equals(type.getRawClass()) || (isConvertibleToPublisher(type) && Void.class.isAssignableFrom(type.getGeneric(0).getRawClass()))); } @@ -75,12 +77,14 @@ public class SimpleHandlerResultHandler implements Ordered, HandlerResultHandler @SuppressWarnings("unchecked") @Override public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { - Object value = result.getResult(); - if (Void.TYPE.equals(result.getResultType().getRawClass())) { + Optional value = result.getReturnValue(); + if (!value.isPresent() || Void.TYPE.equals(result.getReturnValueType().getRawClass())) { return Mono.empty(); } - return (value instanceof Mono ? (Mono)value : - Mono.from(this.conversionService.convert(value, Publisher.class))); + if (value.get() instanceof Mono) { + return (Mono) value.get(); + } + return Mono.from(this.conversionService.convert(value.get(), Publisher.class)); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/HandlerMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/HandlerMethodArgumentResolver.java index defc46ea649..2c2e7c0c72c 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/HandlerMethodArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/HandlerMethodArgumentResolver.java @@ -19,6 +19,7 @@ package org.springframework.web.reactive.method; import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; +import org.springframework.ui.ModelMap; import org.springframework.web.server.ServerWebExchange; @@ -34,7 +35,10 @@ public interface HandlerMethodArgumentResolver { * The returned {@link Mono} may produce one or zero values if the argument * does not resolve to any value, which will result in {@code null} passed * as the argument value. + * @param parameter the method parameter + * @param model the implicit model for request handling + * @param exchange the current exchange */ - Mono resolveArgument(MethodParameter parameter, ServerWebExchange exchange); + Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange); } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/InvocableHandlerMethod.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/InvocableHandlerMethod.java index 47d84dfc6c6..860995a9624 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/InvocableHandlerMethod.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/InvocableHandlerMethod.java @@ -32,6 +32,7 @@ import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.ResolvableType; +import org.springframework.ui.ModelMap; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.web.method.HandlerMethod; @@ -77,17 +78,20 @@ public class InvocableHandlerMethod extends HandlerMethod { /** * Invoke the method and return a Publisher for the return value. * @param exchange the current exchange + * @param model the model for request handling * @param providedArgs optional list of argument values to check by type * (via {@code instanceof}) for resolving method arguments. * @return Publisher that produces a single HandlerResult or an error signal; * never throws an exception */ - public Mono invokeForRequest(ServerWebExchange exchange, Object... providedArgs) { - return resolveArguments(exchange, providedArgs).then(args -> { + public Mono invokeForRequest(ServerWebExchange exchange, ModelMap model, + Object... providedArgs) { + + return resolveArguments(exchange, model, providedArgs).then(args -> { try { Object value = doInvoke(args); ResolvableType type = ResolvableType.forMethodParameter(getReturnType()); - HandlerResult handlerResult = new HandlerResult(this, value, type); + HandlerResult handlerResult = new HandlerResult(this, value, type, model); return Mono.just(handlerResult); } catch (InvocationTargetException ex) { @@ -100,7 +104,7 @@ public class InvocableHandlerMethod extends HandlerMethod { }); } - private Mono resolveArguments(ServerWebExchange exchange, Object... providedArgs) { + private Mono resolveArguments(ServerWebExchange exchange, ModelMap model, Object... providedArgs) { if (ObjectUtils.isEmpty(getMethodParameters())) { return NO_ARGS; } @@ -121,7 +125,7 @@ public class InvocableHandlerMethod extends HandlerMethod { .findFirst() .orElseThrow(() -> getArgError("No resolver for ", param, null)); try { - return resolver.resolveArgument(param, exchange) + return resolver.resolveArgument(param, model, exchange) .defaultIfEmpty(NO_VALUE) .otherwise(ex -> Mono.error(getArgError("Error resolving ", param, ex))) .log("reactor.unresolved"); diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ModelArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ModelArgumentResolver.java new file mode 100644 index 00000000000..9365172f87e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/ModelArgumentResolver.java @@ -0,0 +1,44 @@ +/* + * 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.method.annotation; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.ui.Model; +import org.springframework.ui.ModelMap; +import org.springframework.web.reactive.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolver for the {@link Model} controller method argument. + * + * @author Rossen Stoyanchev + */ +public class ModelArgumentResolver implements HandlerMethodArgumentResolver { + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Model.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange) { + return Mono.just(model); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestBodyArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestBodyArgumentResolver.java index d7bbf501490..c39aa0da503 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestBodyArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestBodyArgumentResolver.java @@ -28,6 +28,7 @@ import org.springframework.core.codec.Decoder; import org.springframework.core.convert.ConversionService; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; +import org.springframework.ui.ModelMap; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.reactive.method.HandlerMethodArgumentResolver; @@ -58,7 +59,9 @@ public class RequestBodyArgumentResolver implements HandlerMethodArgumentResolve } @Override - public Mono resolveArgument(MethodParameter parameter, ServerWebExchange exchange) { + public Mono resolveArgument(MethodParameter parameter, ModelMap model, + ServerWebExchange exchange) { + MediaType mediaType = exchange.getRequest().getHeaders().getContentType(); if (mediaType == null) { mediaType = MediaType.APPLICATION_OCTET_STREAM; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerAdapter.java index 9457a395fb6..5a81e4de7a9 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerAdapter.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerAdapter.java @@ -38,6 +38,8 @@ import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.buffer.DataBufferAllocator; import org.springframework.core.io.buffer.NettyDataBufferAllocator; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; import org.springframework.util.ObjectUtils; import org.springframework.web.method.HandlerMethod; import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; @@ -64,7 +66,7 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, Initializin new NettyDataBufferAllocator(new UnpooledByteBufAllocator(false)); private final Map, ExceptionHandlerMethodResolver> exceptionHandlerCache = - new ConcurrentHashMap, ExceptionHandlerMethodResolver>(64); + new ConcurrentHashMap<>(64); /** @@ -105,6 +107,7 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, Initializin this.argumentResolvers.add(new RequestParamArgumentResolver()); this.argumentResolvers.add(new RequestBodyArgumentResolver(decoders, this.conversionService)); + this.argumentResolvers.add(new ModelArgumentResolver()); } } @@ -118,8 +121,8 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, Initializin HandlerMethod handlerMethod = (HandlerMethod) handler; InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); invocable.setHandlerMethodArgumentResolvers(this.argumentResolvers); - - return invocable.invokeForRequest(exchange) + ModelMap model = new ExtendedModelMap(); + return invocable.invokeForRequest(exchange, model) .map(result -> result.setExceptionHandler(ex -> handleException(ex, handlerMethod, exchange))) .otherwise(ex -> handleException(ex, handlerMethod, exchange)); } @@ -135,7 +138,8 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, Initializin logger.debug("Invoking @ExceptionHandler method: " + invocable); } invocable.setHandlerMethodArgumentResolvers(getArgumentResolvers()); - return invocable.invokeForRequest(exchange, ex); + ExtendedModelMap errorModel = new ExtendedModelMap(); + return invocable.invokeForRequest(exchange, errorModel, ex); } catch (Exception invocationEx) { if (logger.isErrorEnabled()) { diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestParamArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestParamArgumentResolver.java index 0bbc40906f1..0d0dff9b2a3 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestParamArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/method/annotation/RequestParamArgumentResolver.java @@ -19,6 +19,7 @@ package org.springframework.web.reactive.method.annotation; import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; +import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.reactive.method.HandlerMethodArgumentResolver; import org.springframework.web.server.ServerWebExchange; @@ -41,7 +42,7 @@ public class RequestParamArgumentResolver implements HandlerMethodArgumentResolv @Override - public Mono resolveArgument(MethodParameter param, ServerWebExchange exchange) { + public Mono resolveArgument(MethodParameter param, ModelMap model, ServerWebExchange exchange) { RequestParam annotation = param.getParameterAnnotation(RequestParam.class); String name = (annotation.value().length() != 0 ? annotation.value() : param.getParameterName()); UriComponents uriComponents = UriComponentsBuilder.fromUri(exchange.getRequest().getURI()).build(); 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 da7d4a01cc3..8c0b57d9694 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 @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -65,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) { @@ -132,23 +133,23 @@ public class ResponseBodyResultHandler implements HandlerResultHandler, Ordered @SuppressWarnings("unchecked") public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { - Object value = result.getResult(); - if (value == null) { + Optional value = result.getReturnValue(); + if (!value.isPresent()) { return Mono.empty(); } Publisher publisher; ResolvableType elementType; - ResolvableType returnType = result.getResultType(); + ResolvableType returnType = result.getReturnValueType(); if (this.conversionService.canConvert(returnType.getRawClass(), Publisher.class)) { - publisher = this.conversionService.convert(value, Publisher.class); + publisher = this.conversionService.convert(value.get(), Publisher.class); elementType = returnType.getGeneric(0); if (Void.class.equals(elementType.getRawClass())) { return (Mono)Mono.from(publisher); } } else { - publisher = Mono.just(value); + publisher = Mono.just(value.get()); elementType = returnType; } 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 00000000000..50ffac0b3cd --- /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 00000000000..436bb63fe7a --- /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 00000000000..5449493789f --- /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 new file mode 100644 index 00000000000..317e23e2a5b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java @@ -0,0 +1,151 @@ +/* + * 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.List; +import java.util.Locale; +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; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.reactive.View; +import org.springframework.web.reactive.ViewResolver; +import org.springframework.web.server.ServerWebExchange; + + +/** + * {@code HandlerResultHandler} that resolves a String return value from a + * handler to a {@link View} which is then used to render the response. + * A handler may also return a {@code View} instance and/or async variants that + * provide a String view name or a {@code View}. + * + *

This result handler should be ordered after others that may also interpret + * a String return value for example in combination with {@code @ResponseBody}. + * + * @author Rossen Stoyanchev + */ +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."); + Assert.notNull(service, "'conversionService' is required."); + this.viewResolvers.addAll(resolvers); + this.conversionService = service; + } + + + /** + * Return a read-only list of view resolvers. + */ + public List getViewResolvers() { + 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) + + @Override + public boolean supports(HandlerResult result) { + Class clazz = result.getReturnValueType().getRawClass(); + if (isViewNameOrViewReference(clazz)) { + return true; + } + if (this.conversionService.canConvert(clazz, Mono.class)) { + clazz = result.getReturnValueType().getGeneric(0).getRawClass(); + return isViewNameOrViewReference(clazz); + } + return false; + } + + private boolean isViewNameOrViewReference(Class clazz) { + return (CharSequence.class.isAssignableFrom(clazz) || View.class.isAssignableFrom(clazz)); + } + + @Override + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + + Mono returnValueMono; + if (this.conversionService.canConvert(result.getReturnValueType().getRawClass(), Mono.class)) { + returnValueMono = this.conversionService.convert(result.getReturnValue().get(), Mono.class); + } + else if (result.getReturnValue().isPresent()) { + returnValueMono = Mono.just(result.getReturnValue().get()); + } + else { + Optional viewName = getDefaultViewName(result, exchange); + if (viewName.isPresent()) { + returnValueMono = Mono.just(viewName.get()); + } + else { + returnValueMono = Mono.error(new IllegalStateException("Handler [" + result.getHandler() + "] " + + "neither returned a view name nor a View object")); + } + } + + return returnValueMono.then(returnValue -> { + if (returnValue instanceof View) { + Flux body = ((View) returnValue).render(result, Optional.empty(), exchange); + return exchange.getResponse().setBody(body); + } + else if (returnValue instanceof CharSequence) { + String viewName = returnValue.toString(); + Locale locale = Locale.getDefault(); // TODO + return Flux.fromIterable(getViewResolvers()) + .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) + .next() + .then(view -> { + Flux body = view.render(result, Optional.empty(), exchange); + return exchange.getResponse().setBody(body); + }); + } + else { + // Should not happen + return Mono.error(new IllegalStateException( + "Unexpected return value: " + returnValue.getClass())); + } + }); + } + + protected Optional getDefaultViewName(HandlerResult result, ServerWebExchange exchange) { + return Optional.empty(); + } + +} 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 00000000000..7bafe058eca --- /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 00000000000..6502f3e1821 --- /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 00000000000..5dc3901ea5b --- /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 00000000000..9efd05265ba --- /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 00000000000..631d6fbcd74 --- /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/main/java/org/springframework/web/reactive/view/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/package-info.java new file mode 100644 index 00000000000..602c2ea9924 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/package-info.java @@ -0,0 +1,4 @@ +/** + * Support for result handling through view resolution. + */ +package org.springframework.web.reactive.view; diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java index cfd7f18305f..4fd1d1e2b2b 100644 --- a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java @@ -28,7 +28,7 @@ import org.springframework.http.HttpStatus; /** * @author Rossen Stoyanchev */ -public class MockServerHttpResponse extends AbstractServerHttpResponse { +public class MockServerHttpResponse implements ServerHttpResponse { private HttpStatus status; @@ -56,19 +56,11 @@ public class MockServerHttpResponse extends AbstractServerHttpResponse { } @Override - protected Mono setBodyInternal(Publisher body) { + public Mono setBody(Publisher body) { this.body = body; return Flux.from(this.body).after(); } - @Override - protected void writeHeaders() { - } - - @Override - protected void writeCookies() { - } - @Override public void beforeCommit(Supplier> action) { } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandlerTests.java index 9e6381c6727..907992e14db 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleHandlerResultHandlerTests.java @@ -30,6 +30,7 @@ import org.springframework.core.convert.support.GenericConversionService; import org.springframework.core.convert.support.ReactiveStreamsToCompletableFutureConverter; import org.springframework.core.convert.support.ReactiveStreamsToReactorStreamConverter; import org.springframework.core.convert.support.ReactiveStreamsToRxJava1Converter; +import org.springframework.ui.ExtendedModelMap; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; @@ -46,28 +47,28 @@ public class SimpleHandlerResultHandlerTests { HandlerMethod hm = new HandlerMethod(controller, TestController.class.getMethod("voidReturnValue")); ResolvableType type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherString")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertFalse(resultHandler.supports(new HandlerResult(hm, null, type))); + assertFalse(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("streamVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); // Reactor Stream is a Publisher - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("observableVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertFalse(resultHandler.supports(new HandlerResult(hm, null, type))); + assertFalse(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("completableFutureVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertFalse(resultHandler.supports(new HandlerResult(hm, null, type))); + assertFalse(resultHandler.supports(createHandlerResult(hm, type))); } @Test @@ -82,27 +83,31 @@ public class SimpleHandlerResultHandlerTests { HandlerMethod hm = new HandlerMethod(controller, TestController.class.getMethod("voidReturnValue")); ResolvableType type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherString")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertFalse(resultHandler.supports(new HandlerResult(hm, null, type))); + assertFalse(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("streamVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("observableVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); hm = new HandlerMethod(controller, TestController.class.getMethod("completableFutureVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(resultHandler.supports(new HandlerResult(hm, null, type))); + assertTrue(resultHandler.supports(createHandlerResult(hm, type))); + } + + private HandlerResult createHandlerResult(HandlerMethod hm, ResolvableType type) { + return new HandlerResult(hm, null, type, new ExtendedModelMap()); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java index 892d554e2ad..26e4c3ac36c 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java @@ -31,6 +31,8 @@ import reactor.rx.Stream; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; @@ -54,12 +56,15 @@ public class InvocableHandlerMethodTests { private ServerWebExchange exchange; + private ModelMap model; + @Before public void setUp() throws Exception { WebSessionManager sessionManager = mock(WebSessionManager.class); this.request = mock(ServerHttpRequest.class); this.exchange = new DefaultServerWebExchange(request, mock(ServerHttpResponse.class), sessionManager); + this.model = new ExtendedModelMap(); } @@ -67,11 +72,11 @@ public class InvocableHandlerMethodTests { public void noArgsMethod() throws Exception { InvocableHandlerMethod hm = createHandlerMethod("noArgs"); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); List values = Stream.from(publisher).toList().get(); assertEquals(1, values.size()); - assertEquals("success", values.get(0).getResult()); + assertEquals("success", values.get(0).getReturnValue().get()); } @Test @@ -80,11 +85,11 @@ public class InvocableHandlerMethodTests { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); hm.setHandlerMethodArgumentResolvers(Collections.singletonList(new RequestParamArgumentResolver())); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); List values = Stream.from(publisher).toList().get(); assertEquals(1, values.size()); - assertEquals("success:null", values.get(0).getResult()); + assertEquals("success:null", values.get(0).getReturnValue().get()); } @Test @@ -92,11 +97,11 @@ public class InvocableHandlerMethodTests { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); addResolver(hm, Mono.just("value1")); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); List values = Stream.from(publisher).toList().get(); assertEquals(1, values.size()); - assertEquals("success:value1", values.get(0).getResult()); + assertEquals("success:value1", values.get(0).getReturnValue().get()); } @Test @@ -104,18 +109,18 @@ public class InvocableHandlerMethodTests { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); addResolver(hm, Flux.fromIterable(Arrays.asList("value1", "value2", "value3"))); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); List values = Stream.from(publisher).toList().get(); assertEquals(1, values.size()); - assertEquals("success:value1", values.get(0).getResult()); + assertEquals("success:value1", values.get(0).getReturnValue().get()); } @Test public void noResolverForArg() throws Exception { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); Throwable ex = awaitErrorSignal(publisher); assertEquals(IllegalStateException.class, ex.getClass()); @@ -127,12 +132,12 @@ public class InvocableHandlerMethodTests { public void resolveArgumentWithThrownException() throws Exception { HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class); when(resolver.supportsParameter(any())).thenReturn(true); - when(resolver.resolveArgument(any(), any())).thenThrow(new IllegalStateException("boo")); + when(resolver.resolveArgument(any(), any(), any())).thenThrow(new IllegalStateException("boo")); InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); hm.setHandlerMethodArgumentResolvers(Collections.singletonList(resolver)); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); Throwable ex = awaitErrorSignal(publisher); assertEquals(IllegalStateException.class, ex.getClass()); @@ -146,7 +151,7 @@ public class InvocableHandlerMethodTests { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); addResolver(hm, Mono.error(new IllegalStateException("boo"))); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); Throwable ex = awaitErrorSignal(publisher); assertEquals(IllegalStateException.class, ex.getClass()); @@ -160,7 +165,7 @@ public class InvocableHandlerMethodTests { InvocableHandlerMethod hm = createHandlerMethod("singleArg", String.class); addResolver(hm, Mono.just(1)); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); Throwable ex = awaitErrorSignal(publisher); assertEquals(IllegalStateException.class, ex.getClass()); @@ -173,7 +178,7 @@ public class InvocableHandlerMethodTests { public void invocationTargetExceptionIsUnwrapped() throws Exception { InvocableHandlerMethod hm = createHandlerMethod("exceptionMethod"); - Publisher publisher = hm.invokeForRequest(this.exchange); + Publisher publisher = hm.invokeForRequest(this.exchange, this.model); Throwable ex = awaitErrorSignal(publisher); assertEquals(IllegalStateException.class, ex.getClass()); @@ -190,7 +195,7 @@ public class InvocableHandlerMethodTests { private void addResolver(InvocableHandlerMethod handlerMethod, Publisher resolvedValue) { HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class); when(resolver.supportsParameter(any())).thenReturn(true); - when(resolver.resolveArgument(any(), any())).thenReturn(Mono.from(resolvedValue)); + when(resolver.resolveArgument(any(), any(), any())).thenReturn(Mono.from(resolvedValue)); handlerMethod.setHandlerMethodArgumentResolvers(Collections.singletonList(resolver)); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingIntegrationTests.java index 2ec5b58d947..7c84c6e6eb3 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingIntegrationTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingIntegrationTests.java @@ -20,6 +20,7 @@ import java.net.URI; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -38,6 +39,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Encoder; import org.springframework.core.codec.support.ByteBufferEncoder; import org.springframework.core.codec.support.JacksonJsonEncoder; import org.springframework.core.codec.support.JsonObjectEncoder; @@ -56,6 +58,8 @@ import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -63,7 +67,11 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.reactive.ViewResolver; import org.springframework.web.reactive.handler.SimpleHandlerResultHandler; +import org.springframework.web.reactive.view.ViewResolverResultHandler; +import org.springframework.web.reactive.view.freemarker.FreeMarkerConfigurer; +import org.springframework.web.reactive.view.freemarker.FreeMarkerViewResolver; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import static org.junit.Assert.assertArrayEquals; @@ -291,6 +299,18 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati create("http://localhost:" + this.port + "/observable-create"); } + @Test + public void html() throws Exception { + + RestTemplate restTemplate = new RestTemplate(); + + URI url = new URI("http://localhost:" + port + "/html?name=Jason"); + RequestEntity request = RequestEntity.get(url).accept(MediaType.TEXT_HTML).build(); + ResponseEntity response = restTemplate.exchange(request, String.class); + + assertEquals("Hello: Jason!", response.getBody()); + } + private void serializeAsPojo(String requestUrl) throws Exception { RestTemplate restTemplate = new RestTemplate(); @@ -350,7 +370,7 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati ResponseEntity response = restTemplate.exchange(request, Void.class); assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(2, this.wac.getBean(TestController.class).persons.size()); + assertEquals(2, this.wac.getBean(TestRestController.class).persons.size()); } @@ -358,6 +378,9 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati @SuppressWarnings("unused") static class FrameworkConfig { + private DataBufferAllocator allocator = new DefaultDataBufferAllocator(); + + @Bean public RequestMappingHandlerMapping handlerMapping() { return new RequestMappingHandlerMapping(); @@ -382,16 +405,42 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati @Bean public ResponseBodyResultHandler responseBodyResultHandler() { - DataBufferAllocator allocator = new DefaultDataBufferAllocator(); - return new ResponseBodyResultHandler(Arrays.asList( - new ByteBufferEncoder(allocator), new StringEncoder(allocator), - new JacksonJsonEncoder(allocator, new JsonObjectEncoder(allocator))), - conversionService()); + List> encoders = Arrays.asList( + new ByteBufferEncoder(this.allocator), new StringEncoder(this.allocator), + new JacksonJsonEncoder(this.allocator, new JsonObjectEncoder(this.allocator))); + ResponseBodyResultHandler resultHandler = new ResponseBodyResultHandler(encoders, conversionService()); + resultHandler.setOrder(1); + return resultHandler; } @Bean public SimpleHandlerResultHandler simpleHandlerResultHandler() { - return new SimpleHandlerResultHandler(conversionService()); + SimpleHandlerResultHandler resultHandler = new SimpleHandlerResultHandler(conversionService()); + resultHandler.setOrder(2); + return resultHandler; + } + + @Bean + public ViewResolverResultHandler viewResolverResultHandler() { + List resolvers = Collections.singletonList(freeMarkerViewResolver()); + ViewResolverResultHandler resultHandler = new ViewResolverResultHandler(resolvers, conversionService()); + resultHandler.setOrder(3); + return resultHandler; + } + + @Bean + public ViewResolver freeMarkerViewResolver() { + FreeMarkerViewResolver viewResolver = new FreeMarkerViewResolver("", ".ftl"); + viewResolver.setBufferAllocator(this.allocator); + return viewResolver; + } + + @Bean + public FreeMarkerConfigurer freeMarkerConfig() { + FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); + configurer.setPreferFileSystemAccess(false); + configurer.setTemplateLoaderPath("classpath*:org/springframework/web/reactive/view/freemarker/"); + return configurer; } } @@ -400,6 +449,11 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati @SuppressWarnings("unused") static class ApplicationConfig { + @Bean + public TestRestController testRestController() { + return new TestRestController(); + } + @Bean public TestController testController() { return new TestController(); @@ -409,7 +463,7 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati @RestController @SuppressWarnings("unused") - private static class TestController { + private static class TestRestController { final List persons = new ArrayList<>(); @@ -601,6 +655,18 @@ public class RequestMappingIntegrationTests extends AbstractHttpHandlerIntegrati } + @Controller + @SuppressWarnings("unused") + private static class TestController { + + @RequestMapping("/html") + public String getHtmlPage(@RequestParam String name, Model model) { + model.addAttribute("hello", "Hello: " + name + "!"); + return "test"; + } + + } + private static class Person { private String name; diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandlerTests.java index 3fd05e77c23..b0e0b09c509 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/ResponseBodyResultHandlerTests.java @@ -25,6 +25,7 @@ import org.springframework.core.ResolvableType; import org.springframework.core.codec.support.StringEncoder; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.buffer.DefaultDataBufferAllocator; +import org.springframework.ui.ExtendedModelMap; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; @@ -47,15 +48,15 @@ public class ResponseBodyResultHandlerTests { HandlerMethod hm = new HandlerMethod(controller,TestController.class.getMethod("notAnnotated")); ResolvableType type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertFalse(handler.supports(new HandlerResult(hm, null, type))); + assertFalse(handler.supports(new HandlerResult(hm, null, type, new ExtendedModelMap()))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherString")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(handler.supports(new HandlerResult(hm, null, type))); + assertTrue(handler.supports(new HandlerResult(hm, null, type, new ExtendedModelMap()))); hm = new HandlerMethod(controller, TestController.class.getMethod("publisherVoid")); type = ResolvableType.forMethodParameter(hm.getReturnType()); - assertTrue(handler.supports(new HandlerResult(hm, null, type))); + assertTrue(handler.supports(new HandlerResult(hm, null, type, new ExtendedModelMap()))); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java new file mode 100644 index 00000000000..36a787e3497 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java @@ -0,0 +1,289 @@ +/* + * 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.lang.reflect.Method; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Single; + +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.support.ReactiveStreamsToRxJava1Converter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferAllocator; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.reactive.View; +import org.springframework.web.reactive.ViewResolver; +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.endsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + + +/** + * Unit tests for {@link ViewResolverResultHandler}. + * @author Rossen Stoyanchev + */ +public class ViewResolverResultHandlerTests { + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + + private MockServerHttpResponse response; + + private ServerWebExchange exchange; + + private ModelMap model; + + private DefaultConversionService conversionService; + + + @Before + public void setUp() throws Exception { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.response = new MockServerHttpResponse(); + WebSessionManager sessionManager = new DefaultWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, this.response, sessionManager); + this.model = new ExtendedModelMap().addAttribute("id", "123"); + this.conversionService = new DefaultConversionService(); + this.conversionService.addConverter(new ReactiveStreamsToRxJava1Converter()); + } + + + @Test + public void supportsWithNullReturnValue() throws Exception { + testSupports("handleString", null); + testSupports("handleView", null); + testSupports("handleMonoString", null); + testSupports("handleMonoView", null); + testSupports("handleSingleString", null); + testSupports("handleSingleView", null); + } + + private void testSupports(String methodName, Object returnValue) throws NoSuchMethodException { + Method method = TestController.class.getMethod(methodName); + ResolvableType returnType = ResolvableType.forMethodParameter(method, -1); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, this.model); + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + assertTrue(handler.supports(result)); + } + + @Test + public void viewReference() throws Exception { + TestView view = new TestView("account"); + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, view, ResolvableType.forClass(View.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); + } + + @Test + public void viewReferenceMono() throws Exception { + TestView view = new TestView("account"); + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, Mono.just(view), ResolvableType.forClass(Mono.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); + } + + @Test + public void viewName() throws Exception { + TestView view = new TestView("account"); + TestViewResolver resolver = new TestViewResolver().addView(view); + List resolvers = Collections.singletonList(resolver); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, "account", ResolvableType.forClass(String.class)); + + TestSubscriber subscriber = new TestSubscriber<>(); + subscriber.bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); + } + + @Test + public void viewNameMono() throws Exception { + TestView view = new TestView("account"); + TestViewResolver resolver = new TestViewResolver().addView(view); + List resolvers = Collections.singletonList(resolver); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, Mono.just("account"), ResolvableType.forClass(Mono.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); + } + + @Test + public void viewNameWithMultipleResolvers() throws Exception { + TestView view1 = new TestView("account"); + TestView view2 = new TestView("profile"); + TestViewResolver resolver1 = new TestViewResolver().addView(view1); + TestViewResolver resolver2 = new TestViewResolver().addView(view2); + List resolvers = Arrays.asList(resolver1, resolver2); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, "profile", ResolvableType.forClass(String.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("profile: {id=123}", asString(buf))); + } + + @Test + public void viewNameWithNoMatch() throws Exception { + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + TestSubscriber subscriber = handle(handler, "account", ResolvableType.forClass(String.class)); + + subscriber.assertNoValues(); + } + + @Test + public void viewNameNotSpecified() throws Exception { + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + TestSubscriber subscriber = handle(handler, null, ResolvableType.forClass(String.class)); + + subscriber.assertErrorWith(ex -> + assertThat(ex.getMessage(), endsWith("neither returned a view name nor a View object"))); + } + + private TestSubscriber handle(HandlerResultHandler handler, Object value, ResolvableType type) { + HandlerResult result = new HandlerResult(new Object(), value, type, this.model); + Mono mono = handler.handleResult(this.exchange, result); + TestSubscriber subscriber = new TestSubscriber<>(); + return subscriber.bindTo(mono).await(1, TimeUnit.SECONDS); + } + + private static DataBuffer asDataBuffer(String value) { + ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(UTF_8)); + return new DefaultDataBufferAllocator().wrap(byteBuffer); + } + + 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); + } + + + private static class TestViewResolver implements ViewResolver { + + private final Map views = new HashMap<>(); + + + public TestViewResolver addView(TestView view) { + this.views.put(view.getName(), view); + return this; + } + + @Override + public Mono resolveViewName(String viewName, Locale locale) { + View view = this.views.get(viewName); + return (view != null ? Mono.just(view) : Mono.empty()); + } + } + + public static final class TestView implements View { + + private final String name; + + + public TestView(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public List getSupportedMediaTypes() { + return null; + } + + @Override + public Flux render(HandlerResult result, Optional contentType, + ServerWebExchange exchange) { + + String value = this.name + ": " + result.getModel().toString(); + assertNotNull(value); + return Flux.just(asDataBuffer(value)); + } + } + + @SuppressWarnings("unused") + private static class TestController { + + public String handleString() { + return null; + } + + public Mono handleMonoString() { + return null; + } + + public Single handleSingleString() { + return null; + } + + public View handleView() { + return null; + } + + public Mono handleMonoView() { + return null; + } + + public Single handleSingleView() { + return null; + } + } + +} \ No newline at end of file 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 00000000000..7123553d47b --- /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 00000000000..f9ad1fdc6ec --- /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