From 6ee8786385d3c6920ca445621a6ad00242fc4aa8 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 10 Jul 2024 11:25:27 +0100 Subject: [PATCH] Updates to WebFlux fragment rendering API See gh-33162 --- .../view/DefaultFragmentRenderingBuilder.java | 108 -------------- .../DefaultFragmentsRenderingBuilder.java | 139 ++++++++++++++++++ .../web/reactive/result/view/Fragment.java | 46 ++++-- ...Rendering.java => FragmentsRendering.java} | 77 ++++++++-- .../view/ViewResolutionResultHandler.java | 16 +- ...gmentViewResolutionResultHandlerTests.java | 10 +- .../ViewResolutionResultHandlerTests.java | 4 +- 7 files changed, 257 insertions(+), 143 deletions(-) delete mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentRenderingBuilder.java create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentsRenderingBuilder.java rename spring-webflux/src/main/java/org/springframework/web/reactive/result/view/{FragmentRendering.java => FragmentsRendering.java} (51%) 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 deleted file mode 100644 index 98c9fa3fa3..0000000000 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentRenderingBuilder.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * 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/DefaultFragmentsRenderingBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentsRenderingBuilder.java new file mode 100644 index 0000000000..5eeb4094a0 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/DefaultFragmentsRenderingBuilder.java @@ -0,0 +1,139 @@ +/* + * 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.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.function.Consumer; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.lang.Nullable; + +/** + * Default implementation of {@link FragmentsRendering.Builder}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +class DefaultFragmentsRenderingBuilder implements FragmentsRendering.Builder { + + @Nullable + private Collection fragmentsCollection; + + @Nullable + private final Flux fragmentsFlux; + + @Nullable + private HttpStatusCode status; + + @Nullable + private HttpHeaders headers; + + DefaultFragmentsRenderingBuilder(Collection fragments) { + this.fragmentsCollection = new ArrayList<>(fragments); + this.fragmentsFlux = null; + } + + DefaultFragmentsRenderingBuilder(Publisher fragments) { + this.fragmentsFlux = Flux.from(fragments); + } + + + @Override + public FragmentsRendering.Builder status(HttpStatusCode status) { + this.status = status; + return this; + } + + @Override + public FragmentsRendering.Builder header(String headerName, String... headerValues) { + initHeaders().put(headerName, Arrays.asList(headerValues)); + return this; + } + + @Override + public FragmentsRendering.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 FragmentsRendering.Builder fragment(String viewName, Map model) { + return fragment(Fragment.create(viewName, model)); + } + + @Override + public FragmentsRendering.Builder fragment(String viewName) { + return fragment(Fragment.create(viewName)); + } + + @Override + public FragmentsRendering.Builder fragment(Fragment fragment) { + initFragmentsCollection().add(fragment); + return this; + } + + private Collection initFragmentsCollection() { + if (this.fragmentsCollection == null) { + this.fragmentsCollection = new ArrayList<>(); + } + return this.fragmentsCollection; + } + + @Override + public FragmentsRendering build() { + return new DefaultFragmentsRendering( + this.status, (this.headers != null ? this.headers : HttpHeaders.EMPTY), getFragmentsFlux()); + } + + private Flux getFragmentsFlux() { + if (this.fragmentsFlux != null && this.fragmentsCollection != null) { + return this.fragmentsFlux.concatWith(Flux.fromIterable(this.fragmentsCollection)); + } + else if (this.fragmentsFlux != null) { + return this.fragmentsFlux; + } + else if (this.fragmentsCollection != null) { + return Flux.fromIterable(this.fragmentsCollection); + } + else { + return Flux.empty(); + } + } + + + /** + * Default implementation of {@link FragmentsRendering}. + */ + private record DefaultFragmentsRendering(@Nullable HttpStatusCode status, HttpHeaders headers, Flux fragments) + implements FragmentsRendering { + } + +} 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 index 94a5764771..f835b3f680 100644 --- 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 @@ -16,19 +16,23 @@ package org.springframework.web.reactive.result.view; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import org.springframework.lang.Nullable; +import org.springframework.ui.Model; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** - * Container for a model and a view for use with {@link FragmentRendering} and + * Container for a model and a view for use with {@link FragmentsRendering} 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 + * @see FragmentsRendering */ public final class Fragment { @@ -38,10 +42,11 @@ public final class Fragment { @Nullable private final View view; - private final Map model; + @Nullable + private Map model; - private Fragment(@Nullable String viewName, @Nullable View view, Map model) { + private Fragment(@Nullable String viewName, @Nullable View view, @Nullable Map model) { this.viewName = viewName; this.view = view; this.model = model; @@ -73,15 +78,29 @@ public final class Fragment { } /** - * Return the model for this Fragment. + * Return the model for this Fragment, or an empty map. */ public Map model() { - return this.model; + return (this.model != null ? this.model : Collections.emptyMap()); } + /** + * Merge attributes from the request model if not already present. + */ + public void mergeAttributes(Model model) { + if (CollectionUtils.isEmpty(model.asMap())) { + return; + } + if (this.model == null) { + this.model = new LinkedHashMap<>(); + } + model.asMap().forEach((key, value) -> this.model.putIfAbsent(key, value)); + } + + @Override public String toString() { - return "Fragment [view=" + formatView() + "; model=" + this.model + "]"; + return "Fragment [view=" + formatView() + "; model=" + model() + "]"; } private String formatView() { @@ -90,14 +109,23 @@ public final class Fragment { /** - * Create a Fragment with a view name and a model. + * Create a Fragment with a view name and a model, also inheriting model + * attributes from the top-level model for the request. */ 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. + * Create a Fragment with a view name only, inheriting model attributes from + * the top-level model for the request. + */ + public static Fragment create(String viewName) { + return new Fragment(viewName, null, null); + } + + /** + * Variant of {@link #create(String, Map)} with a resolved {@link View}. */ 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/FragmentsRendering.java similarity index 51% rename from spring-webflux/src/main/java/org/springframework/web/reactive/result/view/FragmentRendering.java rename to spring-webflux/src/main/java/org/springframework/web/reactive/result/view/FragmentsRendering.java index 2839b1690c..6bb25eb15b 100644 --- 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/FragmentsRendering.java @@ -17,15 +17,19 @@ package org.springframework.web.reactive.result.view; import java.util.Collection; +import java.util.List; +import java.util.Map; 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; /** * Public API for HTML rendering from a collection or from a stream of @@ -39,7 +43,7 @@ import org.springframework.lang.Nullable; * @author Rossen Stoyanchev * @since 6.2 */ -public interface FragmentRendering { +public interface FragmentsRendering { /** * Return the HTTP status to set the response to. @@ -58,32 +62,59 @@ public interface FragmentRendering { Flux fragments(); + /** + * Create a builder and 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 model for the request + * @return this builder + */ + static Builder with(String viewName, Map model) { + return withCollection(List.of(Fragment.create(viewName, model))); + } + + /** + * Create a builder and add a fragment with a view name only, also + * inheriting model attributes from the model for the request. + * @param viewName the name of the view for the fragment + * @return this builder + */ + static Builder with(String viewName) { + return withCollection(List.of(Fragment.create(viewName))); + } + /** * Create a builder to render with a collection of Fragments. */ - static Builder fromCollection(Collection fragments) { - return new DefaultFragmentRenderingBuilder(fragments); + static Builder withCollection(Collection fragments) { + return new DefaultFragmentsRenderingBuilder(fragments); } /** * Create a builder to render with a {@link Publisher} of Fragments. */ - static

> Builder fromPublisher(P fragments) { - return new DefaultFragmentRenderingBuilder(fragments); + static

> Builder withPublisher(P fragmentsPublisher) { + return new DefaultFragmentsRenderingBuilder(fragmentsPublisher); } /** - * Variant of {@link #fromPublisher(Publisher)} that allows using any + * Variant of {@link #withPublisher(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); + static Builder withProducer(Object fragmentsProducer) { + return new DefaultFragmentsRenderingBuilder(adaptProducer(fragmentsProducer)); + } + + private static Publisher adaptProducer(Object producer) { + ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(producer.getClass()); + Assert.isTrue(adapter != null, "Unknown producer " + producer.getClass()); + return adapter.toPublisher(producer); } /** - * Defines a builder for {@link FragmentRendering}. + * Defines a builder for {@link FragmentsRendering}. */ interface Builder { @@ -111,9 +142,33 @@ public interface FragmentRendering { Builder headers(Consumer headersConsumer); /** - * Build the {@link FragmentRendering} instance. + * 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 model for the request + * @return this builder */ - FragmentRendering build(); + 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 + * @return this builder + */ + Builder fragment(Fragment fragment); + + /** + * Build the {@link FragmentsRendering} instance. + */ + FragmentsRendering 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 51dc2baecb..a5c33dc973 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 @@ -174,7 +174,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp return (CharSequence.class.isAssignableFrom(type) || Rendering.class.isAssignableFrom(type) || - FragmentRendering.class.isAssignableFrom(type) || + FragmentsRendering.class.isAssignableFrom(type) || Model.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type) || View.class.isAssignableFrom(type) || @@ -201,10 +201,10 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp if (adapter != null) { if (adapter.isMultiValue()) { valueMono = (result.getReturnValue() != null ? - Mono.just(FragmentRendering.fromPublisher(adapter.toPublisher(result.getReturnValue())).build()) : + Mono.just(FragmentsRendering.withPublisher(adapter.toPublisher(result.getReturnValue())).build()) : Mono.empty()); - valueType = ResolvableType.forClass(FragmentRendering.class); + valueType = ResolvableType.forClass(FragmentsRendering.class); } else { valueMono = (result.getReturnValue() != null ? @@ -235,8 +235,8 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp } if (Collection.class.isAssignableFrom(clazz)) { - returnValue = FragmentRendering.fromCollection((Collection) returnValue).build(); - clazz = FragmentRendering.class; + returnValue = FragmentsRendering.withCollection((Collection) returnValue).build(); + clazz = FragmentsRendering.class; } if (returnValue == NO_VALUE || ClassUtils.isVoidType(clazz)) { @@ -260,8 +260,8 @@ 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; + else if (FragmentsRendering.class.isAssignableFrom(clazz)) { + FragmentsRendering render = (FragmentsRendering) returnValue; HttpStatusCode status = render.status(); if (status != null) { exchange.getResponse().setStatusCode(status); @@ -328,7 +328,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport imp 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)); + fragment.mergeAttributes(bindingContext.getModel()); BodySavingResponse response = new BodySavingResponse(exchange.getResponse()); ServerWebExchange mutatedExchange = exchange.mutate().response(response).build(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java index 449b9f8b3e..aa89136773 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/FragmentViewResolutionResultHandlerTests.java @@ -59,15 +59,15 @@ public class FragmentViewResolutionResultHandlerTests { Fragment f2 = Fragment.create("fragment2", Map.of("bar", "Bar")); return Stream.of( Arguments.of( - FragmentRendering.fromPublisher(Flux.just(f1, f2).subscribeOn(Schedulers.boundedElastic())) + FragmentsRendering.withPublisher(Flux.just(f1, f2).subscribeOn(Schedulers.boundedElastic())) .headers(headers -> headers.setContentType(MediaType.TEXT_HTML)) .build(), - on(Handler.class).resolveReturnType(FragmentRendering.class)), + on(Handler.class).resolveReturnType(FragmentsRendering.class)), Arguments.of( - FragmentRendering.fromCollection(List.of(f1, f2)) + FragmentsRendering.withCollection(List.of(f1, f2)) .headers(headers -> headers.setContentType(MediaType.TEXT_HTML)) .build(), - on(Handler.class).resolveReturnType(FragmentRendering.class)), + on(Handler.class).resolveReturnType(FragmentsRendering.class)), Arguments.of( Flux.just(f1, f2).subscribeOn(Schedulers.boundedElastic()), on(Handler.class).resolveReturnType(Flux.class, Fragment.class)), @@ -110,7 +110,7 @@ public class FragmentViewResolutionResultHandlerTests { @SuppressWarnings("unused") private static class Handler { - FragmentRendering rendering() { return null; } + FragmentsRendering rendering() { return null; } Flux fragmentFlux() { return null; } 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 bd8e529fb6..51bea963c9 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 @@ -81,7 +81,7 @@ class ViewResolutionResultHandlerTests { testSupports(on(Handler.class).resolveReturnType(Rendering.class)); testSupports(on(Handler.class).resolveReturnType(Mono.class, Rendering.class)); - testSupports(on(Handler.class).resolveReturnType(FragmentRendering.class)); + testSupports(on(Handler.class).resolveReturnType(FragmentsRendering.class)); testSupports(on(Handler.class).resolveReturnType(Flux.class, Fragment.class)); testSupports(on(Handler.class).resolveReturnType(List.class, Fragment.class)); testSupports(on(Handler.class).resolveReturnType( @@ -440,7 +440,7 @@ class ViewResolutionResultHandlerTests { Rendering rendering() { return null; } Mono monoRendering() { return null; } - FragmentRendering fragmentRendering() { return null; } + FragmentsRendering fragmentsRendering() { return null; } Flux fragmentFlux() { return null; } Mono> monoFragmentList() { return null; } List fragmentList() { return null; }