Spring Boot 3: doc, test y prep. para impl.: Aula 2

This commit is contained in:
devfzn 2023-09-18 22:17:12 -03:00
parent 3b667e35cb
commit a51a1cc54b
Signed by: devfzn
GPG Key ID: E070ECF4A754FDB1
14 changed files with 498 additions and 22 deletions

View File

@ -3,7 +3,6 @@ package med.voll.api.controller;
import jakarta.validation.Valid;
import med.voll.api.domain.consulta.AgendaDeConsultaService;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import med.voll.api.domain.consulta.DatosDetalleConsulta;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@ -25,8 +24,8 @@ public class ConsultaController {
@Transactional // ojo de donde se importa esta anotación
public ResponseEntity agendar(@RequestBody @Valid DatosAgendarConsulta datos) {
System.out.println(datos);
service.agendar(datos);
var response = service.agendar(datos);
return ResponseEntity.ok(new DatosDetalleConsulta(null, null, null, null));
return ResponseEntity.ok(response);
}
}

View File

@ -1,13 +1,15 @@
package med.voll.api.domain.consulta;
import med.voll.api.domain.consulta.validaciones.ValidadorDeConsultas;
import med.voll.api.domain.medico.Medico;
import med.voll.api.domain.medico.MedicoRepository;
import med.voll.api.domain.paciente.Paciente;
import med.voll.api.domain.paciente.PacienteRepository;
import med.voll.api.infra.errores.ValidacionDeIntegridad;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AgendaDeConsultaService {
@ -18,7 +20,10 @@ public class AgendaDeConsultaService {
@Autowired
private PacienteRepository pacienteRepository;
public void agendar(DatosAgendarConsulta datos) {
@Autowired
List<ValidadorDeConsultas> validadores;
public DatosDetalleConsulta agendar(DatosAgendarConsulta datos) {
if (!pacienteRepository.findById(datos.idPaciente()).isPresent()) {
throw new ValidacionDeIntegridad("Id de paciente no encontrado");
@ -27,10 +32,17 @@ public class AgendaDeConsultaService {
throw new ValidacionDeIntegridad("Id de médico no encontrado");
}
validadores.forEach(v-> v.validar(datos));
var paciente = pacienteRepository.findById(datos.idPaciente()).get();
var medico = seleccionarMedico(datos);
if (medico == null) {
throw new ValidacionDeIntegridad("No hay especialistas disponibles para este horario");
}
var consulta = new Consulta(null, medico, paciente, datos.fecha());
consultaRepository.save(consulta);
return new DatosDetalleConsulta(consulta);
}
private Medico seleccionarMedico(DatosAgendarConsulta datos) {

View File

@ -3,6 +3,15 @@ package med.voll.api.domain.consulta;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface ConsultaRepository extends JpaRepository<Consulta, Long> {
Boolean existsByPacienteIdAndFechaBetween(Long idPaciente,
LocalDateTime primerHorario,
LocalDateTime ultimoHorario);
Boolean existsByMedicoIdAndFecha(Long idMedico, LocalDateTime fecha);
}

View File

@ -0,0 +1,25 @@
package med.voll.api.domain.consulta.validaciones;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import med.voll.api.infra.errores.ValidacionDeIntegridad;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
@Component
public class HorarioDeAnticipacion implements ValidadorDeConsultas {
public void validar(DatosAgendarConsulta datos) {
var ahora = LocalDateTime.now();
var horaDeConsulta = datos.fecha();
var diferenciaDe30Min = Duration.between(ahora, horaDeConsulta).toMinutes() < 30;
if (diferenciaDe30Min) {
throw new ValidacionDeIntegridad("La consulta debe ser agendada con al menos"
+" 30 minutos de anticipación");
}
}
}

View File

@ -0,0 +1,23 @@
package med.voll.api.domain.consulta.validaciones;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import med.voll.api.infra.errores.ValidacionDeIntegridad;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.DayOfWeek;
@Component
public class HorarioDeFuncionamiento implements ValidadorDeConsultas {
public void validar(DatosAgendarConsulta datos) {
var domingo = DayOfWeek.SUNDAY.equals(datos.fecha().getDayOfWeek());
var antesDeApertura = datos.fecha().getHour() < 7;
var despuesDeCierre = datos.fecha().getHour() > 19;
if (domingo || antesDeApertura || despuesDeCierre) {
throw new ValidacionDeIntegridad("El horario de atención es del Lunes a Sábado,"
+" de 07:00 a 19:00 horas");
}
}
}

View File

@ -0,0 +1,26 @@
package med.voll.api.domain.consulta.validaciones;
import jakarta.validation.ValidationException;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import med.voll.api.domain.medico.MedicoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MedicoActivo implements ValidadorDeConsultas {
@Autowired
private MedicoRepository repository;
public void validar(DatosAgendarConsulta datos) {
if (datos.idMedico() == null) {
return;
}
var medicoActivo = repository.findActivoById(datos.idMedico());
if (!medicoActivo) {
throw new ValidationException("No se permite agendar una consulta,"
+" medico no se encuentra activo");
}
}
}

View File

@ -0,0 +1,28 @@
package med.voll.api.domain.consulta.validaciones;
import jakarta.validation.ValidationException;
import med.voll.api.domain.consulta.ConsultaRepository;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MedicoConConsulta implements ValidadorDeConsultas {
@Autowired
private ConsultaRepository repositorio;
public void validar(DatosAgendarConsulta datos) {
if (datos.idMedico() == null) {
return;
}
var medicoConConsulta = repositorio.existsByMedicoIdAndFecha(datos.idMedico(), datos.fecha());
if (medicoConConsulta) {
throw new ValidationException("Este médico ya tiene agendada una consulta en este horario");
}
}
}

View File

@ -0,0 +1,27 @@
package med.voll.api.domain.consulta.validaciones;
import jakarta.validation.ValidationException;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import med.voll.api.domain.paciente.PacienteRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class PacienteActivo implements ValidadorDeConsultas {
@Autowired
private PacienteRepository repository;
public void validar(DatosAgendarConsulta datos) {
if (datos.idPaciente() == null) {
return;
}
var pacienteActivo = repository.findActivoById(datos.idPaciente());
if (!pacienteActivo) {
throw new ValidationException("No se permite agendar una consulta,"
+" paciente no se encuentra activo");
}
}
}

View File

@ -0,0 +1,28 @@
package med.voll.api.domain.consulta.validaciones;
import jakarta.validation.ValidationException;
import med.voll.api.domain.consulta.ConsultaRepository;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class PacienteSinConsulta implements ValidadorDeConsultas {
@Autowired
private ConsultaRepository repositorio;
public void validar(DatosAgendarConsulta datos) {
var primerHorario = datos.fecha().withHour(7);
var ultimoHorario = datos.fecha().withHour(18);
var pacienteConConsulta = repositorio.existsByPacienteIdAndFechaBetween(
datos.idPaciente(),
primerHorario,
ultimoHorario);
if (pacienteConConsulta) {
throw new ValidationException("No se permite agendar mas de una consulta por día");
}
}
}

View File

@ -0,0 +1,7 @@
package med.voll.api.domain.consulta.validaciones;
import med.voll.api.domain.consulta.DatosAgendarConsulta;
public interface ValidadorDeConsultas {
public void validar(DatosAgendarConsulta datos);
}

View File

@ -44,10 +44,11 @@ public interface MedicoRepository extends JpaRepository<Medico, Long> {
""")
Medico seleccionarMedicoConEspecialidadEnFecha(Especialidad especialidad, LocalDateTime fecha);
//@Query("""
// select m.activo
// from Medico m
// where m.id=:idMedico
// """)
//Boolean findActivoById(Long idMedico);
@Query("""
SELECT m.activo
FROM Medico m
WHERE m.id=:idMedico
""")
Boolean findActivoById(Long idMedico);
}

View File

@ -3,7 +3,15 @@ package med.voll.api.domain.paciente;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface PacienteRepository extends JpaRepository<Paciente, Long> {
Page<Paciente> findByActivoTrue(Pageable paginacion);
@Query("""
SELECT p.activo
FROM Paciente p
WHERE p.id=:idPaciente
""")
Boolean findActivoById(Long idPaciente);
}

View File

@ -1,27 +1,42 @@
package med.voll.api.infra.errores;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ValidationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ManejadorDeErrores {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity manejarError404(){
return ResponseEntity.notFound().build();
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity manejarError400(MethodArgumentNotValidException e){
var errores = e.getFieldErrors().stream().map(DatosErrorValidacion::new).toList();
return ResponseEntity.badRequest().body(errores);
}
@ExceptionHandler(ValidacionDeIntegridad.class)
public ResponseEntity errorHandlerValidacionesDeIntegridad(Exception e){
return ResponseEntity.badRequest().body(e.getMessage());
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity errorHandlerValidacionesDeNegocio(Exception e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity manejarError500(DataIntegrityViolationException e) {
var errores = e.getMostSpecificCause().getLocalizedMessage();
@ -33,4 +48,8 @@ public class ManejadorDeErrores {
this(error.getField(), error.getDefaultMessage());
}
}
//@ExceptionHandler(DataIntegrityViolationException.class)
//@ExceptionHandler(MethodArgumentNotValidException.class)
}

View File

@ -506,6 +506,7 @@ consulta utilizando la sintaxis del ***Java Persistence Query Language (JPQL)***
```
### En resumen
Creación de nuevo *package*
[`domain.consulta`](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/)
donde se crean entidad **Consulta**, clases `ConsultaRepository`,
@ -514,8 +515,8 @@ donde se crean entidad **Consulta**, clases `ConsultaRepository`,
- [Consulta](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/Consulta.java)
- [ConsultaRepository](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/ConsultaRepository.java)
- [DatosAgendarConsulta](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/DatosAgendarConsulta.java)
- [DatosDetalleConsulta](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/DatosDetalleConsulta.java)
- [AgendaDeConsultasService](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/AgendaDeConsultaService.java)
- [DatosDetalleConsulta](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/DatosDetalleConsulta.java)
- [AgendaDeConsultasService](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/AgendaDeConsultaService.java)
- [migración](./api_rest/api3/src/main/resources/db/migration/V6__create-table-consultas.sql)
---
@ -527,9 +528,9 @@ con los atributos de las clases DTO, se pueden utilizar alias
```java
public record DatosCompra(
@JsonAlias("producto_id") Long idProducto,
@JsonAlias("fecha_compra") LocalDate fechaCompra
){}
@JsonAlias("producto_id") Long idProducto,
@JsonAlias("fecha_compra") LocalDate fechaCompra
){}
```
La anotación `@JsonAlias` sirve para mapear *alias* alternativos para los campos
@ -537,9 +538,9 @@ que se recibirán del JSON, y es posible asignar múltiples alias:
```java
public record DatosCompra(
@JsonAlias({"producto_id", "id_producto"}) Long idProducto,
@JsonAlias({"fecha_compra", "fecha"}) LocalDate fechaCompra
){}
@JsonAlias({"producto_id", "id_producto"}) Long idProducto,
@JsonAlias({"fecha_compra", "fecha"}) LocalDate fechaCompra
){}
```
### Formato fechas
@ -605,10 +606,273 @@ Entonces, se podrían crear los siguientes **Services**:
Pero es importante estar atentos, ya que muchas veces no es necesario crear un
**Service** y, por lo tanto, agregar otra capa y complejidad innecesarias a
una aplicación. Una regla que podemos utilizar es la siguiente: si no hay reglas
de negocio, simplemente se puede realizar la comunicación directa entre los
controllers y los repositories de la aplicación.
una aplicación. Una regla que se puede utilizar es la siguiente:
***si no hay reglas de negocio, simplemente se puede realizar la comunicación
directa entre los controllers y los repositories de la aplicación.***
---
## Principios SOLID
**SOLID** es un acrónimo que representa cinco principios de programación
- Principio de **Responsabilidad Única** (*Single Responsibility Principle*)
- Principio **Abierto-Cerrado** (*Open-Closed Principle*)
- Principio de **Sustitución de Liskov** (*Liskov Substitution Principle*)
- Principio de **Segregación de Interfaces** (*Interface Segregation Principle*)
- Principio de **Inversión de Dependencia** (*Dependency Inversion Principle*)
Cada principio representa una buena práctica de programación que, cuando se
aplica en una aplicación, facilita mucho su mantenimiento y extensión. Estos
principios fueron creados por *Robert Martin*, conocido como *Uncle Bob*, en su
artículo ***Design Principles and Design Patterns***.
---
## Reglas de negocio
Para cada validación de las reglas de negocio, se crea una clase específica.
La primera regla trata sobre el horario de la clínica: ***"El horario de
funcionamiento de la clínica es de lunes a sábado, de 07:00 a 19:00"***.
Si llega una solicitud a nuestra API con una fecha programada para una consulta,
¿qué sucede si el cliente intenta programar una consulta para un domingo?
¿O para un lunes a las 4 de la mañana? Por esto es necesario validar el horario
de la consulta.
Creción de subpaquete `consulta.validaciones`.
Dentro de `validaciones`, se crean las clases. Primero, una nueva clase llamada
`HorarioDeFuncionamientoClinica`. La idea es crear un método dentro de esta
clase para realizar la validación del horario de funcionamiento de la clínica.
Crearemos el método `validar()` y recibiremos el DTO `DatosAgendarConsulta` como
parámetro.
```java
public class HorarioDeFuncionamientoClinica{
public void validar(DatosAgendarConsulta datos) {
var domingo = DayOfWeek.SUNDAY.equals(datos.fecha().getDayOfWeek());
var antesdDeApertura=datos.fecha().getHour()<7;
var despuesDeCierre=datos.fecha().getHour()>19;
if(domingo || antesdDeApertura || despuesDeCierre){
throw new ValidationException(
"El horario de atención de la clínica es de lunes a
+"sábado, de 07:00 a 19:00 horas");
}
}
}
```
Esto concluye la primera validación. El único objetivo de esta clase es
ejecutar esa única validación. El código queda pequeño, simple y fácil de dar
mantenimiento y de probar de manera automatizada.
La siguiente validación de la lista es ***"Las consultas tienen una duración
fija de 1 hora"***. Esta validación será implícita, la aplicación estará
disponible de una en una hora para agendar la consulta.
Siguiente validación: ***"Las consultas deben ser agendadas con un mínimo de 30
minutos de antelación"***.
Dentro del paquete de validaciones, se crea una nueva clase llamada
`HorarioDeAnticipacion`.Con un método similar al creado anteriormente.
```java
public void validar(DatosAgendarConsulta datos) {
var ahora = LocalDateTime.now();
var horaDeConsulta= datos.fecha();
var diferenciaDe30Min= Duration.between(ahora,horaDeConsulta).toMinutes()<30;
if(diferenciaDe30Min){
throw new ValidationException(
"Las consultas deben programarse con al "
+"menos 30 minutos de anticipación");
}
}
```
En la clase `MedicoActivo`, la única diferencia es el uso del Repositorio.
Se está buscando solo el atributo activo del médico, filtrando por el Ii.
Y si el médico no tiene ID, lanza una excepción.
Se creó el método `findAtivoByID`. En este caso, no se quiere cargar el objeto
completo del médico solo para verificar si el atributo activo es `true`.
Entonces, se puede hacer una consulta personalizada trayendo solo un único
atributo, `SELECT m.activo`.
Luego en la clase `MedicoConConsulta` también es necesario consultar la base de
datos para verificar si hay una consulta con este médico en la misma fecha.
Se realiza la consulta usando el patrón de nomenclatura de SpringData:
`existsByMedicoIdAndFecha(idMedico, fecha)`.
`PacienteActivo` se parece a `ValidadorMedicoActivo`.
Con la validación para que el paciente no tenga consulta en la misma fecha,
`PacienteSinConsulta`. También consulta la base de datos para ver si hay una
consulta para este paciente, colocando la fecha de inicio y la fecha de
finalización de ese día, tomando la primera y la última hora del día.
Se crea una interfáz `ValidadorDeConsultas` y dentro de esta se declara el
método que las clases tienen en común, `validar(DatosAgendarConsulta datos)`.
No es necesario usar la palabra clave `public` ya que ***es implícito que todos
los métodos de una interfaz son públicos***.
```java
public interface ValidadorDeConsultas {
public void validar(DatosAgendarConsulta datos);
}
```
De esta manera, se estandariza el proyecto. Cada validador debe implementa esta
interfaz y obligatoriamente, deben implementar el método
`validar(DatosAgendarConsulta)`. Solo el cuerpo del método de cada clase será
diferente.
Otra cosa importante: para poder inyectar estas clases en algún lugar, Spring
necesita conocerlas. Entonces, encima de ellas debe haber alguna anotación de
Spring.
Se podría usar la anotación `@Service` para indicar que es un servicio, una
validación. Pero se usa la anotación `@Component` que es para componentes
genéricos. Porque en ocasiones se tiene una clase que no es ni una clase de
configuración, ni un controlador ni un servicio. Entonces el `@Component`
indica a Spring que esta clase es un componente genérico y lo cargará en la
inicialización del proyecto. Podría ser `@Service` también. No olvidar las
respectivas anotaciones `@Autowired`
En la interfaz no es necesario colocar ninguna anotación porque el Spring la
carga automáticamente.
Para inyectar estos validadores en la clase service, la clase
`AgendaDeConsultaService`, se usa un esquema de Spring que facilita la vida.
Se puede pensar que se necesita inyectar cada uno de los validadores de la misma
manera que se están inyectando los repositorios. Pero ese es el problema que
se quiere evitar, no se desea declararlos uno por uno. Así que se hace un "truco"
que Spring permite hacer.
En el código de AgendaDeConsultaService, se declara un atributo, con la anotación
`@Autowired`, pero este atributo es declarado como una **lista** `java.util.List`
y, dentro de la lista, se declara la **interfaz** `ValidadorDeConsultas` y es
llamada `validadores`.
```java
@Autowired
List<ValidadorDeConsultas> validadores;
```
Spring identifica automáticamente que se esta inyectando una lista y buscará
todas las clases que implementan la interfaz `ValidadorDeConsultas`. Así, no
importa la cantidad de validadores, Spring inyectará uno por uno. Es mucho más
práctico hacerlo de esta manera.
En el método agendar(), antes de crear las variables del paciente y del médico,
se obtener esta lista de validadores y se recorre con
`forEach(v -> v.validar(datos))`.
```java
validadores.forEach(v-> v.validar(datos));
```
De esta forma se logran inyectar todos los validadores y el código queda bastante
flexible.
Si se quiere excluir un validador, basta con eliminar la clase de ese validador.
No es necesario modificar la clase `AgendaDeConsultaService`, la lista simplemente
quedará con una clase menos.
Si cambia una validación o si deja de existir, no es necesario modificar la clase
de servicio.
Probar agendar una cita, **POST** `http://localhost:8080/consultas`
```json
{
"idPaciente": 1,
"idMedico": 1,
"fecha": "2023-09-18T10:00,
}
```
```java
public DatosDetalleConsulta agendar(DatosAgendarConsulta datos){
if(!pacienteRepository.findById(datos.idPaciente()).isPresent()){
throw new ValidacionDeIntegridad("este id para el paciente no fue encontrado");
}
if(datos.idMedico()!=null && !medicoRepository.existsById(datos.idMedico())){
throw new ValidacionDeIntegridad("este id para el medico no fue encontrado");
}
validadores.forEach(v-> v.validar(datos));
var paciente = pacienteRepository.findById(datos.idPaciente()).get();
var medico = seleccionarMedico(datos);
if(medico==null){
throw new ValidacionDeIntegridad("No hay especialistas disponibles "
+" para este horario");
}
var consulta = new Consulta(medico,paciente,datos.fecha());
consultaRepository.save(consulta);
return new DatosDetalleConsulta(consulta);
}
```
El constructor que recibe el objeto consulta en
[DatosDetalleConsulta](./api_rest/api3/src/main/java/med/voll/api/domain/consulta/DatosDetalleConsulta.java)
```java
public record DatosDetalleConsulta(Long id,
Long idPaciente,
Long idMedico,
LocalDateTime fecha) {
public DatosDetalleConsulta(Consulta consulta) {
this(consulta.getId(),
consulta.getPaciente().getId(),
consulta.getMedico().getId(),
consulta.getFecha());
}
}
```
Se tiene un DTO con los datos siendo devueltos correctamente.
En `ConsultaController`, el método `agendar()` devuelve el DTO. Se guarda el DTO
en una variable y en `ResponseEntity.ok` se pasa el DTO que fue devuelto por el
service.
```java
@PostMapping
@Transactional
public ResponseEntity agendar(@RequestBody @Valid DatosAgendarConsulta datos) {
var dto= service.agendar(datos);
return ResponseEntity.ok(dto);
}
```
El envio de un paciente que no existe en la base de datos:
**POST** `http://localhost:8080/consultas`
```json
{
"idPaciente": 88888,
"idMedico": 1,
"fecha": "2023-10-10T10:00"
}
```
En el paquete [errores](./api_rest/api3/src/main/java/med/voll/api/infra/errores),
en clase
[ManejadorDeErrores](./api_rest/api3/src/main/java/med/voll/api/infra/errores/ManejadorDeErrores.java)
se crea un nuevo método para `ValidationException`:
```java
@ExceptionHandler(ValidationException.class)
public ResponseEntity errorHandlerValidacionesDeNegocio(Exception e){
return ResponseEntity.badRequest().body(e.getMessage());
}
```
---
## Documentación