parent
545228d693
commit
ef6a3768ee
|
@ -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<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 {
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, Object> model;
|
||||
|
||||
|
||||
private Fragment(@Nullable String viewName, @Nullable View view, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> model) {
|
||||
return new Fragment(null, view, model);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 <a href="https://htmx.org/">htmx</a> where multiple
|
||||
* page fragments may be rendered in a single response. Supported as a return
|
||||
* value from a WebFlux controller method.
|
||||
*
|
||||
* <p>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<Fragment> fragments();
|
||||
|
||||
|
||||
/**
|
||||
* Create a builder to render with a collection of Fragments.
|
||||
*/
|
||||
static Builder fromCollection(Collection<Fragment> fragments) {
|
||||
return new DefaultFragmentRenderingBuilder(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HttpHeaders> headersConsumer);
|
||||
|
||||
/**
|
||||
* Build the {@link FragmentRendering} instance.
|
||||
*/
|
||||
FragmentRendering build();
|
||||
}
|
||||
|
||||
}
|
|
@ -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<List<View>> 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<Flux<DataBuffer>> 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<Flux<DataBuffer>> 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<List<View>> 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<DataBuffer> 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<DataBuffer> getBodyFlux() {
|
||||
Assert.state(this.bodyFlux != null, "Body not set");
|
||||
return this.bodyFlux;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
|
||||
this.bodyFlux = Flux.from(body);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
|
||||
this.bodyFlux = Flux.from(body).flatMap(Flux::from);
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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> 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("<p>Hello Foo</p><p>Hello Bar</p>");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Rendering> monoRendering() { return null; }
|
||||
|
||||
FragmentRendering fragmentRendering() { return null; }
|
||||
|
||||
View view() { return null; }
|
||||
Mono<View> monoView() { return null; }
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import org.springframework.web.reactive.result.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $foo</p>"""
|
|
@ -0,0 +1,3 @@
|
|||
import org.springframework.web.reactive.result.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $bar</p>"""
|
|
@ -1418,6 +1418,10 @@ public class DispatcherServlet extends FrameworkServlet {
|
|||
}
|
||||
}
|
||||
|
||||
if (view instanceof SmartView smartView) {
|
||||
smartView.resolveNestedViews(this::resolveViewNameInternal, locale);
|
||||
}
|
||||
|
||||
// Delegate to the View object for rendering.
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Rendering view [" + view + "] ");
|
||||
|
@ -1466,6 +1470,11 @@ public class DispatcherServlet extends FrameworkServlet {
|
|||
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model,
|
||||
Locale locale, HttpServletRequest request) throws Exception {
|
||||
|
||||
return resolveViewNameInternal(viewName, locale);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private View resolveViewNameInternal(String viewName, Locale locale) throws Exception {
|
||||
if (this.viewResolvers != null) {
|
||||
for (ViewResolver viewResolver : this.viewResolvers) {
|
||||
View view = viewResolver.resolveViewName(viewName, locale);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2012 the original author or authors.
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
@ -16,6 +16,8 @@
|
|||
|
||||
package org.springframework.web.servlet;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Provides additional information about a View such as whether it
|
||||
* performs redirects.
|
||||
|
@ -25,9 +27,27 @@ package org.springframework.web.servlet;
|
|||
*/
|
||||
public interface SmartView extends View {
|
||||
|
||||
|
||||
/**
|
||||
* Whether the view performs a redirect.
|
||||
*/
|
||||
boolean isRedirectView();
|
||||
|
||||
/**
|
||||
* In most cases, the {@link DispatcherServlet} uses {@link ViewResolver}s
|
||||
* to resolve {@link View} instances. However, a special type of
|
||||
* {@link View} may actually render a collection of fragments, each with its
|
||||
* own model and view.
|
||||
* <p>This callback provides such a view with the opportunity to resolve
|
||||
* any nested views it contains prior to rendering.
|
||||
* @param resolver to resolve views with
|
||||
* @param locale the resolved locale for the request
|
||||
* @throws Exception if any view cannot be resolved, or in case of problems
|
||||
* creating an actual View instance
|
||||
* @since 6.2
|
||||
*/
|
||||
default void resolveNestedViews(ViewResolver resolver, Locale locale) throws Exception {
|
||||
// no-op
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2023 the original author or authors.
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
@ -16,6 +16,8 @@
|
|||
|
||||
package org.springframework.web.servlet.mvc.method.annotation;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.PatternMatchUtils;
|
||||
|
@ -25,6 +27,7 @@ import org.springframework.web.method.support.ModelAndViewContainer;
|
|||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.SmartView;
|
||||
import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.view.FragmentsView;
|
||||
|
||||
/**
|
||||
* Handles return values of type {@link ModelAndView} copying view and model
|
||||
|
@ -71,9 +74,14 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn
|
|||
|
||||
@Override
|
||||
public boolean supportsReturnType(MethodParameter returnType) {
|
||||
return ModelAndView.class.isAssignableFrom(returnType.getParameterType());
|
||||
Class<?> type = returnType.getParameterType();
|
||||
if (Collection.class.isAssignableFrom(type)) {
|
||||
type = returnType.nested().getNestedParameterType();
|
||||
}
|
||||
return ModelAndView.class.isAssignableFrom(type);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
|
||||
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
|
||||
|
@ -83,6 +91,11 @@ public class ModelAndViewMethodReturnValueHandler implements HandlerMethodReturn
|
|||
return;
|
||||
}
|
||||
|
||||
if (returnValue instanceof Collection<?> mavs) {
|
||||
mavContainer.setView(FragmentsView.create((Collection<ModelAndView>) mavs));
|
||||
return;
|
||||
}
|
||||
|
||||
ModelAndView mav = (ModelAndView) returnValue;
|
||||
if (mav.isReference()) {
|
||||
String viewName = mav.getViewName();
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.web.servlet.view;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.WriteListener;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpServletResponseWrapper;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.SmartView;
|
||||
import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
|
||||
/**
|
||||
* {@link View} that enables rendering of a collection of fragments, each with
|
||||
* its own view and model, also inheriting common attributes from the top-level model.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 6.2
|
||||
*/
|
||||
public class FragmentsView implements SmartView {
|
||||
|
||||
private final Collection<ModelAndView> modelAndViews;
|
||||
|
||||
|
||||
/**
|
||||
* Protected constructor to allow extension.
|
||||
*/
|
||||
protected FragmentsView(Collection<ModelAndView> modelAndViews) {
|
||||
this.modelAndViews = modelAndViews;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean isRedirectView() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resolveNestedViews(ViewResolver resolver, Locale locale) throws Exception {
|
||||
for (ModelAndView mv : this.modelAndViews) {
|
||||
View view = resolveView(resolver, locale, mv);
|
||||
mv.setView(view);
|
||||
}
|
||||
}
|
||||
|
||||
private static View resolveView(ViewResolver viewResolver, Locale locale, ModelAndView mv) throws Exception {
|
||||
String viewName = mv.getViewName();
|
||||
View view = (viewName != null ? viewResolver.resolveViewName(viewName, locale) : mv.getView());
|
||||
if (view == null) {
|
||||
throw new ServletException("Could not resolve view in " + mv);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
@Nullable Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
|
||||
throws Exception {
|
||||
|
||||
if (model != null) {
|
||||
model.forEach((key, value) ->
|
||||
this.modelAndViews.forEach(mv -> mv.getModel().putIfAbsent(key, value)));
|
||||
}
|
||||
|
||||
HttpServletResponse nonClosingResponse = new NonClosingHttpServletResponse(response);
|
||||
for (ModelAndView mv : this.modelAndViews) {
|
||||
Assert.state(mv.getView() != null, "Expected View");
|
||||
mv.getView().render(mv.getModel(), request, nonClosingResponse);
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FragmentsView " + this.modelAndViews;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Factory method to create an instance with the given fragments.
|
||||
* @param modelAndViews the {@link ModelAndView} to use
|
||||
* @return the created {@code FragmentsView} instance
|
||||
*/
|
||||
public static FragmentsView create(Collection<ModelAndView> modelAndViews) {
|
||||
return new FragmentsView(modelAndViews);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Response wrapper that in turn applies {@link NonClosingServletOutputStream}.
|
||||
*/
|
||||
private static final class NonClosingHttpServletResponse extends HttpServletResponseWrapper {
|
||||
|
||||
@Nullable
|
||||
private ServletOutputStream os;
|
||||
|
||||
public NonClosingHttpServletResponse(HttpServletResponse response) {
|
||||
super(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletOutputStream getOutputStream() throws IOException {
|
||||
if (this.os == null) {
|
||||
this.os = new NonClosingServletOutputStream(getResponse().getOutputStream());
|
||||
}
|
||||
return this.os;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* {@code OutputStream} that leaves the response open, ignoring calls to close it.
|
||||
*/
|
||||
private static final class NonClosingServletOutputStream extends ServletOutputStream {
|
||||
|
||||
private final ServletOutputStream os;
|
||||
|
||||
public NonClosingServletOutputStream(ServletOutputStream os) {
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
this.os.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
this.os.write(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(WriteListener writeListener) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.web.servlet.view;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.DisabledForJreRange;
|
||||
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.view.script.ScriptTemplateConfigurer;
|
||||
import org.springframework.web.servlet.view.script.ScriptTemplateViewResolver;
|
||||
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
|
||||
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.condition.JRE.JAVA_21;
|
||||
|
||||
/**
|
||||
* Tests for rendering through {@link FragmentsView}.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
@DisabledForJreRange(min = JAVA_21, disabledReason = "Kotlin doesn't support Java 21+ yet")
|
||||
public class FragmentsViewTests {
|
||||
|
||||
|
||||
@Test
|
||||
void render() throws Exception {
|
||||
|
||||
AnnotationConfigApplicationContext context =
|
||||
new AnnotationConfigApplicationContext(ScriptTemplatingConfiguration.class);
|
||||
|
||||
String prefix = "org/springframework/web/servlet/view/script/kotlin/";
|
||||
ScriptTemplateViewResolver viewResolver = new ScriptTemplateViewResolver(prefix, ".kts");
|
||||
viewResolver.setApplicationContext(context);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FragmentsView view = FragmentsView.create(List.of(
|
||||
new ModelAndView("fragment1", Map.of("foo", "Foo")),
|
||||
new ModelAndView("fragment2", Map.of("bar", "Bar"))));
|
||||
|
||||
view.resolveNestedViews(viewResolver, Locale.ENGLISH);
|
||||
view.render(Collections.emptyMap(), request, response);
|
||||
|
||||
assertThat(response.getContentAsString()).isEqualTo("<p>Hello Foo</p><p>Hello Bar</p>");
|
||||
}
|
||||
|
||||
|
||||
@Configuration
|
||||
static class ScriptTemplatingConfiguration {
|
||||
|
||||
@Bean
|
||||
ScriptTemplateConfigurer kotlinScriptConfigurer() {
|
||||
ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
|
||||
configurer.setEngineName("kotlin");
|
||||
configurer.setScripts("org/springframework/web/servlet/view/script/kotlin/render.kts");
|
||||
configurer.setRenderFunction("render");
|
||||
return configurer;
|
||||
}
|
||||
|
||||
@Bean
|
||||
ResourceBundleMessageSource messageSource() {
|
||||
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
|
||||
messageSource.setBasename("org/springframework/web/servlet/view/script/messages");
|
||||
return messageSource;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import org.springframework.web.servlet.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $foo</p>"""
|
|
@ -0,0 +1,3 @@
|
|||
import org.springframework.web.servlet.view.script.*
|
||||
|
||||
"""<p>${i18n("hello")} $bar</p>"""
|
Loading…
Reference in New Issue