Support multi-view rendering
See gh-33162
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import org.springframework.web.servlet.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $foo</p>"""
|
||||
@@ -0,0 +1,3 @@
|
||||
import org.springframework.web.servlet.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $bar</p>"""
|
||||
Reference in New Issue
Block a user