Original article: Spring Boot Tutorial – How to Build Fast and Modern Java Apps

En este artículo te voy a guiar en la construcción de un prototipo con Spring Boot. Piénsalo como crear un proyecto para una hackaton o un proyecto para tu startup en poco tiempo.

En otras palabras, no estamos intentando crear algo perfecto sino algo que funcione.

Si te atascas en cualquier parte de este tutorial o si olvidé mencionar algo, puedes revisar el repositorio GitHub que he incluido en la Conclusión.

Requisitos previos

  • Fundamentos de JAVA y POO
  • Conocimiento básico de bases de datos relacionales (uno a uno, varios a varios, y así)
  • Los fundamentos de Spring serían de gran ayuda.
  • Nivel básico de HTML.

También asegúrate de tener lo siguiente:

¿Qué estamos construyendo?

Vamos a construir un sistema de reservación de servicios donde los usuarios ingresarán y harán una reservación para utilizar algún servicio como un gimnasio, piscina, o sauna.

Cada servicio tendrá cierta capacidad (número de personas que pueden utilizar el servicio al mismo tiempo) así las personas podrán hacer uso de las comodidades de forma segura durante la pandemia del Covid-19.

Lista de características para la App

Podemos pensar de nuestra app como el sistema de reservaciones para un departamento complejo.

  • Los usuarios debieran poder ingresar.
  • Asumiremos que las cuentas de los residentes son pre-creadas y no habrá registro de usuarios.
  • Los usuarios deben poder ver sus reservaciones.
  • Los usuarios debieran poder crear nuevas reservas al seleccionar el tipo de servicio, fecha y hora.
  • Sólo los usuarios ingresados debieran poder ver la página de reservas y crear reservas.
  • Debemos revisar la capacidad y sólo crear nuevas reservaciones si el número actual de reservas no excede la capacidad.

Tecnologías que usaremos

Vamos a aprender sobre muchas tecnologías útiles que te harán más eficiente como desarrollador Spring Boot. Mencionaré brevemente qué son y para qué son buenas y después las veremos en acción.

  • Bootify
  • Hibernate
  • Spring Boot
  • Maven
  • JPA
  • Swagger
  • H2 (Base de datos en memoria)
  • Thymeleaf
  • Bootstrap
  • Spring Security

¿Por qué Spring Boot?

El framework Spring es generalmente utilizado para trabajos a gran escala de nivel empresarial. Usualmente, no es la primera opción para proyectos pequeños, pero debo argumentar que puede ser un tanto rápido para hacer prototipos.

Tiene las siguientes ventajas:

  • La programación basada en anotaciones genera mucho código para ti detrás de escenas. Y especialmente con la disponibilidad de librerías como Lombok, se ha vuelto mucho más fácil enfocarse en la lógica del trabajo.
  • Tiene un buen soporte para bases de datos en memoria, por lo que no tenemos que crear una base de datos real y conectarnos a ella. (H2)
  • Tiene un ecosistema maduro, así que puedes encontrar fácilmente respuestas a la mayoría de las preguntas.
  • Casi "no requiere" configuración. Con la ayuda de Spring Boot, nos deshacemos de las feas configuraciones XML en el lado Spring de las cosas y configurar tu aplicación es realmente fácil.
  • Hay muchas cosas pasando detrás de las escenas. Spring provee tanta magia y hace tantas cosas para hacer que las cosas funcionen. Así que usualmente no tienes que preocuparte de eso y simplemente debes dejar que el framework maneje las cosas.
  • Tenemos Spring Security (repositorio en inglés). Teniendo uno de los frameworks de seguridad más completos y probados para la batalla a tu lado te otorga mayor confianza en la seguridad de tu aplicación. También se preocupa de gran parte del trabajo duro por ti.

Cómo crear el proyecto con Bootify

Para crear el proyecto, vas a utilizar Bootify. Es un servicio freemium (De gratis y pago dependiendo del plan) que hace el desarrollo en Spring Boot más rápido al generar muchos modelos de código por ti, lo cual te permite enfocarte en el trabajo lógico.

Bootify nos permite especificar nuestras preferencias y automáticamente importar las dependencias, similar a Spring Initializr.

Pero hay más que eso. También puedes especificar tus entidades y generará el modelo correspondiente y sus clases DTO. Puede incluso generar el servicio y el código a nivel de control para operaciones comunes de CRUD.

Creo que es una herramienta más conveniente para el desarrollo de APIs que para aplicaciones MVC debido a que genera el código de REST API por defecto. Pero aun así hará nuestras vidas más fáciles, incluso con una aplicación Spring Boot MVC que contiene vistas. Sólo tenemos que hacerle algunos ajustes al código generado.

Abramos el sitio web de Bootify y hagamos clic en el botón "Start Project" en la esquina superior derecha.

Debes seleccionar:

  • Maven como el tipo de compilación
  • Versión de Java: 14
  • Habilita "Enable Lombok" para activar Lombok
  • Base de datos: H2
  • Habilita "add dateCreated/lastUpdated to entities"
  • Packages: "Technical"
  • Habilita "OpenAPI/Swagger UI"
  • Añade org.springframework.boot:spring-boot-devtools en "further dependencies"

Después de terminar, deberías ver esto:

screencapture-bootify-io-app-8U9U2BBTLEAX-2021-04-09-16_06_29-1024x754

Ahora vamos a especificar nuestras entidades. Comienza por clicar la pestaña Entities en el menú de la izquierda.

Vamos a tener las siguientes entidades y relaciones:

  1. "Reservation" que contiene la información relacionada con cada reservación, tales como la fecha de reserva, la hora de inicio de reserva, la hora de término de la reserva, y el usuario que tiene esta reserva.
  2. La entidad "User" que contiene nuestro modelo de usuario y que tendrá relaciones con "Reservation".
  3. La entidad "Amenity" (Servicio) que contiene el tipo de servicio y su capacidad (el número máximo de reservaciones por cierto tiempo, por ejemplo: 2 personas pueden utilizar y reservar la Sauna al mismo tiempo).

Vamos a definir nuestra entidad Reservation como se muestra a continuación y habilitaremos "Add REST endpoints" (Añadir endpoints REST) (aunque modificaremos la salida). Después haz clic en el botón "Save" (guardar).

image-1-1024x577

Vamos a especificar las relaciones más tarde, así que el único campo que nuestra entidad "User" (usuario) tiene es id.

image-1024x445

Podríamos crear una entidad para "Amenities" (servicios) para guardar la información sobre el nombre del servicio y su capacidad y después podríamos referenciarlo desde Reservation. Pero la relación entre Amenity y Reservation sería uno-a-uno.

Así que en vez de ello, por la simplicidad, vamos a crear una enum llamado AmenityType (tipo de servicio) y guardaremos AmenityType dentro de Reservation.

Ahora crearemos una relación entre las entidades User (usuario) y Reservation (Reserva) al hacer click en el botón + al lado del menú Relations (relaciones).

image-2
Menú para crear relaciones

Será una relación Varios-a-Uno, ya que un usuario puede tener diversas reservaciones, pero una reserva debe tener uno y sólo un usuario. Nos aseguraremos que así sea el caso al activar la casilla requerida.

image-3-1024x507
User-Reservation Relation

Hacemos clic en "Save Changes" y terminamos. Tu modelo final debería verse así:

image-4-1024x481

Ahora haz clic en el enlace de descarga en el menú de la izquierda para descargar el código del proyecto generado para así poder empezar a trabajar en el. Puedes ver el primer commit del repositorio github del proyecto para comparar y ver si tienes algún problema.

Después de descargar el proyecto, ábrelo en un IDE - Yo usaré IntelliJ IDEA. Tu estructura de archivos debiese verse así:

