Support multi-view rendering

See gh-33162
This commit is contained in:
rstoyanchev 2024-07-05 20:45:52 +01:00
parent 545228d693
commit ef6a3768ee
15 changed files with 870 additions and 7 deletions

View File

@ -0,0 +1,108 @@
/*
* Copyright 2002-2024 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
*
* https://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.result.view;
import java.util.Arrays;
import java.util.Collection;
import java.util.function.Consumer;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Default implementation of {@link FragmentRendering.Builder}.
*
* @author Rossen Stoyanchev
* @since 6.2
*/
class DefaultFragmentRenderingBuilder implements FragmentRendering.Builder {
private final Flux<Fragment> fragments;
@Nullable
private HttpStatusCode status;
@Nullable
private HttpHeaders headers;
DefaultFragmentRenderingBuilder(Collection<Fragment> fragments) {
this(Flux.fromIterable(fragments));
}
DefaultFragmentRenderingBuilder(Object fragments) {
this(adaptProducer(fragments));
}
DefaultFragmentRenderingBuilder(Publisher<Fragment> fragments) {
this.fragments = Flux.from(fragments);
}
private static Publisher<Fragment> adaptProducer(Object fragments) {
ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(fragments.getClass());
Assert.isTrue(adapter != null, "Unknown producer " + fragments.getClass());
return adapter.toPublisher(fragments);
}
@Override
public FragmentRendering.Builder status(HttpStatusCode status) {
this.status = status;
return this;
}
@Override
public FragmentRendering.Builder header(String headerName, String... headerValues) {
initHeaders().put(headerName, Arrays.asList(headerValues));
return this;
}
@Override
public FragmentRendering.Builder headers(Consumer<HttpHeaders> headersConsumer) {
headersConsumer.accept(initHeaders());
return this;
}
private HttpHeaders initHeaders() {
if (this.headers == null) {
this.headers = new HttpHeaders();
}
return this.headers;
}
@Override
public FragmentRendering build() {
return new DefaultFragmentRendering(
this.status, (this.headers != null ? this.headers : HttpHeaders.EMPTY), this.fragments);
}
/**
* Default implementation of {@link FragmentRendering}.
*/
private record DefaultFragmentRendering(@Nullable HttpStatusCode status, HttpHeaders headers, Flux<Fragment> fragments)
implements FragmentRendering {
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright 2002-2024 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
*
* https://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.result.view;
import java.util.Map;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Container for a model and a view for use with {@link FragmentRendering} and
* multi-view rendering. For full page rendering with a single model and view,
* use {@link Rendering}.
*
* @author Rossen Stoyanchev
* @since 6.2
* @see FragmentRendering
*/
public final class Fragment {
@Nullable
private final String viewName;
@Nullable
private final View view;
private final Map<String, Object> model;
private Fragment(@Nullable String viewName, @Nullable View view, Map<String, Object> model) {
this.viewName = viewName;
this.view = view;
this.model = model;
}
/**
* Whether this Fragment contains a resolved {@link View} instance.
*/
public boolean isResolved() {
return (this.view != null);
}
/**
* Return the view name of the Fragment, or {@code null} if not set.
*/
@Nullable
public String viewName() {
return this.viewName;
}
/**
* Return the resolved {@link View} instance. This should be called only
* after an {@link #isResolved()} check.
*/
public View view() {
Assert.state(this.view != null, "View not resolved");
return this.view;
}
/**
* Return the model for this Fragment.
*/
public Map<String, Object> model() {
return this.model;
}
@Override
public String toString() {
return "Fragment [view=" + formatView() + "; model=" + this.model + "]";
}
private String formatView() {
return (isResolved() ? "\"" + view() + "\"" : "[" + viewName() + "]");
}
/**
* Create a Fragment with a view name and a model.
*/
public static Fragment create(String viewName, Map<String, Object> model) {
return new Fragment(viewName, null, model);
}
/**
* Create a Fragment with a resolved {@link View} instance and a model.
*/
public static Fragment create(View view, Map<String, Object> model) {
return new Fragment(null, view, model);
}
}

View File

@ -0,0 +1,119 @@
/*
* Copyright 2002-2024 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
*
* https://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.result.view;
import java.util.Collection;
import java.util.function.Consumer;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.Nullable;
/**
* Public API for HTML rendering from a collection or from a stream of
* {@link Fragment}s each with its own view and model. For use with
* view technologies such as <a href="https://htmx.org/">htmx</a> where multiple
* page fragments may be rendered in a single response. Supported as a return
* value from a WebFlux controller method.
*
* <p>For full page rendering with a single model and view, use {@link Rendering}.
*
* @author Rossen Stoyanchev
* @since 6.2
*/
public interface FragmentRendering {
/**
* Return the HTTP status to set the response to.
*/
@Nullable
HttpStatusCode status();
/**
* Return headers to add to the response.
*/
HttpHeaders headers();
/**
* Return the fragments to render.
*/
Flux<Fragment> fragments();
/**
* Create a builder to render with a collection of Fragments.
*/
static Builder fromCollection(Collection<Fragment> fragments) {
return new DefaultFragmentRenderingBuilder(fragments);
}
/**
* Create a builder to render with a {@link Publisher} of Fragments.
*/
static <P extends Publisher<Fragment>> Builder fromPublisher(P fragments) {
return new DefaultFragmentRenderingBuilder(fragments);
}
/**
* Variant of {@link #fromPublisher(Publisher)} that allows using any
* producer that can be resolved to {@link Publisher} via
* {@link ReactiveAdapterRegistry}.
*/
static Builder fromProducer(Object fragments) {
return new DefaultFragmentRenderingBuilder(fragments);
}
/**
* Defines a builder for {@link FragmentRendering}.
*/
interface Builder {
/**
* Specify the status to use for the response.
* @param status the status to set
* @return this builder
*/
Builder status(HttpStatusCode status);
/**
* Add the given, single header value under the given name.
* @param headerName the header name
* @param headerValues the header value(s)
* @return this builder
*/
Builder header(String headerName, String... headerValues);
/**
* Provides access to every header declared so far with the possibility
* to add, replace, or remove values.
* @param headersConsumer the consumer to provide access to
* @return this builder
*/
Builder headers(Consumer<HttpHeaders> headersConsumer);
/**
* Build the {@link FragmentRendering} instance.
*/
FragmentRendering build();
}
}

View File

@ -23,6 +23,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -35,10 +36,15 @@ import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.lang.Nullable;
import org.springframework.ui.Model;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ModelAttribute;
@ -159,6 +165,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
return (CharSequence.class.isAssignableFrom(type) ||
Rendering.class.isAssignableFrom(type) ||
FragmentRendering.class.isAssignableFrom(type) ||
Model.class.isAssignableFrom(type) ||
Map.class.isAssignableFrom(type) ||
View.class.isAssignableFrom(type) ||
@ -174,8 +181,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
if (adapter != null) {
if (adapter.isMultiValue()) {
throw new IllegalArgumentException(
"Multi-value reactive types not supported in view resolution: " + result.getReturnType());
throw new IllegalArgumentException("Multi-value producer: " + result.getReturnType());
}
valueMono = (result.getReturnValue() != null ?
@ -196,6 +202,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
Mono<List<View>> viewsMono;
Model model = result.getModel();
MethodParameter parameter = result.getReturnTypeSource();
BindingContext bindingContext = result.getBindingContext();
Locale locale = LocaleContextHolder.getLocale(exchange.getLocaleContext());
Class<?> clazz = valueType.toClass();
@ -224,6 +231,20 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
viewsMono = (view instanceof String viewName ? resolveViews(viewName, locale) :
Mono.just(Collections.singletonList((View) view)));
}
else if (FragmentRendering.class.isAssignableFrom(clazz)) {
FragmentRendering render = (FragmentRendering) returnValue;
HttpStatusCode status = render.status();
if (status != null) {
exchange.getResponse().setStatusCode(status);
}
exchange.getResponse().getHeaders().putAll(render.headers());
bindingContext.updateModel(exchange);
Flux<Flux<DataBuffer>> renderFlux = render.fragments()
.concatMap(fragment -> renderFragment(fragment, locale, bindingContext, exchange));
return exchange.getResponse().writeAndFlushWith(renderFlux);
}
else if (Model.class.isAssignableFrom(clazz)) {
model.addAllAttributes(((Model) returnValue).asMap());
viewsMono = resolveViews(getDefaultViewName(exchange), locale);
@ -240,13 +261,11 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
model.addAttribute(name, returnValue);
viewsMono = resolveViews(getDefaultViewName(exchange), locale);
}
BindingContext bindingContext = result.getBindingContext();
bindingContext.updateModel(exchange);
return viewsMono.flatMap(views -> render(views, model.asMap(), bindingContext, exchange));
});
}
private boolean hasModelAnnotation(MethodParameter parameter) {
return parameter.hasMethodAnnotation(ModelAttribute.class);
}
@ -280,6 +299,23 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
});
}
private Mono<Flux<DataBuffer>> renderFragment(
Fragment fragment, Locale locale, BindingContext bindingContext, ServerWebExchange exchange) {
// Merge attributes from top-level model
bindingContext.getModel().asMap().forEach((key, value) -> fragment.model().putIfAbsent(key, value));
BodySavingResponse response = new BodySavingResponse(exchange.getResponse());
ServerWebExchange mutatedExchange = exchange.mutate().response(response).build();
Mono<List<View>> selectedViews = (fragment.isResolved() ?
Mono.just(List.of(fragment.view())) :
resolveViews(fragment.viewName() != null ? fragment.viewName() : getDefaultViewName(exchange), locale));
return selectedViews.flatMap(views -> render(views, fragment.model(), bindingContext, mutatedExchange))
.then(Mono.fromSupplier(response::getBodyFlux));
}
private String getNameForReturnValue(MethodParameter returnType) {
return Optional.ofNullable(returnType.getMethodAnnotation(ModelAttribute.class))
.filter(ann -> StringUtils.hasText(ann.value()))
@ -336,4 +372,43 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp
.toList();
}
/**
* ServerHttpResponse that saves the body Flux and does not write.
*/
private static class BodySavingResponse extends ServerHttpResponseDecorator {
@Nullable
private Flux<DataBuffer> bodyFlux;
private final HttpHeaders headers;
BodySavingResponse(ServerHttpResponse delegate) {
super(delegate);
this.headers = new HttpHeaders(delegate.getHeaders()); // Ignore header changes
}
@Override
public HttpHeaders getHeaders() {
return this.headers;
}
public Flux<DataBuffer> getBodyFlux() {
Assert.state(this.bodyFlux != null, "Body not set");
return this.bodyFlux;
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
this.bodyFlux = Flux.from(body);
return Mono.empty();
}
@Override
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
this.bodyFlux = Flux.from(body).flatMap(Flux::from);
return Mono.empty();
}
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright 2002-2024 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
*
* https://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.result.view;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.accept.HeaderContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.result.view.script.ScriptTemplateConfigurer;
import org.springframework.web.reactive.result.view.script.ScriptTemplateViewResolver;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Named.named;
import static org.springframework.web.testfixture.method.ResolvableMethod.on;
/**
* Tests for multi-view rendering through {@link ViewResolutionResultHandler}.
*
* @author Rossen Stoyanchev
*/
public class FragmentResolutionResultHandlerTests {
static Stream<Arguments> arguments() {
Fragment f1 = Fragment.create("fragment1", Map.of("foo", "Foo"));
Fragment f2 = Fragment.create("fragment2", Map.of("bar", "Bar"));
return Stream.of(
Arguments.of(named("Flux",
FragmentRendering.fromPublisher(Flux.just(f1, f2).subscribeOn(Schedulers.boundedElastic()))
.headers(headers -> headers.setContentType(MediaType.TEXT_HTML))
.build())),
Arguments.of(named("List",
FragmentRendering.fromCollection(List.of(f1, f2))
.headers(headers -> headers.setContentType(MediaType.TEXT_HTML))
.build()))
);}
@ParameterizedTest
@MethodSource("arguments")
void render(FragmentRendering rendering) {
Locale locale = Locale.ENGLISH;
MockServerHttpRequest request = MockServerHttpRequest.get("/").acceptLanguageAsLocales(locale).build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
HandlerResult result = new HandlerResult(
new Handler(), rendering, on(Handler.class).resolveReturnType(FragmentRendering.class),
new BindingContext());
String body = initHandler().handleResult(exchange, result)
.then(Mono.defer(() -> exchange.getResponse().getBodyAsString()))
.block(Duration.ofSeconds(60));
assertThat(body).isEqualTo("<p>Hello Foo</p><p>Hello Bar</p>");
}
private ViewResolutionResultHandler initHandler() {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(ScriptTemplatingConfiguration.class);
String prefix = "org/springframework/web/reactive/result/view/script/kotlin/";
ScriptTemplateViewResolver viewResolver = new ScriptTemplateViewResolver(prefix, ".kts");
viewResolver.setApplicationContext(context);
RequestedContentTypeResolver contentTypeResolver = new HeaderContentTypeResolver();
return new ViewResolutionResultHandler(List.of(viewResolver), contentTypeResolver);
}
private static class Handler {
FragmentRendering rendering() { return null; }
}
@Configuration
static class ScriptTemplatingConfiguration {
@Bean
public ScriptTemplateConfigurer kotlinScriptConfigurer() {
ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
configurer.setEngineName("kotlin");
configurer.setScripts("org/springframework/web/reactive/result/view/script/kotlin/render.kts");
configurer.setRenderFunction("render");
return configurer;
}
@Bean
public ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("org/springframework/web/reactive/result/view/script/messages");
return messageSource;
}
}
}

