Search code examples
springspring-bootthymeleafspring-webfluxspring-data-r2dbc

Upgrading to Spring boot from 2.7.5 to 3.0.4 - Thymeleaf broken, Dependency Injection broken (r2dbc)


I'm upgrading a Spring WebFlux based Reactive microservice from 2.7.4 to 3.0.4 and getting a handful of Thymeleaf errors.

  • UI: Thymeleaf
  • DB: r2bdc + MySQL (w/ Flyway generating the DB).

Upon further investigation; it seems like the upgrade path for 3.0 is possibly a bit more complex than I anticipated ... but I don't believe this is doing anything fairly complex or unique in comparison to other Spring projects.


import com.smbdevops.brokenpreauthorize.entity.PrincipalEntity;
import com.smbdevops.brokenpreauthorize.repository.PrincipalsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import reactor.core.publisher.Mono;

@Controller
@RequiredArgsConstructor
public class IndexController {

    final PrincipalsRepository repository;

    @GetMapping("")
    @PreAuthorize("hasRole('USER')")
    public Mono<String> indexAction(final Model model) {
        return this.repository.findByUsername("exists")
                .doOnNext(usr -> {
                    model.addAttribute("usr", usr);
                })
                .switchIfEmpty(this.repository.save(PrincipalEntity.builder()
                        .username("exists")
                        .profileDescription("in the database").build()))
                .thenReturn("index");
    }
}

with a thymeleaf html template as

<html xmlns:th="http://www.thymeleaf.org">
<h1>Logged in. Should see user info</h1>
<table>
    <thead>
    <tr>
        <th>Username</th>
        <th>Profile summary</th>
    </tr>
    </thead>
    <tbody>
    <tr>
        <td th:text="${usr.username}">username goes here</td>
        <td th:text="${usr.profileDescription}">Description goes here"></td>
    </tr>
    </tbody>
</table>
</html>

would yield errors like,

Mon Mar 20 23:59:59 PDT 2023
[6e9ece63-8] There was an unexpected error (type=Internal Server Error, status=500).
org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "usr.username" (template: "index" - line 12, col 13)
    at org.thymeleaf.spring6.expression.SPELVariableExpressionEvaluator.evaluate(SPELVariableExpressionEvaluator.java:292)
    Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below: 
Error has been observed at the following site(s):
    *__checkpoint ⇢ Handler org.springframework.boot.autoconfigure.web.reactive.WelcomePageRouterFunctionFactory$$Lambda$1082/0x00000008010f2b58@f7cc765 [DispatcherHandler]
    *__checkpoint ⇢ org.springframework.security.web.server.authorization.AuthorizationWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.authentication.logout.LogoutWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.savedrequest.ServerRequestCacheWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.authentication.AuthenticationWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.context.ReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.csrf.CsrfWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.header.HttpHeaderWriterWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.config.web.server.ServerHttpSecurity$ServerWebExchangeReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.WebFilterChainProxy [DefaultWebFilterChain]
    *__checkpoint ⇢ HTTP GET "/" [ExceptionHandlingWebHandler]
Original Stack Trace:

... shortened...

Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1007E: Property or field 'username' cannot be found on null
    at org.springframework.expression.spel.ast.PropertyOrFieldReference.readProperty(PropertyOrFieldReference.java:213)
    at org.springframework.expression.spel.ast.PropertyOrFieldReference.getValueInternal(PropertyOrFieldReference.java:104)
    at org.springframework.expression.spel.ast.PropertyOrFieldReference$AccessorLValue.getValue(PropertyOrFieldReference.java:405)
    at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:92)
    at org.springframework.expression.spel.ast.SpelNodeImpl.getValue(SpelNodeImpl.java:112)
    at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:338)
    at org.thymeleaf.spring6.expression.SPELVariableExpressionEvaluator.evaluate(SPELVariableExpressionEvaluator.java:265)
    at org.thymeleaf.standard.expression.VariableExpression.executeVariableExpression(VariableExpression.java:166)
    at org.thymeleaf.standard.expression.SimpleExpression.executeSimple(SimpleExpression.java:66)
    at org.thymeleaf.standard.expression.Expression.execute(Expression.java:109)
    at org.thymeleaf.standard.expression.Expression.execute(Expression.java:138)


Full code available on github

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.smbdevops</groupId>
    <artifactId>broken-preauthorize</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>broken-preauthorize</name>
    <description>broken-preauthorize</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-r2dbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
        </dependency>
        <dependency>
            <groupId>io.asyncer</groupId>
            <artifactId>r2dbc-mysql</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.32</version>
        </dependency>


        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

To clarify some additional comments:

  • JDK Version - Both the 2.7.x and the 3.0.x versions of this were started using Correto 17 (I've tried other JDKs as well, all within the 17 major release). 2.7.x works; 3.0.4 doesn't.
  • Even if I remove all of the fancy bits WebFlux + Thymeleaf appears to be broken. For the section below, which exhibits the same broken thymeleaf interpolation of attributes being added by the
Mono.just(obj).doOnNext(addToModel).then("index")

Simplified version also demonstrates the same behavior. enter image description here

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
package com.example.demo.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class PrincipalEntity {

    private Long id;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private String username;
    private String profileDescription;

}
package com.example.demo.controller;


import com.example.demo.entity.PrincipalEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import reactor.core.publisher.Mono;

@Controller
class IndexController {
    @GetMapping("")
    @PreAuthorize("hasRole('USER')")
    public Mono<String> indexAction(final Model model) {
        return Mono.just(PrincipalEntity.builder().username("testusername").profileDescription("some description"))
                .doOnNext(usr -> {
                    model.addAttribute("usr", usr);
                })
                .thenReturn("index");
    }
}
package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
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;

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class CustomSecurityConfiguration {

    @Bean
    public MapReactiveUserDetailsService userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("user")
                        .password("password")
                        .roles("USER")
                        .build();

        return new MapReactiveUserDetailsService(user);
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http
                .authorizeExchange(exchanges -> exchanges
                        .anyExchange().authenticated()
                )
                .formLogin(Customizer.withDefaults())

        ;
        return http.build();
    }


}


Solution

  • I was able to recreate your error (although I simplified things by not using Lombok).

    To fix the issue, I changed this:

    @GetMapping("")
    

    to this:

    @GetMapping("/")
    

    Now I see the expected HTML data:

    <body><h1>Logged in. Should see user info</h1>
        <table>
            <thead>
                <tr>
                    <th>Username</th>
                    <th>Profile summary</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td>testusername</td>
                    <td>some description</td>
                </tr>
            </tbody>
        </table>
    
    </body>
    

    Also, I used the following instead of your Lombok builder - just as a quick test:

    Mono.just(new PrincipalEntity("testusername", "some description"))
    

    But I wonder if you should be calling .build() at the end of your builder code, in your case.


    I would have expected @GetMapping("") to not cause a problem, because as the servlet spec says:

    The empty string ("") is a special URL pattern that exactly maps to the application's context root

    So, that part of the problem I can't explain.

    But it does seem that the application will attempt to serve the index.html file as a default web page, in this case - and that means your indexAction() handler is never actually called, in your code, resulting in Thymeleaf trying to process a template with no model values.

    Therefore usr ends up being null, of course, and you get the error message:

    Property or field 'username' cannot be found on null