From 14c1faa5ee51211901dc200509ad0517b74cb189 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 10 Jul 2024 13:30:54 +0100 Subject: [PATCH] Updates to WebMVC fragment rendering API See gh-33162 --- .../ModelAndViewMethodReturnValueHandler.java | 10 +- ...ew.java => DefaultFragmentsRendering.java} | 28 ++--- .../DefaultFragmentsRenderingBuilder.java | 64 ++++++++++ .../web/servlet/view/FragmentsRendering.java | 116 ++++++++++++++++++ ...lAndViewMethodReturnValueHandlerTests.java | 33 +++++ ...va => DefaultFragmentsRenderingTests.java} | 6 +- 6 files changed, 230 insertions(+), 27 deletions(-) rename spring-webmvc/src/main/java/org/springframework/web/servlet/view/{FragmentsView.java => DefaultFragmentsRendering.java} (83%) create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsRendering.java rename spring-webmvc/src/test/java/org/springframework/web/servlet/view/{FragmentsViewTests.java => DefaultFragmentsRenderingTests.java} (94%) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandler.java index 3cd850304e9..11dfd8fcddd 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandler.java @@ -27,7 +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; +import org.springframework.web.servlet.view.FragmentsRendering; /** * Handles return values of type {@link ModelAndView} copying view and model @@ -78,7 +78,7 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn if (Collection.class.isAssignableFrom(type)) { type = returnType.nested().getNestedParameterType(); } - return ModelAndView.class.isAssignableFrom(type); + return (ModelAndView.class.isAssignableFrom(type) || FragmentsRendering.class.isAssignableFrom(type)); } @SuppressWarnings("unchecked") @@ -92,7 +92,11 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn } if (returnValue instanceof Collection mavs) { - mavContainer.setView(FragmentsView.create((Collection) mavs)); + returnValue = FragmentsRendering.with((Collection) mavs).build(); + } + + if (returnValue instanceof FragmentsRendering rendering) { + mavContainer.setView(rendering); return; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRendering.java similarity index 83% rename from spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsView.java rename to spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRendering.java index 105720b44be..b7c8bf59421 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRendering.java @@ -17,6 +17,7 @@ package org.springframework.web.servlet.view; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.Locale; import java.util.Map; @@ -31,27 +32,23 @@ 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. + * Default implementation of {@link FragmentsRendering} that can render fragments + * through the {@link org.springframework.web.servlet.SmartView} contract. * * @author Rossen Stoyanchev * @since 6.2 */ -public class FragmentsView implements SmartView { +final class DefaultFragmentsRendering implements FragmentsRendering { private final Collection modelAndViews; - /** - * Protected constructor to allow extension. - */ - protected FragmentsView(Collection modelAndViews) { - this.modelAndViews = modelAndViews; + DefaultFragmentsRendering(Collection modelAndViews) { + this.modelAndViews = new ArrayList<>(modelAndViews); } @@ -95,20 +92,9 @@ public class FragmentsView implements SmartView { } } - @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 modelAndViews) { - return new FragmentsView(modelAndViews); + return "DefaultFragmentsRendering " + this.modelAndViews; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingBuilder.java new file mode 100644 index 00000000000..afaa7770273 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingBuilder.java @@ -0,0 +1,64 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.web.servlet.ModelAndView; + +/** + * Default {@link FragmentsRendering.Builder} implementation that collects the + * fragments and creates a {@link DefaultFragmentsRendering}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +final class DefaultFragmentsRenderingBuilder implements FragmentsRendering.Builder { + + private final Collection fragments = new ArrayList<>(); + + + @Override + public DefaultFragmentsRenderingBuilder fragment(String viewName, Map model) { + return fragment(new ModelAndView(viewName, model)); + } + + @Override + public DefaultFragmentsRenderingBuilder fragment(String viewName) { + return fragment(new ModelAndView(viewName)); + } + + @Override + public DefaultFragmentsRenderingBuilder fragment(ModelAndView fragment) { + this.fragments.add(fragment); + return this; + } + + @Override + public DefaultFragmentsRenderingBuilder fragments(Collection fragments) { + this.fragments.addAll(fragments); + return this; + } + + @Override + public FragmentsRendering build() { + return new DefaultFragmentsRendering(this.fragments); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsRendering.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsRendering.java new file mode 100644 index 00000000000..9affca28aa7 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsRendering.java @@ -0,0 +1,116 @@ +/* + * 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.Collection; +import java.util.Map; + +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.SmartView; + +/** + * Public API for HTML rendering a collection fragments each with its own view + * and model. For use with view technologies such as + * htmx where multiple page fragments may be + * rendered in a single response. Supported as a return value from a Spring MVC + * controller method. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public interface FragmentsRendering extends SmartView { + + + /** + * Create a builder for {@link FragmentsRendering}, adding a fragment with + * the given view name and model. + * @param viewName the name of the view for the fragment + * @param model attributes for the fragment in addition to model + * attributes inherited from the shared model for the request + * @return the created builder + */ + static Builder with(String viewName, Map model) { + return new DefaultFragmentsRenderingBuilder().fragment(viewName, model); + } + + /** + * Variant of {@link #with(String, Map)} with a view name only, but also + * inheriting model attributes from the shared model for the request. + * @param viewName the name of the view for the fragment + * @return the created builder + */ + static Builder with(String viewName) { + return new DefaultFragmentsRenderingBuilder().fragment(viewName); + } + + /** + * Variant of {@link #with(String, Map)} with a collection of fragments. + * @param fragments the fragments to add; each fragment also inherits model + * attributes from the shared model for the request + * @return the created builder + */ + static Builder with(Collection fragments) { + return new DefaultFragmentsRenderingBuilder().fragments(fragments); + } + + + /** + * Defines a builder for {@link FragmentsRendering}. + */ + interface Builder { + + /** + * Add a fragment with a view name and a model. + * @param viewName the name of the view for the fragment + * @param model attributes for the fragment in addition to model + * attributes inherited from the shared model for the request + * @return this builder + */ + Builder fragment(String viewName, Map model); + + /** + * Add a fragment with a view name only, inheriting model attributes from + * the model for the request. + * @param viewName the name of the view for the fragment + * @return this builder + */ + Builder fragment(String viewName); + + /** + * Add a fragment. + * @param fragment the fragment to add; the fragment also inherits model + * attributes from the shared model for the request + * @return this builder + */ + Builder fragment(ModelAndView fragment); + + /** + * Add a collection of fragments. + * @param fragments the fragments to add; each fragment also inherits model + * attributes from the shared model for the request + * @return this builder + */ + Builder fragments(Collection fragments); + + /** + * Build a {@link FragmentsRendering} instance. + */ + FragmentsRendering build(); + + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandlerTests.java index 3b1a591ef50..269f76d47d0 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandlerTests.java @@ -17,6 +17,8 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,7 +28,9 @@ import org.springframework.ui.ModelMap; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.method.support.ModelAndViewContainer; import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.SmartView; import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap; +import org.springframework.web.servlet.view.FragmentsRendering; import org.springframework.web.servlet.view.RedirectView; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; @@ -60,6 +64,9 @@ class ModelAndViewMethodReturnValueHandlerTests { @Test void supportsReturnType() throws Exception { assertThat(handler.supportsReturnType(returnParamModelAndView)).isTrue(); + assertThat(handler.supportsReturnType(getReturnValueParam("fragmentsRendering"))).isTrue(); + assertThat(handler.supportsReturnType(getReturnValueParam("fragmentsCollection"))).isTrue(); + assertThat(handler.supportsReturnType(getReturnValueParam("viewName"))).isFalse(); } @@ -81,6 +88,22 @@ class ModelAndViewMethodReturnValueHandlerTests { assertThat(mavContainer.getModel().get("attrName")).isEqualTo("attrValue"); } + @Test + void handleFragmentsRendering() throws Exception { + FragmentsRendering rendering = FragmentsRendering.with("viewName").build(); + + handler.handleReturnValue(rendering, returnParamModelAndView, mavContainer, webRequest); + assertThat(mavContainer.getView()).isInstanceOf(SmartView.class); + } + + @Test + void handleFragmentsCollection() throws Exception { + Collection fragments = List.of(new ModelAndView("viewName")); + + handler.handleReturnValue(fragments, returnParamModelAndView, mavContainer, webRequest); + assertThat(mavContainer.getView()).isInstanceOf(SmartView.class); + } + @Test void handleNull() throws Exception { handler.handleReturnValue(null, returnParamModelAndView, mavContainer, webRequest); @@ -173,4 +196,14 @@ class ModelAndViewMethodReturnValueHandlerTests { return null; } + @SuppressWarnings("unused") + FragmentsRendering fragmentsRendering() { + return null; + } + + @SuppressWarnings("unused") + Collection fragmentsCollection() { + return null; + } + } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/FragmentsViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingTests.java similarity index 94% rename from spring-webmvc/src/test/java/org/springframework/web/servlet/view/FragmentsViewTests.java rename to spring-webmvc/src/test/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingTests.java index 6fbaaad260d..3d50e270e3b 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/FragmentsViewTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingTests.java @@ -38,12 +38,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.condition.JRE.JAVA_21; /** - * Tests for rendering through {@link FragmentsView}. + * Tests for rendering through {@link DefaultFragmentsRendering}. * * @author Rossen Stoyanchev */ @DisabledForJreRange(min = JAVA_21, disabledReason = "Kotlin doesn't support Java 21+ yet") -public class FragmentsViewTests { +public class DefaultFragmentsRenderingTests { @Test @@ -59,7 +59,7 @@ public class FragmentsViewTests { MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); - FragmentsView view = FragmentsView.create(List.of( + DefaultFragmentsRendering view = new DefaultFragmentsRendering(List.of( new ModelAndView("fragment1", Map.of("foo", "Foo")), new ModelAndView("fragment2", Map.of("bar", "Bar"))));