diff --git a/samples/javaconfig/webflux-form/spring-security-samples-javaconfig-webflux-form.gradle b/samples/javaconfig/webflux-form/spring-security-samples-javaconfig-webflux-form.gradle new file mode 100644 index 0000000000..504380adc6 --- /dev/null +++ b/samples/javaconfig/webflux-form/spring-security-samples-javaconfig-webflux-form.gradle @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2017 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 + * + * http://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. + */ + +apply plugin: 'io.spring.convention.spring-sample' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-config') + compile project(':spring-security-web') + compile 'com.fasterxml.jackson.core:jackson-databind' + compile 'io.netty:netty-buffer' + compile 'io.projectreactor.ipc:reactor-netty' + compile 'org.springframework:spring-context' + compile 'org.springframework:spring-webflux' + compile 'org.thymeleaf:thymeleaf-spring5' + compile slf4jDependencies + + testCompile project(':spring-security-test') + testCompile project(':spring-security-test') + testCompile 'io.projectreactor:reactor-test' + testCompile 'org.skyscreamer:jsonassert' + testCompile 'org.springframework:spring-test' + + integrationTestCompile seleniumDependencies +} diff --git a/samples/javaconfig/webflux-form/src/integration-test/java/sample/WebfluxFormApplicationTests.java b/samples/javaconfig/webflux-form/src/integration-test/java/sample/WebfluxFormApplicationTests.java new file mode 100644 index 0000000000..7e6e137345 --- /dev/null +++ b/samples/javaconfig/webflux-form/src/integration-test/java/sample/WebfluxFormApplicationTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample; + +import com.gargoylesoftware.htmlunit.BrowserVersion; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.htmlunit.HtmlUnitDriver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import sample.webdriver.IndexPage; +import sample.webdriver.LoginPage; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = WebfluxFormApplication.class) +@TestPropertySource(properties = "server.port=0") +public class WebfluxFormApplicationTests { + WebDriver driver; + + @Value("#{@nettyContext.address().getPort()}") + int port; + + @Before + public void setup() { + this.driver = new HtmlUnitDriver(BrowserVersion.CHROME); + } + + @Test + public void loginWhenInvalidUsernameThenError() throws Exception { + LoginPage login = IndexPage.to(this.driver, this.port, LoginPage.class); + login.assertAt(); + + login + .loginForm() + .username("invalid") + .password("password") + .submit(LoginPage.class) + .assertError(); + } + + @Test + public void loginAndLogout() throws Exception { + LoginPage login = IndexPage.to(this.driver, this.port, LoginPage.class); + login.assertAt(); + + IndexPage index = login + .loginForm() + .username("user") + .password("password") + .submit(IndexPage.class); + index.assertAt(); + + login = index.logout(); + login + .assertAt() + .assertLogout(); + } +} diff --git a/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/IndexPage.java b/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/IndexPage.java new file mode 100644 index 0000000000..dcf6530954 --- /dev/null +++ b/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/IndexPage.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample.webdriver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class IndexPage { + + private WebDriver driver; + + private WebElement logout; + + public IndexPage(WebDriver webDriver) { + this.driver = webDriver; + } + + public static T to(WebDriver driver, int port, Class page) { + driver.get("http://localhost:" + port +"/"); + return (T) PageFactory.initElements(driver, page); + } + + public IndexPage assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Secured"); + return this; + } + + public LoginPage logout() { + this.logout.click(); + return LoginPage.create(this.driver); + } +} diff --git a/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/LoginPage.java b/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/LoginPage.java new file mode 100644 index 0000000000..169ccbb1bd --- /dev/null +++ b/samples/javaconfig/webflux-form/src/integration-test/java/sample/webdriver/LoginPage.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample.webdriver; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class LoginPage { + + private WebDriver driver; + @FindBy(css = "div[role=alert]") + private WebElement alert; + + private LoginForm loginForm; + + public LoginPage(WebDriver webDriver) { + this.driver = webDriver; + this.loginForm = PageFactory.initElements(webDriver, LoginForm.class); + } + + static LoginPage create(WebDriver driver) { + return PageFactory.initElements(driver, LoginPage.class); + } + + public LoginPage assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Please Log In"); + return this; + } + + public LoginPage assertError() { + assertThat(this.alert.getText()).isEqualTo("Invalid username and password."); + return this; + } + + public LoginPage assertLogout() { + assertThat(this.alert.getText()).isEqualTo("You have been logged out."); + return this; + } + + public LoginForm loginForm() { + return this.loginForm; + } + + public static class LoginForm { + private WebDriver driver; + private WebElement username; + private WebElement password; + @FindBy(css = "button[type=submit]") + private WebElement submit; + + public LoginForm(WebDriver driver) { + this.driver = driver; + } + + public LoginForm username(String username) { + this.username.sendKeys(username); + return this; + } + + public LoginForm password(String password) { + this.password.sendKeys(password); + return this; + } + + public T submit(Class page) { + this.submit.click(); + return PageFactory.initElements(this.driver, page); + } + } +} diff --git a/samples/javaconfig/webflux-form/src/main/java/sample/IndexController.java b/samples/javaconfig/webflux-form/src/main/java/sample/IndexController.java new file mode 100644 index 0000000000..e381ec7a2f --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/java/sample/IndexController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Controller +public class IndexController { + + @GetMapping("/") + public String index() { + return "index"; + } + + @GetMapping("/login") + public String login() { + return "login"; + } +} diff --git a/samples/javaconfig/webflux-form/src/main/java/sample/ThymeleafConfig.java b/samples/javaconfig/webflux-form/src/main/java/sample/ThymeleafConfig.java new file mode 100644 index 0000000000..440be68161 --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/java/sample/ThymeleafConfig.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.config.ViewResolverRegistry; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.thymeleaf.spring5.ISpringWebFluxTemplateEngine; +import org.thymeleaf.spring5.SpringWebFluxTemplateEngine; +import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver; +import org.thymeleaf.spring5.view.reactive.ThymeleafReactiveViewResolver; +import org.thymeleaf.templatemode.TemplateMode; +import thymeleaf.PatchThymeleafReactiveView; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Configuration +public class ThymeleafConfig implements WebFluxConfigurer { + private ApplicationContext applicationContext; + + public ThymeleafConfig(final ApplicationContext applicationContext) { + super(); + this.applicationContext = applicationContext; + } + + @Bean + public SpringResourceTemplateResolver thymeleafTemplateResolver() { + + SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver(); + resolver.setApplicationContext(this.applicationContext); + resolver.setPrefix("classpath:/templates/"); + resolver.setSuffix(".html"); + resolver.setTemplateMode(TemplateMode.HTML); + resolver.setCacheable(false); + resolver.setCheckExistence(true); + return resolver; + + } + + @Bean + public ISpringWebFluxTemplateEngine thymeleafTemplateEngine() { + SpringWebFluxTemplateEngine templateEngine = new SpringWebFluxTemplateEngine(); + templateEngine.setTemplateResolver(thymeleafTemplateResolver()); + return templateEngine; + } + + @Bean + public ThymeleafReactiveViewResolver thymeleafChunkedAndDataDrivenViewResolver() { + ThymeleafReactiveViewResolver viewResolver = new ThymeleafReactiveViewResolver(); + viewResolver.setTemplateEngine(thymeleafTemplateEngine()); + viewResolver.setOrder(1); + viewResolver.setResponseMaxChunkSizeBytes(8192); // OUTPUT BUFFER size limit + viewResolver.setViewClass(PatchThymeleafReactiveView.class); + return viewResolver; + } + + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + registry.viewResolver(thymeleafChunkedAndDataDrivenViewResolver()); + } +} diff --git a/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormApplication.java b/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormApplication.java new file mode 100644 index 0000000000..f942a1cf15 --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormApplication.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.*; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import reactor.ipc.netty.NettyContext; +import reactor.ipc.netty.http.server.HttpServer; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Configuration +@EnableWebFlux +@ComponentScan +public class WebfluxFormApplication { + @Value("${server.port:8080}") + private int port = 8080; + + public static void main(String[] args) throws Exception { + try(AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + WebfluxFormApplication.class)) { + context.getBean(NettyContext.class).onClose().block(); + } + } + + @Profile("default") + @Bean + public NettyContext nettyContext(ApplicationContext context) { + HttpHandler handler = WebHttpHandlerBuilder.applicationContext(context) + .build(); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler); + HttpServer httpServer = HttpServer.create("localhost", port); + return httpServer.newHandler(adapter).block(); + } +} diff --git a/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormSecurityConfig.java b/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormSecurityConfig.java new file mode 100644 index 0000000000..4d19048b1a --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/java/sample/WebfluxFormSecurityConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 sample; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; + +/** + * @author Rob Winch + * @since 5.0 + */ +@EnableWebFluxSecurity +public class WebfluxFormSecurityConfig { + + @Bean + public MapReactiveUserDetailsService userDetailsRepository() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + return new MapReactiveUserDetailsService(user); + } + + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange() + .pathMatchers("/login").permitAll() + .anyExchange().authenticated() + .and() + .httpBasic().and() + .formLogin() + .loginPage("/login"); + return http.build(); + } +} diff --git a/samples/javaconfig/webflux-form/src/main/java/thymeleaf/PatchThymeleafReactiveView.java b/samples/javaconfig/webflux-form/src/main/java/thymeleaf/PatchThymeleafReactiveView.java new file mode 100644 index 0000000000..a8c1b7b9af --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/java/thymeleaf/PatchThymeleafReactiveView.java @@ -0,0 +1,341 @@ +/* + * Copyright 2002-2017 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 + * + * http://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 thymeleaf; + +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.view.RequestContext; +import org.springframework.web.server.ServerWebExchange; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.exceptions.TemplateProcessingException; +import org.thymeleaf.spring5.ISpringWebFluxTemplateEngine; +import org.thymeleaf.spring5.context.webflux.IReactiveDataDriverContextVariable; +import org.thymeleaf.spring5.context.webflux.SpringWebFluxExpressionContext; +import org.thymeleaf.spring5.context.webflux.SpringWebFluxThymeleafRequestContext; +import org.thymeleaf.spring5.expression.ThymeleafEvaluationContext; +import org.thymeleaf.spring5.naming.SpringContextVariableNames; +import org.thymeleaf.spring5.view.reactive.ThymeleafReactiveView; +import org.thymeleaf.standard.expression.FragmentExpression; +import org.thymeleaf.standard.expression.IStandardExpressionParser; +import org.thymeleaf.standard.expression.StandardExpressionExecutionContext; +import org.thymeleaf.standard.expression.StandardExpressions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class PatchThymeleafReactiveView extends ThymeleafReactiveView { + private static final String WEBFLUX_CONVERSION_SERVICE_NAME = "webFluxConversionService"; + @Override + protected Mono renderFragmentInternal( + Set markupSelectorsToRender, Map renderAttributes, + MediaType contentType, ServerWebExchange exchange) { + final String viewTemplateName = getTemplateName(); + final ISpringWebFluxTemplateEngine viewTemplateEngine = getTemplateEngine(); + + if (viewTemplateName == null) { + return Mono.error(new IllegalArgumentException("Property 'templateName' is required")); + } + if (getLocale() == null) { + return Mono.error(new IllegalArgumentException("Property 'locale' is required")); + } + if (viewTemplateEngine == null) { + return Mono.error(new IllegalArgumentException("Property 'thymeleafTemplateEngine' is required")); + } + + final ServerHttpResponse response = exchange.getResponse(); + + /* + * ---------------------------------------------------------------------------------------------------------- + * GATHERING OF THE MERGED MODEL + * ---------------------------------------------------------------------------------------------------------- + * - The merged model is the map that will be used for initialising the Thymelef IContext. This context will + * contain all the data accessible by the template during its execution. + * - The base of the merged model is the ModelMap created by the Controller, but there are some additional + * things + * ---------------------------------------------------------------------------------------------------------- + */ + + final Map mergedModel = new HashMap<>(30); + // First of all, set all the static variables into the mergedModel + final Map templateStaticVariables = getStaticVariables(); + if (templateStaticVariables != null) { + mergedModel.putAll(templateStaticVariables); + } + // Add path variables to merged model (if there are any) + final Map pathVars = + (Map) exchange.getAttributes().get(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (pathVars != null) { + mergedModel.putAll(pathVars); + } + // Simply dump all the renderAttributes (model coming from the controller) into the merged model + if (renderAttributes != null) { + mergedModel.putAll(renderAttributes); + } + + final ApplicationContext applicationContext = getApplicationContext(); + + // Initialize RequestContext (reactive version) and add it to the model as another attribute, + // so that it can be retrieved from elsewhere. + final RequestContext requestContext = createRequestContext(exchange, mergedModel); + final SpringWebFluxThymeleafRequestContext thymeleafRequestContext = + new SpringWebFluxThymeleafRequestContext(requestContext, exchange); + + mergedModel.put(SpringContextVariableNames.SPRING_REQUEST_CONTEXT, requestContext); + // Add the Thymeleaf RequestContext wrapper that we will be using in this dialect (the bare RequestContext + // stays in the context to for compatibility with other dialects) + mergedModel.put(SpringContextVariableNames.THYMELEAF_REQUEST_CONTEXT, thymeleafRequestContext); + + + // Expose Thymeleaf's own evaluation context as a model variable + // + // Note Spring's EvaluationContexts are NOT THREAD-SAFE (in exchange for SpelExpressions being thread-safe). + // That's why we need to create a new EvaluationContext for each request / template execution, even if it is + // quite expensive to create because of requiring the initialization of several ConcurrentHashMaps. + final ConversionService conversionService = + applicationContext.containsBean(WEBFLUX_CONVERSION_SERVICE_NAME)? + (ConversionService)applicationContext.getBean(WEBFLUX_CONVERSION_SERVICE_NAME): null; + final ThymeleafEvaluationContext evaluationContext = + new ThymeleafEvaluationContext(applicationContext, conversionService); + mergedModel.put(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, evaluationContext); + + + // Determine if we have a data-driver variable, and therefore will need to configure flushing of output chunks + final boolean dataDriven = isDataDriven(mergedModel); + + + /* + * ---------------------------------------------------------------------------------------------------------- + * INSTANTIATION OF THE CONTEXT + * ---------------------------------------------------------------------------------------------------------- + * - Once the model has been merged, we can create the Thymeleaf context object itself. + * - The reason it is an ExpressionContext and not a Context is that before executing the template itself, + * we might need to use it for computing the markup selectors (if "template :: selector" was specified). + * - The reason it is not a WebExpressionContext is that this class is linked to the Servlet API, which + * might not be present in a Spring WebFlux environment. + * ---------------------------------------------------------------------------------------------------------- + */ + + final IEngineConfiguration configuration = viewTemplateEngine.getConfiguration(); + final SpringWebFluxExpressionContext context = + new SpringWebFluxExpressionContext( + configuration, exchange, getReactiveAdapterRegistry(), getLocale(), mergedModel); + + + /* + * ---------------------------------------------------------------------------------------------------------- + * COMPUTATION OF (OPTIONAL) MARKUP SELECTORS + * ---------------------------------------------------------------------------------------------------------- + * - If view name has been specified with a template selector (in order to execute only a fragment of + * the template) like "template :: selector", we will extract it and compute it. + * ---------------------------------------------------------------------------------------------------------- + */ + + final String templateName; + final Set markupSelectors; + if (!viewTemplateName.contains("::")) { + // No fragment specified at the template name + + templateName = viewTemplateName; + markupSelectors = null; + + } else { + // Template name contains a fragment name, so we should parse it as such + + final IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration); + + final FragmentExpression fragmentExpression; + try { + // By parsing it as a standard expression, we might profit from the expression cache + fragmentExpression = (FragmentExpression) parser.parseExpression(context, "~{" + viewTemplateName + "}"); + } catch (final TemplateProcessingException e) { + return Mono.error( + new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'")); + } + + final FragmentExpression.ExecutedFragmentExpression fragment = + FragmentExpression.createExecutedFragmentExpression(context, fragmentExpression, StandardExpressionExecutionContext.NORMAL); + + templateName = FragmentExpression.resolveTemplateName(fragment); + markupSelectors = FragmentExpression.resolveFragments(fragment); + final Map nameFragmentParameters = fragment.getFragmentParameters(); + + if (nameFragmentParameters != null) { + + if (fragment.hasSyntheticParameters()) { + // We cannot allow synthetic parameters because there is no way to specify them at the template + // engine execution! + return Mono.error(new IllegalArgumentException( + "Parameters in a view specification must be named (non-synthetic): '" + viewTemplateName + "'")); + } + + context.setVariables(nameFragmentParameters); + + } + + } + + final Set processMarkupSelectors; + if (markupSelectors != null && markupSelectors.size() > 0) { + if (markupSelectorsToRender != null && markupSelectorsToRender.size() > 0) { + return Mono.error(new IllegalArgumentException( + "A markup selector has been specified (" + Arrays.asList(markupSelectors) + ") for a view " + + "that was already being executed as a fragment (" + Arrays.asList(markupSelectorsToRender) + "). " + + "Only one fragment selection is allowed.")); + } + processMarkupSelectors = markupSelectors; + } else { + if (markupSelectorsToRender != null && markupSelectorsToRender.size() > 0) { + processMarkupSelectors = markupSelectorsToRender; + } else { + processMarkupSelectors = null; + } + } + + + /* + * ---------------------------------------------------------------------------------------------------------- + * COMPUTATION OF TEMPLATE PROCESSING PARAMETERS AND HTTP HEADERS + * ---------------------------------------------------------------------------------------------------------- + * - At this point we will compute the final values of the different parameters needed for processing the + * template (locale, encoding, buffer sizes, etc.) + * ---------------------------------------------------------------------------------------------------------- + */ + + final int templateResponseMaxChunkSizeBytes = getResponseMaxChunkSizeBytes(); + + final HttpHeaders responseHeaders = exchange.getResponse().getHeaders(); + final Locale templateLocale = getLocale(); + if (templateLocale != null) { + responseHeaders.setContentLanguage(templateLocale); + } + + // Get the charset from the selected content type (or use default) + final Charset charset = getCharset(contentType).orElse(getDefaultCharset()); + + + /* + * ----------------------------------------------------------------------------------------------------------- + * SET (AND RETURN) THE TEMPLATE PROCESSING Flux OBJECTS + * ----------------------------------------------------------------------------------------------------------- + * - There are three possible processing modes, for each of which a Publisher will be created in a + * different way: + * + * 1. FULL: Output chunks not limited in size (templateResponseMaxChunkSizeBytes == Integer.MAX_VALUE) and + * no data-driven execution (no context variable of type Publisher driving the template engine + * execution): In this case Thymeleaf will be executed unthrottled, in full mode, writing output + * to a single DataBuffer chunk instanced before execution, and which will be passed to the output + * channels in a single onNext(buffer) call (immediately followed by onComplete()). + * + * 2. CHUNKED: Output chunks limited in size (responseMaxChunkSizeBytes) but no data-driven + * execution (no Publisher driving engine execution). All model attributes are expected to be + * fully resolved (in a non-blocking fashion) by WebFlux before engine execution and the Thymeleaf + * engine will execute in throttled mode, performing a full-stop each time the chunk reaches the + * specified size, sending it to the output channels with onNext(chunk) and then waiting until + * these output channels make the engine resume its work with a new request(n) call. This + * execution mode will request an output flush from the server after producing each chunk. + * + * 3. DATA-DRIVEN: one of the model attributes is a Publisher wrapped inside an implementation + * of the IReactiveDataDriverContextVariable interface. In this case, the Thymeleaf engine will + * execute as a response to onNext(List) events triggered by this Publisher. The + * "bufferSizeElements" specified at the model attribute will define the amount of elements + * produced by this Publisher that will be buffered into a List before triggering the template + * engine each time (which is why Thymeleaf will react on onNext(List) and not onNext(X)). Thymeleaf + * will expect to find a "th:each" iteration on the data-driven variable inside the processed template, + * and will be executed in throttled mode for the published elements, sending the resulting DataBuffer + * output chunks to the output channels via onNext(chunk) and stopping until a new onNext(List) + * event is triggered. When execution is data-driven, a limit in size can be optionally specified for + * the output chunks (responseMaxChunkSizeBytes) which will make Thymeleaf never send + * to the output channels a chunk bigger than that (thus splitting the output generated for a List + * of published elements into several chunks if required). When executing in DATA-DRIVEN mode, + * Thymeleaf will always request flushing of the output channels after producing each chunk. + * ---------------------------------------------------------------------------------------------------------- + */ + + + final Publisher stream = + viewTemplateEngine.processStream( + templateName, processMarkupSelectors, context, response.bufferFactory(), contentType, charset, + templateResponseMaxChunkSizeBytes); // FULL/DATADRIVEN if MAX_VALUE, CHUNKED/DATADRIVEN if other + + if (templateResponseMaxChunkSizeBytes == Integer.MAX_VALUE && !dataDriven) { + + // No size limit for output chunks has been set (FULL mode), so we will let the + // server apply its standard behaviour ("writeWith"). + return response.writeWith(stream); + + } + + // Either we are in DATA-DRIVEN mode or a limit for output chunks has been set (CHUNKED mode), so we will + // use "writeAndFlushWith" in order to make sure that output is flushed after each buffer. + return response.writeAndFlushWith(Flux.from(stream).window(1)); + } + + + + private static boolean isDataDriven(final Map mergedModel) { + if (mergedModel == null || mergedModel.size() == 0) { + return false; + } + for (final Object value : mergedModel.values()) { + if (value instanceof IReactiveDataDriverContextVariable) { + return true; + } + } + return false; + } + + private ReactiveAdapterRegistry getReactiveAdapterRegistry() { + + final ApplicationContext applicationContext = getApplicationContext(); + if (applicationContext == null) { + return null; + } + + if (applicationContext != null) { + try { + return applicationContext.getBean(ReactiveAdapterRegistry.class); + } catch (final NoSuchBeanDefinitionException ignored) { + // No registry, but note that we can live without it (though limited to Flux and Mono) + } + } + return null; + + } + + private static Optional getCharset(final MediaType mediaType) { + return mediaType != null ? Optional.ofNullable(mediaType.getCharset()) : Optional.empty(); + } +} diff --git a/samples/javaconfig/webflux-form/src/main/resources/templates/index.html b/samples/javaconfig/webflux-form/src/main/resources/templates/index.html new file mode 100644 index 0000000000..992450d43f --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/resources/templates/index.html @@ -0,0 +1,17 @@ + + + + + Secured + + + +
+

Secured

+ +
+ +
+
+ + diff --git a/samples/javaconfig/webflux-form/src/main/resources/templates/login.html b/samples/javaconfig/webflux-form/src/main/resources/templates/login.html new file mode 100644 index 0000000000..c28b4c97eb --- /dev/null +++ b/samples/javaconfig/webflux-form/src/main/resources/templates/login.html @@ -0,0 +1,32 @@ + + + + + + + + Please Log In + + + + +
+ +
+ +