mirror of
https://github.com/spring-projects/spring-security.git
synced 2025-05-31 09:12:14 +00:00
webflux-form sample
Fixes gh-4802
This commit is contained in:
parent
adec62cdf2
commit
75e77292cf
@ -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
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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> T to(WebDriver driver, int port, Class<T> 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);
|
||||
}
|
||||
}
|
@ -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> T submit(Class<T> page) {
|
||||
this.submit.click();
|
||||
return PageFactory.initElements(this.driver, page);
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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<Void> renderFragmentInternal(
|
||||
Set<String> markupSelectorsToRender, Map<String, Object> 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<String, Object> mergedModel = new HashMap<>(30);
|
||||
// First of all, set all the static variables into the mergedModel
|
||||
final Map<String, Object> templateStaticVariables = getStaticVariables();
|
||||
if (templateStaticVariables != null) {
|
||||
mergedModel.putAll(templateStaticVariables);
|
||||
}
|
||||
// Add path variables to merged model (if there are any)
|
||||
final Map<String, Object> pathVars =
|
||||
(Map<String, Object>) 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<String> 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<String,Object> 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<String> 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<DataBuffer> OBJECTS
|
||||
* -----------------------------------------------------------------------------------------------------------
|
||||
* - There are three possible processing modes, for each of which a Publisher<DataBuffer> 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<X> 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<X> 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<X> wrapped inside an implementation
|
||||
* of the IReactiveDataDriverContextVariable<?> interface. In this case, the Thymeleaf engine will
|
||||
* execute as a response to onNext(List<X>) 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<X> before triggering the template
|
||||
* engine each time (which is why Thymeleaf will react on onNext(List<X>) 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<X>)
|
||||
* 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<X>
|
||||
* 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<DataBuffer> 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<String,Object> 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<Charset> getCharset(final MediaType mediaType) {
|
||||
return mediaType != null ? Optional.ofNullable(mediaType.getCharset()) : Optional.empty();
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Secured</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Secured</h1>
|
||||
|
||||
<form th:action="@{/logout}" method="post">
|
||||
<input id="logout" type="submit" value="Log Out"/>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<title>Please Log In</title>
|
||||
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
|
||||
<link href="http://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<form class="form-signin" method="post" th:action="@{/login}">
|
||||
<h2 class="form-signin-heading">Please Log In</h2>
|
||||
<div th:if="${param.error}" class="alert alert-danger" role="alert">Invalid
|
||||
username and password.</div>
|
||||
<div th:if="${param.logout}" class="alert alert-success" role="alert">You
|
||||
have been logged out.</div>
|
||||
<p>
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
|
||||
</p>
|
||||
<p>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
|
||||
</p>
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user