11 changed files with 882 additions and 0 deletions
@ -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…
Reference in new issue