Spring Boot 2 - JPA - Thymeleaf

Descarga el proyecto de GitHub.

Introducción

Ahora que sabemos construir un proyecto con Spring Boot, usar el motor de plantillas Thymeleaf e implementar un CRUD con Spring Mvc, implementemos un proyecto utilizando todo lo anteriormente expuesto. Lo completaremos en un proyecto posterior añadiendo alguna funcionalidad como un autocompletado con jQuery y una paginación.

Además en este proyecto tendremos dos modelos y de esta manera estableceremos una relación uno a muchos, de esta manera, cuando hayamos terminado espero tener un proyecto medianamente serio y realista.

El Proyecto

La estructura del proyecto
application.properties

spring.jpa.hibernate.ddl-auto: update
spring.jpa.show-sql: true
spring.jpa.properties.hibernate.dialect: org.hibernate.dialect.MySQL5Dialect

spring.datasource.url: jdbc:mysql://localhost:3306/testdb
spring.datasource.username: root
spring.datasource.password:
spring.datasource.driverClassName: com.mysql.jdbc.Driver
spring.datasource.type: com.zaxxer.hikari.HikariDataSource

 ## HikariCP config 
spring.datasource.hikari.pool-name: pool-hikari-example
spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 60000                
              

Encontramos unas lineas desconocidas en el application.properties:


...

 ## HikariCP config 
spring.datasource.hikari.pool-name: pool-hikari-example
spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 60000                
              

Estamos estableciendo un pool de conexiones con HikariCP. Os dejo unos enlaces interesantes al respecto.

Las dependencias
pom.xml

<dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
      <groupId>nz.net.ultraq.thymeleaf</groupId>
      <artifactId>thymeleaf-layout-dialect</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <scope>runtime</scope>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
        
        <!-- webjars -->
    <dependency>
          <groupId>org.webjars</groupId>
          <artifactId>bootstrap</artifactId>
          <version>4.1.0</version>
       </dependency>
     <dependency>
       <groupId>org.webjars</groupId>
        <artifactId>jquery</artifactId>
        <version>3.4.1</version>
     </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery-ui</artifactId>
        <version>1.12.1</version>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery-ui-themes</artifactId>
        <version>1.12.1</version>
        <scope>runtime</scope>
    </dependency>  
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>font-awesome</artifactId>
        <version>5.11.2</version>
    </dependency>
  </dependencies>   
            

Como novedad vemos el starter spring-boot-starter-data-jpa y los webjars que vamos a necesitar en el proyecto.


Nota: Cuando me refiero al proyecto, estoy refiriendome a este que estamos viendo y al próximo, pues es la continuación de este. Quiere decir que para este puede que me sobren algún webjar, pero me harán falta para la continuación.


La Configuración

Ver el proyecto anterior

El modelo
Player.java

package com.wanchopi.models;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import lombok.Data;

import javax.validation.constraints.Email;

/**
 * Player entity
 * @author Wanchopi
 *
 */
@Entity
@Table(name = "PLAYERS")
public @Data class Player {
  
  @Id
  @Column(name = "ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @Column(name = "NAME")  
  @NotNull
  @Size(min=5, max=24)
  private String name;
  
  @Column(name = "EMAIL")
  @NotNull
  @Email
  private String email;
  
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "ID_TEAM")
  @NotNull
  private Team team;

}
            
Team.java

package com.wanchopi.models;

import java.util.ArrayList;
import java.util.List;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;

import lombok.Getter;
import lombok.Setter;


/**
 * Team entity
 * @author Wanchopi
 *
 */
@Entity
@Table(name = "TEAMS")
public class Team {
  
  @Id
  @Column(name = "ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Setter @Getter
  private Long id;
  
  @Column(name = "NAME")
  @Setter @Getter
  private String name;
  
  @Column(name = "CITY")
  @Setter @Getter
  private String city;
  
  @OneToMany(mappedBy = "team")
  @Setter @Getter
  private List<Player> players = new ArrayList<Player>();

}              
            

Establecemos una relación de muchos a uno, un Team (equipo) puede tener muchos Player (jugadores).

El Controlador

AppController.java

package com.wanchopi.controllers;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.wanchopi.models.Player;
import com.wanchopi.repositories.PlayerRepository;
import com.wanchopi.repositories.TeamRepository;

/**
 * Main controller
 * 
 * @author Wanchopi
 *
 */
@Controller
public class AppController {
  
