Updates to WebFlux fragment rendering API

See gh-33162
This commit is contained in:
rstoyanchev 2024-07-10 11:25:27 +01:00
parent 54e76c8105
commit 6ee8786385
7 changed files with 257 additions and 143 deletions

View File

@ -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<Fragment> fragments;
@Nullable
private HttpStatusCode status;
@Nullable
private HttpHeaders headers;
DefaultFragmentRenderingBuilder(Collection<Fragment> fragments) {
this(Flux.fromIterable(fragments));
}
DefaultFragmentRenderingBuilder(Object fragments) {
this(adaptProducer(fragments));
}
DefaultFragmentRenderingBuilder(Publisher<Fragment> fragments) {
this.fragments = Flux.from(fragments);
}
private static Publisher<Fragment> 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<HttpHeaders> 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<Fragment> fragments)
implements FragmentRendering {
}
}

View File

@ -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<Fragment> fragmentsCollection;
@Nullable
private final Flux<Fragment> fragmentsFlux;
@Nullable
private HttpStatusCode status;
@Nullable
private HttpHeaders headers;
DefaultFragmentsRenderingBuilder(Collection<Fragment> fragments) {
this.fragmentsCollection = new ArrayList<>(fragments);
this.fragmentsFlux = null;
}
DefaultFragmentsRenderingBuilder(Publisher<Fragment> 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<HttpHeaders> 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<String, Object> 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<Fragment> 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<Fragment> 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<Fragment> fragments)
implements FragmentsRendering {
}
}

View File

@ -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<String, Object> model;
@Nullable
private Map<String, Object> model;
private Fragment(@Nullable String viewName, @Nullable View view, Map<String, Object> model) {
private Fragment(@Nullable String viewName, @Nullable View view, @Nullable Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> model) {
return new Fragment(null, view, model);

View File

@ -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<Fragment> 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<String, Object> 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<Fragment> fragments) {
return new DefaultFragmentRenderingBuilder(fragments);
static Builder withCollection(Collection<Fragment> fragments) {
return new DefaultFragmentsRenderingBuilder(fragments);
}
/**
* Create a builder to render with a {@link Publisher} of Fragments.
*/
static <P extends Publisher<Fragment>> Builder fromPublisher(P fragments) {
return new DefaultFragmentRenderingBuilder(fragments);
static <P extends Publisher<Fragment>> 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<Fragment> 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<HttpHeaders> 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<String, Object> 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();
}
}

View File

@ -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<Fragment>) returnValue).build();
clazz = FragmentRendering.class;
returnValue = FragmentsRendering.withCollection((Collection<Fragment>) 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();

View File

@ -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<Fragment> fragmentFlux() { return null; }

View File

@ -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<Rendering> monoRendering() { return null; }
FragmentRendering fragmentRendering() { return null; }
FragmentsRendering fragmentsRendering() { return null; }
Flux<Fragment> fragmentFlux() { return null; }
Mono<List<Fragment>> monoFragmentList() { return null; }
List<Fragment> fragmentList() { return null; }