View File

@ -79,6 +79,7 @@ class ViewResolutionResultHandlerTests {
testSupports(on(Handler.class).resolveReturnType(Mono.class, String.class));
testSupports(on(Handler.class).resolveReturnType(Rendering.class));
testSupports(on(Handler.class).resolveReturnType(FragmentRendering.class));
testSupports(on(Handler.class).resolveReturnType(Mono.class, Rendering.class));
testSupports(on(Handler.class).resolveReturnType(View.class));
@ -434,6 +435,8 @@ class ViewResolutionResultHandlerTests {
Rendering rendering() { return null; }
Mono<Rendering> monoRendering() { return null; }
FragmentRendering fragmentRendering() { return null; }
View view() { return null; }
Mono<View> monoView() { return null; }

View File

@ -0,0 +1,3 @@
import org.springframework.web.reactive.result.view.script.*
"""<p>${i18n("hello")} $foo</p>"""

View File

@ -0,0 +1,3 @@
import org.springframework.web.reactive.result.view.script.*
"""<p>${i18n("hello")} $bar</p>"""

View File

@ -1418,6 +1418,10 @@ public class DispatcherServlet extends FrameworkServlet {
}
}
if (view instanceof SmartView smartView) {
smartView.resolveNestedViews(this::resolveViewNameInternal, locale);
}
// Delegate to the View object for rendering.
if (logger.isTraceEnabled()) {
logger.trace("Rendering view [" + view + "] ");
@ -1466,6 +1470,11 @@ public class DispatcherServlet extends FrameworkServlet {
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model,
Locale locale, HttpServletRequest request) throws Exception {
return resolveViewNameInternal(viewName, locale);
}
@Nullable
private View resolveViewNameInternal(String viewName, Locale locale) throws Exception {
if (this.viewResolvers != null) {
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2012 the original author or authors.
* Copyright 2002-2024 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.
@ -16,6 +16,8 @@
package org.springframework.web.servlet;
import java.util.Locale;
/**
* Provides additional information about a View such as whether it
* performs redirects.
@ -25,9 +27,27 @@ package org.springframework.web.servlet;
*/
public interface SmartView extends View {
/**
* Whether the view performs a redirect.
*/
boolean isRedirectView();
/**
* In most cases, the {@link DispatcherServlet} uses {@link ViewResolver}s
* to resolve {@link View} instances. However, a special type of
* {@link View} may actually render a collection of fragments, each with its
* own model and view.
* <p>This callback provides such a view with the opportunity to resolve
* any nested views it contains prior to rendering.
* @param resolver to resolve views with
* @param locale the resolved locale for the request
* @throws Exception if any view cannot be resolved, or in case of problems
* creating an actual View instance
* @since 6.2
*/
default void resolveNestedViews(ViewResolver resolver, Locale locale) throws Exception {
// no-op
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 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.
@ -16,6 +16,8 @@
package org.springframework.web.servlet.mvc.method.annotation;
import java.util.Collection;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.util.PatternMatchUtils;
@ -25,6 +27,7 @@ import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.SmartView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.FragmentsView;
/**
* Handles return values of type {@link ModelAndView} copying view and model
@ -71,9 +74,14 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return ModelAndView.class.isAssignableFrom(returnType.getParameterType());
Class<?> type = returnType.getParameterType();
if (Collection.class.isAssignableFrom(type)) {
type = returnType.nested().getNestedParameterType();
}
return ModelAndView.class.isAssignableFrom(type);
}
@SuppressWarnings("unchecked")
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
@ -83,6 +91,11 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn
return;
}
if (returnValue instanceof Collection<?> mavs) {
mavContainer.setView(FragmentsView.create((Collection<ModelAndView>) mavs));
return;
}
ModelAndView mav = (ModelAndView) returnValue;
if (mav.isReference()) {
String viewName = mav.getViewName();

View File

@ -0,0 +1,173 @@
/*
* Copyright 2002-2024 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
*
* https://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.servlet.view;
import java.io.IOException;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.SmartView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
/**
* {@link View} that enables rendering of a collection of fragments, each with
* its own view and model, also inheriting common attributes from the top-level model.
*
* @author Rossen Stoyanchev
* @since 6.2
*/
public class FragmentsView implements SmartView {
private final Collection<ModelAndView> modelAndViews;
/**
* Protected constructor to allow extension.
*/
protected FragmentsView(Collection<ModelAndView> modelAndViews) {
this.modelAndViews = modelAndViews;
}
@Override
public boolean isRedirectView() {
return false;
}
@Override
public void resolveNestedViews(ViewResolver resolver, Locale locale) throws Exception {
for (ModelAndView mv : this.modelAndViews) {
View view = resolveView(resolver, locale, mv);
mv.setView(view);
}
}
private static View resolveView(ViewResolver viewResolver, Locale locale, ModelAndView mv) throws Exception {
String viewName = mv.getViewName();
View view = (viewName != null ? viewResolver.resolveViewName(viewName, locale) : mv.getView());
if (view == null) {
throw new ServletException("Could not resolve view in " + mv);
}
return view;
}
@Override
public void render(
@Nullable Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
throws Exception {
if (model != null) {
model.forEach((key, value) ->
this.modelAndViews.forEach(mv -> mv.getModel().putIfAbsent(key, value)));
}
HttpServletResponse nonClosingResponse = new NonClosingHttpServletResponse(response);
for (ModelAndView mv : this.modelAndViews) {
Assert.state(mv.getView() != null, "Expected View");
mv.getView().render(mv.getModel(), request, nonClosingResponse);
response.flushBuffer();
}
}
@Override
public String toString() {
return "FragmentsView " + this.modelAndViews;
}
/**
* Factory method to create an instance with the given fragments.
* @param modelAndViews the {@link ModelAndView} to use
* @return the created {@code FragmentsView} instance
*/
public static FragmentsView create(Collection<ModelAndView> modelAndViews) {
return new FragmentsView(modelAndViews);
}
/**
* Response wrapper that in turn applies {@link NonClosingServletOutputStream}.
*/
private static final class NonClosingHttpServletResponse extends HttpServletResponseWrapper {
@Nullable
private ServletOutputStream os;
public NonClosingHttpServletResponse(HttpServletResponse response) {
super(response);
}
@Override
public ServletOutputStream getOutputStream() throws IOException {
if (this.os == null) {
this.os = new NonClosingServletOutputStream(getResponse().getOutputStream());
}
return this.os;
}
}
/**
* {@code OutputStream} that leaves the response open, ignoring calls to close it.
*/
private static final class NonClosingServletOutputStream extends ServletOutputStream {
private final ServletOutputStream os;
public NonClosingServletOutputStream(ServletOutputStream os) {
this.os = os;
}
@Override
public void write(int b) throws IOException {
this.os.write(b);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
this.os.write(b, off, len);
}
@Override
public boolean isReady() {
throw new UnsupportedOperationException();
}
@Override
public void close() {
}
@Override
public void setWriteListener(WriteListener writeListener) {
throw new UnsupportedOperationException();
}
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright 2002-2024 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
*
* https://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.servlet.view;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledForJreRange;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.script.ScriptTemplateConfigurer;
import org.springframework.web.servlet.view.script.ScriptTemplateViewResolver;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.condition.JRE.JAVA_21;
/**
* Tests for rendering through {@link FragmentsView}.
*
* @author Rossen Stoyanchev
*/
@DisabledForJreRange(min = JAVA_21, disabledReason = "Kotlin doesn't support Java 21+ yet")
public class FragmentsViewTests {
@Test
void render() throws Exception {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(ScriptTemplatingConfiguration.class);
String prefix = "org/springframework/web/servlet/view/script/kotlin/";
ScriptTemplateViewResolver viewResolver = new ScriptTemplateViewResolver(prefix, ".kts");
viewResolver.setApplicationContext(context);
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
FragmentsView view = FragmentsView.create(List.of(
new ModelAndView("fragment1", Map.of("foo", "Foo")),
new ModelAndView("fragment2", Map.of("bar", "Bar"))));
view.resolveNestedViews(viewResolver, Locale.ENGLISH);
view.render(Collections.emptyMap(), request, response);
assertThat(response.getContentAsString()).isEqualTo("<p>Hello Foo</p><p>Hello Bar</p>");
}
@Configuration
static class ScriptTemplatingConfiguration {
@Bean
ScriptTemplateConfigurer kotlinScriptConfigurer() {
ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
configurer.setEngineName("kotlin");
configurer.setScripts("org/springframework/web/servlet/view/script/kotlin/render.kts");
configurer.setRenderFunction("render");
return configurer;
}
@Bean
ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("org/springframework/web/servlet/view/script/messages");
return messageSource;
}
}
}

View File

@ -0,0 +1,3 @@
import org.springframework.web.servlet.view.script.*
"""<p>${i18n("hello")} $foo</p>"""

View File

@ -0,0 +1,3 @@
import org.springframework.web.servlet.view.script.*
"""<p>${i18n("hello")} $bar</p>"""