  @Autowired 
  private TeamRepository teamRepository;
  @Autowired 
  private PlayerRepository playerRepository;
  
  @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
  
  @RequestMapping(value = "/")
  public String home(ModelMap mp) {
    mp.put("players", playerRepository.findAll());
    return "index";
  }

  @GetMapping(value = "/create")
  public String showForm(ModelMap mp) {
    mp.put("player", new Player());
    mp.put("teams", teamRepository.findAll());
    return "player-form";
  }
  
  @PostMapping(value = "/save")
  public String save(@Valid Player player, BindingResult br , ModelMap mp) {
    if (br.hasErrors()) {
      mp.put("teams", teamRepository.findAll());
      return "player-form";   
    } else {
      playerRepository.save(player);
      mp.put("players", playerRepository.findAll());
      return "redirect:/";
    } 
  }
  
  @GetMapping(value = "/edit/{id}")
  public String showEditForm(@PathVariable("id") long id, ModelMap mp) {
    Player player = playerRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("Invalid user Id:" + id));
    mp.put("player", player);
    mp.put("teams", teamRepository.findAll());
    return "/update-form";
  }
  
  @PostMapping(value = "/update")
  public String update(@Valid Player player, BindingResult br , ModelMap mp) {
    if (br.hasErrors()) {
      mp.put("teams", teamRepository.findAll());
      return "/update-form";
    } else {
      playerRepository.save(player);
      mp.put("players", playerRepository.findAll());
      return "redirect:/";
    }
  } 
  
  @GetMapping(value = "/delete/{id}")
  public String delete(@PathVariable("id") long id, ModelMap mp) {
    Player player = playerRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("Invalid user Id:" + id));
    playerRepository.delete(player);
    mp.put("players", playerRepository.findAll());
    return "redirect:/";
  }

}
            

En el controller los métodos típicos para un CRUD con su validación etc...

El Repositorio
PlayerRepository.java

package com.wanchopi.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.wanchopi.models.Player;

/**
 * Player repository
 * 
 * @author Wanchopi
 *
 */
@Repository
public interface PlayerRepository extends JpaRepository<Player, Long>{}
            

TeamRepository.java

package com.wanchopi.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.wanchopi.models.Team;

/**
 * Team repository
 * 
 * @author Wanchopi
 *
 */
@Repository
public interface TeamRepository extends JpaRepository<Team, Long>{}
            

En los repositorios ya hemos visto lo que Spring Data JPA hace por los desarrolladores. No hay nada mas que añadir.


Nota: Cuando completemos este proyecto con una paginación y un autocompletado implementaremos una capa de servicio pues a parte de los métodos que nos ofrece JpaRepository tendremos que implementar los nuestros propios.


Testing

AppControllerTest.java

package com.wanchopi.controllers;

import static org.assertj.core.api.Assertions.assertThat;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import com.wanchopi.repositories.PlayerRepository;
import com.wanchopi.repositories.TeamRepository;


@RunWith(SpringRunner.class)
@WebMvcTest(AppController.class)
@AutoConfigureMockMvc
public class AppControllerTest {
  
  @Autowired
  private MockMvc mockMvc;
  
  @Autowired
  private AppController controller;
  
  @MockBean
  private TeamRepository teamRepository;
  
  @MockBean
  private PlayerRepository playerRepository;
  
  @Test
  public void contexLoads() throws Exception {
    assertThat(controller).isNotNull();
  }
  
  @Test
  public void testHome() throws Exception {
    this.mockMvc.perform(get("/"))
      .andExpect(status().isOk())
      .andDo(print())
      .andExpect(view().name("index"));
  }
  
}
            

Test de integración con MockMVC. Hay mucho que decir respecto a los test de integración pero lo dejaremos para otro tutorial pues se extendería este demasiado. Simplemente dejar un enlace como introducción a los test de integración.

Las vistas
header.html

<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
  <!-- Static navbar -->
  <nav class="navbar navbar-light bg-light">
    <a th:href="@{/}"><img alt="logo" th:src="@{/assets/images/mini-logo.png}"></a>
    <form class="form-inline my-2 my-lg-0" action="#" method="post">
        <input class="form-control mr-sm-2" type="text" name="keyword" placeholder="Team search" 
        aria-label="Search" id="teamName">
        <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
      </form>
  </nav>
  <!-- header -->
  <div class="container">
    <div class="row">
      <div class="col-lg-12" align="center">
        <img alt="logo" th:src="@{/assets/images/main-logo.png}">
      </div>
    </div>
  </div>
