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

@@ -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>"""