├── amenity-reservation-system.iml
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│   └── main
│       ├── java
│       │   └── com
│       │       └── amenity_reservation_system
│       │           ├── AmenityReservationSystemApplication.java
│       │           ├── HomeController.java
│       │           ├── config
│       │           │   ├── DomainConfig.java
│       │           │   ├── JacksonConfig.java
│       │           │   └── RestExceptionHandler.java
│       │           ├── domain
│       │           │   ├── Reservation.java
│       │           │   └── User.java
│       │           ├── model
│       │           │   ├── ErrorResponse.java
│       │           │   ├── FieldError.java
│       │           │   ├── ReservationDTO.java
│       │           │   └── UserDTO.java
│       │           ├── repos
│       │           │   ├── ReservationRepository.java
│       │           │   └── UserRepository.java
│       │           ├── rest
│       │           │   ├── ReservationController.java
│       │           │   └── UserController.java
│       │           └── service
│       │               ├── ReservationService.java
│       │               └── UserService.java
│       └── resources
│           └── application.yml
└── target
    ├── classes
    │   ├── application.yml
    │   └── com
    │       └── amenity_reservation_system
    │           ├── AmenityReservationSystemApplication.class
    │           ├── HomeController.class
    │           ├── config
    │           │   ├── DomainConfig.class
    │           │   ├── JacksonConfig.class
    │           │   └── RestExceptionHandler.class
    │           ├── domain
    │           │   ├── Reservation.class
    │           │   └── User.class
    │           ├── model
    │           │   ├── ErrorResponse.class
    │           │   ├── FieldError.class
    │           │   ├── ReservationDTO.class
    │           │   └── UserDTO.class
    │           ├── repos
    │           │   ├── ReservationRepository.class
    │           │   └── UserRepository.class
    │           ├── rest
    │           │   ├── ReservationController.class
    │           │   └── UserController.class
    │           └── service
    │               ├── ReservationService.class
    │               └── UserService.class
    └── generated-sources
        └── annotations

Cómo probar y explorar el código generado

Tomaremos nuestro tiempo para experimentar el código generado y entenderlo capa por capa.

La carpeta Repos contiene el código de la capa de acceso de datos, es decir nuestros repositorios. Vamos a usar métodos JPA para obtener nuestros datos, los cuales son métodos prefabricados que puedes utilizar al definirlos dentro de la interfaz del repositorio.

Nota que las clases de nuestro repositorio heredan la interfaz JpaRepository. Esta es la interfaz que nos permitirá utilizar los métodos mencionados.

Las peticiones JPA siguen cierta convención, y cuando creamos el método que obedece las convenciones, este sabrá automáticamente qué datos quieres recuperar, detrás de las escenas. Si aún no comprendes, no te preocupes, veremos ejemplos.

image-5-1024x719
Ejemplos de palabras clave, oraciones y sus peticiones (snippets) JPQL correspondientes.

Las clases Model presentan nuestro modelo de datos, y qué clases tendrán qué campos.

Cada clase model(modelo) corresponde a una tabla de base de datos con el mismo nombre y los campos en la clase modelo serán las columnas en la tabla correspondiente.

Vea la anotación @Entity al comienzo de nuestras clases modelo. Esta anotación es manejada por Hibernate (artículo en inglés) y cuando Hibernate ve @Entity, creará una tabla usando el nombre de nuestra clase como el nombre de la tabla.

Si te preguntas, "¿Qué es Hibernate de todos modos?", es una herramienta de  mapeo de objetos relacionales (ORM por sus siglas en inglés) en Java que nos permite crear mapas de POJOs (Objeto Java Plano antiguo) hacia las tablas de la base de datos. También provee cualidades tales como restricción de validación de datos, pero no profundizaremos en Hibernate en este post debido a que es un tema vasto de por sí.

Una cualidad increíble de Hibernate es que maneja toda la creación de tablas y las operaciones de borrado para que así no debas usar scripts SQL adicionales.

También representamos las relaciones entre objetos en las clases modelo. Para ver un ejemplo, demos un vistazo en nuestra clase User:

    @OneToMany(mappedBy = "user")
    private Set<Reservation> userReservations;

Esta tiene un objeto userReservations que contiene un set de referencias que se asemeja a las reservaciones de este usuario en particular. En la clase Reservation tenemos la relación inversa:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

Teniendo referencias en los dos lados hace posible acceder el otro lado de la relación (objeto usuario hacia reservación y viceversa).

Controllers  va a manejar las peticiones que son pasadas a este controlador por el handler de peticiones y retornará las vistas correspondientes en este caso.

Los controladores que fueron generados por Bootify están configurados para devolver respuestas JSON, y las vamos a modificar en la siguiente sección para retornar nuestras vistas.

Services tendrá la lógica de nuestra aplicación. La mejor práctica es mantener los controladores simples, esto al mantener la lógica del programa en un lugar separado, las clases de servicio.

Los controladores no debieran interactuar con los repositorios directamente, sino llamar el servicio que interactúa con el repositorio, realizar cualquier operación adicional y devolver el resultado al controlador.

¡Probemos la API!

Ahora vamos a la parte divertida, intentar ver la API en acción. Ejecuta la aplicación Spring en tu IDE favorito. Abre tu navegador y ve a esta dirección:

http://localhost:8080/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config#/

Swagger automáticamente documenta nuestro código y nos permite enviar peticiones fácilmente. Deberías estar viendo esto:

screencapture-localhost-8080-swagger-ui-index-html-2021-04-17-21_27_48-1024x914

Vamos a primero crear un usuario al mandar una petición POST a UserController. Vamos a hacer eso al hacer click en la última caja (la verde) bajo la lista user-controller (controlador de usuario).

Screen-Shot-2021-04-17-at-21.30.41-1024x565

Swagger nos muestra los parámetros que este endpoint espera - por ahora solo el id -  y también las respuestas que la API retorna.

Haz clic en el botón "Try it out" (pruébalo) en la esquina superior derecha. Te pide que ingreses un id. Sé que no tiene sentido y que el código siquiera va a utilizar el id que ingreses, pero vamos a arreglar eso en la sección siguiente (solo es un problema con el código generado)

Por el bien de la experimentación, ingresa cualquier número, como 1 para el id, y haz clic en el botón execute (ejecutar).

screencapture-localhost-8080-swagger-ui-index-html-2021-04-17-21_39_32-547x1024

El cuerpo de la respuesta contiene el id del objeto creado. Podemos confirmar que es creado en la base de datos al crear la consola H2.

Pero antes de hacer eso, tenemos que hacer un pequeño ajuste al archivo application.yml que contiene la configuración y ajustes de la app.

Abre tu archivo application.yml y pega el siguiente código:

spring:
  datasource:
    url: ${JDBC_DATABASE_URL:jdbc:h2:mem:amenity-reservation-system}
    username: ${JDBC_DATABASE_USERNAME:sa}
    password: ${JDBC_DATABASE_PASSWORD:}
  dbcp2:
    max-wait-millis: 30000
    validation-query: "SELECT 1"
    validation-query-timeout: 30
  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: false
    properties:
      hibernate:
        jdbc:
          lob:
            non_contextual_creation: true
        id:
          new_generator_mappings: true