</header>
            
footer.html

<div th:fragment="footer" class="footer" xmlns:th="http://www.w3.org/1999/xhtml">
    <div class="container">
        <p><span th:text="#{text.footer}" class="text-muted">App footer</span></p>
    </div>
</div>
            
header-error.html

<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
  <!-- header -->
  <div class="container">
    <div class="row">
      <div class="col-lg-12" align="center">
        <img alt="logo" th:src="@{/assets/images/main-logo.png}">
      </div>
    </div>
  </div>
</header>
            
layout.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
  <meta
    content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    name="viewport" />
  <link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
  <link rel="stylesheet" type="text/css" href="/webjars/jquery/3.4.1/jquery.min.css" />
  <link rel="stylesheet" type="text/css" href="webjars/jquery-ui/1.12.1/jquery-ui.min.css" />
  <link rel="stylesheet" type="text/css" href="webjars/jquery-ui-themes/1.12.1/blitzer/theme.css" /> 
  <link rel="stylesheet" type="text/css" href="webjars/font-awesome/5.11.2/css/all.min.css" /> 
  <link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>

<body>
  <!--header.html  that's here  -->
  <header>
    <div th:replace="fragments/header :: header"></div>
  </header>
  
  <div layout:fragment="content">
    <!-- Your Page Content Here -->
  </div>

  <!--footer.html  that's here  -->
  <footer>
    <div th:replace="fragments/footer :: footer"></div>
  </footer>

  <!-- JS -->
  <script type="text/javascript" src="/webjars/bootstrap/4.1.0/js/bootstrap.min.js"></script>
  <script type="text/javascript" src="/webjars/jquery/3.4.1/jquery.min.js"></script>
    <script type="text/javascript" src="/webjars/jquery-ui/1.12.1/jquery-ui.min.js"></script>
    <script type="text/javascript">
    $(document).ready(function () {
      $('.fa-times').css('color', '#ff0000');
      $('.fa-edit').css('color', '#0000ff');
    });
  </script>
  <!-- blinking effect -->
  <script>
  $(document).ready(function blink() {
    $('#a-save').fadeIn(750).delay(250).fadeOut(1000, blink);
  });
  </script>
  <script>
  $(document).ready(function() {
    $('#name').focus();
  });
  </script>
</body>
</html>
            
layout-error.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
  <meta
    content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    name="viewport" />
  <link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
  <link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>
<body>

  <!--header.html  that's here  -->
  <header>
    <div th:replace="fragments/header-error :: header"></div>
  </header>

  <div layout:fragment="content">
    <!-- Your Page Content Here -->
  </div>
  
  <!--footer.html  that's here  -->
  <footer>
    <div th:replace="fragments/footer :: footer"></div>
  </footer>

</body>
</html>
            
index.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <title>index :: n.b.a.</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  
      <div class="col-lg-9" style="margin-top: 50px;">
        <div th:if="${!players.isEmpty()}">
          <table class="table">
            <thead class="thead-dark">
              <tr align="center">
                <th scope="col">ID</th>
                <th scope="col">NAME</th>
                <th scope="col">E-MAIL</th>
                <th scope="col">TEAM</th>
                <th scope="col">EDIT</th>
                <th scope="col">DELETE</th>
              </tr>
            </thead>
            <tbody>
              <tr align="center" th:each="player: ${players}">
                <td th:text="${player.id}"></td>
                <td th:text="${player.name}"></td>
                <td th:text="${player.email}"></td>
                <td th:text="${player.team.name}"></td>
                <td><a th:href="@{/edit/{id}(id=${player.id})}"><i class="fa fa-edit"></i></a></td>
                <td><a th:href="@{/delete/{id}(id=${player.id})}"><i class="fa fa-times"></i></a></td>
              </tr>
            </tbody>
          </table>
        </div>
        <div th:unless="${!players.isEmpty()}">
          <p>no items to show</p> 
        </div>
        <a class="a-save" id="a-save" th:href="@{/create}">save a new Player</a>
      </div>
    </div>
  </div> 

</body>
</html>
            
