diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentRenderingBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentRenderingBuilder.java new file mode 100644 index 00000000000..98c9fa3fa38 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentRenderingBuilder.java @@ -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 fragments; + + @Nullable + private HttpStatusCode status; + + @Nullable + private HttpHeaders headers; + + + DefaultFragmentRenderingBuilder(Collection fragments) { + this(Flux.fromIterable(fragments)); + } + + DefaultFragmentRenderingBuilder(Object fragments) { + this(adaptProducer(fragments)); + } + + DefaultFragmentRenderingBuilder(Publisher fragments) { + this.fragments = Flux.from(fragments); + } + + private static Publisher 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 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 fragments) + implements FragmentRendering { + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/Fragment.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/Fragment.java new file mode 100644 index 00000000000..94a57647713 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/Fragment.java @@ -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 model; + + + private Fragment(@Nullable String viewName, @Nullable View view, Map 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 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 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 model) { + return new Fragment(null, view, model); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/FragmentRendering.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/FragmentRendering.java new file mode 100644 index 00000000000..2839b1690c8 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/FragmentRendering.java @@ -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 htmx where multiple + * page fragments may be rendered in a single response. Supported as a return + * value from a WebFlux controller method. + * + *

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 fragments(); + + + /** + * Create a builder to render with a collection of Fragments. + */ + static Builder fromCollection(Collection fragments) { + return new DefaultFragmentRenderingBuilder(fragments); + } + + /** + * Create a builder to render with a {@link Publisher} of Fragments. + */ + static

> 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 headersConsumer); + + /** + * Build the {@link FragmentRendering} instance. + */ + FragmentRendering build(); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java index 46bdc86b593..b3eb55e19da 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -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> 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> 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> 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> 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 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 getBodyFlux() { + Assert.state(this.bodyFlux != null, "Body not set"); + return this.bodyFlux; + } + + @Override + public Mono writeWith(Publisher body) { + this.bodyFlux = Flux.from(body); + return Mono.empty(); + } + + @Override + public Mono writeAndFlushWith(Publisher> body) { + this.bodyFlux = Flux.from(body).flatMap(Flux::from); + return Mono.empty(); + } + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentResolutionResultHandlerTests.java new file mode 100644 index 00000000000..9159877bde2 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentResolutionResultHandlerTests.java @@ -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() { + 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("

Hello Foo

Hello Bar

"); + } + + 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; + } + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java index f08d538121e..5353dcd3f6a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java @@ -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 monoRendering() { return null; } + FragmentRendering fragmentRendering() { return null; } + View view() { return null; } Mono monoView() { return null; } diff --git a/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment1.kts b/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment1.kts new file mode 100644 index 00000000000..0c3747378b5 --- /dev/null +++ b/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment1.kts @@ -0,0 +1,3 @@ +import org.springframework.web.reactive.result.view.script.* + +"""

${i18n("hello")} $foo

""" diff --git a/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment2.kts b/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment2.kts new file mode 100644 index 00000000000..46de4e25486 --- /dev/null +++ b/spring-webflux/src/test/resources/org/springframework/web/reactive/result/view/script/kotlin/fragment2.kts @@ -0,0 +1,3 @@ +import org.springframework.web.reactive.result.view.script.* + +"""

${i18n("hello")} $bar

""" diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java index b2a782f38cb..c0922bfb3be 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java @@ -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 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); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/SmartView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/SmartView.java index fa7ae98a66d..5bfa9ed2fa1 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/SmartView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/SmartView.java @@ -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. + *

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 + } + } 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 dbff025b50a..3cd850304e9 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 @@ -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) mavs)); + return; + } + ModelAndView mav = (ModelAndView) returnValue; if (mav.isReference()) { String viewName = mav.getViewName(); 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/FragmentsView.java new file mode 100644 index 00000000000..105720b44be --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsView.java @@ -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 modelAndViews; + + + /** + * Protected constructor to allow extension. + */ + protected FragmentsView(Collection 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 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 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(); + } + } + +} 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/FragmentsViewTests.java new file mode 100644 index 00000000000..6fbaaad260d --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/FragmentsViewTests.java @@ -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("

Hello Foo

Hello Bar

"); + } + + + @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; + } + } + +} diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment1.kts b/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment1.kts new file mode 100644 index 00000000000..ffc5972f8b9 --- /dev/null +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment1.kts @@ -0,0 +1,3 @@ +import org.springframework.web.servlet.view.script.* + +"""

${i18n("hello")} $foo

""" diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment2.kts b/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment2.kts new file mode 100644 index 00000000000..9d0ad1ddab0 --- /dev/null +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/view/script/kotlin/fragment2.kts @@ -0,0 +1,3 @@ +import org.springframework.web.servlet.view.script.* + +"""

${i18n("hello")} $bar

"""