springdoc:
  pathsToMatch: /api/**

Luego deberíamos poder acceder la consola H2 al ir a esta dirección:

http://localhost:8080/h2-console/
image-6-1024x724

Aquí tienes que revisar que el username (nombre de usuario) es "sa" y hacer clic en el botón Connect (conectar)

Haz clic en la tabla USER en el menú de la izquierda y la consola escribirá la petición select all por ti.

image-7-1024x573
Panel de administración de la base de datos H2

Vamos a hacer click en el botón Run(ejecutar) que está arriba de la consulta.

image-8-1024x466

Y podemos ver que el objeto User es en efecto creado - excelente!

Ya tenemos una API funcional en este punto y no hemos escrito ni una línea de código.

Cómo Ajustar el Código para Nuestro Caso Particular

Como mencioné antes, el código generado no se adapta totalmente al uso que necesitamos y tenemos que hacerle algunos ajustes.

Vamos a remover la carpeta "model" que contiene los DTOs y cosas que no vamos a usar. En cambio vamos a mostrar la información dentro de las vistas.

cd src/main/java/com/amenity_reservation_system/ 
rm -rf model
Eliminamos la carpeta model dentro del proyecto

Por ahora tendremos muchos errores ya que el código utiliza clases DTO, pero nos desharemos de la mayoría de ellos después de remover las clases controladoras actuales.

Eliminaremos las clases controladoras (controllers) porque ya no queremos exponer la funcionalidad de modificar nuestra información. Nuestros usuarios debieran ser capaces de hacer eso al interactuar con nuestra UI, y para ello vamos a crear nuevos controladores para retornar los componentes de las vistas en la siguiente sección.

rm -rf rest
Eliminamos las clases controladoras

Finalmente, necesitamos refactorizar un poco nuestras clases service ya que las clases DTO ya no están presentes:

package com.amenity_reservation_system.service;

import com.amenity_reservation_system.domain.User;
import com.amenity_reservation_system.repos.UserRepository;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;


@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(final UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public List<User> findAll() {
        return userRepository.findAll();
    }

    public User get(final Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    public Long create(final User user) {
        return userRepository.save(user).getId();
    }

    public void update(final Long id, final User user) {
        final User existingUser = userRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        
        userRepository.save(user);
    }

    public void delete(final Long id) {
        userRepository.deleteById(id);
    }
}
Clases service refactorizadas

Básicamente eliminamos el código relacionado con las clases DTO de la clase UserService y reemplazamos los typos de retorno con User. Hagamos lo mismo para ReservationService.

package com.amenity_reservation_system.service;

import com.amenity_reservation_system.domain.Reservation;
import com.amenity_reservation_system.domain.User;
import com.amenity_reservation_system.repos.ReservationRepository;
import com.amenity_reservation_system.repos.UserRepository;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;


@Service
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final UserRepository userRepository;

    public ReservationService(final ReservationRepository reservationRepository,
            final UserRepository userRepository) {
        this.reservationRepository = reservationRepository;
        this.userRepository = userRepository;
    }

    public List<Reservation> findAll() {
        return reservationRepository.findAll();
    }

    public Reservation get(final Long id) {
        return reservationRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    public Long create(final Reservation reservation) {
        return reservationRepository.save(reservation).getId();
    }

    public void update(final Long id, final Reservation reservation) {
        final Reservation existingReservation = reservationRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        reservationRepository.save(reservation);
    }

    public void delete(final Long id) {
        reservationRepository.deleteById(id);
    }

}

Vamos también a eliminar las clases config:

rm -rf config

Y renombramos la carpeta "domain" a "model". Si estás usando un IDE, te recomiendo fuertemente que utilices la función de renombre de tu IDE para cambiar el nombre de esta carpeta, ya que renombrará automáticamente los imports para que sean iguales al nuevo nombre del paquete.

mv domain model

También, revisa que tus clases modelo (User y Reservation) tienen los nombres de paquete correctos después de esta operación. La primera línea de estos dos archivos debiera ser:

package com.amenity_reservation_system.model;

Si se mantiene como paquete domain, puedes tener errores.

En este punto, deberías ser capaz de compilar y correr el proyecto sin ningún problema.

Como crear los Archivos de Controladores y Vistas para Mostrar Información

Thymeleaf es un motor de plantillas para Spring que nos permite crear UIs y mostrar nuestros modelos de información a los usuarios.

Podemos acceder a los objetos Java dentro de la plantilla Thymeleaf, y también podemos usar HTML, CSS y JavaScript. Si sabes sobre JSPs, esto es JSP con esteroides.

Vamos a crear algunas plantillas Thymeleaf que no harán nada sino que sólo mostrarán la información por ahora. Les daremos estilo en la siguiente sección. También crearemos los controladores que nos retornarán esas vistas.

Antes de comenzar con las plantillas Thymeleaf, necesitamos añadir una dependencia Maven para Spring Boot Thymeleaf, Tus dependencias deberían verse así en tu archivo pom.xml:

<?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>2.4.4</version>
        <relativePath /><!-- lookup parent from repository -->
    </parent>
    <groupId>com</groupId>
    <artifactId>amenity-reservation-system</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>amenity-reservation-system</name>

    <properties>
        <java.version>14</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.5.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </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>

Si quieres puedes simplemente copiar y pegar el contenido de la etiqueta "dependencies". Ahora le diremos a Maven que instale las dependencias.

mvn clean install
Realizamos la instalación limpia de todas las dependencias

Ahora estamos listos para crear nuestras vistas. Vamos a crear una carpeta bajo resources para mantener nuestras plantillas, así:

cd ../../../resources
mkdir templates

Y crearemos un archivo de vista:

cd templates
touch index.html

Copia y pega el siguiente fragmento de código en el. Este archivo será nuestra página de inicio en el futuro.

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Amenities Reservation App</title>

    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "/>
</head>
<body>

<div>
hello world!
</div>

<script th:src="@{/webjars/jquery/3.0.0/jquery.min.js}"></script>
<script th:src="@{/webjars/popper.js/1.12.9-1/umd/popper.min.js}"></script>
<script th:src="@{/webjars/bootstrap/4.0.0-2/js/bootstrap.min.js}"></script>

</body>
</html>

También vamos a necesitar crear un controlador que nos devuelva esta vista para así poder verla en el navegador.

cd ../java/com/amenity_reservation_system
mkdir controller && cd controller
touch HomeController

Pega este código en HomeController:

package com.amenity_reservation_system.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;


@Controller
public class HomeController {

    @GetMapping("/")
    public String index(Model model) {

        return "index";
    }
}

Nota cómo anotamos nuestro método con @Controller en vez de @RestController esta vez. La anotación @RestController implica que el controlador retornará una respuesta REST mientras que @Controller puede retornar vistas HTML pre-renderizadas (Renderizado del lado del Servidor o SSR en inglés)

Cuando una petición es recibida en nuestra aplicación, Spring automáticamente ejecutará este método controlador. Después encontrará el archivo index.html que creamos previamente bajo la carpeta "resources" y enviará ese archivo al cliente.

Confirmemos que está funcionando al enviar una petición a nuestra aplicación. No te olvides de reiniciarla, después envía esta petición:

GET localhost:8080

Deberías poder ver el mensaje "Hello World" en el navegador.

Cómo Definir Distintos Tipos de Servicios

Tenemos la clase Reservation pero no hemos creado alguna forma de especificar qué tipo de servicio está siendo reservado (piscina, sauna, gimnasio, etc)

Hay múltiples formas de hacer esto. Una de ellas sería crear una entidad llamada Amenity (Servicio) para guardar la información compartida entre entidades. Después crearemos las clases PoolAmenity, SaunaAmenity, y GymAmenity las cuales se extenderían de la clase Amenity.

Esta es una solución extensible pero se siente un tanto exagerada para nuestra simple aplicación, ya que no tenemos mucha información por cada tipo de servicio y solo vamos a especificar la capacidad de cada servicio.

Para mantener las cosas simples y no molestarnos con tablas de herencia y otras cosas complicadas, solo crearemos una clase enum para indicar el tipo de servicio como un String y dejar que cada reservación tenga una de ellas.

Iremos a la carpeta de modelos desde el controlador de archivos y crearemos la clase enum para AmenityType (Tipo de servicio):

cd ../model
touch AmenityType.java
public enum AmenityType {
    POOL("POOL"), SAUNA("SAUNA"), GYM("GYM");

    private final String name;

    private AmenityType(String value) {
        name = value;
    }

    @Override
    public String toString() {
        return name;
    }
}

En esta enum, definiremos una variable name (nombre) para mantener el nombre de la enum y crear un constructor privado para solo permitir conjuntos limitados de tipos. Nota que las declaraciones del tipo de servicio llaman al constructor desde dentro de la clase con sus valores de nombre.

Ahora necesitamos modificar la clase "Reservation" para que contenga una referencia a AmenityType:

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AmenityType amenityType;

Usaremos la anotación @Enumerated para describir cómo queremos guardar la enum en nuestra base de datos. También vamos a hacerla no nula, ya que cada Reservation (reservación) debe tener un AmenityType (tipo de servicio).

Como Mostrar las Reservaciones de un Usuario

¿Cuál es la característica más crucial de nuestra app? Crear y mostrar las reservaciones de un usuario.

Aún no tenemos una forma de autenticar a los usuarios, por lo que no podemos realmente pedirle al usuario que inicie sesión y mostrarle sus reservaciones. Pero aun así queremos implementarlo y probar la funcionalidad de hacer y mostrar las reservas.

Con ese propósito, podemos pedirle a Spring que ponga alguna información inicial en nuestra base de datos cada vez que nuestra aplicación se ejecute. Después podemos pedir esa información para ver si nuestras peticiones realmente funcionan. Y en ese momento, podemos proceder a llamar estos servicios desde Views y añadir autenticación en nuestra aplicación para las siguientes secciones.

Vamos a utilizar un bean CommandLineRunner para ejecutar el código inicial. Cada vez que el Container de Spring encuentre un bean de tipo CommandLineRunner ejecutará el código dentro de sí. Antes de ese paso, añadiremos unos cuantos métodos a nuestras clases modelo para hacer la creación de objetos más fácil y menos verbosa.

Dale un vistazo a las anotaciones de las clases modelo y deberías ver anotaciones como @Getter y @Setter. Esas son anotaciones Lombok.

Lombok es un procesador de anotaciones que podemos utilizar para hacer nuestra experiencia de código mejor al permitir que genere el código por nosotros. Cuando anotamos una clase con @Getter y @Setter, Lombok genera los setters y getters para cada campo de esta clase.

Spring usa métodos setter y getter para muchas de las operaciones triviales detrás de las escenas, así que son casi siempre requeridos. Además de que crearlos para cada entidad, se vuelve un problema sin la ayuda de Lombok.

Sin embargo Lombok puede hacer más que ello. También añadiremos las siguientes anotaciones a nuestras clases Reservation y User:

@Builder
@NoArgsConstructor
@AllArgsConstructor

Con estas anotaciones, Lombok implementa el patrón creacional de construcción para esta clase y también crea 2 constructores: Uno sin argumentos (constructor por defecto) y otro con todos los argumentos. Creo que es increíble que podamos hacer tanto con solo unas pocas anotaciones.

Ahora estamos listos para añadir información inicial. Ve a tu clase principal (AmenityReservationSystemApplication.java) y añade este método:

package com.amenity_reservation_system;

import com.amenity_reservation_system.model.AmenityType;
import com.amenity_reservation_system.model.Reservation;
import com.amenity_reservation_system.model.User;
import com.amenity_reservation_system.repos.ReservationRepository;
import com.amenity_reservation_system.repos.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Date;


@SpringBootApplication
public class AmenityReservationSystemApplication {

    public static void main(String[] args) {
        SpringApplication.run(AmenityReservationSystemApplication.class, args);
    }

    @Bean
    public CommandLineRunner loadData(UserRepository userRepository,
                                      ReservationRepository reservationRepository) {
        return (args) -> {
            User user = userRepository.save(new User());
            DateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss");
            Date date = new Date();
            LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
            Reservation reservation = Reservation.builder()
                    .reservationDate(localDate)
                    .startTime(LocalTime.of(12, 00))
                    .endTime(LocalTime.of(13, 00))
                    .user(user)
                    .amenityType(AmenityType.POOL)
                    .build();

            reservationRepository.save(reservation);
        };
    }
}

Si tienes un error sobre las operaciones de guardado, algo como "Inferred type 'S' for parameter ... does not match" (El tipo inferido 'S' para el parámetro ... no coincide), es porque renombramos el dominio del directorio a model. Ve a las clases del repositorio y arregla las rutas de importación a model.User y model.Reservation.

Nota cómo hemos usado el builder pattern (patrón de constructor) para crear los objetos de reservación más fácilmente. Cuando la creación de objetos se torna compleja y el constructor requiere muchos parámetros, es fácil olvidar el orden de los parámetros o revolver el orden.

Sin el patrón de constructor, necesitamos o llamar a un constructor con muchos parámetros o llamar al constructor por defecto y escribir código #properties para llamar a los setters.

Después de que estés listo, corre tu aplicación nuevamente para insertar la información inicial y conéctate a la consola H2 como aprendimos antes para así confirmar que nuestra información está efectivamente insertada. Si no tienes ningún error, deberías ser capaz de ver que el usuario y la reservación son insertadas exitosamente.

image-9-1024x325

Hemos insertado una reservación para ser capaces de probar la funcionalidad de listado de las reservaciones, pero nuestras vistas actualmente no tienen una forma de mostrar las reservaciones y añadir las reservaciones. Necesitamos crear una UI para ello.

No tenemos un mecanismo de autenticación o registro aún, así que actuaremos como si el usuario con ID 10001 estuviese con la sesión iniciada. Más tarde mejoraremos ello al revisar dinámicamente quién está conectado y mostraremos una página distinta si el usuario no está conectado (si no ha hecho inicio de sesión).

Como Crear Vistas con Thymeleaf

Comenzaremos por crear una página home simple y una barra de navegación para nosotros mismos. Utilizaremos fragmentos de Thymeleaf para el código de la "navbar".

Los fragmentos de Thymeleaf nos permiten crear componentes reusables de estructura similar a los componentes de React/Vue si es que estás familiarizado. Vamos a crear una carpeta para nuestros fragmentos bajo "templates" y la llamaremos "fragments".

mkdir fragments
touch nav.html

Pondremos nuestra navbar dentro del archivo nav.html. Copia y pega el siguiente código:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<nav th:fragment="nav" class="navbar navbar-expand navbar-dark bg-primary">
    <div class="navbar-nav w-100">
        <a class="navbar-brand text-color" href="/">Amenities Reservation System</a>
    </div>
</nav>
</body>
</html>
Fragmento Thymeleaf de la barra de navegación

En su estado actual no hace mucho, pero en el futuro podríamos añadir un botón de inicio de sesión o algunos enlaces.

Ahora crearemos una página de inicio simple que servirá a los usuarios que no están conectados. Tendremos nuestro fragmento de navbar arriba y tendremos un botón de login que le pedirá al usuario que inicie sesión antes de utilizar la aplicación.

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Amenities Reservation App</title>

    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "/>
</head>
<body>

<div>
    <div th:insert="fragments/nav :: nav"></div>
    <div class="text-light" style="background-image: url('https://source.unsplash.com/1920x1080/?nature');
                                   position: absolute;
                                   left: 0;
                                   top: 0;
                                   opacity: 0.6;
                                   z-index: -1;
                                   min-height: 100vh;
                                   min-width: 100vw;">
    </div>

    <div class="container" style="padding-top: 20vh; display: flex; flex-direction: column; align-items: center;">
        <h1 class="display-3">Reservation management made easy.</h1>
        <p class="lead">Lorem, ipsum dolor sit amet consectetur adipisicing elit.
            Numquam in quia natus magnam ducimus quas molestias velit vero maiores.
            Eaque sunt laudantium voluptas. Fugiat molestiae ipsa delectus iusto vel quod.</p>
        <a href="/reservations" class="btn btn-success btn-lg my-2">Reserve an Amenity</a>
    </div>
</div>

<script th:src="@{/webjars/jquery/3.0.0/jquery.min.js}"></script>
<script th:src="@{/webjars/popper.js/1.12.9-1/umd/popper.min.js}"></script>
<script th:src="@{/webjars/bootstrap/4.0.0-2/js/bootstrap.min.js}"></script>

</body>
</html>
nav.html

Debería verse así:

image-1024x533

Crearemos otra página para mostrar si el usuario ya inició sesión. Para mantenerlo simple también la trataremos como página principal, y si el usuario ya está conectado, serán capaces de ver sus reservas en la página.

También es bueno en términos de practicidad para el usuario debido a que disminuye los pasos que deben tomar para ver sus reservaciones.

Ahora crearemos esta página como otro endpoint. Pero antes de añadir el login a nuestra aplicación mostraremos esta página previa si el usuario no está conectado y la página siguiente si están conectados, dinámicamente.

Antes de comenzar a trabajar en nuestra nueva página, añadiremos otro mapeo a HomeController que nos retornará nuestra nueva página. Más tarde fusionaremos estos dos controladores:

package com.amenity_reservation_system;

import com.amenity_reservation_system.domain.User;
import com.amenity_reservation_system.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;


@Controller
public class HomeController {

    final UserService userService;

    public HomeController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/")
    public String index(Model model) {
        return "index";
    }

    @GetMapping("/reservations")
    public String reservations(Model model) {
        User user = userService.get(10000L);
        model.addAttribute("user", user);

        return "reservations";
    }
}
HomeController.java

Si una petición es recibida en "/reservations", este código llamará a nuestro "userService" y preguntará por el usuario con id 10000L. Después añadirá este usuario a Model.

View accederá a este modelo y presentará la información sobre las reservaciones de este usuario. También hemos "auto-conectado" el servicio de usuario para que lo utilice.

Navega a la carpeta "templates" si es que ya no estás allí y crea otro archivo llamado "reservations.html":

touch reservations.html

Copia y pega el siguiente código:

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Reservations</title>

    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "/>
</head>
<body>

<div>
    <div th:insert="fragments/nav :: nav"></div>
    <div class="container" style="padding-top: 10vh; display: flex; flex-direction: column; align-items: center;">
        <h3>Welcome <span th:text=" ${user.getFullName()}"></span></h3>
        <br>
        <table class="table">
            <thead>
                <tr>
                    <th scope="col">Amenity</th>
                    <th scope="col">Date</th>
                    <th scope="col">Start Time</th>
                    <th scope="col">End Time</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="reservation : ${user.getReservations()}">
                    <td th:text="${reservation.getAmenityType()}"></td>
                    <td th:text="${reservation.getReservationDate()}"></td>
                    <td th:text="${reservation.getStartTime()}"></td>
                    <td th:text="${reservation.getEndTime()}"></td>
                </tr>
            </tbody>
        </table>
    </div>
</div>

<script th:src="@{/webjars/jquery/3.0.0/jquery.min.js}"></script>
<script th:src="@{/webjars/popper.js/1.12.9-1/umd/popper.min.js}"></script>
<script th:src="@{/webjars/bootstrap/4.0.0-2/js/bootstrap.min.js}"></script>

</body>
</html>

En esta plantilla Thymeleaf, importamos Bootstrap y Thymeleaf tal y como antes y accedemos a la variable usuario que fue añadida al modelo en nuestro controlador mediante el uso de la sintaxis ${}.

Para acceder a la información, Thymeleaf utiliza los métodos getter de los objetos y podemos imprimir esa información al usar el atributo th:text. Thymeleaf también soporta bucles. En el tbody tenemos un bucle th:each, el cual lo podemos pensar como un bucle foreach sobre las reservaciones del usuario.

Puede que tengas un error que diga algo como "Could not initialize proxy, ... lazy loading". Esto sucede ya que la view está intentando acceder al objeto "reservations" mientras que aún no existe. Para sacarnos de encima este problema podemos modificar las siguientes líneas en User.java:

    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private Set<Reservation> reservations = new HashSet<>();

Añadimos una declaración que le indica a Java que traiga este objeto prematuramente:

Ahora deberías poder ver la página de reservaciones:

image-1-1024x488

Cómo Crear una Reservación

También necesitaremos alguna forma de crear nuevas reservas, así que construiremos ese mecanismo para nuestro usuario pre-creado que mostramos en las reservaciones. Después podeemos alterarlo para mostrar las reservas del usuario conectado.

Antes de proseguir, necesitaremos actualizar los formatos de fecha en nuestro archivo Reservation.java para evitar cualquier problema de formatos no coincidentes. Asegúrate que tus formatos para estas variables son iguales:

    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @Column(nullable = false)
    private LocalDate reservationDate;

    @DateTimeFormat(pattern = "HH:mm")
    @Column
    private LocalTime startTime;

    @DateTimeFormat(pattern = "HH:mm")
    @Column
    private LocalTime endTime;

En la sección previa, creamos nuestro controlador reservations. Ahora necesitamos modificarlo un poco para añadirle otro atributo al modelo.

Aprendimos que podemos acceder a los objetos que son añadidos al modelo al utilizar la sintaxis ${}. Ahora haremos algo similar:

@GetMapping("/reservations")
    public String reservations(Model model, HttpSession session) {
        User user = userService.get(10000L);
        session.setAttribute("user", user);
        Reservation reservation = new Reservation();
        model.addAttribute("reservation", reservation);

        return "reservations";
    }

Estamos actualizando nuestro controlador de reservaciones para mover el objeto usuario a la sesión ya que queremos que sea accesible desde otro método de controlador y no solo desde una plantilla.

Piénsalo así: una vez que el usuario inicia sesión, la cuenta de este usuario será responsable por cada acción que sea hecha después de ese punto. Puedes pensar de "Session" como una variable global que es accesible desde cualquier parte.

También creamos un objeto Reservation y lo añadimos al modelo. Thymeleaf tendrá acceso a este objeto recién creado en nuestra plantilla de vista usando este modelo y llamará a los setters para establecer sus campos.

Ahora vamos a crear la vista para crear la reservación. Utilizaremos Bootstrap Modal (Bootstrap v4.0) para mostrar un formulario modal después de hacer click en el botón.

Primero podemos manejar el código para llamar el modal que crearemos en el siguiente paso, ve al archivo "reservations.html", y añade este fragmento después de la tabla que añadimos anteriormente:

<button
  type="button"
  class="btn btn-primary"
  data-toggle="modal"
  data-target="#createReservationModal"
>
  Create Reservation
</button>

<!-- Modal -->
<div
  th:insert="fragments/modal :: modal"
  th:with="reservation=${reservation}"
></div>

Este botón gatillará nuestro modal. En el div, insertamos este modal que crearemos usando la etiqueta th:with para pasar el objeto reservation que fue puesto en el modelo, en el controlador. Si no hacemos esto, el fragmento no sabrá sobre el objeto reservation.

También debemos cambiar la manera en como accedemos el usuario para imprimir su nombre ya que no lo almacenaremos en el modal, sino que en la sesión:

<h3>Welcome <span th:text=" ${session.user.getFullName()}"></span></h3>

Así que finalmente tu archivo reservations.html debiera verse así:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8" />
    <title>Reservations</title>

    <link
      th:rel="stylesheet"
      th:href="@{/webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "
    />
  </head>
  <body>
    <div>
      <div th:insert="fragments/nav :: nav"></div>
      <div
        class="container"
        style="padding-top: 10vh; display: flex; flex-direction: column; align-items: center;"
      >
        <h3>Welcome <span th:text=" ${session.user.getFullName()}"></span></h3>
        <br />
        <table class="table">
          <thead>
            <tr>
              <th scope="col">Amenity</th>
              <th scope="col">Date</th>
              <th scope="col">Start Time</th>
              <th scope="col">End Time</th>
            </tr>
          </thead>
          <tbody>
            <tr th:each="reservation : ${session.user.getReservations()}">
              <td th:text="${reservation.getAmenityType()}"></td>
              <td th:text="${reservation.getReservationDate()}"></td>
              <td th:text="${reservation.getStartTime()}"></td>
              <td th:text="${reservation.getEndTime()}"></td>
            </tr>
          </tbody>
        </table>

        <button
          type="button"
          class="btn btn-primary"
          data-toggle="modal"
          data-target="#createReservationModal"
        >
          Create Reservation
        </button>

        <!-- Modal -->
        <div
          th:insert="fragments/modal :: modal"
          th:with="reservation=${reservation}"
        ></div>
      </div>
    </div>

    <script th:src="@{/webjars/jquery/3.0.0/jquery.min.js}"></script>
    <script th:src="@{/webjars/popper.js/1.12.9-1/umd/popper.min.js}"></script>
    <script th:src="@{/webjars/bootstrap/4.0.0-2/js/bootstrap.min.js}"></script>
  </body>
</html>

Ahora estamos listos para crear el fragmento modal. Podemos crear un fragmento para el modal igual a como lo hicimos con el nav.

pwd
/src/main/resources
cd templates/fragments
touch modal.html
Creando el archivo modal.html

Y añade la siguiente plantilla de código:

<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <body>
    <div
      class="modal fade"
      th:fragment="modal"
      id="createReservationModal"
      tabindex="-1"
      role="dialog"
      aria-labelledby="createReservationModalTitle"
      aria-hidden="true"
    >
      <div class="modal-dialog" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title" id="createReservationModalTitle">
              Create Reservation
            </h5>
            <button
              type="button"
              class="close"
              data-dismiss="modal"
              aria-label="Close"
            >
              <span aria-hidden="true">&times;</span>
            </button>
          </div>

          <div class="modal-body">
            <form
              action="#"
              th:action="@{/reservations-submit}"
              th:object="${reservation}"
              method="post"
            >
              <div class="form-group row">
                <label for="type-select" class="col-2 col-form-label"
                  >Amenity</label
                >
                <div class="col-10">
                  <select
                    class="form-control"
                    id="type-select"
                    th:field="*{amenityType}"
                  >
                    <option value="POOL">POOL</option>
                    <option value="SAUNA">SAUNA</option>
                    <option value="GYM">GYM</option>
                  </select>
                </div>
              </div>
              <div class="form-group row">
                <label for="start-date" class="col-2 col-form-label"
                  >Date</label
                >
                <div class="col-10">
                  <input
                    class="form-control"
                    type="date"
                    id="start-date"
                    name="trip-start"
                    th:field="*{reservationDate}"
                    value="2018-07-22"
                    min="2021-05-01"
                    max="2021-12-31"
                  />
                </div>
              </div>
              <div class="form-group row">
                <label for="start-time" class="col-2 col-form-label"
                  >From</label
                >
                <div class="col-10">
                  <input
                    class="form-control"
                    type="time"
                    id="start-time"
                    name="time"
                    th:field="*{startTime}"
                    min="08:00"
                    max="19:30"
                    required
                  />
                </div>
              </div>
              <div class="form-group row">
                <label for="end-time" class="col-2 col-form-label">To</label>
                <div class="col-10">
                  <input
                    class="form-control"
                    type="time"
                    id="end-time"
                    name="time"
                    th:field="*{endTime}"
                    min="08:30"
                    max="20:00"
                    required
                  />
                  <small>Amenities are available from 8 am to 8 pm</small>
                </div>
              </div>
              <div class="modal-footer">
                <button
                  type="button"
                  class="btn btn-secondary"
                  data-dismiss="modal"
                >
                  Close
                </button>
                <button type="submit" class="btn btn-primary" value="Submit">
                  Save changes
                </button>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

Hay algunos puntos importantes aquí de los cuales debes tomar nota.

Nota como hemos accedido al objeto reservation en la etiqueta form:

<form
  action="#"
  th:action="@{/reservations-submit}"
  th:object="${reservation}"
  method="post"
></form>

La etiqueta th:object asocia este formulario con el objeto reservation que creamos antes. th:action determina donde este objeto será enviado cuando el formulario es enviado, y nuestro método de envío será POST. Crearemos este controlador con la ruta /reservations-submit después de este paso.

Usamos la etiqueta th:field para enlazar las entradas de los campos de nuestro objeto reservation. Thymeleaf llama los setters del objeto reservation cuando los valores de los campos de entrada cambian.

Ahora vamos a crear el controlador que recibirá este formulario. Ve a HomeController y añade el siguiente método:

@PostMapping("/reservations-submit")
    public String reservationsSubmit(@ModelAttribute Reservation reservation,
                                     @SessionAttribute("user") User user) {

        // Save to DB after updating
        assert user != null;
        reservation.setUser(user);
        reservationService.create(reservation);
        Set<Reservation> userReservations = user.getReservations();
        userReservations.add(reservation);
        user.setReservations(userReservations);
        userService.update(user.getId(), user);
        return "redirect:/reservations";
    }

Y también añade ReservationService a nuestras dependencias:

    final UserService userService;
    final ReservationService reservationService;

    public HomeController(UserService userService, ReservationService reservationService) {
        this.userService = userService;
        this.reservationService = reservationService;
    }

Después que nuestro fragmento modal envía el objeto reservation a este controlador, ese objeto será enlazado con la anotación @ModelAttribute. También necesitamos el usuario así que usamos @SessionAttribute para tener una referencia a él.

Los campos del objeto reservation debieran ser todos establecidos por el formulario. Ahora solo necesitamos guardarlo en la base de datos.

Esto lo hacemos al llamar al método create. Después añadimos la nueva reserva a la lista de reservas del usuario y actualizamos el usuario para reflejar esos cambios. Después entonces redirigimos el usuario a la página de reservaciones para mostrar la lista de reservas actualizada.

Tu nueva página de reservaciones debiera verse así:

LFJE0Ad---Imgur

Y cuando haces click en el botón, el modal para crear la reserva debería aparecer.

image-42

Cómo Añadir Autenticación y Autorización a la App

Utilizaremos Spring Security para añadir autenticación y autorización en nuestra aplicación. Queremos asegurarnos que nadie pueda ver las reservas de nadie más y que los usuarios deban iniciar sesión para crear reservas.

Si deseas aprender más de ello, escribí un artículo que provee una vista general de Spring Security (Artículo en inglés).

Lo mantendremos simple y mayormente usaremos los elementos por defecto ya que es un elemento complejo ya de por sí. Si quieres aprender cómo establecer Spring Security Auth debidamente, puedes revisar mi artículo (artículo en inglés) en ello.

Necesitamos añadir "Spring Security" y "Thymeleaf Spring Security" a nuestras dependencias, así que abre tu pom.xml y añade lo siguiente a tu lista de dependencias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    <version>3.0.4.RELEASE</version>
</dependency>

Ahora, por defecto, Spring Security protege todos los endpoints, así que necesitaremos configurarlo para que permita ver la página de inicio.

Vamos a crear una carpeta de configuración para guardar nuestro archivo WebSecurityConfig. Asumiendo que estás en la carpeta raíz:

cd /src/main/java/com/amenity_reservation_system
mkdir config && cd config
touch WebSecurityConfig.java

Este debiera ser el contenido de tu archivo config:

package com.amenity_reservation_system.config;

import com.amenity_reservation_system.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsServiceImpl userDetailsService;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public WebSecurityConfig(UserDetailsServiceImpl userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.userDetailsService = userDetailsService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/webjars/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                .logout()
                .permitAll()
                .logoutSuccessUrl("/");
    }

    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

}
WebSecurityConfig.java

No iré en los detalles, pero aquí hay un resumen de lo que ha pasado:

  • Configuramos Spring Security para permitir todas las peticiones hechas a la página de inicio ("/")
  • Configuramos nuestros estilos ("/webjars/**")
  • Le solicitamos que nos provea con formularios de inicio y cierre de sesión.
  • Le solicitamos que permita las peticiones a ellos así como redirigir a la página principal después que el cierre de sesión sea exitoso.

¿No es increíble lo que puedes lograr usando solo unas pocas declaraciones?

Tambien configuramos nuestro AuthenticationManagerBuilder para usar bCryptPasswordEncoder y userDetailsService. Pero espera, aún no tenemos ninguno de ellos, y tu IDE puede que se esté quejando de eso. Así que los crearemos.

Antes de seguir adelante, puede ser una buena idea añadir campos username y passwordHash a nuestra clase User. Los utilizaremos para autenticar el usuario en vez de usar su nombre completo. Después lo añadiremos al constructor.

package com.amenity_reservation_system.model;

import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.Set;
import javax.persistence.*;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;


@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {

    @Id
    @Column(nullable = false, updatable = false)
    @SequenceGenerator(
            name = "primary_sequence",
            sequenceName = "primary_sequence",
            allocationSize = 1,
            initialValue = 10000
    )
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "primary_sequence"
    )
    private Long id;

    @Column(nullable = false, unique = true)
    private String fullName;

    @Column(nullable = false, unique = true)
    private String username;

    @Column
    private String passwordHash;

    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private Set<Reservation> reservations = new HashSet<>();

    @Column(nullable = false, updatable = false)
    private OffsetDateTime dateCreated;

    @Column(nullable = false)
    private OffsetDateTime lastUpdated;

    @PrePersist
    public void prePersist() {
        dateCreated = OffsetDateTime.now();
        lastUpdated = dateCreated;
    }

    @PreUpdate
    public void preUpdate() {
        lastUpdated = OffsetDateTime.now();
    }

    public User(String fullName, String username, String passwordHash) {
        this.fullName = fullName;
        this.username = username;
        this.passwordHash = passwordHash;
    }
}
User.java

Crea un archivo llamado UserDetailsServiceImpl bajo la carpeta services:

cd service
touch UserDetailsServiceImpl.java
package com.amenity_reservation_system.service;

import com.amenity_reservation_system.model.User;
import com.amenity_reservation_system.repos.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        final User user = userRepository.findUserByUsername(username);

        if (user == null) {
            throw new UsernameNotFoundException(username);
        }

        UserDetails userDetails = org.springframework.security.core.userdetails.User.withUsername(
                user.getUsername()).password(user.getPwHash()).roles("USER").build();

        return userDetails;
    }
}
UserDetailsServiceImpl.java

Esto básicamente le dice a Spring Security que queremos usar la entidad User  que creamos antes al obtener el objeto User de nuestra base de datos y que use el método JPA de nuestro repositorio. Pero nuevamente, no tenemos el método findUserByUsername (encontrarUsuarioPorNombredeusuario) en nuestro UserRepository (RepositorioUsuario). Puedes intentar arreglar esto por tu cuenta a modo de reto, es bastante simple.

Recuerda, no necesitamos escribir consultas. Es suficiente proveer la firma y dejar que JPA haga el trabajo.

package com.amenity_reservation_system.repos;

import com.amenity_reservation_system.model.User;
import org.springframework.data.jpa.repository.JpaRepository;


public interface UserRepository extends JpaRepository<User, Long> {

    User findUserByUsername(String username);
}
UserRepository.java

We also need a BCryptPasswordEncoder bean to satisfy that dependency in WebSecurityConfig and to make it work. Let's modify our main class to add a bean and change the constructor parameters to give our predefined User a username.

También necesitamos el bean BCryptPasswordEncoder para satisfacer esa dependencia en WebSecurityConfig y para hacer que funcione. Modificaremos nuestra clase principal para añadir un bean y cambiar los parámetros del constructor para darle a nuestro User predefinido un username (nombre de usuario).

package com.amenity_reservation_system;

import com.amenity_reservation_system.model.AmenityType;
import com.amenity_reservation_system.model.Reservation;
import com.amenity_reservation_system.model.User;
import com.amenity_reservation_system.repos.ReservationRepository;
import com.amenity_reservation_system.repos.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Date;


@SpringBootApplication
public class AmenityReservationSystemApplication {

    public static void main(String[] args) {
        SpringApplication.run(AmenityReservationSystemApplication.class, args);
    }


    @Bean
    public CommandLineRunner loadData(UserRepository userRepository,
                                      ReservationRepository reservationRepository) {
    return (args) -> {
      User user =
          userRepository.save(
              new User("Yigit Kemal Erinc",
                      "yigiterinc",
                      bCryptPasswordEncoder().encode("12345")));
      DateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss");
      Date date = new Date();
      LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
      Reservation reservation =
          Reservation.builder()
              .reservationDate(localDate)
              .startTime(LocalTime.of(12, 00))
              .endTime(LocalTime.of(13, 00))
              .user(user)
              .amenityType(AmenityType.POOL)
              .build();

      reservationRepository.save(reservation);
    };
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Tu aplicación ahora debería estar lista para compilar y ya tendría que redirigirte a la página de login si envías una petición a "/reservations".

Sería agradable tener botones para iniciar y cerrar sesión en la barra de navegación, y queremos mostrar el inicio de sesión si no está autenticado y viceversa. Podemos hacerlo de esta manera en nav.htm:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<body>
<nav th:fragment="nav" class="navbar navbar-expand navbar-dark bg-primary">
    <div class="navbar-nav w-100">
        <a class="navbar-brand text-color" href="/">Amenities Reservation System</a>
    </div>
        <a sec:authorize="isAnonymous()"
           class="navbar-brand text-color" th:href="@{/login}">Log in</a>
        <a sec:authorize="isAuthenticated()"
               class="navbar-brand text-color" th:href="@{/logout}">Log out</a>
</nav>
</body>
</html>
nav.html

El enlace para iniciar sesión ahora debiera ser visible en la navbar.

Screen-Shot-2021-09-10-at-02.19.09
Página principal sin la sesión iniciada.

Cómo Mostrar las Reservaciones de los Usuarios Conectados

Nuestra página de reservaciones actualmente está mostrando las reservaciones de un usuario fijo y no las reservas del usuario conectado.

    @GetMapping("/reservations")
    public String reservations(Model model, HttpSession session) {
        User user = userService.get(10000L);
        session.setAttribute("user", user);
        Reservation reservation = new Reservation();
        model.addAttribute("reservation", reservation);

        return "reservations";
    }
Controlador de reservas actual

Necesitamos mostrar las reservas del usuario actual (con la sesión iniciada). Para lograr eso, debemos usar algo de Spring Security.

Ve a la clase HomeController (Lo sé, ese nombre es un tanto problemático ahora) y cámbiala con el código a continuación:

@GetMapping("/reservations")
    public String reservations(Model model, HttpSession session) {
        UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        String name = principal.getUsername();
        User user = userService.getUserByUsername(name);

        // This should always be the case 
        if (user != null) {
            session.setAttribute("user", user);

            // Empty reservation object in case the user creates a new reservation
            Reservation reservation = new Reservation();
            model.addAttribute("reservation", reservation);

            return "reservations";
        }

        return "index";    
        }
Controlador de reservas

Debido a que añadimos Spring Security al proyecto, este automáticamente crea el objeto Authentication detrás de las escenas - obtenemos eso desde SecurityContextHolder.

Obtenemos el objeto UserDetails que guarda la información relacionada al usuario. Después evaluamos si el objeto usuario es nulo. Esto debería ser siempre el caso ya que reservations es un endpoint protegido y el usuario siempre debe iniciar sesión para ver esa página - pero siempre es bueno asegurarse que todo es según lo esperado.

Después llamámos a la clase UserService para obtener el objeto User que tiene su nombre de usuario - pero aún no hemos añadido el método getUserByUsername. Así que iremos a UserService y añadiremos este simple método.

    public User getUserByUsername(String username) {
        return userRepository.findUserByUsername(username);
    }

Ahora deberías poder ver las reservas del usuario conectado. Puedes intentarlo al añadir otro usuario y crear reservas para ese usuario también.

Cómo Evaluar la Capacidad

Actualmente no tenemos un mecanismo para almacenar la capacidad de cada tipo de servicio. Necesitamos almacenarlos de alguna forma y también evaluar si hay suficiente capacidad antes de aprobar alguna reservación.

Con ese propósito, vamos a crear una clase llamada Capacity bajo la carpeta model.

package com.amenity_reservation_system.model;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Capacity {

    @Id
    @Column(nullable = false, updatable = false)
    @SequenceGenerator(
            name = "primary_sequence",
            sequenceName = "primary_sequence",
            allocationSize = 1,
            initialValue = 10000
    )
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "primary_sequence"
    )
    private Long id;

    @Column(nullable = false, unique = true)
    @Enumerated(EnumType.STRING)
    private AmenityType amenityType;

    @Column(nullable = false)
    private int capacity;

    public Capacity(AmenityType amenityType, int capacity) {
        this.amenityType = amenityType;
        this.capacity = capacity;
    }
}
Capacity.java

Esta es la entidad que representará nuestro construct lógico a ser guardado en nuestra base de datos. Es básicamente una entrada de ruta con un AmenityType (tipo de servicio) y su capacidad correspondiente.

También necesitamos un repositorio para guardar las entradas de Capacity, así que vamos a crear CapacityRepository bajo la carpeta repos.

package com.amenity_reservation_system.repos;

import com.amenity_reservation_system.model.Capacity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CapacityRepository extends JpaRepository<Capacity, Long> {
}

Necesitamos llenar esta nueva tabla con las capacidades iniciales. Podríamos leerlas desde un archivo de configuración o algo, pero lo mantendremos simple y lo estableceremos directamente usando loadData en nuestro método principal.

package com.amenity_reservation_system;

import com.amenity_reservation_system.model.AmenityType;
import com.amenity_reservation_system.model.Capacity;
import com.amenity_reservation_system.model.Reservation;
import com.amenity_reservation_system.model.User;
import com.amenity_reservation_system.repos.CapacityRepository;
import com.amenity_reservation_system.repos.ReservationRepository;
import com.amenity_reservation_system.repos.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
public class AmenityReservationSystemApplication {

  private Map<AmenityType, Integer> initialCapacities =
      new HashMap<>() {
        {
          put(AmenityType.GYM, 20);
          put(AmenityType.POOL, 4);
          put(AmenityType.SAUNA, 1);
        }
      };

  public static void main(String[] args) {
    SpringApplication.run(AmenityReservationSystemApplication.class, args);
  }

  @Bean
  public CommandLineRunner loadData(
      UserRepository userRepository,
      CapacityRepository capacityRepository) {
    return (args) -> {
      userRepository.save(
          new User("Yigit Kemal Erinc", "yigiterinc", bCryptPasswordEncoder().encode("12345")));

      for (AmenityType amenityType : initialCapacities.keySet()) {
        capacityRepository.save(new Capacity(amenityType, initialCapacities.get(amenityType)));
      }
    };
  }

  @Bean
  public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

Acabo de añadir las capacidades dentro del mapa initialCapacities (capacidades iniciales) y luego las guarde en CapacityRepository (repositorio de capacidades) dentro del método loadData.

Ahora podemos revisar si el número de reservaciones en la hora solicitada excede la capacidad y rechazar la petición de reserva si lo hace.

Esta es la lógica: Necesitamos obtener el número de reservas que están en un mismo día y superponerlas con la petición actual. Después necesitamos obtener el aforo para este tipo de servicio y si la capacidad es excedida podemos lanzar una excepción.

Por lo tanto, necesitamos una consulta para obtener el número de posibles reservas superpuestas. No es la consulta más fácil de escribir, pero JPA es bastante conveniente y tenemos acceso a esa consulta dentro de nuestro ReservationRepository sin la necesidad de escribir SQL o HQL.

Te animo a que lo intentes por tu cuenta antes de seguir, ya que esta es la única razón de por qué he añadido este concepto de capacidad en este tutorial (para mostrar un ejemplo más avanzado de consultas JPA)

Así que así es como el método ReservationService se ve. Necesitas reemplazar el 0 (cero) con una llamada a reservationRepository para obtener el número de reservas superpuestas.

Si el número actual de reservas superpuestas es igual a la capacidad, significa que la siguiente la excederá, así que lanzamos una excepción.

public Long create(final Reservation reservation) {
        int capacity = capacityRepository.findByAmenityType(reservation.getAmenityType()).getCapacity();
        int overlappingReservations = 0; // TODO

        if (overlappingReservations >= capacity) {
            // Throw a custom exception
        }

        return reservationRepository.save(reservation).getId();
    }

Para encontrar las reservaciones superpuestas hay algunas condiciones que debemos evaluar:

Primero que todo, la fecha de la reservación debe ser la misma que de la petición:

  1. La hora de inicio puede ser antes que el startTime (hora de inicio) de una nueva petición. En ese caso, la hora de término debe ser más tarde que en nuestra petición, en orden de que solapen (startTimeBeforeAndEndTimeAfter) (Hora de inicio antes y hora de término después).
  2. O que la hora de término puede ser posteriormente, pero la hora de inicio puede estar entre la hora de inicio o la hora de término de la petición (endTimeAfterOrStartTimeBetween) (Hora de término después u hora de inicio entre medio).

Así que nuestra consulta final debiera retornar todas las reservas que coincidan con cualquiera de esas 2 posibilidades.

Podemos expresarlo así:

List<Reservation> findReservationsByReservationDateAndStartTimeBeforeAndEndTimeAfterOrStartTimeBetween
            (LocalDate reservationDate, LocalTime startTime, LocalTime endTime, LocalTime betweenStart, LocalTime betweenEnd);

Y el método create final se ve así:

 public Long create(final Reservation reservation) {
        int capacity = capacityRepository.findByAmenityType(reservation.getAmenityType()).getCapacity();
        int overlappingReservations = reservationRepository
                .findReservationsByReservationDateAndStartTimeBeforeAndEndTimeAfterOrStartTimeBetween(
                        reservation.getReservationDate(),
                        reservation.getStartTime(), reservation.getEndTime(),
                        reservation.getStartTime(), reservation.getEndTime()).size();

        if (overlappingReservations >= capacity) {
            throw new CapacityFullException("This amenity's capacity is full at desired time");
        }

        return reservationRepository.save(reservation).getId();
    }

No necesitas preocuparte sobre la excepción customizada, pero si estás interesado, aquí está el código:

package com.amenity_reservation_system.exception;

public class CapacityFullException extends RuntimeException {
    public CapacityFullException(String message) {
        super(message);
    }
}

Normalmente deberíamos mostrar un error modal si la capacidad es excedida pero lo saltaré para evitar cosas de UI repetitivas. Puedes intentarlo como un reto si deseas.

Conclusión

En este tutorial, hemos aprendido sobre tantas tecnologías que hacen el desarrollo de Spring Boot más fácil y más rápido.

Creo que muchas personas subestiman el framework en términos de velocidad de desarrollo y la calidad del trabajo resultante.

Asumiendo que eres fluido con la tecnología, argumentaría que Spring Boot no es más lento (en desarrollo) que cualquier otro framework backend si haces todo en la moda moderna.

Puedes encontrar todo el código en este repositorio (en inglés):

https://github.com/yigiterinc/amenity-reservation-system.git

Si estás interesado en leer más contenido como este, siéntete libre de suscribirte a mi blog en https://erinc.io (blog en inglés) para ser notificado de mis nuevas publicaciones.