player-form.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <!-- fonts googleapis-->
  <link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet">
  <title>form :: springboot</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  <!-- main container -->
      <div class="col-lg-9 player-form">
        <form th:action="@{save}" th:object="${player}" method="post" id="player-form"> 
          <!-- name -->
          <div class="form-group">
            <label for="name" class="col-lg-4 control-label font-weight-bold">Name</label>
            <div class="col-lg-4">
              <input type="text" th:field="*{name}" class="form-control" id="name" placeholder="Name" required="required"/>
              <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- email -->
          <div class="form-group">
            <label for="email" class="col-lg-4 control-label font-weight-bold">Email</label>
            <div class="col-lg-4">
              <input type="text" th:field="*{email}" class="form-control" id="email" placeholder="Email" required="required"/>
              <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- team -->
            <div class="form-group">
              <label for="team" class="col-lg-4 control-label font-weight-bold">Team</label>
              <div class="col-lg-4" align="left">
                <select th:field="*{team}" class="form-control">
                  <option value=""> Select team </option>
                  <option th:each="team : ${teams}"
                    th:value="${team.id}"
                    th:text="${team.name}"
                    th:selected="false"
                  />
                </select>
                <span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- Button -->
          <div class="form-group">
            <div class="col-lg-4" align="left">
              <input type="submit" class="btn" value="Save"> 
            </div>
          </div>
        </form>
      </div>
    </div> <!-- /main container -->
  </div> <!-- /content -->
</body>
</html>
            
update-form.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <!-- fonts googleapis-->
  <link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet"> 
  <title>form :: springboot</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  <!-- main container -->
      <div class="col-lg-9 player-form">
        <form th:action="@{/update}" th:object="${player}" method="post" id="player-form">
          <!-- id -->
          <div class="form-group">
            <label for="id" class="col-md-4 control-label font-weight-bold">ID</label>
            <div class="col-md-4">
              <input type="text" th:field="*{id}" class="form-control" id="id" placeholder="" readonly="readonly"/>
              <span th:if="${#fields.hasErrors('id')}" th:errors="*{id}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- name -->
          <div class="form-group">
            <label for="name" class="col-md-4 control-label font-weight-bold">Name</label>
            <div class="col-md-4">
              <input type="text" th:field="*{name}" class="form-control" id="name" placeholder="" required="required"/>
              <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- email -->
          <div class="form-group">
            <label for="email" class="col-md-4 control-label font-weight-bold">Email</label>
            <div class="col-md-4">
              <input type="text" th:field="*{email}" class="form-control" id="email" placeholder="" required="required"/>
              <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- team -->
            <div class="form-group">
              <label for="team" class="col-md-4 control-label font-weight-bold">Team</label>
              <div class="col-lg-4" align="left">
                <select th:field="*{team}" class="form-control">
                  <option value=""></option>
                  <option th:each="team : ${teams}"
                    th:value="${team.id}"
                    th:text="${team.name}"
                    th:selected="false"
                  />
                </select>
                <span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span>  
            </div>
          </div>
          <!-- Button -->
          <div class="form-group">
            <div class="col-md-4" align="left">
              <input type="submit" class="btn" value="Update"> 
            </div>
          </div>
        </form>
      </div>
    </div> <!-- /main container -->
  </div>
</body>
</html>
            
error.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout-error">
<head>
  <title>error :: n.b.a.</title>
</head>
<body>
  <div layout:fragment="content">
    <div class="container-fluid" align="center">
      <h1>Custom error page</h1>
      
      <div class="messages-error"> 
        <ul>
          <li><span>Status : </span><span th:text="${status}">status</span></li>
              <li><span>Error : </span><span th:text="${error}">error</span></li>
              <li><span>Message : </span><span th:text="${message}">message</span></li>
              <li><span>Path : </span><span th:text="${path}">path</span></li>
              <!-- <li><span>Trace : </span><span th:text="${trace}">trace</span></li> -->
        </ul>
      </div>  
      
      <a id="a-error" th:href="@{/}"> - Return main page - </a>
    </div>
  </div>
</body>
</html>
            

Que después de ejecutar tendriamos algo así:


Herramientas y Software

  • Eclipse Version: 2019-03 (4.11.0)
  • Java SE-1.8 (jdk1.8.0)
  • Spring Boot 2.2.4
  • Spring MVC 5.2.3
  • thymeleaf 3.0.11
  • thymeleaf-layout-dialect 2.4.1
  • mysql 8.0.19
  • bootstrap 4.1.0
  • jquery 3.4.1
anterior

Spring Boot 2 - Thymeleaf

siguiente

Spring Boot 2 - JPA - Page