├── images ├── cache.jpg ├── cors.png ├── expla.png ├── tsl.jpg ├── bcrypt.png ├── docker.jpg ├── layers.png ├── postman.png ├── railway.png ├── swagger.png ├── testing.png ├── tokens.png ├── components.png ├── observer.png ├── reactive.gif ├── spring-boot.png ├── springboot.jpeg ├── spring-security.png └── spring-security-3.png ├── postman └── nuevoavatar.jpg ├── clean-docker.sh ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── resources │ │ ├── application.properties │ │ ├── static │ │ │ ├── springboot.jpeg │ │ │ └── index.html │ │ ├── cert │ │ │ ├── server_keystore.jks │ │ │ ├── server_keystore.p12 │ │ │ └── keys.sh │ │ ├── application-dev.properties │ │ ├── application-prod.properties │ │ └── schema.sql │ └── kotlin │ │ └── es │ │ └── joseluisgs │ │ └── tenistasrestspringboot │ │ ├── wsclient │ │ └── README.md │ │ ├── config │ │ ├── websocket │ │ │ ├── WebSocketSender.kt │ │ │ ├── WebSocketConfig.kt │ │ │ └── WebSocketHandler.kt │ │ ├── security │ │ │ ├── password │ │ │ │ └── EncoderConfig.kt │ │ │ ├── jwt │ │ │ │ ├── JwtAuthorizationFilter.kt │ │ │ │ ├── JwtAuthenticationFilter.kt │ │ │ │ └── JwtTokenUtils.kt │ │ │ └── SecurityConfig.kt │ │ ├── APIConfig.kt │ │ ├── ssl │ │ │ └── SSLConfig.kt │ │ ├── cors │ │ │ └── CorsConfig.kt │ │ └── swagger │ │ │ └── SwaggerConfig.kt │ │ ├── utils │ │ └── UuidUtils.kt │ │ ├── exceptions │ │ ├── TokenException.kt │ │ ├── RaquetaException.kt │ │ ├── UsuariosException.kt │ │ ├── StorageException.kt │ │ └── RepresentanteException.kt │ │ ├── errors │ │ ├── RepresentanteError.kt │ │ ├── TenistaError.kt │ │ └── RaquetaError.kt │ │ ├── dto │ │ ├── Test.kt │ │ ├── Representantes.kt │ │ ├── Raqueta.kt │ │ ├── Usuario.kt │ │ └── Tenista.kt │ │ ├── repositories │ │ ├── usuarios │ │ │ └── UsuariosRepository.kt │ │ ├── raquetas │ │ │ ├── RaquetasRepository.kt │ │ │ ├── RaquetasCachedRepository.kt │ │ │ └── RaquetasCachedRepositoryImpl.kt │ │ ├── representantes │ │ │ ├── RepresentantesRepository.kt │ │ │ └── RepresentantesCachedRepository.kt │ │ └── tenistas │ │ │ ├── TenistasRepository.kt │ │ │ ├── TenistasCachedRepository.kt │ │ │ └── TenistasCachedRepositoryImpl.kt │ │ ├── validators │ │ ├── Raqueta.kt │ │ ├── Representantes.kt │ │ ├── Usuario.kt │ │ └── Tenista.kt │ │ ├── mappers │ │ ├── Representante.kt │ │ ├── Usuario.kt │ │ ├── Raqueta.kt │ │ └── Tenista.kt │ │ ├── services │ │ ├── storage │ │ │ ├── StorageService.kt │ │ │ └── StorageServiceFileSystemImpl.kt │ │ ├── raquetas │ │ │ ├── RaquetasService.kt │ │ │ └── RaquetasServiceImpl.kt │ │ ├── representantes │ │ │ └── RepresentantesService.kt │ │ ├── tenistas │ │ │ ├── TenistasService.kt │ │ │ └── TenistasServiceImpl.kt │ │ └── usuarios │ │ │ └── UsuariosService.kt │ │ ├── models │ │ ├── Notifacion.kt │ │ ├── Raqueta.kt │ │ ├── Representante.kt │ │ ├── Tenista.kt │ │ └── Usuario.kt │ │ ├── TenistasRestSpringbootApplication.kt │ │ ├── controllers │ │ ├── StorageController.kt │ │ ├── TestController.kt │ │ └── UsuarioController.kt │ │ └── db │ │ └── Data.kt └── test │ └── kotlin │ └── es │ └── joseluisgs │ └── tenistasrestspringboot │ ├── TenistasRestSpringbootApplicationTests.kt │ ├── controllers │ └── Test.kt │ └── repositories │ ├── raquetas │ ├── RaquetasRepositoryTest.kt │ └── RaquetasCachedRepositoryImplTest.kt │ ├── representantes │ ├── RepresentantesRepositoryTest.kt │ └── RepresentantesCachedRepositoryImplTest.kt │ └── tenistas │ ├── TenistasRepositoryTest.kt │ └── TenistasCachedRepositoryImplTest.kt ├── docker-compose.yml ├── settings.gradle.kts ├── .gitignore ├── wsclient ├── WS-Client.kt └── MyWSSessionHandler.kt ├── Dockerfile ├── gradlew.bat └── gradlew /images/cache.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/cache.jpg -------------------------------------------------------------------------------- /images/cors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/cors.png -------------------------------------------------------------------------------- /images/expla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/expla.png -------------------------------------------------------------------------------- /images/tsl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/tsl.jpg -------------------------------------------------------------------------------- /images/bcrypt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/bcrypt.png -------------------------------------------------------------------------------- /images/docker.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/docker.jpg -------------------------------------------------------------------------------- /images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/layers.png -------------------------------------------------------------------------------- /images/postman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/postman.png -------------------------------------------------------------------------------- /images/railway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/railway.png -------------------------------------------------------------------------------- /images/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/swagger.png -------------------------------------------------------------------------------- /images/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/testing.png -------------------------------------------------------------------------------- /images/tokens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/tokens.png -------------------------------------------------------------------------------- /images/components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/components.png -------------------------------------------------------------------------------- /images/observer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/observer.png -------------------------------------------------------------------------------- /images/reactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/reactive.gif -------------------------------------------------------------------------------- /images/spring-boot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/spring-boot.png -------------------------------------------------------------------------------- /images/springboot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/springboot.jpeg -------------------------------------------------------------------------------- /postman/nuevoavatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/postman/nuevoavatar.jpg -------------------------------------------------------------------------------- /clean-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker-compose down 3 | docker system prune -f -a --volumes 4 | # docker system prune -f --volumes -------------------------------------------------------------------------------- /images/spring-security.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/spring-security.png -------------------------------------------------------------------------------- /images/spring-security-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/images/spring-security-3.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/application.properties -------------------------------------------------------------------------------- /src/main/resources/static/springboot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/static/springboot.jpeg -------------------------------------------------------------------------------- /src/main/resources/cert/server_keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/cert/server_keystore.jks -------------------------------------------------------------------------------- /src/main/resources/cert/server_keystore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/cert/server_keystore.p12 -------------------------------------------------------------------------------- /src/main/resources/application-dev.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/application-dev.properties -------------------------------------------------------------------------------- /src/main/resources/application-prod.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/tenistas-rest-springboot-2022-2023/HEAD/src/main/resources/application-prod.properties -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/wsclient/README.md: -------------------------------------------------------------------------------- 1 | # Cliente STOMP para el WS 2 | 3 | NO ES OBLIGATORIO PORQUE LO HE HECHO DE OTRA MANERA MIRA SU CONFIGURACIÓN en config -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | springboot-api-rest: 4 | build: . 5 | container_name: springboot-api-rest 6 | ports: 7 | - "6969:6969" 8 | - "6963:6963" 9 | 10 | 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { url = uri("https://repo.spring.io/milestone") } 4 | maven { url = uri("https://repo.spring.io/snapshot") } 5 | gradlePluginPortal() 6 | } 7 | } 8 | rootProject.name = "tenistas-rest-springboot" 9 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/websocket/WebSocketSender.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.websocket 2 | 3 | interface WebSocketSender { 4 | fun sendMessage(message: String) // Método para enviar mensajes a todos los clientes 5 | fun sendPeriodicMessages() 6 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/TenistasRestSpringbootApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class TenistasRestSpringbootApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/utils/UuidUtils.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.es.utils 2 | 3 | import java.util.* 4 | 5 | class UUIDException(message: String) : Exception(message) 6 | 7 | fun String.toUUID(): UUID { 8 | return try { 9 | UUID.fromString(this.trim()) 10 | } catch (e: IllegalArgumentException) { 11 | throw UUIDException("El id no es válido o no está en el formato UUID") 12 | } 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/exceptions/TokenException.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.exceptions 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | sealed class TokenException(message: String) : RuntimeException(message) 7 | 8 | @ResponseStatus(HttpStatus.UNAUTHORIZED) 9 | class TokenInvalidException(message: String) : TokenException(message) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/exceptions/RaquetaException.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.exceptions 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | sealed class RaquetaException(message: String) : RuntimeException(message) 7 | 8 | @ResponseStatus(HttpStatus.BAD_REQUEST) 9 | class RaquetaConflictIntegrityException(message: String) : RaquetaException(message) 10 | -------------------------------------------------------------------------------- /src/main/resources/cert/keys.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Llavero Servidor: Par de claves del servidor (privada y pública) formato PEM 4 | keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype PKCS12 -keystore server_keystore.p12 -storepass 1234567 5 | 6 | ## Llavero Cliente: Par de claves del cliente (privada y pública) formato JKS 7 | keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype JKS -keystore server_keystore.jks -storepass 1234567 -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/errors/RepresentanteError.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.errors 2 | 3 | /** 4 | * RepresentanteError 5 | * @param message: String Mensaje del error 6 | */ 7 | sealed class RepresentanteError(val message: String) { 8 | class NotFound(message: String) : RepresentanteError(message) 9 | class BadRequest(message: String) : RepresentanteError(message) 10 | class ConflictIntegrity(message: String) : RepresentanteError(message) 11 | 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/errors/TenistaError.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.errors 2 | 3 | /** 4 | * TenistaError 5 | * @param message: String Mensaje del error 6 | */ 7 | sealed class TenistaError(val message: String) { 8 | class NotFound(message: String) : TenistaError(message) 9 | class BadRequest(message: String) : TenistaError(message) 10 | class ConflictIntegrity(message: String) : TenistaError(message) 11 | class RaquetaNotFound(message: String) : TenistaError(message) 12 | 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/errors/RaquetaError.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.errors 2 | 3 | /** 4 | * RaquetaError 5 | * @param message: String Mensaje del error 6 | */ 7 | sealed class RaquetaError(val message: String) { 8 | class NotFound(message: String) : RaquetaError(message) 9 | class BadRequest(message: String) : RaquetaError(message) 10 | class ConflictIntegrity(message: String) : RaquetaError(message) 11 | class RepresentanteNotFound(message: String) : RaquetaError(message) 12 | 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/dto/Test.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.dto 2 | 3 | import jakarta.validation.constraints.NotBlank 4 | import jakarta.validation.constraints.NotEmpty 5 | import java.time.LocalDateTime 6 | 7 | data class TestDto( 8 | // Podemos validar los campos con anotaciones de Spring 9 | @NotEmpty(message = "El mensaje no puede estar vacío") 10 | val message: String, 11 | @NotBlank(message = "La fecha no puede estar vacía") 12 | val createdAt: String = LocalDateTime.now().toString() 13 | ) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/exceptions/UsuariosException.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.exceptions 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | sealed class UsuariosException(message: String) : RuntimeException(message) 7 | 8 | @ResponseStatus(HttpStatus.NOT_FOUND) 9 | class UsuariosNotFoundException(message: String) : RuntimeException(message) 10 | 11 | @ResponseStatus(HttpStatus.BAD_REQUEST) 12 | class UsuariosBadRequestException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Mis directorios ### 40 | uploads/ 41 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/security/password/EncoderConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.security.password 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 6 | import org.springframework.security.crypto.password.PasswordEncoder 7 | 8 | @Configuration 9 | class EncoderConfig { 10 | // Siempre que se inyecte un PasswordEncoder se inyectará este 11 | @Bean 12 | fun passwordEncoder(): PasswordEncoder { 13 | return BCryptPasswordEncoder() 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/usuarios/UsuariosRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.usuarios 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 6 | import org.springframework.stereotype.Repository 7 | import java.util.* 8 | 9 | @Repository 10 | interface UsuariosRepository : CoroutineCrudRepository { 11 | fun findByUuid(uuid: UUID): Flow 12 | fun findByUsername(username: String): Flow 13 | fun findByEmail(email: String): Flow 14 | } -------------------------------------------------------------------------------- /src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Kotlin Spring Boot API REST 8 | 9 | 10 | ktor logo 11 |

Kotlin Spring Boot API REST

12 |

👋 ¡Bienvenid@ a mi API REST!

13 |

Toda la web se ha realizado usando Kotlin con Spring Boot

14 |

Más información en mi repositorio de GitHub joseluisgs. 15 |

16 | 17 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/validators/Raqueta.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.validators 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import es.joseluisgs.tenistasrestspringboot.dto.RaquetaCreateDto 7 | import es.joseluisgs.tenistasrestspringboot.errors.RaquetaError 8 | 9 | 10 | fun RaquetaCreateDto.validate(): Result { 11 | if (this.marca.isBlank()) 12 | return Err(RaquetaError.BadRequest("La marca no puede estar vacía")) 13 | if (this.precio < 0.0) 14 | return Err(RaquetaError.BadRequest("El precio no puede ser negativo")) 15 | return Ok(this) 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/raquetas/RaquetasRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Pageable 6 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 7 | import org.springframework.stereotype.Repository 8 | import java.util.* 9 | 10 | @Repository 11 | interface RaquetasRepository : CoroutineCrudRepository { 12 | fun findByUuid(uuid: UUID): Flow 13 | fun findByMarcaContainsIgnoreCase(marca: String): Flow 14 | fun findAllBy(pageable: Pageable?): Flow 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/representantes/RepresentantesRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.representantes 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Representante 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Pageable 6 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 7 | import org.springframework.stereotype.Repository 8 | import java.util.* 9 | 10 | @Repository 11 | interface RepresentantesRepository : CoroutineCrudRepository { 12 | fun findByUuid(uuid: UUID): Flow 13 | fun findByNombreContainsIgnoreCase(nombre: String): Flow 14 | fun findAllBy(pageable: Pageable?): Flow 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/tenistas/TenistasRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.tenistas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Pageable 6 | import org.springframework.data.repository.kotlin.CoroutineCrudRepository 7 | import org.springframework.stereotype.Repository 8 | import java.util.* 9 | 10 | @Repository 11 | interface TenistasRepository : CoroutineCrudRepository { 12 | fun findByUuid(uuid: UUID): Flow 13 | fun findByNombreContainsIgnoreCase(nombre: String): Flow 14 | fun findByRanking(ranking: Int): Flow 15 | fun findAllBy(pageable: Pageable?): Flow 16 | fun findByOrderByRankingAsc(): Flow 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/validators/Representantes.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.validators 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import es.joseluisgs.tenistasrestspringboot.dto.RepresentanteRequestDto 7 | import es.joseluisgs.tenistasrestspringboot.errors.RepresentanteError 8 | 9 | /** 10 | * Validador de Representantes 11 | * @see RepresentanteRequestDto 12 | */ 13 | fun RepresentanteRequestDto.validate(): Result { 14 | if (this.nombre.isBlank()) 15 | return Err(RepresentanteError.BadRequest("El nombre no puede estar vacío")) 16 | if (this.email.isBlank() || !this.email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))) 17 | return Err(RepresentanteError.BadRequest("El email no puede estar vacío y debe ser válido")) 18 | 19 | return Ok(this) 20 | } -------------------------------------------------------------------------------- /wsclient/WS-Client.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.wsclient 2 | 3 | import org.springframework.messaging.converter.StringMessageConverter 4 | import org.springframework.messaging.simp.stomp.StompSessionHandler 5 | import org.springframework.web.socket.client.WebSocketClient 6 | import org.springframework.web.socket.client.standard.StandardWebSocketClient 7 | import org.springframework.web.socket.messaging.WebSocketStompClient 8 | import java.util.* 9 | 10 | private const val URL = "ws://localhost:6969/ws" 11 | fun main(args: Array) { 12 | val client: WebSocketClient = StandardWebSocketClient() 13 | val stompClient = WebSocketStompClient(client) 14 | stompClient.messageConverter = StringMessageConverter() //MappingJackson2MessageConverter() 15 | val sessionHandler: StompSessionHandler = MyWSSessionHandler() 16 | stompClient.connectAsync(URL, sessionHandler) 17 | Scanner(System.`in`).nextLine() // Don't close immediately. 18 | } 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Con este Dockerfile se crea una imagen de Docker que 2 | # compila la aplicación gracias a Gradle 3 | FROM gradle:7-jdk17 AS build 4 | # Copiamos el codigo fuente de la aplicación, es decir, 5 | # lo que hay en el directorio actual 6 | COPY --chown=gradle:gradle . /home/gradle/src 7 | WORKDIR /home/gradle/src 8 | RUN gradle bootJar --no-daemon 9 | 10 | # Con esto hacemos una imagen de Docker que ejecuta la aplicación 11 | FROM openjdk:17-jdk-slim-buster 12 | EXPOSE 6969:6969 13 | EXPOSE 6963:6963 14 | # Directorio donde se guarda la aplicación 15 | RUN mkdir /app 16 | # Copiamos los certificados y los recursos de la aplicación en los directorios que necesita 17 | # ya van en resources 18 | # RUN mkdir /cert 19 | # COPY --from=build /home/gradle/src/cert/* /cert/ 20 | # Copiamos el JAR de la aplicación 21 | COPY --from=build /home/gradle/src/build/libs/tenistas-rest-springboot-0.0.1-SNAPSHOT.jar /app/tenistas-rest-springboot.jar 22 | # Ejecutamos la aplicación, y le pasamos los argumentos si tiene 23 | ENTRYPOINT ["java","-jar","/app/tenistas-rest-springboot.jar"] -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/raquetas/RaquetasCachedRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Page 6 | import org.springframework.data.domain.PageRequest 7 | import java.util.* 8 | 9 | interface RaquetasCachedRepository { 10 | suspend fun findAll(): Flow 11 | suspend fun findById(id: Long): Raqueta? 12 | suspend fun findByUuid(uuid: UUID): Raqueta? 13 | suspend fun findByMarca(marca: String): Flow 14 | suspend fun save(raqueta: Raqueta): Raqueta 15 | suspend fun update(uuid: UUID, raqueta: Raqueta): Raqueta? 16 | suspend fun delete(raqueta: Raqueta): Raqueta? 17 | suspend fun deleteByUuid(uuid: UUID): Raqueta? 18 | suspend fun deleteById(id: Long) 19 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 20 | suspend fun countAll(): Long 21 | suspend fun deleteAll() 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/exceptions/StorageException.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.exceptions 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | 7 | // También podemos usar la anotación @ResponseStatus para indicar el código de error 8 | // que queremos que devuelva el servidor 9 | sealed class StorageException : RuntimeException { 10 | constructor(message: String?) : super(message) 11 | constructor(message: String?, cause: Throwable?) : super(message, cause) 12 | } 13 | 14 | @ResponseStatus(HttpStatus.BAD_REQUEST) 15 | class StorageBadRequestException : StorageException { 16 | constructor(message: String?) : super(message) 17 | constructor(message: String?, cause: Throwable?) : super(message, cause) 18 | } 19 | 20 | 21 | @ResponseStatus(HttpStatus.NOT_FOUND) 22 | class StorageFileNotFoundException : StorageException { 23 | constructor(message: String?) : super(message) 24 | constructor(message: String?, cause: Throwable?) : super(message, cause) 25 | 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/tenistas/TenistasCachedRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.tenistas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Page 6 | import org.springframework.data.domain.PageRequest 7 | import java.util.* 8 | 9 | interface TenistasCachedRepository { 10 | suspend fun findAll(): Flow 11 | suspend fun findById(id: Long): Tenista? 12 | suspend fun findByUuid(uuid: UUID): Tenista? 13 | suspend fun findByNombre(nombre: String): Flow 14 | suspend fun findByRanking(ranking: Int): Flow 15 | suspend fun save(tenista: Tenista): Tenista 16 | suspend fun update(uuid: UUID, tenista: Tenista): Tenista? 17 | suspend fun delete(tenista: Tenista): Tenista? 18 | suspend fun deleteByUuid(uuid: UUID): Tenista? 19 | suspend fun deleteById(id: Long) 20 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 21 | suspend fun countAll(): Long 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/mappers/Representante.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.mappers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.RepresentanteDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.RepresentanteRequestDto 5 | import es.joseluisgs.tenistasrestspringboot.models.Representante 6 | 7 | /** 8 | * Transformamos un Representante en un RepresentanteDto 9 | * @receiver Representante 10 | * @return RepresentanteDto 11 | */ 12 | fun Representante.toDto() = RepresentanteDto( 13 | id = this.uuid, // cambio el id por el uuid, pero para el dto es id 14 | nombre = this.nombre, 15 | email = this.email, 16 | metadata = RepresentanteDto.MetaData( 17 | createdAt = this.createdAt.toString(), 18 | updatedAt = this.updatedAt.toString(), 19 | deleted = this.deleted // Solo se verá en el Json si es true 20 | ) 21 | ) 22 | 23 | /** 24 | * Transformamos un RepresentanteDto en un Representante 25 | * @receiver RepresentanteDto 26 | * @return Representante 27 | */ 28 | fun RepresentanteRequestDto.toModel() = Representante( 29 | nombre = this.nombre, 30 | email = this.email 31 | ) 32 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/storage/StorageService.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.storage 2 | 3 | import org.springframework.core.io.Resource 4 | import org.springframework.web.multipart.MultipartFile 5 | import java.nio.file.Path 6 | import java.util.stream.Stream 7 | 8 | // Interfaz del servicio de almacenamiento 9 | interface StorageService { 10 | // Inicia sl sistema de ficheros 11 | fun init() 12 | 13 | // Almacena un fichero llegado como un contenido multiparte 14 | fun store(file: MultipartFile): String 15 | 16 | // Devuleve un Stream con todos los ficheros 17 | fun loadAll(): Stream 18 | 19 | // Devuleve el Path o ruta de un fichero 20 | fun load(filename: String): Path 21 | 22 | // Devuelve el fichero como recurso 23 | fun loadAsResource(filename: String): Resource 24 | 25 | // Borra un fichero 26 | fun delete(filename: String) 27 | 28 | // Borra todos los ficheros 29 | fun deleteAll() 30 | 31 | // Obtiene la URL del fichero 32 | fun getUrl(filename: String): String 33 | fun store(file: MultipartFile, filenameFromUser: String): String 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/representantes/RepresentantesCachedRepository.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.representantes 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Representante 4 | import kotlinx.coroutines.flow.Flow 5 | import org.springframework.data.domain.Page 6 | import org.springframework.data.domain.PageRequest 7 | import java.util.* 8 | 9 | interface RepresentantesCachedRepository { 10 | suspend fun findAll(): Flow 11 | suspend fun findById(id: Long): Representante? 12 | suspend fun findByUuid(uuid: UUID): Representante? 13 | suspend fun findByNombre(nombre: String): Flow 14 | suspend fun save(representante: Representante): Representante 15 | suspend fun update(uuid: UUID, representante: Representante): Representante? 16 | suspend fun delete(representante: Representante): Representante? 17 | suspend fun deleteByUuid(uuid: UUID): Representante? 18 | suspend fun deleteById(id: Long) 19 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 20 | suspend fun countAll(): Long 21 | suspend fun deleteAll() 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/models/Notifacion.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.models 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.RaquetaDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.RepresentanteDto 5 | import es.joseluisgs.tenistasrestspringboot.dto.TenistaDto 6 | import java.time.LocalDateTime 7 | import java.util.* 8 | 9 | // Las notificaciones son un modelo de datos que se usan para enviar mensajes a los usuarios 10 | // Los tipos de cambios que permito son 11 | data class Notificacion( 12 | val entity: String, 13 | val type: Tipo, 14 | val id: UUID, 15 | val data: T, 16 | val createdAt: String = LocalDateTime.now().toString() 17 | ) { 18 | enum class Tipo { CREATE, UPDATE, DELETE } 19 | } 20 | 21 | // Mis alias, para no estar con los genéricos, mando el DTO por que es lo que quiero que se envíe con sus datos 22 | // visibles en el DTO igual que se ven en las llamadas REST 23 | typealias RepresentanteNotification = Notificacion // RepresentanteDto? 24 | typealias RaquetaNotification = Notificacion // RaquetaDto? 25 | typealias TenistaNotification = Notificacion // TenistaDto? 26 | 27 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/exceptions/RepresentanteException.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.exceptions 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | // Vamos a tipificar las excepciones y a crear una jerarquía de excepciones 7 | /** 8 | * RepresentanteException 9 | * @param message: String Mensaje de la excepción 10 | */ 11 | sealed class RepresentanteException(message: String) : RuntimeException(message) 12 | 13 | /** 14 | * RepresentanteNotFoundException 15 | * @param message: String Mensaje de la excepción 16 | * @see RepresentanteException 17 | * @see ResponseStatus 18 | * @see HttpStatus NOT_FOUND 19 | */ 20 | @ResponseStatus(HttpStatus.NOT_FOUND) 21 | class RepresentanteNotFoundException(message: String) : RepresentanteException(message) 22 | 23 | /** 24 | * RepresentanteConflictIntegrityException 25 | * @param message: String Mensaje de la excepción 26 | * @see RepresentanteException 27 | * @see ResponseStatus 28 | * @see HttpStatus BAD_REQUEST 29 | */ 30 | @ResponseStatus(HttpStatus.BAD_REQUEST) 31 | class RepresentanteConflictIntegrityException(message: String) : RepresentanteException(message) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/mappers/Usuario.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.mappers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.UsuarioCreateDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.UsuarioDto 5 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 6 | 7 | fun Usuario.toDto(): UsuarioDto { 8 | return UsuarioDto( 9 | id = this.uuid, 10 | nombre = this.nombre, 11 | email = this.email, 12 | username = this.username, 13 | avatar = this.avatar, 14 | rol = this.rol.split(",").map { it.trim() }.toSet(), 15 | metadata = UsuarioDto.MetaData( 16 | createdAt = this.createdAt, 17 | updatedAt = this.updatedAt, 18 | deleted = this.deleted 19 | ) 20 | ) 21 | } 22 | 23 | fun UsuarioCreateDto.toModel(): Usuario { 24 | return Usuario( 25 | nombre = this.nombre, 26 | email = this.email, 27 | username = this.username, 28 | password = this.password, 29 | avatar = this.avatar ?: "https://upload.wikimedia.org/wikipedia/commons/f/f4/User_Avatar_2.png", 30 | rol = this.rol.joinToString(", ") { it.uppercase().trim() }, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/APIConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Configuration 5 | 6 | /** 7 | * Configuración global para 8 | */ 9 | @Configuration 10 | // @EnableJpaAuditing // Activamos la auditoria, esto por ejemplo nos permite no meter la fecha si no que la tome automáticamente 11 | class APIConfig { 12 | companion object { 13 | // Versión de la Api y versión del path, tomados de application.properties 14 | @Value("\${api.path}") 15 | const val API_PATH = "/api" 16 | 17 | @Value("\${api.version}") 18 | const val API_VERSION = "1.0" 19 | 20 | @Value("\${pagination.init}") 21 | const val PAGINATION_INIT = "0" 22 | 23 | @Value("\${pagination.size}") 24 | const val PAGINATION_SIZE = "10" 25 | 26 | @Value("\${pagination.sort}") 27 | const val PAGINATION_SORT = "id" 28 | 29 | @Value("\${project.name}") 30 | const val PROJECT_NAME = "Tenistas API REST Spring Boot" 31 | 32 | @Value("\${spring.profiles.active}") 33 | const val PROFILE = "dev" 34 | 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/ssl/SSLConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.ssl 2 | 3 | import org.apache.catalina.connector.Connector 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory 6 | import org.springframework.boot.web.servlet.server.ServletWebServerFactory 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | 10 | 11 | // Por defecto la conexion es con SSL, por lo que vamos a decirle que use el puerto 6969 12 | // para la conexión sin SSL 13 | @Configuration 14 | class SSLConfig { 15 | // (User-defined Property) 16 | @Value("\${server.http.port}") 17 | private val httpPort = "6969" 18 | 19 | // Creamos un bean que nos permita configurar el puerto de conexión sin SSL 20 | @Bean 21 | fun servletContainer(): ServletWebServerFactory { 22 | val connector = Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL) 23 | connector.port = httpPort.toInt() 24 | val tomcat = TomcatServletWebServerFactory() 25 | tomcat.addAdditionalTomcatConnectors(connector) 26 | return tomcat 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/dto/Representantes.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.dto 2 | 3 | import jakarta.validation.constraints.Email 4 | import jakarta.validation.constraints.NotEmpty 5 | import java.time.LocalDateTime 6 | import java.util.* 7 | 8 | /** 9 | * Representante DTO para paginas de datos 10 | */ 11 | data class RepresentantesPageDto( 12 | val content: List, 13 | val currentPage: Int, 14 | val pageSize: Int, 15 | val totalPages: Long, 16 | val totalElements: Long, 17 | val sort: String, 18 | val createdAt: String = LocalDateTime.now().toString() 19 | ) 20 | 21 | /** 22 | * Representante DTO 23 | */ 24 | data class RepresentanteDto( 25 | val id: UUID, 26 | val nombre: String, 27 | val email: String, 28 | val metadata: MetaData? = null, 29 | ) { 30 | data class MetaData( 31 | val createdAt: String = LocalDateTime.now().toString(), 32 | val updatedAt: String = LocalDateTime.now().toString(), 33 | val deleted: Boolean = false 34 | ) 35 | } 36 | 37 | /** 38 | * Representante DTO para crear 39 | */ 40 | data class RepresentanteRequestDto( 41 | @NotEmpty(message = "El nombre no puede estar vacío") 42 | val nombre: String, 43 | @Email(message = "El email debe ser válido") 44 | val email: String, 45 | ) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/raquetas/RaquetasService.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.raquetas 2 | 3 | import com.github.michaelbull.result.Result 4 | import es.joseluisgs.tenistasrestspringboot.errors.RaquetaError 5 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 6 | import es.joseluisgs.tenistasrestspringboot.models.Representante 7 | import kotlinx.coroutines.flow.Flow 8 | import org.springframework.data.domain.Page 9 | import org.springframework.data.domain.PageRequest 10 | import java.util.* 11 | 12 | interface RaquetasService { 13 | suspend fun findAll(): Flow 14 | suspend fun findById(id: Long): Result 15 | suspend fun findByUuid(uuid: UUID): Result 16 | suspend fun findByMarca(marca: String): Flow 17 | suspend fun save(raqueta: Raqueta): Result 18 | suspend fun update(uuid: UUID, raqueta: Raqueta): Result 19 | suspend fun delete(raqueta: Raqueta): Result 20 | suspend fun deleteByUuid(uuid: UUID): Result 21 | suspend fun deleteById(id: Long): Result 22 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 23 | suspend fun countAll(): Long 24 | suspend fun findRepresentante(id: UUID): Result 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/representantes/RepresentantesService.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.representantes 2 | 3 | import com.github.michaelbull.result.Result 4 | import es.joseluisgs.tenistasrestspringboot.errors.RepresentanteError 5 | import es.joseluisgs.tenistasrestspringboot.models.Representante 6 | import kotlinx.coroutines.flow.Flow 7 | import org.springframework.data.domain.Page 8 | import org.springframework.data.domain.PageRequest 9 | import java.util.* 10 | 11 | interface RepresentantesService { 12 | suspend fun findAll(): Flow 13 | suspend fun findById(id: Long): Result 14 | suspend fun findByUuid(uuid: UUID): Result 15 | suspend fun findByNombre(nombre: String): Flow 16 | suspend fun save(representante: Representante): Result 17 | suspend fun update(uuid: UUID, representante: Representante): Result 18 | suspend fun delete(representante: Representante): Result 19 | suspend fun deleteByUuid(uuid: UUID): Result 20 | suspend fun deleteById(id: Long): Result 21 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 22 | suspend fun countAll(): Long 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/mappers/Raqueta.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.mappers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.RaquetaCreateDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.RaquetaDto 5 | import es.joseluisgs.tenistasrestspringboot.dto.RaquetaTenistaDto 6 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 7 | import es.joseluisgs.tenistasrestspringboot.models.Representante 8 | 9 | fun Raqueta.toDto(representante: Representante) = RaquetaDto( 10 | id = this.uuid, 11 | marca = this.marca, 12 | precio = this.precio, 13 | representante = representante.toDto(), 14 | metadata = RaquetaDto.MetaData( 15 | createdAt = this.createdAt.toString(), 16 | updatedAt = this.updatedAt.toString(), 17 | deleted = this.deleted // Solo se verá en el Json si es true 18 | ) 19 | ) 20 | 21 | fun Raqueta.toTenistaDto() = RaquetaTenistaDto( 22 | id = this.uuid, 23 | marca = this.marca, 24 | precio = this.precio, 25 | representanteId = this.representanteId, 26 | metadata = RaquetaTenistaDto.MetaData( 27 | createdAt = this.createdAt.toString(), 28 | updatedAt = this.updatedAt.toString(), 29 | deleted = this.deleted // Solo se verá en el Json si es true 30 | ) 31 | ) 32 | 33 | fun RaquetaCreateDto.toModel() = Raqueta( 34 | marca = this.marca, 35 | precio = this.precio, 36 | representanteId = this.representanteId, 37 | ) 38 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/models/Raqueta.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.models 2 | 3 | import jakarta.validation.constraints.Min 4 | import jakarta.validation.constraints.NotEmpty 5 | import jakarta.validation.constraints.NotNull 6 | import org.springframework.data.annotation.Id 7 | import org.springframework.data.relational.core.mapping.Column 8 | import org.springframework.data.relational.core.mapping.Table 9 | import java.time.LocalDateTime 10 | import java.util.* 11 | 12 | @Table(name = "RAQUETAS") 13 | data class Raqueta( 14 | // Identificador, si usamos Spring Data Reactive con H2, los uuid fallan, por eso usamos Long 15 | @Id 16 | val id: Long? = null, 17 | 18 | val uuid: UUID = UUID.randomUUID(), 19 | 20 | // Datos 21 | @NotEmpty(message = "La marca no puede estar vacía") 22 | val marca: String, 23 | // No negative 24 | @Min(value = 0, message = "El precio no puede ser negativo") 25 | val precio: Double, 26 | 27 | // Relaciones 28 | @NotNull(message = "El representante no puede ser nulo") 29 | @Column("representante_id") 30 | val representanteId: UUID, // UUID del representante 31 | 32 | // Historicos y metadata 33 | @Column("created_at") 34 | val createdAt: LocalDateTime = LocalDateTime.now(), 35 | @Column("updated_at") 36 | val updatedAt: LocalDateTime = LocalDateTime.now(), 37 | val deleted: Boolean = false // Para el borrado lógico si es necesario 38 | 39 | ) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/tenistas/TenistasService.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.tenistas 2 | 3 | import com.github.michaelbull.result.Result 4 | import es.joseluisgs.tenistasrestspringboot.errors.TenistaError 5 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 6 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 7 | import kotlinx.coroutines.flow.Flow 8 | import org.springframework.data.domain.Page 9 | import org.springframework.data.domain.PageRequest 10 | import java.util.* 11 | 12 | interface TenistasService { 13 | suspend fun findAll(): Flow 14 | suspend fun findById(id: Long): Result 15 | suspend fun findByUuid(uuid: UUID): Result 16 | suspend fun findByNombre(nombre: String): Flow 17 | suspend fun findByRanking(ranking: Int): Result 18 | suspend fun save(tenista: Tenista): Result 19 | suspend fun update(uuid: UUID, tenista: Tenista): Result 20 | suspend fun delete(tenista: Tenista): Result 21 | suspend fun deleteByUuid(uuid: UUID): Result 22 | suspend fun deleteById(id: Long): Result 23 | suspend fun findAllPage(pageRequest: PageRequest): Flow> 24 | suspend fun countAll(): Long 25 | suspend fun findRaqueta(id: UUID?): Result 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/validators/Usuario.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.validators 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.UsuarioCreateDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.UsuarioUpdateDto 5 | import es.joseluisgs.tenistasrestspringboot.exceptions.UsuariosBadRequestException 6 | 7 | fun UsuarioCreateDto.validate(): UsuarioCreateDto { 8 | if (this.nombre.isBlank()) { 9 | throw UsuariosBadRequestException("El nombre no puede estar vacío") 10 | } else if (this.email.isBlank() || !this.email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))) 11 | throw UsuariosBadRequestException("El email no puede estar vacío o no tiene el formato correcto") 12 | else if (this.username.isBlank()) 13 | throw UsuariosBadRequestException("El username no puede estar vacío") 14 | else if (this.password.isBlank() || this.password.length < 5) 15 | throw UsuariosBadRequestException("El password no puede estar vacío o ser menor de 5 caracteres") 16 | 17 | return this 18 | } 19 | 20 | fun UsuarioUpdateDto.validate(): UsuarioUpdateDto { 21 | if (this.nombre.isBlank()) { 22 | throw UsuariosBadRequestException("El nombre no puede estar vacío") 23 | } else if (this.email.isBlank() || !this.email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))) 24 | throw UsuariosBadRequestException("El email no puede estar vacío o no tiene el formato correcto") 25 | else if (this.username.isBlank()) 26 | throw UsuariosBadRequestException("El username no puede estar vacío") 27 | 28 | return this 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/validators/Tenista.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.validators 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import es.joseluisgs.tenistasrestspringboot.dto.TenistaCreateDto 7 | import es.joseluisgs.tenistasrestspringboot.errors.TenistaError 8 | import java.time.LocalDate 9 | 10 | 11 | fun TenistaCreateDto.validate(): Result { 12 | if (this.nombre.isBlank()) { 13 | return Err(TenistaError.BadRequest("El nombre no puede estar vacío")) 14 | } else if (this.ranking <= 0) { 15 | return Err(TenistaError.BadRequest("El ranking debe ser mayor que 0")) 16 | } else if (LocalDate.parse(this.fechaNacimiento).isAfter(LocalDate.now())) { 17 | return Err(TenistaError.BadRequest("La fecha de nacimiento no puede ser posterior a la actual")) 18 | } else if (this.añoProfesional <= 0) { 19 | return Err(TenistaError.BadRequest("El año profesional debe ser mayor que 0")) 20 | } else if (this.altura <= 0) { 21 | return Err(TenistaError.BadRequest("La altura debe ser mayor que 0")) 22 | } else if (this.peso <= 0) { 23 | return Err(TenistaError.BadRequest("El peso debe ser mayor que 0")) 24 | } else if (this.puntos <= 0) { 25 | return Err(TenistaError.BadRequest("Los puntos deben ser mayor que 0")) 26 | } else if (this.pais.isBlank()) { 27 | return Err(TenistaError.BadRequest("El país no puede estar vacío")) 28 | } 29 | return Ok(this) 30 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/controllers/Test.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.controllers 2 | 3 | /** 4 | * Y así lo podemos testear con JUnit 5 y Spring Boot 5 | * Usando un lciente con reactividad 6 | */ 7 | /* 8 | @ExtendWith(SpringExtension::class) // We create a `@SpringBootTest`, starting an actual server on a `RANDOM_PORT` 9 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) 10 | class GreetingRouterTest { 11 | // Spring Boot will create a `WebTestClient` for you, 12 | // already configure and ready to issue requests against "localhost:RANDOM_PORT" 13 | 14 | val sslContext = SslContextBuilder 15 | .forClient().trustManager(InsecureTrustManagerFactory.INSTANCE) 16 | .build() 17 | val httpClient = HttpClient.create().secure { spec: SslProvider.SslContextSpec -> spec.sslContext(sslContext) } 18 | final val connector = ReactorClientHttpConnector(httpClient) 19 | var client: WebTestClient = WebTestClient 20 | .bindToServer(connector) 21 | .baseUrl("https://localhost:6969/api") 22 | .build() 23 | 24 | @Test 25 | fun testHello() { 26 | client // Create a GET request to test an endpoint 27 | .get().uri("/api/test") 28 | .accept(MediaType.APPLICATION_JSON) 29 | .exchange() // and use the dedicated DSL to test assertions against the response 30 | .expectStatus().isOk 31 | .expectBody(TestDto::class.java) 32 | .value { greeting -> assertThat(greeting.message).isEqualTo("Hello, Spring!") } 33 | } 34 | } 35 | */ 36 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/mappers/Tenista.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.mappers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.dto.TenistaCreateDto 4 | import es.joseluisgs.tenistasrestspringboot.dto.TenistaDto 5 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 6 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 7 | import java.time.LocalDate 8 | 9 | fun Tenista.toDto(raqueta: Raqueta?) = TenistaDto( 10 | id = this.uuid, 11 | nombre = this.nombre, 12 | ranking = this.ranking, 13 | fechaNacimiento = this.fechaNacimiento.toString(), 14 | añoProfesional = this.añoProfesional, 15 | altura = this.altura, 16 | peso = this.peso, 17 | manoDominante = this.manoDominante, 18 | tipoReves = this.tipoReves, 19 | puntos = this.puntos, 20 | pais = this.pais, 21 | raqueta = raqueta?.toTenistaDto(), 22 | metadata = TenistaDto.MetaData( 23 | createdAt = this.createdAt.toString(), 24 | updatedAt = this.updatedAt.toString(), 25 | deleted = this.deleted // Solo se verá en el Json si es true 26 | ) 27 | ) 28 | 29 | fun TenistaCreateDto.toModel() = Tenista( 30 | nombre = this.nombre, 31 | ranking = this.ranking, 32 | fechaNacimiento = LocalDate.parse(this.fechaNacimiento), 33 | añoProfesional = this.añoProfesional, 34 | altura = this.altura, 35 | peso = this.peso, 36 | manoDominante = this.manoDominante ?: Tenista.ManoDominante.DERECHA, 37 | tipoReves = this.tipoReves ?: Tenista.TipoReves.DOS_MANOS, 38 | puntos = this.puntos, 39 | pais = this.pais, 40 | raquetaId = this.raquetaId, 41 | ) 42 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/models/Representante.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.models 2 | 3 | import jakarta.validation.constraints.Email 4 | import jakarta.validation.constraints.NotEmpty 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.relational.core.mapping.Column 7 | import org.springframework.data.relational.core.mapping.Table 8 | import java.time.LocalDateTime 9 | import java.util.* 10 | 11 | /** 12 | * Representante Model 13 | * @param id: Long? Identificador 14 | * @param uuid: UUID UUID del representante 15 | * @param nombre: String Nombre del representante 16 | * @param email: String Email del representante 17 | * @param createdAt: LocalDateTime Fecha de creación 18 | * @param updatedAt: LocalDateTime Fecha de actualización 19 | * @param deleted: Boolean Borrado lógico 20 | */ 21 | @Table(name = "REPRESENTANTES") 22 | data class Representante( 23 | // Identificador, si usamos Spring Data Reactive con H2, los uuid fallan, por eso usamos Long 24 | @Id 25 | val id: Long? = null, 26 | 27 | val uuid: UUID = UUID.randomUUID(), 28 | 29 | 30 | // Datos 31 | @NotEmpty(message = "El nombre no puede estar vacío") 32 | val nombre: String, 33 | @Email(regexp = ".*@.*\\..*", message = "Email debe ser un email valido") 34 | val email: String, 35 | 36 | // Historicos y metadata 37 | @Column("created_at") 38 | val createdAt: LocalDateTime = LocalDateTime.now(), 39 | @Column("updated_at") 40 | val updatedAt: LocalDateTime = LocalDateTime.now(), 41 | val deleted: Boolean = false // Para el borrado lógico si es necesario 42 | ) -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/dto/Raqueta.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.dto 2 | 3 | import jakarta.validation.constraints.Min 4 | import jakarta.validation.constraints.NotEmpty 5 | import java.time.LocalDateTime 6 | import java.util.* 7 | 8 | data class RaquetasPageDto( 9 | val content: List, 10 | val currentPage: Int, 11 | val pageSize: Int, 12 | val totalPages: Long, 13 | val totalElements: Long, 14 | val sort: String, 15 | val createdAt: String = LocalDateTime.now().toString() 16 | ) 17 | 18 | data class RaquetaCreateDto( 19 | @NotEmpty(message = "La marca no puede estar vacía") 20 | val marca: String, 21 | @Min(value = 0, message = "El precio no puede ser negativo") 22 | val precio: Double, 23 | val representanteId: UUID, 24 | ) 25 | 26 | data class RaquetaDto( 27 | val id: UUID, 28 | val marca: String, 29 | val precio: Double, 30 | val representante: RepresentanteDto, 31 | val metadata: MetaData? = null, 32 | ) { 33 | 34 | data class MetaData( 35 | val createdAt: String? = LocalDateTime.now().toString(), 36 | val updatedAt: String? = LocalDateTime.now().toString(), 37 | val deleted: Boolean = false 38 | ) 39 | } 40 | 41 | data class RaquetaTenistaDto( 42 | val id: UUID, 43 | val marca: String, 44 | val precio: Double, 45 | val representanteId: UUID? = null, 46 | val metadata: MetaData? = null, 47 | ) { 48 | data class MetaData( 49 | val createdAt: String? = LocalDateTime.now().toString(), 50 | val updatedAt: String? = LocalDateTime.now().toString(), 51 | val deleted: Boolean = false 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/cors/CorsConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.kotlinspringbootrestservice.config.cors 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.servlet.config.annotation.CorsRegistry 6 | 7 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 8 | 9 | 10 | @Configuration 11 | class CorsConfig { 12 | // @Bean 13 | // Cors para permitir cualquier petición 14 | /* 15 | public WebMvcConfigurer corsConfigurer() { 16 | return new WebMvcConfigurer() { 17 | @Override 18 | public void addCorsMappings(CorsRegistry registry) { 19 | registry.addMapping("/ **"); 20 | } 21 | }; 22 | } 23 | */ 24 | /** 25 | * CORS: Configuración más ajustada. 26 | */ 27 | @Bean 28 | fun corsConfigurer(): WebMvcConfigurer { 29 | return object : WebMvcConfigurer { 30 | // Ajustamos una configuración específica para cada serie de métodos 31 | // Así por cada fuente podemos permitir lo que queremos 32 | // Por ejemplo ene esta configuración solo permitirmos el dominio producto 33 | // Permitimos solo un dominio 34 | // e indicamos los verbos que queremos usar 35 | // Debes probar con uncliente desde ese puerto 36 | override fun addCorsMappings(registry: CorsRegistry) { 37 | registry.addMapping("/rest/**") 38 | .allowedOrigins("http://localhost:6969") 39 | .allowedHeaders("*") 40 | .allowedMethods("GET", "POST", "PUT", "DELETE") 41 | .maxAge(3600) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/TenistasRestSpringbootApplication.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot 2 | 3 | import mu.KotlinLogging 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.runApplication 6 | import org.springframework.cache.annotation.EnableCaching 7 | 8 | private val logger = KotlinLogging.logger {} 9 | 10 | @SpringBootApplication // Indicamos que es una aplicación Spring Boot 11 | @EnableCaching // Habilitamos el cacheo 12 | class TenistasRestSpringbootApplication { 13 | /* 14 | Si quiero ejecutar algo antes de arrancar la aplicación, como por ejemplo cargar datos de prueba 15 | borrar datos de prueba o lo que sea 16 | en la base de datos, puedo hacerlo con esta clase, que implementa CommandLineRunner 17 | 18 | : CommandLineRunner { 19 | @Autowired 20 | lateinit var service: UsuariosService 21 | override fun run(vararg args: String?) = runBlocking { 22 | 23 | logger.info { "Ejecutando código antes de arrancar la aplicación" } 24 | 25 | // vamos a probar los metodos de busqueda 26 | val usuario = service.loadUserById(1)!! 27 | println(usuario.toString()) 28 | println(usuario.rol) 29 | 30 | val usuario2 = service.loadUserByUsername("pepe") 31 | println(usuario2.authorities) 32 | 33 | service.findAll().toList().forEach { println(it) } 34 | 35 | val userCreateDto = UsuarioCreateDto( 36 | nombre = "test", 37 | email = "test@test.com", 38 | username = "test", 39 | password = "test" 40 | ) 41 | } 42 | 43 | */ 44 | } 45 | 46 | 47 | fun main(args: Array) { 48 | runApplication(*args) 49 | } 50 | -------------------------------------------------------------------------------- /wsclient/MyWSSessionHandler.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.wsclient 2 | 3 | import mu.KotlinLogging 4 | import org.springframework.messaging.simp.stomp.StompCommand 5 | import org.springframework.messaging.simp.stomp.StompHeaders 6 | import org.springframework.messaging.simp.stomp.StompSession 7 | import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter 8 | import java.lang.reflect.Type 9 | 10 | 11 | private val logger = KotlinLogging.logger {} 12 | 13 | class MyWSSessionHandler : StompSessionHandlerAdapter() { 14 | override fun afterConnected(session: StompSession, connectedHeaders: StompHeaders) { 15 | logger.info { "Nueva sesión establecida : " + session.sessionId } 16 | session.subscribe("/updates/representantes", this) 17 | logger.info { "Subscribed to /updates/representantes" } 18 | session.subscribe("/updates/raquetas", this) 19 | logger.info { "Subscribed to /updates/raquetas" } 20 | session.subscribe("/updates/tenistas", this) 21 | logger.info { "Subscribed to /updates/tenistas" } 22 | // session.send("/app/chat", sampleMessage) 23 | // logger.info { "Message sent to websocket server" } 24 | } 25 | 26 | override fun handleException( 27 | session: StompSession, 28 | command: StompCommand?, 29 | headers: StompHeaders, 30 | payload: ByteArray, 31 | exception: Throwable 32 | ) { 33 | logger.error { "Got an exception ${exception.message}" } 34 | } 35 | 36 | override fun getPayloadType(headers: StompHeaders): Type { 37 | return String::class.java 38 | } 39 | 40 | override fun handleFrame(headers: StompHeaders, payload: Any?) { 41 | val msg: String? = payload as String? 42 | logger.info("Received : $msg") 43 | println("Received : $msg") 44 | 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/dto/Usuario.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.dto 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 4 | import jakarta.validation.constraints.Email 5 | import jakarta.validation.constraints.NotEmpty 6 | import java.time.LocalDateTime 7 | import java.util.* 8 | 9 | 10 | data class UsuarioCreateDto( 11 | @NotEmpty(message = "El nombre no puede estar vacío") 12 | val nombre: String, 13 | @Email(message = "El email debe ser válido") 14 | val email: String, 15 | @NotEmpty(message = "El username no puede estar vacío") 16 | val username: String, 17 | val avatar: String? = null, 18 | val rol: Set = setOf(Usuario.Rol.USER.name), 19 | @NotEmpty(message = "El password no puede estar vacío") 20 | val password: String 21 | ) 22 | 23 | data class UsuarioLoginDto( 24 | @NotEmpty(message = "El username no puede estar vacío") 25 | val username: String, 26 | @NotEmpty(message = "El password no puede estar vacío") 27 | val password: String 28 | ) 29 | 30 | data class UsuarioDto( 31 | val id: UUID? = null, 32 | val nombre: String, 33 | val username: String, 34 | val email: String, 35 | val avatar: String, 36 | val rol: Set = setOf(Usuario.Rol.USER.name), 37 | val metadata: MetaData? = null, 38 | ) { 39 | data class MetaData( 40 | val createdAt: LocalDateTime? = LocalDateTime.now(), 41 | val updatedAt: LocalDateTime? = LocalDateTime.now(), 42 | val deleted: Boolean = false 43 | ) 44 | } 45 | 46 | data class UsuarioUpdateDto( 47 | @NotEmpty(message = "El nombre no puede estar vacío") 48 | val nombre: String, 49 | @Email(message = "El email debe ser válido") 50 | val email: String, 51 | @NotEmpty(message = "El username no puede estar vacío") 52 | val username: String, 53 | ) 54 | 55 | data class UserWithTokenDto( 56 | val user: UsuarioDto, 57 | val token: String 58 | ) 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/websocket/WebSocketConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.websocket 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.socket.WebSocketHandler 6 | import org.springframework.web.socket.config.annotation.EnableWebSocket 7 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer 8 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry 9 | 10 | 11 | @Configuration 12 | /* 13 | @EnableWebSocketMessageBroker 14 | class WebSocketConfig : WebSocketMessageBrokerConfigurer { 15 | */ 16 | /*override fun configureMessageBroker(config: MessageBrokerRegistry) { 17 | config.enableSimpleBroker("/updates") 18 | // config.setApplicationDestinationPrefixes("/app") 19 | } 20 | 21 | override fun registerStompEndpoints(registry: StompEndpointRegistry) { 22 | // ... 23 | registry.addEndpoint("/ws") 24 | registry.addEndpoint("/ws").withSockJS() 25 | }*//* 26 | 27 | }*/ 28 | 29 | @EnableWebSocket 30 | class ServerWebSocketConfig : WebSocketConfigurer { 31 | // Definimos el endpoints de nuestro WebSocket 32 | override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { 33 | // cada endpoint con su handler 34 | registry.addHandler(webSocketRaquetasHandler(), "api/updates/raquetas") 35 | registry.addHandler(webSocketRepresentantesHandler(), "api/updates/representantes") 36 | registry.addHandler(webSocketTenistasHandler(), "api/updates/tenistas") 37 | } 38 | 39 | // Definimos el Handler de nuestro WebSocket o handler para atenderlos 40 | @Bean 41 | fun webSocketRaquetasHandler(): WebSocketHandler { 42 | return WebSocketHandler("Raquetas") 43 | } 44 | 45 | @Bean 46 | fun webSocketRepresentantesHandler(): WebSocketHandler { 47 | return WebSocketHandler("Representantes") 48 | } 49 | 50 | @Bean 51 | fun webSocketTenistasHandler(): WebSocketHandler { 52 | return WebSocketHandler("Tenistas") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/dto/Tenista.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.dto 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import jakarta.validation.constraints.Min 5 | import jakarta.validation.constraints.NotEmpty 6 | import java.time.LocalDateTime 7 | import java.util.* 8 | 9 | data class TenistasPageDto( 10 | val content: List, 11 | val currentPage: Int, 12 | val pageSize: Int, 13 | val totalPages: Long, 14 | val totalElements: Long, 15 | val sort: String, 16 | val createdAt: String = LocalDateTime.now().toString() 17 | ) 18 | 19 | data class TenistaCreateDto( 20 | @NotEmpty(message = "El nombre no puede estar vacío") 21 | val nombre: String, 22 | @Min(value = 0, message = "El ranking no puede ser negativo") 23 | val ranking: Int, 24 | @NotEmpty(message = "La fecha de nacimiento no puede estar vacía") 25 | val fechaNacimiento: String, 26 | val añoProfesional: Int, 27 | @Min(value = 0, message = "El año profesional no puede ser negativo") 28 | val altura: Int, 29 | @Min(value = 0, message = "El peso no puede ser negativo") 30 | val peso: Int, 31 | val manoDominante: Tenista.ManoDominante? = Tenista.ManoDominante.DERECHA, 32 | val tipoReves: Tenista.TipoReves? = Tenista.TipoReves.DOS_MANOS, 33 | @Min(value = 0, message = "Los puntos no pueden ser negativos") 34 | val puntos: Int, 35 | @NotEmpty(message = "El país no puede estar vacío") 36 | val pais: String, 37 | val raquetaId: UUID? = null, 38 | ) 39 | 40 | data class TenistaDto( 41 | val id: UUID? = null, 42 | val nombre: String, 43 | val ranking: Int, 44 | val fechaNacimiento: String = LocalDateTime.now().toString(), 45 | val añoProfesional: Int, 46 | val altura: Int, 47 | val peso: Int, 48 | val manoDominante: Tenista.ManoDominante, 49 | val tipoReves: Tenista.TipoReves, 50 | val puntos: Int, 51 | val pais: String, 52 | val raqueta: RaquetaTenistaDto?, 53 | val metadata: MetaData? = null, 54 | ) { 55 | data class MetaData( 56 | val createdAt: String = LocalDateTime.now().toString(), 57 | val updatedAt: String = LocalDateTime.now().toString(), 58 | val deleted: Boolean = false 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/models/Tenista.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.models 2 | 3 | import jakarta.validation.constraints.Min 4 | import jakarta.validation.constraints.NotEmpty 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.relational.core.mapping.Column 7 | import org.springframework.data.relational.core.mapping.Table 8 | import java.time.LocalDate 9 | import java.time.LocalDateTime 10 | import java.util.* 11 | 12 | /** 13 | * Representante Model 14 | */ 15 | @Table(name = "TENISTAS") 16 | data class Tenista( 17 | // Identificador, si usamos Spring Data Reactive con H2, los uuid fallan, por eso usamos Long 18 | @Id 19 | val id: Long? = null, 20 | 21 | val uuid: UUID = UUID.randomUUID(), 22 | 23 | // Datos 24 | @NotEmpty(message = "El nombre no puede estar vacío") 25 | var nombre: String, 26 | @Min(value = 1, message = "El número de la línea debe ser mayor que 0") 27 | var ranking: Int, 28 | @Column("fecha_nacimiento") 29 | var fechaNacimiento: LocalDate, 30 | @Min(value = 1, message = "El año debe ser mayor mayor que 0") 31 | @Column("año_profesional") 32 | var añoProfesional: Int, 33 | @Min(value = 1, message = "La altura debe ser mayor que 0") 34 | var altura: Int, 35 | @Min(value = 1, message = "El peso debe ser mayor que 0") 36 | var peso: Int, 37 | @Column("mano_dominante") 38 | var manoDominante: ManoDominante, 39 | @Column("tipo_reves") 40 | var tipoReves: TipoReves, 41 | @Min(value = 1, message = "Los puntos deben ser mayor que 0") 42 | var puntos: Int, 43 | @NotEmpty(message = "El país no puede estar vacío") 44 | var pais: String, 45 | var raquetaId: UUID? = null, // No tiene por que tener raqueta 46 | 47 | // Historicos y metadata 48 | @Column("created_at") 49 | val createdAt: LocalDateTime = LocalDateTime.now(), 50 | @Column("updated_at") 51 | val updatedAt: LocalDateTime = LocalDateTime.now(), 52 | val deleted: Boolean = false // Para el borrado lógico si es necesario 53 | ) { 54 | 55 | // ENUMS de la propia clase 56 | enum class ManoDominante { 57 | DERECHA, IZQUIERDA 58 | } 59 | 60 | enum class TipoReves { 61 | UNA_MANO, DOS_MANOS 62 | } 63 | 64 | 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/swagger/SwaggerConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.swagger 2 | 3 | import io.swagger.v3.oas.models.ExternalDocumentation 4 | import io.swagger.v3.oas.models.OpenAPI 5 | import io.swagger.v3.oas.models.info.Contact 6 | import io.swagger.v3.oas.models.info.Info 7 | import io.swagger.v3.oas.models.info.License 8 | import org.springdoc.core.models.GroupedOpenApi 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | 12 | // https://springdoc.org/v2/#Introduction 13 | // https://stackoverflow.com/questions/74614369/how-to-run-swagger-3-on-spring-boot-3 14 | // http://localhost:XXXX/swagger-ui/index.html 15 | @Configuration 16 | class SwaggerConfig { 17 | @Bean 18 | fun apiInfo(): OpenAPI { 19 | return OpenAPI() 20 | .info( 21 | Info() 22 | .title("API REST Tenistas Spring Boot Reactive") 23 | .version("1.0.0") 24 | .description("API de ejemplo del curso Desarrollo de un API REST con Spring Boot. 2022/2023") 25 | .termsOfService("https://joseluisgs.dev/docs/license/") 26 | .license( 27 | License() 28 | .name("CC BY-NC-SA 4.0") 29 | .url("https://joseluisgs.dev/docs/license/") 30 | ) 31 | .contact( 32 | Contact() 33 | .name("José Luis González Sánchez") 34 | .email("joseluis.gonzales@iesluisvives.org") 35 | .url("https://joseluisgs.dev") 36 | ) 37 | 38 | ) 39 | .externalDocs( 40 | ExternalDocumentation() 41 | .description("Repositorio y Documentación del Proyecto y API") 42 | .url("https://github.com/joseluisgs/tenistas-rest-springboot-2022-2023") 43 | ) 44 | } 45 | 46 | 47 | @Bean 48 | fun httpApi(): GroupedOpenApi { 49 | return GroupedOpenApi.builder() 50 | .group("http") 51 | //.pathsToMatch("/api/**") 52 | //.pathsToMatch("/api/tenistas/**") 53 | .pathsToMatch("/api/test/**") 54 | .displayName("HTTP-API Tenistas Test") 55 | .build() 56 | } 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/security/jwt/JwtAuthorizationFilter.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.security.jwt 2 | 3 | import es.joseluisgs.tenistasrestspringboot.services.usuarios.UsuariosService 4 | import io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION 5 | import jakarta.servlet.FilterChain 6 | import jakarta.servlet.ServletException 7 | import jakarta.servlet.http.HttpServletRequest 8 | import jakarta.servlet.http.HttpServletResponse 9 | import joseluisgs.es.utils.toUUID 10 | import kotlinx.coroutines.runBlocking 11 | import org.springframework.security.authentication.AuthenticationManager 12 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 13 | import org.springframework.security.core.context.SecurityContextHolder 14 | import org.springframework.security.web.authentication.www.BasicAuthenticationFilter 15 | import java.io.IOException 16 | 17 | private val logger = mu.KotlinLogging.logger {} 18 | 19 | class JwtAuthorizationFilter( 20 | private val jwtTokenUtil: JwtTokenUtils, 21 | private val service: UsuariosService, 22 | authManager: AuthenticationManager, 23 | ) : BasicAuthenticationFilter(authManager) { 24 | 25 | @Throws(IOException::class, ServletException::class) 26 | override fun doFilterInternal( 27 | req: HttpServletRequest, 28 | res: HttpServletResponse, 29 | chain: FilterChain 30 | ) { 31 | logger.info { "Filtrando" } 32 | val header = req.getHeader(AUTHORIZATION.toString()) 33 | if (header == null || !header.startsWith(JwtTokenUtils.TOKEN_PREFIX)) { 34 | chain.doFilter(req, res) 35 | return 36 | } 37 | getAuthentication(header.substring(7))?.also { 38 | SecurityContextHolder.getContext().authentication = it 39 | } 40 | chain.doFilter(req, res) 41 | } 42 | 43 | private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? = runBlocking { 44 | logger.info { "Obteniendo autenticación" } 45 | 46 | if (!jwtTokenUtil.isTokenValid(token)) return@runBlocking null 47 | // val username = jwtTokenUtil.getUsernameFromJwt(token) 48 | val userId = jwtTokenUtil.getUserIdFromJwt(token) 49 | // val roles = jwtTokenUtil.getRolesFromJwt(token) 50 | val user = service.loadUserByUuid(userId.toUUID()) 51 | return@runBlocking UsernamePasswordAuthenticationToken( 52 | user, 53 | null, 54 | user?.authorities 55 | ) 56 | } 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/models/Usuario.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.models 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.relational.core.mapping.Column 5 | import org.springframework.data.relational.core.mapping.Table 6 | import org.springframework.security.core.GrantedAuthority 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority 8 | import org.springframework.security.core.userdetails.UserDetails 9 | import java.time.LocalDateTime 10 | import java.util.* 11 | 12 | 13 | @Table(name = "USUARIOS") 14 | data class Usuario( 15 | // Identificador, si usamos Spring Data Reactive con H2, los uuid fallan, por eso usamos Long 16 | @Id 17 | val id: Long? = null, 18 | 19 | val uuid: UUID = UUID.randomUUID(), 20 | 21 | // Datos 22 | val nombre: String, 23 | 24 | @get:JvmName("userName") // Para que no se llame getUsername 25 | val username: String, 26 | val email: String, 27 | 28 | @get:JvmName("userPassword") // Para que no se llame getPassword 29 | val password: String, 30 | 31 | val avatar: String, 32 | 33 | // Conjunto de permisos que tiene 34 | @Column("rol") 35 | val rol: String = Rol.USER.name, 36 | 37 | // Historicos y metadata 38 | @Column("created_at") 39 | val createdAt: LocalDateTime = LocalDateTime.now(), 40 | @Column("updated_at") 41 | val updatedAt: LocalDateTime = LocalDateTime.now(), 42 | val deleted: Boolean = false, // Para el borrado lógico si es necesario 43 | @Column("last_password_change_at") 44 | val lastPasswordChangeAt: LocalDateTime = LocalDateTime.now() 45 | ) : UserDetails { 46 | 47 | enum class Rol { 48 | USER, // Normal 49 | ADMIN // Administrador 50 | } 51 | 52 | // transformamos el conjunto de roles en una lista de GrantedAuthority 53 | override fun getAuthorities(): MutableCollection { 54 | //val ga = SimpleGrantedAuthority("ROLE_" + rol.name) 55 | // return mutableListOf(ga) 56 | return rol.split(",").map { SimpleGrantedAuthority("ROLE_${it.trim()}") }.toMutableList() 57 | } 58 | 59 | override fun getPassword(): String { 60 | return password 61 | } 62 | 63 | override fun getUsername(): String { 64 | return username 65 | } 66 | 67 | 68 | /*override fun getPassword(): String { 69 | return password 70 | } 71 | 72 | override fun getUsername(): String { 73 | return username 74 | } 75 | */ 76 | override fun isAccountNonExpired(): Boolean { 77 | return true 78 | } 79 | 80 | override fun isAccountNonLocked(): Boolean { 81 | return true 82 | } 83 | 84 | override fun isCredentialsNonExpired(): Boolean { 85 | return true 86 | } 87 | 88 | override fun isEnabled(): Boolean { 89 | return true 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/security/jwt/JwtAuthenticationFilter.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.security.jwt 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import es.joseluisgs.tenistasrestspringboot.dto.UsuarioLoginDto 5 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 6 | import jakarta.servlet.FilterChain 7 | import jakarta.servlet.http.HttpServletRequest 8 | import jakarta.servlet.http.HttpServletResponse 9 | import mu.KotlinLogging 10 | import org.springframework.security.authentication.AuthenticationManager 11 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 12 | import org.springframework.security.core.Authentication 13 | import org.springframework.security.core.AuthenticationException 14 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 15 | import java.util.* 16 | 17 | private val logger = KotlinLogging.logger {} 18 | 19 | 20 | class JwtAuthenticationFilter( 21 | private val jwtTokenUtil: JwtTokenUtils, 22 | private val authenticationManager: AuthenticationManager 23 | ) : UsernamePasswordAuthenticationFilter() { 24 | 25 | override fun attemptAuthentication(req: HttpServletRequest, response: HttpServletResponse): Authentication { 26 | logger.info { "Intentando autenticar" } 27 | 28 | val credentials = ObjectMapper().readValue(req.inputStream, UsuarioLoginDto::class.java) 29 | val auth = UsernamePasswordAuthenticationToken( 30 | credentials.username, 31 | credentials.password, 32 | ) 33 | return authenticationManager.authenticate(auth) 34 | } 35 | 36 | override fun successfulAuthentication( 37 | req: HttpServletRequest?, res: HttpServletResponse, chain: FilterChain?, 38 | auth: Authentication 39 | ) { 40 | logger.info { "Autenticación correcta" } 41 | 42 | // val username = (auth.principal as Usuario).username 43 | // val token: String = jwtTokenUtil.generateToken(username) 44 | val user = auth.principal as Usuario 45 | val token: String = jwtTokenUtil.generateToken(user) 46 | res.addHeader("Authorization", token) 47 | // Authorization 48 | res.addHeader("Access-Control-Expose-Headers", JwtTokenUtils.TOKEN_HEADER) 49 | } 50 | 51 | override fun unsuccessfulAuthentication( 52 | request: HttpServletRequest, 53 | response: HttpServletResponse, 54 | failed: AuthenticationException 55 | ) { 56 | logger.info { "Autenticación incorrecta" } 57 | 58 | val error = BadCredentialsError() 59 | response.status = error.status 60 | response.contentType = "application/json" 61 | response.writer.append(error.toString()) 62 | } 63 | 64 | } 65 | 66 | private data class BadCredentialsError( 67 | val timestamp: Long = Date().time, 68 | val status: Int = 401, 69 | val message: String = "Usuario o password incorrectos", 70 | ) { 71 | override fun toString(): String { 72 | return ObjectMapper().writeValueAsString(this) 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/websocket/WebSocketHandler.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.websocket 2 | 3 | import mu.KotlinLogging 4 | import org.springframework.scheduling.annotation.Scheduled 5 | import org.springframework.web.socket.CloseStatus 6 | import org.springframework.web.socket.SubProtocolCapable 7 | import org.springframework.web.socket.TextMessage 8 | import org.springframework.web.socket.WebSocketSession 9 | import org.springframework.web.socket.handler.TextWebSocketHandler 10 | import java.io.IOException 11 | import java.time.LocalTime 12 | import java.util.concurrent.CopyOnWriteArraySet 13 | 14 | private val logger = KotlinLogging.logger {} 15 | 16 | // Le he hecho una pequeña modificación para poder enviar mensajes a todos los clientes 17 | class WebSocketHandler(private val entity: String) : TextWebSocketHandler(), SubProtocolCapable, WebSocketSender { 18 | // Para poder enviar mensajes a todos los clientes almacenamos la sesión de cada uno 19 | // Patron observer 20 | private val sessions: MutableSet = CopyOnWriteArraySet() 21 | 22 | override fun afterConnectionEstablished(session: WebSocketSession) { 23 | logger.info { "Conexión establecida con el servidor" } 24 | logger.info { "Sesión: $session" } 25 | sessions.add(session) 26 | val message = TextMessage("Updates Web socket: $entity - Tenistas API REST Spring Boot") 27 | logger.info { "Servidor envía: $message" } 28 | session.sendMessage(message) 29 | } 30 | 31 | override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { 32 | logger.info { "Conexión cerrada con el servidor: $status" } 33 | sessions.remove(session) 34 | } 35 | 36 | // Para poder enviar mensajes a todos los clientes almacenamos la sesión de cada uno 37 | override fun sendMessage(message: String) { 38 | logger.info { "Enviar mensaje de cambios en $entity: $message" } 39 | sessions.forEach { session -> 40 | if (session.isOpen) { 41 | logger.info { "Servidor envía: $message" } 42 | session.sendMessage(TextMessage(message)) 43 | } 44 | } 45 | } 46 | 47 | @Scheduled(fixedRate = 1000) 48 | @Throws(IOException::class) 49 | override fun sendPeriodicMessages() { 50 | for (session in sessions) { 51 | if (session.isOpen) { 52 | val broadcast = "server periodic message " + LocalTime.now() 53 | logger.info("Server sends: {}", broadcast) 54 | session.sendMessage(TextMessage(broadcast)) 55 | } 56 | } 57 | } 58 | 59 | @Throws(Exception::class) 60 | override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { 61 | // No hago nada con los mensajes que me llegan 62 | 63 | /*val request = message.payload 64 | logger.info("Server received: {}", request) 65 | val response = String.format("response from server to '%s'", HtmlUtils.htmlEscape(request)) 66 | logger.info("Server sends: {}", response) 67 | session.sendMessage(TextMessage(response))*/ 68 | } 69 | 70 | override fun handleTransportError(session: WebSocketSession, exception: Throwable) { 71 | logger.info { "Error de transporte con el servidor ${exception.message}" } 72 | } 73 | 74 | override fun getSubProtocols(): List { 75 | return listOf("subprotocol.demo.websocket") 76 | } 77 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/raquetas/RaquetasRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 4 | import joseluisgs.es.utils.toUUID 5 | import kotlinx.coroutines.flow.first 6 | import kotlinx.coroutines.flow.firstOrNull 7 | import kotlinx.coroutines.flow.toList 8 | import kotlinx.coroutines.test.runTest 9 | import org.junit.jupiter.api.Assertions.* 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.TestInstance 12 | import org.junit.jupiter.api.assertAll 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 15 | import org.springframework.boot.test.context.SpringBootTest 16 | import org.springframework.data.domain.Pageable 17 | import java.util.* 18 | 19 | // Como es reactivo no podemos testear usando @DataJpaTest y entityManager 20 | 21 | 22 | /** 23 | * En el fondo este no hace falta hacerlo, porque ya lo ha testeado spring ;) 24 | * Para eos es suyo!! 25 | */ 26 | @SpringBootTest 27 | // Levanta la base de datos en memoria 28 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) 29 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) // BeforeAll y AfterAll 30 | internal class RaquetasRepositoryTest { 31 | 32 | @Autowired 33 | lateinit var repository: RaquetasRepository 34 | 35 | private val raqueta = Raqueta( 36 | uuid = UUID.fromString("044e6ec7-aa6c-46bb-9433-8094ef4ae8bc"), 37 | marca = "Test", 38 | precio = 199.9, 39 | representanteId = UUID.fromString("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf"), 40 | ) 41 | 42 | 43 | @Test 44 | fun findAll() = runTest { 45 | val result = repository.findAll().toList() 46 | 47 | // Comprobamos que el resultado es correcto 48 | assertAll( 49 | { assertNotNull(result) }, 50 | { assertEquals("Babolat", result[0].marca) }, 51 | ) 52 | } 53 | 54 | @Test 55 | fun findAllPageable() = runTest { 56 | val result = repository.findAllBy(Pageable.ofSize(1)).toList() 57 | 58 | // Comprobamos que el resultado es correcto 59 | assertAll( 60 | { assertNotNull(result) }, 61 | { assertEquals("Babolat", result[0].marca) }, 62 | ) 63 | 64 | } 65 | 66 | @Test 67 | fun findByUuid() = runTest { 68 | val result = repository.findByUuid("86084458-4733-4d71-a3db-34b50cd8d68f".toUUID()).first() 69 | 70 | // Comprobamos que el resultado es correcto 71 | assertAll( 72 | { assertEquals("Babolat", result.marca) }, 73 | { assertEquals(200.0, result.precio) }, 74 | ) 75 | } 76 | 77 | @Test 78 | fun findByUudiNotExists() = runTest { 79 | val result = repository.findByUuid(UUID.randomUUID()).firstOrNull() 80 | 81 | // Comprobamos que el resultado es correcto 82 | assertNull(result) 83 | 84 | } 85 | 86 | @Test 87 | fun save() = runTest { 88 | val result = repository.save(raqueta) 89 | 90 | // Comprobamos que el resultado es correcto 91 | assertAll( 92 | { assertNotNull(result) }, 93 | { assertEquals("Test", result.marca) }, 94 | ) 95 | repository.delete(result) 96 | } 97 | 98 | @Test 99 | fun update() = runTest { 100 | val result = repository.save(raqueta) 101 | 102 | // Comprobamos que el resultado es correcto 103 | assertAll( 104 | { assertNotNull(result) }, 105 | { assertEquals("Test", result.marca) }, 106 | ) 107 | repository.delete(result) 108 | } 109 | 110 | @Test 111 | fun delete() = runTest { 112 | val result = repository.save(raqueta) 113 | 114 | // Comprobamos que el resultado es correcto 115 | assertAll( 116 | { assertNotNull(result) }, 117 | { assertEquals("Test", result.marca) }, 118 | ) 119 | repository.delete(result) 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/representantes/RepresentantesRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Representante 4 | import es.joseluisgs.tenistasrestspringboot.repositories.representantes.RepresentantesRepository 5 | import joseluisgs.es.utils.toUUID 6 | import kotlinx.coroutines.flow.first 7 | import kotlinx.coroutines.flow.firstOrNull 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.jupiter.api.Assertions.* 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.TestInstance 13 | import org.junit.jupiter.api.assertAll 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 16 | import org.springframework.boot.test.context.SpringBootTest 17 | import org.springframework.data.domain.Pageable 18 | import java.util.* 19 | 20 | // Como es reactivo no podemos testear usando @DataJpaTest y entityManager 21 | 22 | 23 | /** 24 | * En el fondo este no hace falta hacerlo, porque ya lo ha testeado spring ;) 25 | * Para eos es suyo!! 26 | */ 27 | @SpringBootTest 28 | // Levanta la base de datos en memoria 29 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) 30 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) // BeforeAll y AfterAll 31 | internal class RepresentantesRepositoryTest { 32 | 33 | @Autowired 34 | lateinit var repository: RepresentantesRepository 35 | 36 | private val representante = Representante( 37 | uuid = UUID.fromString("91e0c247-c611-4ed2-8db8-a495f1f16fee"), 38 | nombre = "Test", 39 | email = "test@example.com", 40 | ) 41 | 42 | 43 | @Test 44 | fun findAll() = runTest { 45 | val result = repository.findAll().toList() 46 | 47 | // Comprobamos que el resultado es correcto 48 | assertAll( 49 | { assertNotNull(result) }, 50 | { assertEquals("Pepe Perez", result[0].nombre) }, 51 | ) 52 | } 53 | 54 | @Test 55 | fun findAllPageable() = runTest { 56 | val result = repository.findAllBy(Pageable.ofSize(1)).toList() 57 | 58 | // Comprobamos que el resultado es correcto 59 | assertAll( 60 | { assertNotNull(result) }, 61 | { assertEquals("Pepe Perez", result[0].nombre) }, 62 | ) 63 | 64 | } 65 | 66 | @Test 67 | fun findByUuid() = runTest { 68 | val result = repository.findByUuid("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf".toUUID()).first() 69 | 70 | // Comprobamos que el resultado es correcto 71 | assertAll( 72 | { assertEquals("Pepe Perez", result.nombre) }, 73 | { assertEquals("pepe@perez.com", result.email) }, 74 | ) 75 | } 76 | 77 | @Test 78 | fun findByUudiNotExists() = runTest { 79 | val result = repository.findByUuid(UUID.randomUUID()).firstOrNull() 80 | 81 | // Comprobamos que el resultado es correcto 82 | assertNull(result) 83 | 84 | } 85 | 86 | @Test 87 | fun save() = runTest { 88 | val result = repository.save(representante) 89 | 90 | // Comprobamos que el resultado es correcto 91 | assertAll( 92 | { assertNotNull(result) }, 93 | { assertEquals("Test", result.nombre) }, 94 | ) 95 | repository.delete(result) 96 | } 97 | 98 | @Test 99 | fun update() = runTest { 100 | val result = repository.save(representante) 101 | 102 | // Comprobamos que el resultado es correcto 103 | assertAll( 104 | { assertNotNull(result) }, 105 | { assertEquals("Test", result.nombre) }, 106 | ) 107 | repository.delete(result) 108 | } 109 | 110 | @Test 111 | fun delete() = runTest { 112 | val result = repository.save(representante) 113 | 114 | // Comprobamos que el resultado es correcto 115 | assertAll( 116 | { assertNotNull(result) }, 117 | { assertEquals("Test", result.nombre) }, 118 | ) 119 | repository.delete(result) 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/security/jwt/JwtTokenUtils.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.security.jwt 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.algorithms.Algorithm 5 | import com.auth0.jwt.interfaces.DecodedJWT 6 | import es.joseluisgs.tenistasrestspringboot.exceptions.TokenInvalidException 7 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 8 | import mu.KotlinLogging 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.stereotype.Component 11 | import java.util.* 12 | 13 | private val logger = KotlinLogging.logger {} 14 | 15 | @Component 16 | class JwtTokenUtils { 17 | // Cargamos el secreto del token desde el fichero de propiedades o valor por defecto 18 | @Value("\${jwt.secret:MeGustanLosPepinosDeLeganes}") 19 | private val jwtSecreto: String? = 20 | null // Secreto, lo cargamos de properties y si no le asignamos un valor por defecto 21 | 22 | @Value("\${jwt.token-expiration:3600}") 23 | private val jwtDuracionTokenEnSegundos = 0 // Tiempo de expiración, idem a secreto 24 | 25 | // Genera el Token 26 | fun generateToken(user: Usuario): String { 27 | logger.info { "Generando token para el usuario: ${user.username}" } 28 | 29 | // Obtenemos el usuario 30 | // val user: Usuario = authentication.principal 31 | 32 | // Creamos el timepo de vida del token, fecha en milisegunods (*1000) Fecha del sistema 33 | // Mas duración del token 34 | val tokenExpirationDate = Date(System.currentTimeMillis() + jwtDuracionTokenEnSegundos * 1000) 35 | 36 | // Construimos el token con sus datos y payload 37 | return JWT.create() 38 | .withSubject(user.uuid.toString()) // Como Subject el ID del usuario 39 | .withHeader(mapOf("typ" to TOKEN_TYPE)) // Tipo de token 40 | .withIssuedAt(Date()) // Fecha actual 41 | .withExpiresAt(tokenExpirationDate) // Fecha de expiración 42 | .withClaim("username", user.username) 43 | .withClaim("nombre", user.nombre) 44 | .withClaim("roles", user.rol.split(",").toSet().toString()) 45 | //.withClaim("authorities", user.authorities.map { it.authority }.toList()) 46 | // Le añadimos los roles o lo que queramos como payload: claims 47 | .sign(Algorithm.HMAC512(jwtSecreto)) // Lo firmamos con nuestro secreto HS512 48 | } 49 | 50 | // A partir de un token obetner el ID de usuario 51 | fun getUserIdFromJwt(token: String?): String { 52 | logger.info { "Obteniendo el ID del usuario: $token" } 53 | return validateToken(token!!)!!.subject 54 | } 55 | 56 | // Nos idica como validar el Token 57 | fun validateToken(authToken: String): DecodedJWT? { 58 | logger.info { "Validando el token: ${authToken}" } 59 | 60 | try { 61 | return JWT.require(Algorithm.HMAC512(jwtSecreto)).build().verify(authToken) 62 | } catch (e: Exception) { 63 | throw TokenInvalidException("Token no válido o expirado") 64 | } 65 | } 66 | 67 | private fun getClaimsFromJwt(token: String) = 68 | validateToken(token)?.claims 69 | 70 | fun getUsernameFromJwt(token: String): String { 71 | logger.info { "Obteniendo el nombre de usuario del token: ${token}" } 72 | 73 | val claims = getClaimsFromJwt(token) 74 | return claims!!["username"]!!.asString() 75 | } 76 | 77 | fun getRolesFromJwt(token: String): String { 78 | logger.info { "Obteniendo los roles del token: ${token}" } 79 | 80 | val claims = getClaimsFromJwt(token) 81 | return claims!!["roles"]!!.asString() 82 | } 83 | 84 | fun isTokenValid(token: String): Boolean { 85 | logger.info { "Comprobando si el token es válido: ${token}" } 86 | 87 | val claims = getClaimsFromJwt(token)!! 88 | val expirationDate = claims["exp"]!!.asDate() 89 | val now = Date(System.currentTimeMillis()) 90 | return now.before(expirationDate) 91 | } 92 | 93 | companion object { 94 | // Naturaleza del Token!!! 95 | const val TOKEN_HEADER = "Authorization" // Encabezado 96 | const val TOKEN_PREFIX = "Bearer " // Prefijo, importante este espacio 97 | const val TOKEN_TYPE = "JWT" // Tipo de Token 98 | } 99 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/controllers/StorageController.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.controllers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.config.APIConfig 4 | import es.joseluisgs.tenistasrestspringboot.exceptions.StorageBadRequestException 5 | import es.joseluisgs.tenistasrestspringboot.exceptions.StorageException 6 | import es.joseluisgs.tenistasrestspringboot.services.storage.StorageService 7 | import jakarta.servlet.http.HttpServletRequest 8 | import kotlinx.coroutines.* 9 | import mu.KotlinLogging 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.core.io.Resource 12 | import org.springframework.http.HttpStatus 13 | import org.springframework.http.MediaType 14 | import org.springframework.http.ResponseEntity 15 | import org.springframework.web.bind.annotation.* 16 | import org.springframework.web.multipart.MultipartFile 17 | import java.io.IOException 18 | import java.time.LocalDateTime 19 | 20 | private val logger = KotlinLogging.logger {} 21 | 22 | // Podemos evitar los try catch ya que podemos usar el ResponseStatusException en las excepciones 23 | // El problema de hacerlo así es que pierdes el control de como mapear los errores 24 | // Elige el que más te guste y que mejor se adapte a tu proyecto 25 | // A mi me gusta mas este, porque sé lo que me va a devolver y puedo controlar el error 26 | // y devolver el que yo quiera, o incluso devolver un error personalizado, o saber qué testear y esperar 27 | 28 | @RestController 29 | @RequestMapping(APIConfig.API_PATH + "/storage") 30 | class StorageController 31 | @Autowired constructor( 32 | private val storageService: StorageService 33 | ) { 34 | @GetMapping(value = ["{filename:.+}"]) 35 | @ResponseBody 36 | fun serveFile( 37 | @PathVariable filename: String?, 38 | request: HttpServletRequest 39 | ) 40 | : ResponseEntity = runBlocking { 41 | 42 | logger.info { "GET File: $filename" } 43 | 44 | val myScope = CoroutineScope(Dispatchers.IO) 45 | 46 | val file: Resource = myScope.async { storageService.loadAsResource(filename.toString()) }.await() 47 | var contentType: String? = null 48 | contentType = try { 49 | request.servletContext.getMimeType(file.file.absolutePath) 50 | } catch (ex: IOException) { 51 | throw StorageBadRequestException("No se puede determinar el tipo del fichero", ex) 52 | } 53 | if (contentType == null) { 54 | contentType = "application/octet-stream" 55 | } 56 | return@runBlocking ResponseEntity.ok() 57 | .contentType(MediaType.parseMediaType(contentType)) 58 | .body(file) 59 | } 60 | 61 | @PostMapping( 62 | value = [""], 63 | consumes = [MediaType.MULTIPART_FORM_DATA_VALUE] 64 | ) 65 | fun uploadFile( 66 | @RequestPart("file") file: MultipartFile 67 | ): ResponseEntity> = runBlocking { 68 | 69 | logger.info { "POST File: ${file.originalFilename}" } 70 | 71 | return@runBlocking try { 72 | if (!file.isEmpty) { 73 | val myScope = CoroutineScope(Dispatchers.IO) 74 | val fileStored = myScope.async { storageService.store(file) }.await() 75 | val urlStored = storageService.getUrl(fileStored) 76 | val response = 77 | mapOf("url" to urlStored, "name" to fileStored, "created_at" to LocalDateTime.now().toString()) 78 | ResponseEntity.status(HttpStatus.CREATED).body(response) 79 | } else { 80 | throw StorageBadRequestException("No se puede subir un fichero vacío") 81 | } 82 | } catch (e: StorageException) { 83 | throw StorageBadRequestException(e.message.toString()) 84 | } 85 | } 86 | 87 | @DeleteMapping(value = ["{filename:.+}"]) 88 | @ResponseBody 89 | fun deleteFile( 90 | @PathVariable filename: String?, 91 | request: HttpServletRequest 92 | ) 93 | : ResponseEntity = runBlocking { 94 | 95 | logger.info { "DELETE File: $filename" } 96 | try { 97 | val myScope = CoroutineScope(Dispatchers.IO) 98 | myScope.launch { storageService.delete(filename.toString()) }.join() 99 | return@runBlocking ResponseEntity.ok().build() 100 | } catch (e: StorageException) { 101 | throw StorageBadRequestException(e.message.toString()) 102 | } 103 | } 104 | 105 | // Implementar el resto de metodos del servicio que nos interesen... 106 | // Delete file, listar ficheros, etc.... 107 | } 108 | -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/tenistas/TenistasRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import es.joseluisgs.tenistasrestspringboot.repositories.tenistas.TenistasRepository 5 | import joseluisgs.es.utils.toUUID 6 | import kotlinx.coroutines.flow.first 7 | import kotlinx.coroutines.flow.firstOrNull 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.jupiter.api.Assertions.* 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.TestInstance 13 | import org.junit.jupiter.api.assertAll 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 16 | import org.springframework.boot.test.context.SpringBootTest 17 | import org.springframework.data.domain.Pageable 18 | import java.time.LocalDate 19 | import java.util.* 20 | 21 | // Como es reactivo no podemos testear usando @DataJpaTest y entityManager 22 | 23 | 24 | /** 25 | * En el fondo este no hace falta hacerlo, porque ya lo ha testeado spring ;) 26 | * Para eos es suyo!! 27 | */ 28 | @SpringBootTest 29 | // Levanta la base de datos en memoria 30 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) 31 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) // BeforeAll y AfterAll 32 | internal class TenistasRepositoryTest { 33 | 34 | @Autowired 35 | lateinit var repository: TenistasRepository 36 | 37 | private val tenista = Tenista( 38 | uuid = UUID.fromString("91e0c247-c611-4ed2-8db8-a495f1f16fee"), 39 | nombre = "Test", 40 | ranking = 99, 41 | fechaNacimiento = LocalDate.parse("1981-01-01"), 42 | añoProfesional = 2000, 43 | altura = 188, 44 | peso = 83, 45 | manoDominante = Tenista.ManoDominante.DERECHA, 46 | tipoReves = Tenista.TipoReves.UNA_MANO, 47 | puntos = 3789, 48 | pais = "Suiza", 49 | raquetaId = UUID.fromString("b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e") 50 | ) 51 | 52 | 53 | @Test 54 | fun findAll() = runTest { 55 | val result = repository.findAll().toList() 56 | 57 | // Comprobamos que el resultado es correcto 58 | assertAll( 59 | { assertNotNull(result) }, 60 | { assertEquals("Rafael Nadal", result[0].nombre) }, 61 | ) 62 | } 63 | 64 | @Test 65 | fun findAllPageable() = runTest { 66 | val result = repository.findAllBy(Pageable.ofSize(1)).toList() 67 | 68 | // Comprobamos que el resultado es correcto 69 | assertAll( 70 | { assertNotNull(result) }, 71 | { assertEquals("Rafael Nadal", result[0].nombre) }, 72 | ) 73 | 74 | } 75 | 76 | @Test 77 | fun findByRankingOrderByRanking() = runTest { 78 | val result = repository.findByOrderByRankingAsc().toList() 79 | 80 | // Comprobamos que el resultado es correcto 81 | assertAll( 82 | { assertNotNull(result) }, 83 | { assertEquals("Carlos Alcaraz", result[0].nombre) }, 84 | ) 85 | } 86 | 87 | @Test 88 | fun findByUuid() = runTest { 89 | val result = repository.findByUuid("ea2962c6-2142-41b8-8dfb-0ecfe67e27df".toUUID()).first() 90 | 91 | // Comprobamos que el resultado es correcto 92 | assertAll( 93 | { assertEquals("Rafael Nadal", result.nombre) }, 94 | { assertEquals(2, result.ranking) } 95 | ) 96 | } 97 | 98 | @Test 99 | fun findByUudiNotExists() = runTest { 100 | val result = repository.findByUuid(UUID.randomUUID()).firstOrNull() 101 | 102 | // Comprobamos que el resultado es correcto 103 | assertNull(result) 104 | 105 | } 106 | 107 | @Test 108 | fun save() = runTest { 109 | val result = repository.save(tenista) 110 | 111 | // Comprobamos que el resultado es correcto 112 | assertAll( 113 | { assertNotNull(result) }, 114 | { assertEquals("Test", result.nombre) }, 115 | ) 116 | repository.delete(result) 117 | } 118 | 119 | @Test 120 | fun update() = runTest { 121 | val result = repository.save(tenista) 122 | 123 | // Comprobamos que el resultado es correcto 124 | assertAll( 125 | { assertNotNull(result) }, 126 | { assertEquals("Test", result.nombre) }, 127 | ) 128 | repository.delete(result) 129 | } 130 | 131 | @Test 132 | fun delete() = runTest { 133 | val result = repository.save(tenista) 134 | 135 | // Comprobamos que el resultado es correcto 136 | assertAll( 137 | { assertNotNull(result) }, 138 | { assertEquals("Test", result.nombre) }, 139 | ) 140 | repository.delete(result) 141 | } 142 | 143 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/usuarios/UsuariosService.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.usuarios 2 | 3 | import es.joseluisgs.tenistasrestspringboot.exceptions.UsuariosBadRequestException 4 | import es.joseluisgs.tenistasrestspringboot.exceptions.UsuariosNotFoundException 5 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 6 | import es.joseluisgs.tenistasrestspringboot.repositories.usuarios.UsuariosRepository 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.firstOrNull 9 | import kotlinx.coroutines.runBlocking 10 | import kotlinx.coroutines.withContext 11 | import mu.KotlinLogging 12 | import org.springframework.beans.factory.annotation.Autowired 13 | import org.springframework.cache.annotation.Cacheable 14 | import org.springframework.security.core.userdetails.UserDetails 15 | import org.springframework.security.core.userdetails.UserDetailsService 16 | import org.springframework.security.crypto.password.PasswordEncoder 17 | import org.springframework.stereotype.Service 18 | import java.time.LocalDateTime 19 | import java.util.* 20 | 21 | private val logger = KotlinLogging.logger {} 22 | 23 | // Este no lo vamos a cachear pero si quisieramos solo es poner la cache como antes 24 | 25 | @Service 26 | class UsuariosService 27 | @Autowired constructor( 28 | private val repository: UsuariosRepository, // Inyectamos el repositorio de Usuarios 29 | private val passwordEncoder: PasswordEncoder // Inyectamos el PasswordEncoder BCrypt en Bean de Config 30 | ) : UserDetailsService { 31 | // No podemos suspenderla porque es una interfaz de Spring Security 32 | override fun loadUserByUsername(username: String): UserDetails = runBlocking { 33 | 34 | // Create a method in your repo to find a user by its username 35 | return@runBlocking repository.findByUsername(username).firstOrNull() 36 | ?: throw UsuariosNotFoundException("Usuario no encontrado con username: $username") 37 | } 38 | 39 | suspend fun findAll() = withContext(Dispatchers.IO) { 40 | return@withContext repository.findAll() 41 | } 42 | 43 | @Cacheable("usuarios") 44 | suspend fun loadUserById(userId: Long) = withContext(Dispatchers.IO) { 45 | return@withContext repository.findById(userId) 46 | } 47 | 48 | @Cacheable("usuarios") 49 | suspend fun loadUserByUuid(uuid: UUID) = withContext(Dispatchers.IO) { 50 | return@withContext repository.findByUuid(uuid).firstOrNull() 51 | } 52 | 53 | suspend fun save(user: Usuario, isAdmin: Boolean = false): Usuario = withContext(Dispatchers.IO) { 54 | logger.info { "Guardando usuario: $user" } 55 | 56 | // existe el username o el email 57 | if (repository.findByUsername(user.username) 58 | .firstOrNull() != null 59 | ) { 60 | logger.info { "El usuario ya existe" } 61 | throw UsuariosBadRequestException("El username ya existe") 62 | } 63 | if (repository.findByEmail(user.email) 64 | .firstOrNull() != null 65 | ) { 66 | logger.info { "El email ya existe" } 67 | throw UsuariosBadRequestException("El email ya existe") 68 | } 69 | 70 | logger.info { "El usuario no existe, lo guardamos" } 71 | // Encriptamos la contraseña 72 | var newUser = user.copy( 73 | uuid = UUID.randomUUID(), 74 | password = passwordEncoder.encode(user.password), 75 | rol = Usuario.Rol.USER.name, 76 | createdAt = LocalDateTime.now(), 77 | updatedAt = LocalDateTime.now() 78 | ) 79 | if (isAdmin) 80 | newUser = newUser.copy( 81 | rol = Usuario.Rol.ADMIN.name 82 | ) 83 | println(newUser) 84 | try { 85 | return@withContext repository.save(newUser) 86 | } catch (e: Exception) { 87 | throw UsuariosBadRequestException("Error al crear el usuario: Nombre de usuario o email ya existen") 88 | } 89 | } 90 | 91 | suspend fun update(user: Usuario) = withContext(Dispatchers.IO) { 92 | logger.info { "Actualizando usuario: $user" } 93 | 94 | // existe el username o el email 95 | var userDB = repository.findByUsername(user.username) 96 | .firstOrNull() 97 | // No soy yo?? 98 | if (userDB != null && userDB.id != user.id) { 99 | throw UsuariosBadRequestException("El username ya existe") 100 | } 101 | 102 | userDB = repository.findByEmail(user.email) 103 | .firstOrNull() 104 | // No soy yo?? 105 | if (userDB != null && userDB.id != user.id) { 106 | throw UsuariosBadRequestException("El email ya existe") 107 | } 108 | 109 | logger.info { "El usuario no existe, lo actualizamos" } 110 | 111 | val updtatedUser = user.copy( 112 | updatedAt = LocalDateTime.now() 113 | ) 114 | 115 | try { 116 | return@withContext repository.save(updtatedUser) 117 | } catch (e: Exception) { 118 | throw UsuariosBadRequestException("Error al actualizar el usuario: Nombre de usuario o email ya existen") 119 | } 120 | 121 | 122 | } 123 | 124 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/config/security/SecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.config.security 2 | 3 | import es.joseluisgs.tenistasrestspringboot.config.security.jwt.JwtAuthenticationFilter 4 | import es.joseluisgs.tenistasrestspringboot.config.security.jwt.JwtAuthorizationFilter 5 | import es.joseluisgs.tenistasrestspringboot.config.security.jwt.JwtTokenUtils 6 | import es.joseluisgs.tenistasrestspringboot.services.usuarios.UsuariosService 7 | import mu.KotlinLogging 8 | import org.springframework.beans.factory.annotation.Autowired 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | import org.springframework.http.HttpMethod 12 | import org.springframework.security.authentication.AuthenticationManager 13 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder 14 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 15 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 16 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 17 | import org.springframework.security.config.http.SessionCreationPolicy 18 | import org.springframework.security.web.SecurityFilterChain 19 | 20 | 21 | //https://blog.devgenius.io/implementing-authentication-and-authorization-using-spring-security-kotlin-and-jwt-an-easy-and-cc82a1f20567 22 | // https://stackoverflow.com/questions/74609057/how-to-fix-spring-authorizerequests-is-deprecated 23 | 24 | private val logger = KotlinLogging.logger {} 25 | 26 | @Configuration 27 | @EnableWebSecurity // Habilitamos la seguridad web 28 | // Activamos la seguridad a nivel de método, por si queremos trabajar a nivel de controlador 29 | @EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) 30 | class SecurityConfig @Autowired constructor( 31 | private val userService: UsuariosService, 32 | private val jwtTokenUtils: JwtTokenUtils 33 | ) { 34 | 35 | @Bean 36 | fun authManager(http: HttpSecurity): AuthenticationManager { 37 | val authenticationManagerBuilder = http.getSharedObject( 38 | AuthenticationManagerBuilder::class.java 39 | ) 40 | authenticationManagerBuilder.userDetailsService(userService) 41 | return authenticationManagerBuilder.build() 42 | } 43 | 44 | // Ignoramos los endpoints que no queremos que se autentiquen 45 | // importante para las excepciones personalizadas de ResponseStatusException 46 | // ya que el simpático de Spring Security se lo come las desvía a /error 47 | // Si no quieres hacer esto puedes añadir la librería que he dejado comentada en el build.gradle 48 | // @Bean 49 | // fun webSecurityCustomizer(): WebSecurityCustomizer { 50 | // return WebSecurityCustomizer { web: WebSecurity -> 51 | // web.ignoring().requestMatchers("/error/**") 52 | // // web.ignoring().requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") 53 | // } 54 | // } 55 | 56 | @Bean 57 | fun filterChain(http: HttpSecurity): SecurityFilterChain { 58 | val authenticationManager = authManager(http) 59 | // Vamos a crear el filtro de autenticación y el de autorización 60 | http 61 | .csrf() 62 | .disable() 63 | .exceptionHandling() 64 | .and() 65 | 66 | // Indicamos que vamos a usar un autenticador basado en JWT 67 | .authenticationManager(authenticationManager) 68 | 69 | // Para el establecimiento de sesiones son estado, no usamos sesiones 70 | .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 71 | 72 | .and() 73 | 74 | .authorizeHttpRequests() 75 | 76 | // Permiso para errores y mostrarlos 77 | .requestMatchers("/error/**").permitAll() 78 | 79 | // Permitimos el acceso a los endpoints de swagger 80 | .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll() 81 | 82 | .requestMatchers("/api/**") 83 | .permitAll() 84 | 85 | // Ahora vamos a permitir el acceso a los endpoints de login y registro 86 | .requestMatchers("users/login", "users/register").permitAll() 87 | 88 | // O permitir por roles en un endpoint 89 | .requestMatchers("/user/me").hasAnyRole("USER", "ADMIN") 90 | 91 | // O por permisos y metodos en un endpoint 92 | .requestMatchers(HttpMethod.GET, "/user/list").hasRole("ADMIN") 93 | 94 | // Las otras peticiones no requerirán autenticación, 95 | .anyRequest().authenticated() // .not().authenticated(); 96 | 97 | .and() 98 | 99 | // Le añadimos el filtro de autenticación y el de autorización a la configuración 100 | // Será el encargado de coger el token y si es válido lo dejaremos pasar... 101 | .addFilter(JwtAuthenticationFilter(jwtTokenUtils, authenticationManager)) 102 | .addFilter(JwtAuthorizationFilter(jwtTokenUtils, userService, authenticationManager)) 103 | // .addFilterBefore( 104 | // JwtAuthenticationFilter(jwtTokenUtils, authenticationManager), 105 | // JwtAuthorizationFilter::class.java 106 | // ) 107 | // .addFilterBefore( 108 | // JwtAuthorizationFilter(jwtTokenUtils, userService, authenticationManager), 109 | // JwtAuthenticationFilter::class.java 110 | // ) 111 | 112 | return http.build() 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/db/Data.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.es.db 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 4 | import es.joseluisgs.tenistasrestspringboot.models.Representante 5 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 6 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 7 | import org.springframework.security.crypto.bcrypt.BCrypt 8 | import java.time.LocalDate 9 | import java.util.* 10 | 11 | // Datos de prueba 12 | 13 | // Representantes 14 | fun getRepresentantesInit() = listOf( 15 | Representante( 16 | uuid = UUID.fromString("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf"), 17 | nombre = "Pepe Perez", 18 | email = "pepe@perez.com" 19 | ), 20 | Representante( 21 | uuid = UUID.fromString("c53062e4-31ea-4f5e-a99d-36c228ed01a3"), 22 | nombre = "Juan Lopez", 23 | email = "juan@lopez.com" 24 | ), 25 | Representante( 26 | uuid = UUID.fromString("a33cd6a6-e767-48c3-b07b-ab7e015a73cd"), 27 | nombre = "Maria Garcia", 28 | email = "maria@garcia.com" 29 | ), 30 | ) 31 | 32 | // Raquetas 33 | fun getRaquetasInit() = listOf( 34 | Raqueta( 35 | uuid = UUID.fromString("86084458-4733-4d71-a3db-34b50cd8d68f"), 36 | marca = "Babolat", 37 | precio = 200.0, 38 | representanteId = UUID.fromString("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf") 39 | ), 40 | Raqueta( 41 | uuid = UUID.fromString("b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e"), 42 | marca = "Wilson", 43 | precio = 250.0, 44 | representanteId = UUID.fromString("c53062e4-31ea-4f5e-a99d-36c228ed01a3") 45 | ), 46 | Raqueta( 47 | uuid = UUID.fromString("e4a7b78e-f9ca-43df-b186-3811554eeeb2"), 48 | marca = "Head", 49 | precio = 225.0, 50 | representanteId = UUID.fromString("a33cd6a6-e767-48c3-b07b-ab7e015a73cd") 51 | ), 52 | ) 53 | 54 | // Tenistas 55 | fun getTenistasInit() = listOf( 56 | Tenista( 57 | uuid = UUID.fromString("ea2962c6-2142-41b8-8dfb-0ecfe67e27df"), 58 | nombre = "Rafael Nadal", 59 | ranking = 2, 60 | fechaNacimiento = LocalDate.parse("1985-06-04"), 61 | añoProfesional = 2005, 62 | altura = 185, 63 | peso = 81, 64 | manoDominante = Tenista.ManoDominante.IZQUIERDA, 65 | tipoReves = Tenista.TipoReves.DOS_MANOS, 66 | puntos = 6789, 67 | pais = "España", 68 | raquetaId = UUID.fromString("86084458-4733-4d71-a3db-34b50cd8d68f") 69 | ), 70 | Tenista( 71 | uuid = UUID.fromString("f629e649-c6b7-4514-94a8-36bbcd4e7e1b"), 72 | nombre = "Roger Federer", 73 | ranking = 3, 74 | fechaNacimiento = LocalDate.parse("1981-01-01"), 75 | añoProfesional = 2000, 76 | altura = 188, 77 | peso = 83, 78 | manoDominante = Tenista.ManoDominante.DERECHA, 79 | tipoReves = Tenista.TipoReves.UNA_MANO, 80 | puntos = 3789, 81 | pais = "Suiza", 82 | raquetaId = UUID.fromString("b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e") 83 | ), 84 | Tenista( 85 | uuid = UUID.fromString("24242ae7-1c81-434f-9b33-849a640d68a0"), 86 | nombre = "Novak Djokovic", 87 | ranking = 4, 88 | fechaNacimiento = LocalDate.parse("1986-05-05"), 89 | añoProfesional = 2004, 90 | altura = 189, 91 | peso = 81, 92 | manoDominante = Tenista.ManoDominante.DERECHA, 93 | tipoReves = Tenista.TipoReves.DOS_MANOS, 94 | puntos = 1970, 95 | pais = "Serbia", 96 | raquetaId = UUID.fromString("e4a7b78e-f9ca-43df-b186-3811554eeeb2") 97 | ), 98 | Tenista( 99 | uuid = UUID.fromString("af04e495-bacc-4bde-8d61-d52f78b52a86"), 100 | nombre = "Dominic Thiem", 101 | ranking = 5, 102 | fechaNacimiento = LocalDate.parse("1995-06-04"), 103 | añoProfesional = 2015, 104 | altura = 188, 105 | peso = 82, 106 | manoDominante = Tenista.ManoDominante.DERECHA, 107 | tipoReves = Tenista.TipoReves.UNA_MANO, 108 | puntos = 1234, 109 | pais = "Austria", 110 | raquetaId = UUID.fromString("86084458-4733-4d71-a3db-34b50cd8d68f") 111 | ), 112 | Tenista( 113 | uuid = UUID.fromString("a711040a-fb0d-4fe4-b726-75883ca8d907"), 114 | nombre = "Carlos Alcaraz", 115 | ranking = 1, 116 | fechaNacimiento = LocalDate.parse("2003-05-05"), 117 | añoProfesional = 2019, 118 | altura = 185, 119 | peso = 80, 120 | manoDominante = Tenista.ManoDominante.DERECHA, 121 | tipoReves = Tenista.TipoReves.DOS_MANOS, 122 | puntos = 6880, 123 | pais = "España", 124 | raquetaId = UUID.fromString("86084458-4733-4d71-a3db-34b50cd8d68f") 125 | ), 126 | ) 127 | 128 | // Usuarios 129 | fun getUsuariosInit() = listOf( 130 | Usuario( 131 | uuid = UUID.fromString("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf"), 132 | nombre = "Pepe Perez", 133 | username = "pepe", 134 | email = "pepe@perez.com", 135 | password = BCrypt.hashpw("pepe1234", BCrypt.gensalt(12)), 136 | avatar = "https://upload.wikimedia.org/wikipedia/commons/f/f4/User_Avatar_2.png", 137 | rol = Usuario.Rol.ADMIN.name 138 | ), 139 | Usuario( 140 | uuid = UUID.fromString("c53062e4-31ea-4f5e-a99d-36c228ed01a3"), 141 | nombre = "Ana Lopez", 142 | username = "ana", 143 | email = "ana@lopez.com", 144 | password = BCrypt.hashpw("ana1234", BCrypt.gensalt(12)), 145 | avatar = "https://upload.wikimedia.org/wikipedia/commons/f/f4/User_Avatar_2.png", 146 | rol = Usuario.Rol.USER.name 147 | ) 148 | ) 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/controllers/TestController.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.controllers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.config.APIConfig 4 | import es.joseluisgs.tenistasrestspringboot.dto.TestDto 5 | import io.swagger.v3.oas.annotations.Operation 6 | import io.swagger.v3.oas.annotations.Parameter 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse 8 | import jakarta.validation.Valid 9 | import mu.KotlinLogging 10 | import org.springframework.http.HttpStatus 11 | import org.springframework.http.ResponseEntity 12 | import org.springframework.web.bind.annotation.* 13 | import java.util.* 14 | 15 | private val logger = KotlinLogging.logger {} 16 | 17 | @RestController 18 | @RequestMapping(APIConfig.API_PATH + "/test") 19 | class TestController { 20 | 21 | // GET: /test?text=Hola 22 | @Operation(summary = "Get all Test", description = "Obtiene una lista de objetos Test", tags = ["Test"]) 23 | @Parameter(name = "texto", description = "Texto a buscar", required = false, example = "Hola") 24 | @ApiResponse(responseCode = "200", description = "Lista de Test") 25 | @GetMapping("") 26 | fun getAll(@RequestParam texto: String?): ResponseEntity> { 27 | logger.info { "GET ALL Test" } 28 | return ResponseEntity.ok(listOf(TestDto("Hola : Query: $texto"), TestDto("Mundo : Query: $texto"))) 29 | } 30 | 31 | // GET: /test/{id} 32 | @Operation(summary = "Get Test by ID", description = "Obtiene un objeto Test por su ID", tags = ["Test"]) 33 | @Parameter(name = "id", description = "ID del Test", required = true, example = "1") 34 | @ApiResponse(responseCode = "200", description = "Test encontrado") 35 | @ApiResponse(responseCode = "404", description = "Test no encontrado si id = kaka") 36 | @ApiResponse(responseCode = "403", description = "No tienes permisos si id = admin") 37 | @ApiResponse(responseCode = "401", description = "No autorizado si id = nopuedes") 38 | @ApiResponse(responseCode = "500", description = "Error interno si id = error") 39 | @ApiResponse(responseCode = "400", description = "Petición incorrecta si id = otro") 40 | @GetMapping("/{id}") 41 | fun getById(@PathVariable id: String): ResponseEntity { 42 | logger.info { "GET BY ID Test" } 43 | return when (id) { 44 | // Ejemplos de codigos de respuesta 45 | "1" -> ResponseEntity.ok(TestDto("Hola GET BY $id")) 46 | "kaka" -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(TestDto("No encontrado")) 47 | "admin" -> ResponseEntity.status(HttpStatus.FORBIDDEN).body(TestDto("No tienes permisos")) 48 | "nopuedes" -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(TestDto("No autorizado")) 49 | "error" -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(TestDto("Error interno")) 50 | else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body(TestDto("Hola GET BY $id")) 51 | 52 | } 53 | } 54 | 55 | // POST:/test 56 | @Operation(summary = "Create Test", description = "Crea un objeto Test", tags = ["Test"]) 57 | @ApiResponse(responseCode = "201", description = "Test creado") 58 | @PostMapping("") 59 | fun create(@Valid @RequestBody testDto: TestDto): ResponseEntity { 60 | logger.info { "POST Test" } 61 | val new = TestDto("Hola POST ${testDto.message}") 62 | return ResponseEntity.status(HttpStatus.CREATED).body(new) 63 | } 64 | 65 | // PUT:/test/{id} 66 | @Operation(summary = "Update Test", description = "Modifica un objeto Test", tags = ["Test"]) 67 | @Parameter(name = "id", description = "ID del Test", required = true, example = "1") 68 | @ApiResponse(responseCode = "200", description = "Test modificado") 69 | @ApiResponse(responseCode = "404", description = "Test no encontrado si id = kaka") 70 | @PutMapping("/{id}") 71 | fun update(@PathVariable id: String, @RequestBody testDto: TestDto): ResponseEntity { 72 | logger.info { "PUT Test" } 73 | return if (id != "kaka") { 74 | val new = TestDto("Hola PUT $id: ${testDto.message}") 75 | ResponseEntity.status(HttpStatus.OK).body(new) 76 | } else 77 | ResponseEntity.status(HttpStatus.NOT_FOUND).body(TestDto("No encontrado")) 78 | } 79 | 80 | // PATCH:/test/{id} 81 | @Operation(summary = "Patch Test", description = "Modifica un objeto Test", tags = ["Test"]) 82 | @Parameter(name = "id", description = "ID del Test", required = true, example = "1") 83 | @ApiResponse(responseCode = "200", description = "Test modificado") 84 | @ApiResponse(responseCode = "404", description = "Test no encontrado si id = kaka") 85 | @PatchMapping("/{id}") 86 | fun patch(@PathVariable id: String, @RequestBody testDto: TestDto): ResponseEntity { 87 | logger.info { "PATCH Test" } 88 | return if (id != "kaka") { 89 | val new = TestDto("Hola PATCH $id: ${testDto.message}") 90 | ResponseEntity.status(HttpStatus.OK).body(new) 91 | } else 92 | ResponseEntity.status(HttpStatus.NOT_FOUND).body(TestDto("No encontrado")) 93 | } 94 | 95 | // DELETE:/test/{id} 96 | @Operation(summary = "Delete Test", description = "Elimina un objeto Test", tags = ["Test"]) 97 | @Parameter(name = "id", description = "ID del Test", required = true, example = "1") 98 | @ApiResponse(responseCode = "204", description = "Test eliminado") 99 | @ApiResponse(responseCode = "404", description = "Test no encontrado si id = kaka") 100 | @DeleteMapping("/{id}") 101 | fun delete(@PathVariable id: String): ResponseEntity { 102 | logger.info { "DELETE Test" } 103 | return if (id != "kaka") { 104 | ResponseEntity.noContent().build() 105 | } else 106 | ResponseEntity.notFound().build() 107 | } 108 | } -------------------------------------------------------------------------------- /src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | -- PARA SPRING DATA REACTIVE DEBES CREAR LA TABLAS 2 | 3 | -- BORRAR TABLAS 4 | DROP TABLE IF EXISTS TENISTAS; 5 | DROP TABLE IF EXISTS RAQUETAS; 6 | DROP TABLE IF EXISTS REPRESENTANTES; 7 | DROP TABLE IF EXISTS USUARIOS; 8 | 9 | -- REPRESENTANTES 10 | CREATE TABLE IF NOT EXISTS REPRESENTANTES 11 | ( 12 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 13 | uuid UUID NOT NULL UNIQUE, 14 | nombre TEXT NOT NULL, 15 | email TEXT NOT NULL, 16 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | deleted BOOLEAN NOT NULL DEFAULT FALSE 19 | ); 20 | 21 | -- RAQUETAS 22 | CREATE TABLE IF NOT EXISTS RAQUETAS 23 | ( 24 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 25 | uuid UUID NOT NULL UNIQUE, 26 | marca TEXT NOT NULL, 27 | precio DOUBLE NOT NULL, 28 | representante_id UUID NOT NULL REFERENCES REPRESENTANTES (uuid), 29 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 30 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | deleted BOOLEAN NOT NULL DEFAULT FALSE 32 | ); 33 | 34 | -- TENISTAS 35 | CREATE TABLE IF NOT EXISTS TENISTAS 36 | ( 37 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 38 | uuid UUID NOT NULL UNIQUE, 39 | nombre TEXT NOT NULL, 40 | ranking INTEGER NOT NULL UNIQUE, 41 | fecha_nacimiento DATE NOT NULL, 42 | año_profesional INTEGER NOT NULL, 43 | altura INTEGER NOT NULL, 44 | peso INTEGER NOT NULL, 45 | mano_dominante TEXT NOT NULL, 46 | tipo_reves TEXT NOT NULL, 47 | puntos INTEGER NOT NULL, 48 | pais TEXT NOT NULL, 49 | raqueta_id UUID REFERENCES RAQUETAS (uuid), 50 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 51 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 52 | deleted BOOLEAN NOT NULL DEFAULT FALSE 53 | ); 54 | 55 | -- USERS 56 | CREATE TABLE IF NOT EXISTS USUARIOS 57 | ( 58 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 59 | uuid UUID NOT NULL UNIQUE, 60 | nombre TEXT NOT NULL, 61 | username TEXT NOT NULL UNIQUE, 62 | email TEXT NOT NULL UNIQUE, 63 | password TEXT NOT NULL, 64 | avatar TEXT, 65 | rol TEXT NOT NULL, 66 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 67 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 68 | deleted BOOLEAN NOT NULL DEFAULT FALSE, 69 | last_password_change_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 70 | ); 71 | 72 | -- DATOS DE PRUEBA 73 | 74 | -- REPRESENTANTES 75 | INSERT INTO REPRESENTANTES (uuid, nombre, email) 76 | VALUES ('b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf', 'Pepe Perez', 'pepe@perez.com'); 77 | INSERT INTO REPRESENTANTES (uuid, nombre, email) 78 | VALUES ('c53062e4-31ea-4f5e-a99d-36c228ed01a3', 'Juan Lopez', 'juan@lopez.com'); 79 | INSERT INTO REPRESENTANTES (uuid, nombre, email) 80 | VALUES ('a33cd6a6-e767-48c3-b07b-ab7e015a73cd', 'Maria Garcia', 'maria@garcia.com'); 81 | 82 | -- RAQUETAS 83 | INSERT INTO RAQUETAS (uuid, marca, precio, representante_id) 84 | VALUES ('86084458-4733-4d71-a3db-34b50cd8d68f', 'Babolat', 200.0, 'b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf'); 85 | INSERT INTO RAQUETAS (uuid, marca, precio, representante_id) 86 | VALUES ('b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e', 'Wilson', 250.0, 'c53062e4-31ea-4f5e-a99d-36c228ed01a3'); 87 | INSERT INTO RAQUETAS (uuid, marca, precio, representante_id) 88 | VALUES ('e4a7b78e-f9ca-43df-b186-3811554eeeb2', 'Head', 225.0, 'a33cd6a6-e767-48c3-b07b-ab7e015a73cd'); 89 | 90 | -- TENISTAS 91 | INSERT INTO TENISTAS (uuid, nombre, ranking, fecha_nacimiento, año_profesional, altura, peso, mano_dominante, 92 | tipo_reves, puntos, pais, raqueta_id) 93 | VALUES ('ea2962c6-2142-41b8-8dfb-0ecfe67e27df', 'Rafael Nadal', 2, '1985-06-04', 2005, 185, 81, 'IZQUIERDA', 94 | 'DOS_MANOS', 6789, 'España', '86084458-4733-4d71-a3db-34b50cd8d68f'); 95 | INSERT INTO TENISTAS (uuid, nombre, ranking, fecha_nacimiento, año_profesional, altura, peso, mano_dominante, 96 | tipo_reves, puntos, pais, raqueta_id) 97 | VALUES ('f629e649-c6b7-4514-94a8-36bbcd4e7e1b', 'Roger Federer', 3, '1981-01-01', 2000, 188, 83, 'DERECHA', 98 | 'UNA_MANO', 3789, 'Suiza', 'b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e'); 99 | INSERT INTO TENISTAS (uuid, nombre, ranking, fecha_nacimiento, año_profesional, altura, peso, mano_dominante, 100 | tipo_reves, puntos, pais, raqueta_id) 101 | VALUES ('24242ae7-1c81-434f-9b33-849a640d68a0', 'Novak Djokovic', 4, '1986-05-05', 2004, 189, 81, 'DERECHA', 102 | 'DOS_MANOS', 1970, 'Serbia', 'e4a7b78e-f9ca-43df-b186-3811554eeeb2'); 103 | INSERT INTO TENISTAS (uuid, nombre, ranking, fecha_nacimiento, año_profesional, altura, peso, mano_dominante, 104 | tipo_reves, puntos, pais, raqueta_id) 105 | VALUES ('af04e495-bacc-4bde-8d61-d52f78b52a86', 'Dominic Thiem', 5, '1995-06-04', 2015, 188, 82, 'DERECHA', 106 | 'UNA_MANO', 1234, 'Austria', '86084458-4733-4d71-a3db-34b50cd8d68f'); 107 | INSERT INTO TENISTAS (uuid, nombre, ranking, fecha_nacimiento, año_profesional, altura, peso, mano_dominante, 108 | tipo_reves, puntos, pais, raqueta_id) 109 | VALUES ('a711040a-fb0d-4fe4-b726-75883ca8d907', 'Carlos Alcaraz', 1, '2003-05-05', 2019, 185, 80, 'DERECHA', 110 | 'DOS_MANOS', 6880, 'España', '86084458-4733-4d71-a3db-34b50cd8d68f'); 111 | 112 | -- USUARIOS 113 | -- Contraseña: pepe1234 114 | INSERT INTO USUARIOS (uuid, nombre, username, email, password, avatar, rol) 115 | VALUES ('b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf', 'Pepe Perez', 'pepe', 'pepe@perez.com', 116 | '$2a$12$249dkPGBT6dH46f4Dbu7ouEuO8eZ7joonzWGefPJbHH8eDpJy0oCq', 117 | 'https://upload.wikimedia.org/wikipedia/commons/f/f4/User_Avatar_2.png', 'ADMIN, USER'); 118 | -- Contraseña: ana1234 119 | INSERT INTO USUARIOS (uuid, nombre, username, email, password, avatar, rol) 120 | VALUES ('c53062e4-31ea-4f5e-a99d-36c228ed01a3', 'Ana Lopez', 'ana', 'ana@lopez.com', 121 | '$2a$12$ZymlZf4Ja48WpBliFEU0qOUwb6HEJnhzlKYUoywhCxutkf1BzMbW2', 122 | 'https://upload.wikimedia.org/wikipedia/commons/f/f4/User_Avatar_2.png', 'USER'); 123 | -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/raquetas/RaquetasCachedRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.exceptions.RaquetaConflictIntegrityException 4 | import es.joseluisgs.tenistasrestspringboot.exceptions.RepresentanteConflictIntegrityException 5 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.asFlow 9 | import kotlinx.coroutines.flow.firstOrNull 10 | import kotlinx.coroutines.flow.toList 11 | import kotlinx.coroutines.withContext 12 | import mu.KotlinLogging 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.springframework.cache.annotation.CacheEvict 15 | import org.springframework.cache.annotation.CachePut 16 | import org.springframework.cache.annotation.Cacheable 17 | import org.springframework.data.domain.Page 18 | import org.springframework.data.domain.PageImpl 19 | import org.springframework.data.domain.PageRequest 20 | import org.springframework.stereotype.Repository 21 | import java.time.LocalDateTime 22 | import java.util.* 23 | 24 | private val logger = KotlinLogging.logger {} 25 | 26 | @Repository 27 | class RaquetasCachedRepositoryImpl 28 | @Autowired constructor( 29 | private val raquetasRepository: RaquetasRepository 30 | ) : RaquetasCachedRepository { 31 | 32 | init { 33 | logger.info { "Iniciando Repositorio Cache de Raquetas" } 34 | } 35 | 36 | override suspend fun findAll(): Flow = withContext(Dispatchers.IO) { 37 | logger.info { "Repositorio de raquetas findAll" } 38 | 39 | return@withContext raquetasRepository.findAll() 40 | } 41 | 42 | @Cacheable("raquetas") 43 | override suspend fun findById(id: Long): Raqueta? = withContext(Dispatchers.IO) { 44 | logger.info { "Repositorio de raquetas findById con id: $id" } 45 | 46 | return@withContext raquetasRepository.findById(id) 47 | } 48 | 49 | @Cacheable("raquetas") 50 | override suspend fun findByUuid(uuid: UUID): Raqueta? = withContext(Dispatchers.IO) { 51 | logger.info { "Repositorio de raquetas findByUuid con uuid: $uuid" } 52 | 53 | return@withContext raquetasRepository.findByUuid(uuid).firstOrNull() 54 | } 55 | 56 | override suspend fun findByMarca(marca: String): Flow = withContext(Dispatchers.IO) { 57 | logger.info { "Repositorio de raquetas findByMarca con marca: $marca" } 58 | 59 | return@withContext raquetasRepository.findByMarcaContainsIgnoreCase(marca) 60 | } 61 | 62 | @CachePut("raquetas") 63 | override suspend fun save(raqueta: Raqueta): Raqueta = withContext(Dispatchers.IO) { 64 | logger.info { "Repositorio de raquetas save raqueta: $raqueta" } 65 | 66 | val saved = 67 | raqueta.copy( 68 | uuid = UUID.randomUUID(), 69 | createdAt = LocalDateTime.now(), 70 | updatedAt = LocalDateTime.now() 71 | ) 72 | 73 | return@withContext raquetasRepository.save(raqueta) 74 | } 75 | 76 | @CachePut("raquetas") 77 | override suspend fun update(uuid: UUID, raqueta: Raqueta): Raqueta? = 78 | withContext(Dispatchers.IO) { 79 | logger.info { "Repositorio de raquetas update con uuid: $uuid raqueta: $raqueta" } 80 | 81 | val raquetaDB = raquetasRepository.findByUuid(uuid).firstOrNull() 82 | 83 | raquetaDB?.let { 84 | val updated = it.copy( 85 | uuid = it.uuid, 86 | marca = raqueta.marca, 87 | precio = raqueta.precio, 88 | representanteId = raqueta.representanteId, 89 | createdAt = it.createdAt, 90 | updatedAt = LocalDateTime.now() 91 | ) 92 | return@withContext raquetasRepository.save(updated) 93 | } 94 | return@withContext null 95 | } 96 | 97 | @CacheEvict("raquetas") 98 | override suspend fun deleteById(id: Long) = withContext(Dispatchers.IO) { 99 | logger.info { "Repositorio de raquetas deleteById con id: $id" } 100 | 101 | try { 102 | raquetasRepository.deleteById(id) 103 | } catch (e: Exception) { 104 | throw RepresentanteConflictIntegrityException("No se puede borrar la raqueta con id: $id porque tiene tenistas asociados") 105 | } 106 | } 107 | 108 | override suspend fun findAllPage(pageRequest: PageRequest): Flow> { 109 | logger.info { "Repositorio de raquetas findAllPage" } 110 | 111 | return raquetasRepository.findAllBy(pageRequest) 112 | .toList() 113 | .windowed(pageRequest.pageSize, pageRequest.pageSize, true) 114 | .map { PageImpl(it, pageRequest, raquetasRepository.count()) } 115 | .asFlow() 116 | } 117 | 118 | override suspend fun countAll(): Long { 119 | logger.info { "Repositorio de raquetas countAll" } 120 | 121 | return raquetasRepository.count() 122 | } 123 | 124 | @CacheEvict("raquetas") 125 | override suspend fun deleteByUuid(uuid: UUID): Raqueta? = withContext(Dispatchers.IO) { 126 | logger.info { "Repositorio de raquetas deleteByUuid con uuid: $uuid" } 127 | 128 | val raquetaDB = raquetasRepository.findByUuid(uuid).firstOrNull() 129 | try { 130 | raquetaDB?.let { 131 | raquetasRepository.deleteById(it.id!!) 132 | return@withContext it 133 | } 134 | } catch (e: Exception) { 135 | throw RaquetaConflictIntegrityException("No se puede borrar la raqueta: ${raquetaDB?.marca} ya que tiene tenistas asociados") 136 | } 137 | return@withContext null 138 | } 139 | 140 | @CacheEvict("raquetas") 141 | override suspend fun delete(raqueta: Raqueta): Raqueta? = withContext(Dispatchers.IO) { 142 | logger.info { "Repositorio de raquetas delete raqueta: $raqueta" } 143 | 144 | val raquetaBD = raquetasRepository.findByUuid(raqueta.uuid).firstOrNull() 145 | try { 146 | raquetaBD?.let { 147 | raquetasRepository.deleteById(it.id!!) 148 | return@withContext it 149 | } 150 | } catch (e: Exception) { 151 | throw RaquetaConflictIntegrityException("No se puede borrar la raqueta: ${raqueta.marca} ya que tiene tenistas asociados") 152 | } 153 | return@withContext null 154 | } 155 | 156 | @CacheEvict("raquetas", allEntries = true) 157 | override suspend fun deleteAll() = withContext(Dispatchers.IO) { 158 | logger.info { "Repositorio de raquetas deleteAll" } 159 | 160 | raquetasRepository.deleteAll() 161 | } 162 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/tenistas/TenistasCachedRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.tenistas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.asFlow 7 | import kotlinx.coroutines.flow.firstOrNull 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.coroutines.withContext 10 | import mu.KotlinLogging 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.cache.annotation.CacheEvict 13 | import org.springframework.cache.annotation.CachePut 14 | import org.springframework.cache.annotation.Cacheable 15 | import org.springframework.data.domain.Page 16 | import org.springframework.data.domain.PageImpl 17 | import org.springframework.data.domain.PageRequest 18 | import org.springframework.stereotype.Repository 19 | import java.time.LocalDateTime 20 | import java.util.* 21 | 22 | private val logger = KotlinLogging.logger {} 23 | 24 | @Repository 25 | class TenistasCachedRepositoryImpl 26 | @Autowired constructor( 27 | private val tenistasRepository: TenistasRepository 28 | ) : TenistasCachedRepository { 29 | 30 | init { 31 | logger.info { "Iniciando Repositorio Cache de Tenistas" } 32 | } 33 | 34 | override suspend fun findAll(): Flow = withContext(Dispatchers.IO) { 35 | logger.info { "Repositorio de tenistas findAll" } 36 | 37 | // Ordenación normal 38 | // return@withContext tenistasRepository.findAll() 39 | // Ordenados por ranking 40 | return@withContext tenistasRepository.findByOrderByRankingAsc() 41 | } 42 | 43 | @Cacheable("tenistas") 44 | override suspend fun findById(id: Long): Tenista? = withContext(Dispatchers.IO) { 45 | logger.info { "Repositorio de tenistas findById con id: $id" } 46 | 47 | return@withContext tenistasRepository.findById(id) 48 | } 49 | 50 | @Cacheable("tenistas") 51 | override suspend fun findByUuid(uuid: UUID): Tenista? = withContext(Dispatchers.IO) { 52 | logger.info { "Repositorio de tenista findByUuid con uuid: $uuid" } 53 | 54 | return@withContext tenistasRepository.findByUuid(uuid).firstOrNull() 55 | } 56 | 57 | override suspend fun findByNombre(nombre: String): Flow = withContext(Dispatchers.IO) { 58 | logger.info { "Repositorio de tenistas findByMarca con nombre: $nombre" } 59 | 60 | return@withContext tenistasRepository.findByNombreContainsIgnoreCase(nombre) 61 | } 62 | 63 | override suspend fun findByRanking(ranking: Int): Flow { 64 | logger.info { "Repositorio de tenistas findByRanking con ranking: $ranking" } 65 | 66 | return tenistasRepository.findByRanking(ranking) 67 | } 68 | 69 | @CachePut("tenistas") 70 | override suspend fun save(tenista: Tenista): Tenista = withContext(Dispatchers.IO) { 71 | logger.info { "Repositorio de tenistas save tenista: $tenista" } 72 | 73 | val saved = 74 | tenista.copy( 75 | uuid = UUID.randomUUID(), 76 | createdAt = LocalDateTime.now(), 77 | updatedAt = LocalDateTime.now() 78 | ) 79 | 80 | return@withContext tenistasRepository.save(tenista) 81 | } 82 | 83 | @CachePut("tenistas") 84 | override suspend fun update(uuid: UUID, tenista: Tenista): Tenista? = 85 | withContext(Dispatchers.IO) { 86 | logger.info { "Repositorio de tenista update con uuid: $uuid tenista: $tenista" } 87 | 88 | val tenistaDB = tenistasRepository.findByUuid(uuid).firstOrNull() 89 | 90 | tenistaDB?.let { 91 | // asigno los campos pero mantengo los que no se pueden cambiar 92 | val updated = it.copy( 93 | uuid = it.uuid, 94 | nombre = tenista.nombre, 95 | ranking = tenista.ranking, 96 | fechaNacimiento = tenista.fechaNacimiento, 97 | añoProfesional = tenista.añoProfesional, 98 | altura = tenista.altura, 99 | peso = tenista.peso, 100 | manoDominante = tenista.manoDominante, 101 | tipoReves = tenista.tipoReves, 102 | puntos = tenista.puntos, 103 | pais = tenista.pais, 104 | raquetaId = tenista.raquetaId, 105 | createdAt = it.createdAt, 106 | updatedAt = LocalDateTime.now() 107 | ) 108 | return@withContext tenistasRepository.save(updated) 109 | } 110 | return@withContext null 111 | } 112 | 113 | @CacheEvict("tenistas") 114 | override suspend fun deleteById(id: Long) = withContext(Dispatchers.IO) { 115 | logger.info { "Repositorio de tenistas deleteById con id: $id" } 116 | 117 | tenistasRepository.deleteById(id) 118 | } 119 | 120 | override suspend fun findAllPage(pageRequest: PageRequest): Flow> { 121 | logger.info { "Repositorio de tenistas findAllPage" } 122 | 123 | return tenistasRepository.findAllBy(pageRequest) 124 | .toList() 125 | .windowed(pageRequest.pageSize, pageRequest.pageSize, true) 126 | .map { PageImpl(it, pageRequest, tenistasRepository.count()) } 127 | .asFlow() 128 | } 129 | 130 | override suspend fun countAll(): Long { 131 | logger.info { "Repositorio de raquetas countAll" } 132 | 133 | return tenistasRepository.count() 134 | } 135 | 136 | @CacheEvict("tenistas") 137 | override suspend fun deleteByUuid(uuid: UUID): Tenista? = withContext(Dispatchers.IO) { 138 | logger.info { "Repositorio de tenistas deleteByUuid con uuid: $uuid" } 139 | 140 | val tenistaDB = tenistasRepository.findByUuid(uuid).firstOrNull() 141 | tenistaDB?.let { 142 | tenistasRepository.deleteById(it.id!!) 143 | return@withContext it 144 | } 145 | return@withContext null 146 | } 147 | 148 | @CacheEvict("tenistas") 149 | override suspend fun delete(tenista: Tenista): Tenista? = withContext(Dispatchers.IO) { 150 | logger.info { "Repositorio de tenistas delete tenista: $tenista" } 151 | 152 | val tenistaBD = tenistasRepository.findByUuid(tenista.uuid).firstOrNull() 153 | tenistaBD?.let { 154 | tenistasRepository.deleteById(it.id!!) 155 | return@withContext it 156 | } 157 | return@withContext null 158 | } 159 | 160 | @CacheEvict("tenistas", allEntries = true) 161 | suspend fun deleteAll() = withContext(Dispatchers.IO) { 162 | logger.info { "Repositorio de tenistas deleteAll" } 163 | 164 | tenistasRepository.deleteAll() 165 | } 166 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/raquetas/RaquetasServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.raquetas 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.github.michaelbull.result.* 5 | import es.joseluisgs.tenistasrestspringboot.config.websocket.ServerWebSocketConfig 6 | import es.joseluisgs.tenistasrestspringboot.config.websocket.WebSocketHandler 7 | import es.joseluisgs.tenistasrestspringboot.errors.RaquetaError 8 | import es.joseluisgs.tenistasrestspringboot.mappers.toDto 9 | import es.joseluisgs.tenistasrestspringboot.models.Notificacion 10 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 11 | import es.joseluisgs.tenistasrestspringboot.models.RaquetaNotification 12 | import es.joseluisgs.tenistasrestspringboot.models.Representante 13 | import es.joseluisgs.tenistasrestspringboot.repositories.raquetas.RaquetasCachedRepository 14 | import es.joseluisgs.tenistasrestspringboot.repositories.representantes.RepresentantesCachedRepository 15 | import kotlinx.coroutines.CoroutineScope 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.flow.Flow 18 | import kotlinx.coroutines.launch 19 | import mu.KotlinLogging 20 | import org.springframework.beans.factory.annotation.Autowired 21 | import org.springframework.data.domain.Page 22 | import org.springframework.data.domain.PageRequest 23 | import org.springframework.stereotype.Service 24 | import java.util.* 25 | 26 | private val logger = KotlinLogging.logger {} 27 | 28 | @Service 29 | class RaquetasServiceImpl 30 | @Autowired constructor( 31 | private val raquetasRepository: RaquetasCachedRepository, // Repositorio de datos 32 | private val representesRepository: RepresentantesCachedRepository, // Repositorio de datos 33 | private val webSocketConfig: ServerWebSocketConfig // Para enviar mensajes a los clientes ws normales 34 | ) : RaquetasService { 35 | // Inyectamos el servicio de websockets, pero lo hacemos de esta forma para que no se inyecte en el constructor 36 | //y casteamos a nuestro handler para poder usarlo (que tiene el método send) 37 | private val webSocketService = webSocketConfig.webSocketRaquetasHandler() as WebSocketHandler 38 | 39 | init { 40 | logger.info { "Iniciando Servicio de Raquetas" } 41 | } 42 | 43 | override suspend fun findAll(): Flow { 44 | logger.info { "Servicio de raquetas findAll" } 45 | 46 | return raquetasRepository.findAll() 47 | } 48 | 49 | override suspend fun findById(id: Long): Result { 50 | logger.debug { "Servicio de raquetas findById con id: $id" } 51 | 52 | return raquetasRepository.findById(id) 53 | ?.let { Ok(it) } 54 | ?: Err(RaquetaError.NotFound("No se ha encontrado la raqueta con id: $id")) 55 | } 56 | 57 | override suspend fun findByUuid(uuid: UUID): Result { 58 | logger.debug { "Servicio de raquetas findByUuid con uuid: $uuid" } 59 | 60 | return raquetasRepository.findByUuid(uuid) 61 | ?.let { Ok(it) } 62 | ?: Err(RaquetaError.NotFound("No se ha encontrado la raqueta con uuid: $uuid")) 63 | } 64 | 65 | override suspend fun findByMarca(marca: String): Flow { 66 | logger.debug { "Servicio de raquetas findByMarca con marca: $marca" } 67 | 68 | return raquetasRepository.findByMarca(marca) 69 | } 70 | 71 | override suspend fun save(raqueta: Raqueta): Result { 72 | logger.debug { "Servicio de raquetas save raqueta: $raqueta" } 73 | 74 | return findRepresentante(raqueta.representanteId).andThen { 75 | raquetasRepository.save(raqueta) 76 | .also { onChange(Notificacion.Tipo.CREATE, it.uuid, it) } 77 | .let { Ok(it) } 78 | } 79 | } 80 | 81 | override suspend fun update(uuid: UUID, raqueta: Raqueta): Result { 82 | logger.debug { "Servicio de raquetas update raqueta con id: $uuid " } 83 | 84 | return findRepresentante(raqueta.representanteId).andThen { 85 | findByUuid(uuid).onSuccess { 86 | raquetasRepository.update(uuid, raqueta) 87 | .also { onChange(Notificacion.Tipo.UPDATE, it!!.uuid, it) } 88 | .let { Ok(it) } 89 | } 90 | } 91 | } 92 | 93 | override suspend fun delete(raqueta: Raqueta): Result { 94 | logger.debug { "Servicio de raquetas delete raqueta: $raqueta" } 95 | 96 | return findByUuid(raqueta.uuid).onSuccess { 97 | raquetasRepository.delete(raqueta) 98 | ?.also { onChange(Notificacion.Tipo.DELETE, it.uuid, it) } 99 | ?.let { Ok(it) } 100 | } 101 | 102 | } 103 | 104 | override suspend fun deleteByUuid(uuid: UUID): Result { 105 | logger.debug { "Servicio de raquetas deleteByUuid con uuid: $uuid" } 106 | 107 | return findByUuid(uuid).onSuccess { 108 | raquetasRepository.deleteByUuid(uuid) 109 | ?.also { onChange(Notificacion.Tipo.DELETE, it.uuid, it) } 110 | ?.let { Ok(it) } 111 | } 112 | } 113 | 114 | override suspend fun deleteById(id: Long): Result { 115 | logger.debug { "Servicio de raquetas deleteById con id: $id" } 116 | 117 | return findById(id).onSuccess { r -> 118 | raquetasRepository.deleteById(id) 119 | .also { onChange(Notificacion.Tipo.DELETE, r.uuid, r) } 120 | .let { Ok(it) } 121 | } 122 | } 123 | 124 | override suspend fun findAllPage(pageRequest: PageRequest): Flow> { 125 | logger.debug { "Servicio de raquetas findAllPage con pageRequest: $pageRequest" } 126 | 127 | return raquetasRepository.findAllPage(pageRequest) 128 | } 129 | 130 | override suspend fun countAll(): Long { 131 | logger.debug { "Servicio de raquetas countAll" } 132 | 133 | return raquetasRepository.countAll() 134 | } 135 | 136 | override suspend fun findRepresentante(id: UUID): Result { 137 | logger.debug { "findRepresentante: Buscando representante en servicio" } 138 | 139 | return representesRepository.findByUuid(id) 140 | ?.let { Ok(it) } 141 | ?: Err(RaquetaError.RepresentanteNotFound("No se ha encontrado el representante con id: $id")) 142 | } 143 | 144 | // Enviamos la notificación a los clientes ws 145 | suspend fun onChange(tipo: Notificacion.Tipo, id: UUID, data: Raqueta? = null) { 146 | logger.debug { "Servicio de raquetas onChange con tipo: $tipo, id: $id, data: $data" } 147 | 148 | // data to json 149 | val mapper = jacksonObjectMapper() 150 | val json = mapper.writeValueAsString( 151 | RaquetaNotification( 152 | "RAQUETA", 153 | tipo, 154 | id, 155 | data?.toDto(findRepresentante(data.representanteId).get()!!) 156 | ) 157 | ) 158 | // Enviamos la notificación a los clientes ws 159 | 160 | logger.info { "Enviando mensaje a los clientes ws" } 161 | 162 | val myScope = CoroutineScope(Dispatchers.IO) 163 | myScope.launch { 164 | webSocketService.sendMessage(json) 165 | } 166 | } 167 | 168 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/tenistas/TenistasServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.tenistas 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.github.michaelbull.result.* 5 | import es.joseluisgs.tenistasrestspringboot.config.websocket.ServerWebSocketConfig 6 | import es.joseluisgs.tenistasrestspringboot.config.websocket.WebSocketHandler 7 | import es.joseluisgs.tenistasrestspringboot.errors.TenistaError 8 | import es.joseluisgs.tenistasrestspringboot.mappers.toDto 9 | import es.joseluisgs.tenistasrestspringboot.models.Notificacion 10 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 11 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 12 | import es.joseluisgs.tenistasrestspringboot.models.TenistaNotification 13 | import es.joseluisgs.tenistasrestspringboot.repositories.raquetas.RaquetasCachedRepository 14 | import es.joseluisgs.tenistasrestspringboot.repositories.tenistas.TenistasCachedRepository 15 | import kotlinx.coroutines.CoroutineScope 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.flow.Flow 18 | import kotlinx.coroutines.flow.firstOrNull 19 | import kotlinx.coroutines.launch 20 | import mu.KotlinLogging 21 | import org.springframework.beans.factory.annotation.Autowired 22 | import org.springframework.data.domain.Page 23 | import org.springframework.data.domain.PageRequest 24 | import org.springframework.stereotype.Service 25 | import java.util.* 26 | 27 | private val logger = KotlinLogging.logger {} 28 | 29 | @Service 30 | class TenistasServiceImpl 31 | @Autowired constructor( 32 | private val tenistasRepository: TenistasCachedRepository, // Repositorio de datos 33 | private val raquetasRepository: RaquetasCachedRepository, // Repositorio de datos 34 | private val webSocketConfig: ServerWebSocketConfig // Configuración del WebSocket 35 | ) : TenistasService { 36 | 37 | private val webSocketService = webSocketConfig.webSocketTenistasHandler() as WebSocketHandler 38 | 39 | init { 40 | logger.info { "Iniciando Servicio de Tenistas" } 41 | } 42 | 43 | override suspend fun findAll(): Flow { 44 | logger.info { "Servicio de tenistas findAll" } 45 | 46 | return tenistasRepository.findAll() 47 | } 48 | 49 | override suspend fun findById(id: Long): Result { 50 | logger.debug { "Servicio de tenista findById con id: $id" } 51 | 52 | return tenistasRepository.findById(id) 53 | ?.let { Ok(it) } 54 | ?: Err(TenistaError.NotFound("No se ha encontrado el tenista con id: $id")) 55 | } 56 | 57 | override suspend fun findByUuid(uuid: UUID): Result { 58 | logger.debug { "Servicio de tenistas findByUuid con uuid: $uuid" } 59 | 60 | return tenistasRepository.findByUuid(uuid) 61 | ?.let { Ok(it) } 62 | ?: Err(TenistaError.NotFound("No se ha encontrado el tenista con uuid: $uuid")) 63 | } 64 | 65 | override suspend fun findByNombre(nombre: String): Flow { 66 | logger.debug { "Servicio de tenistas findByNombre con nombre: $nombre" } 67 | 68 | return tenistasRepository.findByNombre(nombre) 69 | } 70 | 71 | override suspend fun findByRanking(ranking: Int): Result { 72 | logger.debug { "Servicio de tenistas findByRanking con ranking: $ranking" } 73 | 74 | return tenistasRepository.findByRanking(ranking).firstOrNull() 75 | ?.let { Ok(it) } 76 | ?: Err(TenistaError.NotFound("No se ha encontrado el tenista con ranking: $ranking")) 77 | } 78 | 79 | override suspend fun save(tenista: Tenista): Result { 80 | logger.debug { "Servicio de tenistas save tenista: $tenista" } 81 | 82 | return findRaqueta(tenista.raquetaId).andThen { 83 | tenistasRepository.save(tenista) 84 | .also { onChange(Notificacion.Tipo.CREATE, it.uuid, it) } 85 | .let { Ok(it) } 86 | } 87 | } 88 | 89 | override suspend fun update(uuid: UUID, tenista: Tenista): Result { 90 | logger.debug { "Servicio de tenistas update tenista con id: $uuid " } 91 | 92 | return findRaqueta(tenista.raquetaId).andThen { 93 | findByUuid(uuid).onSuccess { 94 | tenistasRepository.update(uuid, tenista) 95 | .also { onChange(Notificacion.Tipo.UPDATE, it!!.uuid, it) } 96 | .let { Ok(it) } 97 | } 98 | } 99 | } 100 | 101 | override suspend fun delete(tenista: Tenista): Result { 102 | logger.debug { "Servicio de tenistas delete raqueta: $tenista" } 103 | 104 | return findByUuid(tenista.uuid).onSuccess { 105 | tenistasRepository.delete(tenista) 106 | ?.also { onChange(Notificacion.Tipo.DELETE, it.uuid, it) } 107 | ?.let { Ok(it) } 108 | } 109 | } 110 | 111 | override suspend fun deleteByUuid(uuid: UUID): Result { 112 | logger.debug { "Servicio de tenistas deleteByUuid con uuid: $uuid" } 113 | 114 | return findByUuid(uuid).onSuccess { 115 | tenistasRepository.deleteByUuid(uuid) 116 | ?.also { onChange(Notificacion.Tipo.DELETE, it.uuid, it) } 117 | ?.let { Ok(it) } 118 | } 119 | 120 | } 121 | 122 | override suspend fun deleteById(id: Long): Result { 123 | logger.debug { "Servicio de tenistas deleteById con id: $id" } 124 | 125 | return findById(id).onSuccess { r -> 126 | tenistasRepository.deleteById(id) 127 | .also { onChange(Notificacion.Tipo.DELETE, r.uuid, r) } 128 | .let { Ok(it) } 129 | } 130 | } 131 | 132 | override suspend fun findAllPage(pageRequest: PageRequest): Flow> { 133 | logger.debug { "Servicio de raquetas findAllPage con pageRequest: $pageRequest" } 134 | 135 | return tenistasRepository.findAllPage(pageRequest) 136 | } 137 | 138 | override suspend fun countAll(): Long { 139 | logger.debug { "Servicio de raquetas countAll" } 140 | 141 | return tenistasRepository.countAll() 142 | } 143 | 144 | override suspend fun findRaqueta(id: UUID?): Result { 145 | logger.debug { "findRepresentante: Buscando raqueta en servicio" } 146 | 147 | return id?.let { 148 | raquetasRepository.findByUuid(id) 149 | ?.let { Ok(it) } 150 | ?: Err(TenistaError.RaquetaNotFound("No se ha encontrado la raqueta con id: $id")) 151 | } ?: Ok(null) 152 | } 153 | 154 | // Enviamos la notificación a los clientes ws 155 | suspend fun onChange(tipo: Notificacion.Tipo, id: UUID, data: Tenista? = null) { 156 | logger.debug { "Servicio de raquetas onChange con tipo: $tipo, id: $id, data: $data" } 157 | 158 | // data to json 159 | val mapper = jacksonObjectMapper() 160 | val json = mapper.writeValueAsString( 161 | TenistaNotification( 162 | "TENISTA", 163 | tipo, 164 | id, 165 | data?.toDto(findRaqueta(data.raquetaId).get()) 166 | ) 167 | ) 168 | // Enviamos la notificación a los clientes ws 169 | 170 | logger.info { "Enviando mensaje a los clientes ws" } 171 | 172 | val myScope = CoroutineScope(Dispatchers.IO) 173 | myScope.launch { 174 | webSocketService.sendMessage(json) 175 | } 176 | } 177 | 178 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/services/storage/StorageServiceFileSystemImpl.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.services.storage 2 | 3 | import es.joseluisgs.tenistasrestspringboot.controllers.StorageController 4 | import es.joseluisgs.tenistasrestspringboot.exceptions.StorageBadRequestException 5 | import es.joseluisgs.tenistasrestspringboot.exceptions.StorageFileNotFoundException 6 | import mu.KotlinLogging 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.core.io.Resource 9 | import org.springframework.core.io.UrlResource 10 | import org.springframework.stereotype.Service 11 | import org.springframework.util.FileSystemUtils 12 | import org.springframework.util.StringUtils 13 | import org.springframework.web.multipart.MultipartFile 14 | import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder 15 | import java.io.IOException 16 | import java.net.MalformedURLException 17 | import java.nio.file.Files 18 | import java.nio.file.Path 19 | import java.nio.file.Paths 20 | import java.nio.file.StandardCopyOption 21 | import java.util.* 22 | import java.util.stream.Stream 23 | 24 | private val logger = KotlinLogging.logger {} 25 | 26 | @Service 27 | class StorageServiceFileSystemImpl( 28 | @Value("\${upload.root-location}") path: String, 29 | @Value("\${spring.profiles.active}") mode: String 30 | ) : StorageService { 31 | // Directorio raiz de nuestro almacén de ficheros 32 | private val rootLocation: Path 33 | 34 | // Inicializador 35 | init { 36 | logger.info { "Inicializando Servicio de Almacenamiento de Ficheros" } 37 | rootLocation = Paths.get(path) 38 | // Inicializamos el servicio de ficheros 39 | // Tomamos el perfil de la aplicación 40 | // Si es dev, borramos todos los ficheros 41 | // Si es prod, no borramos nada 42 | if (mode == "dev") { 43 | this.deleteAll() 44 | } 45 | this.init() // inicializamos 46 | } 47 | 48 | override fun store(file: MultipartFile): String { 49 | logger.info { "Almacenando fichero: ${file.originalFilename}" } 50 | 51 | val filename = StringUtils.cleanPath(file.originalFilename.toString()) 52 | val extension = StringUtils.getFilenameExtension(filename).toString() 53 | val justFilename = filename.replace(".$extension", "") 54 | // Si queremos almacenar el fichero con el nombre original 55 | // val storedFilename = System.currentTimeMillis().toString() + "_" + justFilename + "." + extension 56 | // Si queremos almacenar el fichero con un nombre aleatorio 57 | val storedFilename = UUID.randomUUID().toString() + "." + extension 58 | try { 59 | if (file.isEmpty) { 60 | throw StorageBadRequestException("Fallo al almacenar un fichero vacío $filename") 61 | } 62 | if (filename.contains("..")) { 63 | // This is a security check 64 | throw StorageBadRequestException("No se puede almacenar un fichero fuera del path permitido $filename") 65 | } 66 | file.inputStream.use { inputStream -> 67 | Files.copy( 68 | inputStream, rootLocation.resolve(storedFilename), 69 | StandardCopyOption.REPLACE_EXISTING 70 | ) 71 | return storedFilename 72 | } 73 | } catch (e: IOException) { 74 | throw StorageBadRequestException("Fallo al almacenar fichero $filename", e) 75 | } 76 | } 77 | 78 | override fun store(file: MultipartFile, filenameFromUser: String): String { 79 | logger.info { "Almacenando fichero: ${file.originalFilename}" } 80 | 81 | val filename = StringUtils.cleanPath(file.originalFilename.toString()) 82 | val extension = StringUtils.getFilenameExtension(filename).toString() 83 | val justFilename = filename.replace(".$extension", "") 84 | val storedFilename = "$filenameFromUser.$extension" 85 | try { 86 | if (file.isEmpty) { 87 | throw StorageBadRequestException("Fallo al almacenar un fichero vacío $filename") 88 | } 89 | if (filename.contains("..")) { 90 | // This is a security check 91 | throw StorageBadRequestException("No se puede almacenar un fichero fuera del path permitido $filename") 92 | } 93 | file.inputStream.use { inputStream -> 94 | Files.copy( 95 | inputStream, rootLocation.resolve(storedFilename), 96 | StandardCopyOption.REPLACE_EXISTING 97 | ) 98 | return storedFilename 99 | } 100 | } catch (e: IOException) { 101 | throw StorageBadRequestException("Fallo al almacenar fichero $filename", e) 102 | } 103 | } 104 | 105 | override fun loadAll(): Stream { 106 | logger.info { "Cargando todos los ficheros" } 107 | 108 | return try { 109 | Files.walk(rootLocation, 1) 110 | .filter { path -> !path.equals(rootLocation) } 111 | .map(rootLocation::relativize) 112 | } catch (e: IOException) { 113 | throw StorageBadRequestException("Fallo al leer los ficheros almacenados", e) 114 | } 115 | } 116 | 117 | override fun load(filename: String): Path { 118 | logger.info { "Cargando fichero: $filename" } 119 | 120 | return rootLocation.resolve(filename) 121 | } 122 | 123 | override fun loadAsResource(filename: String): Resource { 124 | logger.info { "Cargando fichero como recurso: $filename" } 125 | 126 | return try { 127 | val file = load(filename) 128 | val resource = UrlResource(file.toUri()) 129 | if (resource.exists() || resource.isReadable) { 130 | resource 131 | } else { 132 | throw StorageFileNotFoundException( 133 | "No se puede leer fichero: $filename" 134 | ) 135 | } 136 | } catch (e: MalformedURLException) { 137 | throw StorageFileNotFoundException("No se puede leer fichero: $filename", e) 138 | } 139 | } 140 | 141 | override fun deleteAll() { 142 | logger.info { "Borrando todos los ficheros" } 143 | 144 | FileSystemUtils.deleteRecursively(rootLocation.toFile()) 145 | } 146 | 147 | 148 | override fun init() { 149 | logger.info { "Inicializando directorio de almacenamiento de ficheros" } 150 | 151 | try { 152 | // Si no existe el directorio lo creamos 153 | if (!Files.exists(rootLocation)) 154 | Files.createDirectory(rootLocation) 155 | } catch (e: IOException) { 156 | throw StorageBadRequestException("No se puede inicializar el sistema de almacenamiento", e) 157 | } 158 | } 159 | 160 | override fun delete(filename: String) { 161 | logger.info { "Borrando fichero: $filename" } 162 | 163 | val justFilename: String = StringUtils.getFilename(filename).toString() 164 | try { 165 | val file = load(justFilename) 166 | // Si el fichero existe lo borramos, pero no ofrecemos error si no existe 167 | Files.deleteIfExists(file) 168 | // Si queremos mostrar un error si el fichero no existe 169 | /* if (!Files.exists(file)) 170 | throw StorageFileNotFoundException("Fichero $filename no existe") 171 | else 172 | Files.delete(file)*/ 173 | } catch (e: IOException) { 174 | throw StorageBadRequestException("Error al eliminar un fichero", e) 175 | } 176 | } 177 | 178 | override fun getUrl(filename: String): String { 179 | logger.info { "Obteniendo URL de fichero: $filename" } 180 | 181 | return MvcUriComponentsBuilder // El segundo argumento es necesario solo cuando queremos obtener la imagen 182 | // En este caso tan solo necesitamos obtener la URL 183 | .fromMethodName(StorageController::class.java, "serveFile", filename, null) 184 | .build().toUriString() 185 | } 186 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/joseluisgs/tenistasrestspringboot/controllers/UsuarioController.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.controllers 2 | 3 | import es.joseluisgs.tenistasrestspringboot.config.APIConfig 4 | import es.joseluisgs.tenistasrestspringboot.config.security.jwt.JwtTokenUtils 5 | import es.joseluisgs.tenistasrestspringboot.dto.* 6 | import es.joseluisgs.tenistasrestspringboot.mappers.toDto 7 | import es.joseluisgs.tenistasrestspringboot.mappers.toModel 8 | import es.joseluisgs.tenistasrestspringboot.models.Usuario 9 | import es.joseluisgs.tenistasrestspringboot.services.storage.StorageService 10 | import es.joseluisgs.tenistasrestspringboot.services.usuarios.UsuariosService 11 | import es.joseluisgs.tenistasrestspringboot.validators.validate 12 | import jakarta.validation.Valid 13 | import kotlinx.coroutines.flow.toList 14 | import mu.KotlinLogging 15 | import org.springframework.beans.factory.annotation.Autowired 16 | import org.springframework.http.HttpStatus 17 | import org.springframework.http.MediaType 18 | import org.springframework.http.ResponseEntity 19 | import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException 20 | import org.springframework.security.access.prepost.PreAuthorize 21 | import org.springframework.security.authentication.AuthenticationManager 22 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 23 | import org.springframework.security.core.Authentication 24 | import org.springframework.security.core.annotation.AuthenticationPrincipal 25 | import org.springframework.security.core.context.SecurityContextHolder 26 | import org.springframework.validation.FieldError 27 | import org.springframework.web.bind.annotation.* 28 | import org.springframework.web.multipart.MultipartFile 29 | 30 | 31 | private val logger = KotlinLogging.logger {} 32 | 33 | @RestController 34 | 35 | // Cuidado que se necesia la barra al final porque la estamos poniendo en los verbos 36 | @RequestMapping(APIConfig.API_PATH + "/users") // Sigue escucnado en el directorio API 37 | class UsuarioController @Autowired constructor( 38 | private val usuariosService: UsuariosService, 39 | private val authenticationManager: AuthenticationManager, 40 | private val jwtTokenUtil: JwtTokenUtils, 41 | private val storageService: StorageService 42 | ) { 43 | 44 | @PostMapping("/login") 45 | fun login(@Valid @RequestBody logingDto: UsuarioLoginDto): ResponseEntity { 46 | logger.info { "Login de usuario: ${logingDto.username}" } 47 | 48 | // podríamos hacerlo preguntándole al servicio si existe el usuario 49 | // pero mejor lo hacemos con el AuthenticationManager que es el que se encarga de ello 50 | // y nos devuelve el usuario autenticado o null 51 | 52 | val authentication: Authentication = authenticationManager.authenticate( 53 | UsernamePasswordAuthenticationToken( 54 | logingDto.username, 55 | logingDto.password 56 | ) 57 | ) 58 | // Autenticamos al usuario, si lo es nos lo devuelve 59 | SecurityContextHolder.getContext().authentication = authentication 60 | 61 | // Devolvemos al usuario autenticado 62 | val user = authentication.principal as Usuario 63 | 64 | // Generamos el token 65 | val jwtToken: String = jwtTokenUtil.generateToken(user) 66 | logger.info { "Token de usuario: ${jwtToken}" } 67 | 68 | // Devolvemos el usuario con el token 69 | val userWithToken = UserWithTokenDto(user.toDto(), jwtToken) 70 | 71 | // La respuesta que queremos 72 | return ResponseEntity.ok(userWithToken) 73 | } 74 | 75 | @PostMapping("/register") 76 | suspend fun register(@Valid @RequestBody usuarioDto: UsuarioCreateDto): ResponseEntity { 77 | logger.info { "Registro de usuario: ${usuarioDto.username}" } 78 | 79 | // Creamos el usuario 80 | val user = usuarioDto.validate().toModel() 81 | // Lo guardamos 82 | user.rol.forEach { println(it) } 83 | val userSaved = usuariosService.save(user) 84 | // Generamos el token 85 | val jwtToken: String = jwtTokenUtil.generateToken(userSaved) 86 | logger.info { "Token de usuario: ${jwtToken}" } 87 | return ResponseEntity.ok(UserWithTokenDto(userSaved.toDto(), jwtToken)) 88 | } 89 | 90 | // Solo los usuarios pueden acceder a esta información 91 | // en el fondo no es necesario porque ya lo hace el AuthenticationManager y es el rol minimo 92 | @PreAuthorize("hasRole('USER')") // hasAnyRole('USER', 'ADMIN') 93 | @GetMapping("/me") 94 | fun meInfo(@AuthenticationPrincipal user: Usuario): ResponseEntity { 95 | // No hay que buscar porque el usuario ya está autenticado y lo tenemos en el contexto 96 | logger.info { "Obteniendo usuario: ${user.username}" } 97 | 98 | return ResponseEntity.ok(user.toDto()) 99 | } 100 | 101 | @PreAuthorize("hasRole('ADMIN')") // Solo los administradores pueden acceder a esta información 102 | @GetMapping("/list") 103 | suspend fun list(@AuthenticationPrincipal user: Usuario): ResponseEntity> { 104 | // Estamos aqui es que somos administradores!!! por el contexto!! 105 | logger.info { "Obteniendo lista de usuarios" } 106 | 107 | // No hay que buscar porque el usuario ya está autenticado y lo tenemos en el contexto 108 | // Si lo hacemos pues estamos yendo de mas a la base de datos pero es para mostrar como se hace 109 | /* 110 | if (!user?.rol?.contains(Usuario.Rol.ADMIN)!!) 111 | throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "No tienes permisos para acceder a esta información") 112 | */ 113 | 114 | val res = usuariosService.findAll().toList().map { it.toDto() } 115 | return ResponseEntity.ok(res) 116 | } 117 | 118 | // No voy a poner el @PreAuthorize porque ya se da por hecho que es usuario 119 | // que es el rol minimo, si no poner para los roles que son 120 | @PutMapping("/me") 121 | suspend fun updateMe( 122 | @AuthenticationPrincipal user: Usuario, 123 | @Valid @RequestBody usuarioDto: UsuarioUpdateDto 124 | ): ResponseEntity { 125 | // No hay que buscar porque el usuario ya está autenticado y lo tenemos en el contexto 126 | logger.info { "Actualizando usuario: ${user.username}" } 127 | 128 | usuarioDto.validate() 129 | 130 | var userUpdated = user.copy( 131 | nombre = usuarioDto.nombre, 132 | username = usuarioDto.username, 133 | email = usuarioDto.email, 134 | ) 135 | 136 | // Actualizamos el usuario 137 | userUpdated = usuariosService.update(userUpdated) 138 | return ResponseEntity.ok(userUpdated.toDto()) 139 | } 140 | 141 | @PatchMapping( 142 | value = ["/me"], 143 | consumes = [MediaType.MULTIPART_FORM_DATA_VALUE] 144 | ) 145 | suspend fun updateAvatar( 146 | @AuthenticationPrincipal user: Usuario, 147 | @RequestPart("file") file: MultipartFile 148 | ): ResponseEntity { 149 | // No hay que buscar porque el usuario ya está autenticado y lo tenemos en el contexto 150 | logger.info { "Actualizando avatar de usuario: ${user.username}" } 151 | 152 | var urlImagen = user.avatar 153 | 154 | // subimos el fichero 155 | if (!file.isEmpty) { 156 | // Podemos pasarle el nombre del fichero con un id del usuario 157 | val imagen: String = storageService.store(file, user.uuid.toString()) 158 | urlImagen = storageService.getUrl(imagen) 159 | } 160 | 161 | val userAvatar = user.copy( 162 | avatar = urlImagen 163 | ) 164 | 165 | // Actualizamos el usuario 166 | val userUpdated = usuariosService.update(userAvatar) 167 | return ResponseEntity.ok(userUpdated.toDto()) 168 | } 169 | 170 | // Para capturar los errores de validación 171 | @ResponseStatus(HttpStatus.BAD_REQUEST) 172 | @ExceptionHandler(MethodArgumentNotValidException::class) 173 | fun handleValidationExceptions( 174 | ex: MethodArgumentNotValidException 175 | ): Map? { 176 | val errors: MutableMap = HashMap() 177 | ex.bindingResult?.allErrors?.forEach { error -> 178 | val fieldName = (error as FieldError).field 179 | val errorMessage: String? = error.getDefaultMessage() 180 | errors[fieldName] = errorMessage ?: "" 181 | } 182 | return errors 183 | } 184 | 185 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/raquetas/RaquetasCachedRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.raquetas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Raqueta 4 | import io.mockk.MockKAnnotations 5 | import io.mockk.coEvery 6 | import io.mockk.coVerify 7 | import io.mockk.impl.annotations.InjectMockKs 8 | import io.mockk.impl.annotations.MockK 9 | import io.mockk.junit5.MockKExtension 10 | import kotlinx.coroutines.flow.flowOf 11 | import kotlinx.coroutines.flow.toList 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.jupiter.api.Assertions.* 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import org.springframework.boot.test.context.SpringBootTest 17 | import org.springframework.data.domain.PageRequest 18 | import java.util.* 19 | 20 | // Voy a usar MocKK en lugar de Mockito 21 | // https://spring.io/guides/tutorials/spring-boot-kotlin/ 22 | // @ExtendWith(SpringExtension::class) 23 | @ExtendWith(MockKExtension::class) 24 | @SpringBootTest 25 | class RaquetasCachedRepositoryImplTest { 26 | 27 | private val raqueta = Raqueta( 28 | id = 99L, 29 | // usa un UUID para que no de problemas 30 | uuid = UUID.fromString("431ad089-d422-4aa4-b9a5-35a620eae8d4"), 31 | marca = "Test", 32 | precio = 199.9, 33 | representanteId = UUID.fromString("b39a2fd2-f7d7-405d-b73c-b68a8dedbcdf"), 34 | ) 35 | 36 | @MockK 37 | lateinit var repo: RaquetasRepository 38 | 39 | @InjectMockKs 40 | lateinit var repository: RaquetasCachedRepositoryImpl 41 | 42 | 43 | init { 44 | MockKAnnotations.init(this) 45 | } 46 | 47 | @Test 48 | fun findAllPageable() = runTest { 49 | // Usamos coEvery para poder usar corutinas 50 | coEvery { repo.findAll() } returns flowOf(raqueta) 51 | 52 | // Llamamos al método 53 | val result = repository.findAll().toList() 54 | 55 | assertAll( 56 | { assertEquals(1, result.size) }, 57 | { assertEquals(raqueta, result[0]) } 58 | ) 59 | 60 | coVerify(exactly = 1) { repo.findAll() } 61 | } 62 | 63 | @Test 64 | fun findById() = runTest { 65 | // Usamos coEvery para poder usar corutinas 66 | coEvery { repo.findById(any()) } returns raqueta 67 | 68 | // Llamamos al método 69 | val result = repository.findById(1L) 70 | 71 | assertAll( 72 | { assertEquals(raqueta.marca, result!!.marca) }, 73 | { assertEquals(raqueta.precio, result!!.precio) }, 74 | ) 75 | 76 | 77 | coVerify { repo.findById(any()) } 78 | } 79 | 80 | @Test 81 | fun findByIdNotFound() = runTest { 82 | // Usamos coEvery para poder usar corutinas 83 | coEvery { repo.findById(any()) } returns null 84 | 85 | // Llamamos al método 86 | val result = repository.findById(1L) 87 | 88 | assertNull(result) 89 | 90 | 91 | coVerify { repo.findById(any()) } 92 | } 93 | 94 | 95 | @Test 96 | fun findByUuid() = runTest { 97 | // Usamos coEvery para poder usar corutinas 98 | coEvery { repo.findByUuid(any()) } returns flowOf(raqueta) 99 | 100 | // Llamamos al método 101 | val result = repository.findByUuid(raqueta.uuid)!! 102 | 103 | assertAll( 104 | { assertEquals(raqueta.marca, result.marca) }, 105 | { assertEquals(raqueta.precio, result.precio) }, 106 | ) 107 | } 108 | 109 | @Test 110 | fun findByUuidNotFound() = runTest { 111 | // Usamos coEvery para poder usar corutinas 112 | coEvery { repo.findByUuid(any()) } returns flowOf() 113 | 114 | // Llamamos al método 115 | val result = repository.findByUuid(raqueta.uuid) 116 | 117 | assertNull(result) 118 | } 119 | 120 | @Test 121 | fun findByMarca() = runTest { 122 | // Usamos coEvery para poder usar corutinas 123 | coEvery { repo.findByMarcaContainsIgnoreCase(any()) } returns flowOf(raqueta) 124 | 125 | // Llamamos al método 126 | val result = repository.findByMarca("Test").toList() 127 | 128 | assertAll( 129 | { assertEquals(1, result.size) }, 130 | { assertEquals(raqueta, result[0]) } 131 | ) 132 | 133 | coVerify { repo.findByMarcaContainsIgnoreCase(any()) } 134 | } 135 | 136 | @Test 137 | fun findByMarcaNotFound() = runTest { 138 | // Usamos coEvery para poder usar corutinas 139 | coEvery { repo.findByMarcaContainsIgnoreCase(any()) } returns flowOf() 140 | 141 | // Llamamos al método 142 | val result = repository.findByMarca("Test").toList() 143 | 144 | assertAll( 145 | { assertEquals(0, result.size) }, 146 | ) 147 | } 148 | 149 | @Test 150 | fun save() = runTest { 151 | // Usamos coEvery para poder usar corutinas 152 | coEvery { repo.save(any()) } returns raqueta 153 | 154 | // Llamamos al método 155 | val result = repository.save(raqueta) 156 | 157 | assertAll( 158 | { assertEquals(raqueta.marca, result.marca) }, 159 | { assertEquals(raqueta.precio, result.precio) }, 160 | ) 161 | 162 | coVerify { repo.save(any()) } 163 | } 164 | 165 | 166 | @Test 167 | fun update() = runTest { 168 | // Usamos coEvery para poder usar corutinas 169 | coEvery { repo.findByUuid(any()) } returns flowOf(raqueta) 170 | coEvery { repo.save(any()) } returns raqueta 171 | 172 | // Llamamos al método 173 | val result = repository.update(raqueta.uuid, raqueta)!! 174 | 175 | assertAll( 176 | { assertEquals(raqueta.marca, result.marca) }, 177 | { assertEquals(raqueta.precio, result.precio) }, 178 | ) 179 | 180 | coVerify { repo.save(any()) } 181 | } 182 | 183 | @Test 184 | fun updateNotFound() = runTest { 185 | // Usamos coEvery para poder usar corutinas 186 | coEvery { repo.findByUuid(any()) } returns flowOf() 187 | 188 | // Llamamos al método 189 | val result = repository.update(raqueta.uuid, raqueta) 190 | 191 | assertNull(result) 192 | 193 | } 194 | 195 | @Test 196 | fun deleteById() = runTest { 197 | // Usamos coEvery para poder usar corutinas 198 | coEvery { repo.deleteById(any()) } returns Unit 199 | 200 | // Llamamos al método 201 | repository.deleteById(1L) 202 | 203 | coVerify { repo.deleteById(any()) } 204 | } 205 | 206 | @Test 207 | fun findAllPage() = runTest { 208 | // Usamos coEvery para poder usar corutinas 209 | coEvery { repo.findAllBy(any()) } returns flowOf(raqueta) 210 | coEvery { repo.count() } returns 1L 211 | 212 | // Llamamos al método 213 | val result = repository.findAllPage(PageRequest.of(0, 10)).toList()[0].content 214 | 215 | assertAll( 216 | { assertEquals(1, result.size) }, 217 | { assertEquals(raqueta, result[0]) } 218 | ) 219 | 220 | coVerify { repo.findAllBy(any()) } 221 | } 222 | 223 | @Test 224 | fun countAll() = runTest { 225 | // Usamos coEvery para poder usar corutinas 226 | coEvery { repo.count() } returns 1L 227 | 228 | // Llamamos al método 229 | val result = repository.countAll() 230 | 231 | assertEquals(1L, result) 232 | 233 | coVerify { repo.count() } 234 | 235 | } 236 | 237 | @Test 238 | fun deleteByUuid() = runTest { 239 | // Usamos coEvery para poder usar corutinas 240 | coEvery { repo.findByUuid(any()) } returns flowOf(raqueta) 241 | coEvery { repo.deleteById(any()) } returns Unit 242 | 243 | // Llamamos al método 244 | repository.deleteByUuid(raqueta.uuid) 245 | 246 | coVerify { repo.findByUuid(any()) } 247 | } 248 | 249 | @Test 250 | fun deleteByUuidNotFound() = runTest { 251 | // Usamos coEvery para poder usar corutinas 252 | coEvery { repo.findByUuid(any()) } returns flowOf() 253 | 254 | // Llamamos al método 255 | repository.deleteByUuid(raqueta.uuid) 256 | 257 | coVerify { repo.findByUuid(any()) } 258 | } 259 | 260 | @Test 261 | fun delete() = runTest { 262 | // Usamos coEvery para poder usar corutinas 263 | coEvery { repo.findByUuid(any()) } returns flowOf(raqueta) 264 | coEvery { repo.deleteById(any()) } returns Unit 265 | 266 | // Llamamos al método 267 | repository.delete(raqueta) 268 | 269 | coVerify { repo.findByUuid(any()) } 270 | coVerify { repo.deleteById(any()) } 271 | } 272 | 273 | @Test 274 | fun deleteAll() = runTest { 275 | // Usamos coEvery para poder usar corutinas 276 | coEvery { repo.deleteAll() } returns Unit 277 | 278 | // Llamamos al método 279 | repository.deleteAll() 280 | 281 | coVerify { repo.deleteAll() } 282 | } 283 | } -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/representantes/RepresentantesCachedRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.representantes 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Representante 4 | import io.mockk.MockKAnnotations 5 | import io.mockk.coEvery 6 | import io.mockk.coVerify 7 | import io.mockk.impl.annotations.InjectMockKs 8 | import io.mockk.impl.annotations.MockK 9 | import io.mockk.junit5.MockKExtension 10 | import kotlinx.coroutines.flow.flowOf 11 | import kotlinx.coroutines.flow.toList 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.jupiter.api.Assertions.* 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import org.springframework.boot.test.context.SpringBootTest 17 | import org.springframework.data.domain.PageRequest 18 | import java.util.* 19 | 20 | // Voy a usar MocKK en lugar de Mockito 21 | // https://spring.io/guides/tutorials/spring-boot-kotlin/ 22 | // @ExtendWith(SpringExtension::class) 23 | @ExtendWith(MockKExtension::class) 24 | @SpringBootTest 25 | class RepresentantesCachedRepositoryImplTest { 26 | 27 | private val representante = Representante( 28 | id = 99L, 29 | uuid = UUID.fromString("91e0c247-c611-4ed2-8db8-a495f1f16fee"), 30 | nombre = "Test", 31 | email = "test@example.com", 32 | ) 33 | 34 | @MockK // @MockkBean 35 | lateinit var repo: RepresentantesRepository 36 | 37 | @InjectMockKs // @Autowired 38 | lateinit var repository: RepresentatesCachedRepositoryImpl 39 | 40 | 41 | init { 42 | MockKAnnotations.init(this) 43 | } 44 | 45 | @Test 46 | fun findAllPageable() = runTest { 47 | // Usamos coEvery para poder usar corutinas 48 | coEvery { repo.findAll() } returns flowOf(representante) 49 | 50 | // Llamamos al método 51 | val result = repository.findAll().toList() 52 | 53 | assertAll( 54 | { assertEquals(1, result.size) }, 55 | { assertEquals(representante, result[0]) } 56 | ) 57 | 58 | coVerify(exactly = 1) { repo.findAll() } 59 | } 60 | 61 | @Test 62 | fun findById() = runTest { 63 | // Usamos coEvery para poder usar corutinas 64 | coEvery { repo.findById(any()) } returns representante 65 | 66 | // Llamamos al método 67 | val result = repository.findById(1L)!! 68 | 69 | assertAll( 70 | { assertEquals(representante.nombre, result.nombre) }, 71 | { assertEquals(representante.email, result.email) }, 72 | ) 73 | 74 | 75 | coVerify { repo.findById(any()) } 76 | } 77 | 78 | @Test 79 | fun findByIdNotFound() = runTest { 80 | // Usamos coEvery para poder usar corutinas 81 | coEvery { repo.findById(any()) } returns null 82 | 83 | // Llamamos al método 84 | val result = repository.findById(1L) 85 | 86 | assertNull(result) 87 | 88 | coVerify { repo.findById(any()) } 89 | } 90 | 91 | 92 | @Test 93 | fun findByUuid() = runTest { 94 | // Usamos coEvery para poder usar corutinas 95 | coEvery { repo.findByUuid(any()) } returns flowOf(representante) 96 | 97 | // Llamamos al método 98 | val result = repository.findByUuid(representante.uuid)!! 99 | 100 | assertAll( 101 | { assertEquals(representante.nombre, result.nombre) }, 102 | { assertEquals(representante.email, result.email) }, 103 | ) 104 | } 105 | 106 | @Test 107 | fun findByUuidNotFound() = runTest { 108 | // Usamos coEvery para poder usar corutinas 109 | coEvery { repo.findByUuid(any()) } returns flowOf() 110 | 111 | // Llamamos al método 112 | val result = repository.findByUuid(representante.uuid) 113 | 114 | assertNull(result) 115 | } 116 | 117 | @Test 118 | fun findByMarca() = runTest { 119 | // Usamos coEvery para poder usar corutinas 120 | coEvery { repo.findByNombreContainsIgnoreCase(any()) } returns flowOf(representante) 121 | 122 | // Llamamos al método 123 | val result = repository.findByNombre("Test").toList() 124 | 125 | assertAll( 126 | { assertEquals(1, result.size) }, 127 | { assertEquals(representante, result[0]) } 128 | ) 129 | 130 | coVerify { repo.findByNombreContainsIgnoreCase(any()) } 131 | } 132 | 133 | @Test 134 | fun findByMarcaNotFound() = runTest { 135 | // Usamos coEvery para poder usar corutinas 136 | coEvery { repo.findByNombreContainsIgnoreCase(any()) } returns flowOf() 137 | 138 | // Llamamos al método 139 | val result = repository.findByNombre("Test").toList() 140 | 141 | assertAll( 142 | { assertEquals(0, result.size) }, 143 | ) 144 | } 145 | 146 | @Test 147 | fun save() = runTest { 148 | // Usamos coEvery para poder usar corutinas 149 | coEvery { repo.save(any()) } returns representante 150 | 151 | // Llamamos al método 152 | val result = repository.save(representante) 153 | 154 | assertAll( 155 | { assertEquals(representante.nombre, result.nombre) }, 156 | { assertEquals(representante.email, result.email) }, 157 | ) 158 | 159 | coVerify { repo.save(any()) } 160 | } 161 | 162 | 163 | @Test 164 | fun update() = runTest { 165 | // Usamos coEvery para poder usar corutinas 166 | coEvery { repo.findByUuid(any()) } returns flowOf(representante) 167 | coEvery { repo.save(any()) } returns representante 168 | 169 | // Llamamos al método 170 | val result = repository.update(representante.uuid, representante)!! 171 | 172 | assertAll( 173 | { assertEquals(representante.nombre, result.nombre) }, 174 | { assertEquals(representante.email, result.email) }, 175 | ) 176 | 177 | coVerify { repo.save(any()) } 178 | } 179 | 180 | @Test 181 | fun updateNotFound() = runTest { 182 | // Usamos coEvery para poder usar corutinas 183 | coEvery { repo.findByUuid(any()) } returns flowOf() 184 | 185 | // Llamamos al método 186 | val result = repository.update(representante.uuid, representante) 187 | 188 | assertNull(result) 189 | 190 | } 191 | 192 | @Test 193 | fun deleteById() = runTest { 194 | // Usamos coEvery para poder usar corutinas 195 | coEvery { repo.deleteById(any()) } returns Unit 196 | 197 | // Llamamos al método 198 | repository.deleteById(1L) 199 | 200 | coVerify { repo.deleteById(any()) } 201 | } 202 | 203 | @Test 204 | fun findAllPage() = runTest { 205 | // Usamos coEvery para poder usar corutinas 206 | coEvery { repo.findAllBy(any()) } returns flowOf(representante) 207 | coEvery { repo.count() } returns 1L 208 | 209 | // Llamamos al método 210 | val result = repository.findAllPage(PageRequest.of(0, 10)).toList()[0].content 211 | 212 | assertAll( 213 | { assertEquals(1, result.size) }, 214 | { assertEquals(representante, result[0]) } 215 | ) 216 | 217 | coVerify { repo.findAllBy(any()) } 218 | } 219 | 220 | @Test 221 | fun countAll() = runTest { 222 | // Usamos coEvery para poder usar corutinas 223 | coEvery { repo.count() } returns 1L 224 | 225 | // Llamamos al método 226 | val result = repository.countAll() 227 | 228 | assertEquals(1L, result) 229 | 230 | coVerify { repo.count() } 231 | 232 | } 233 | 234 | @Test 235 | fun deleteByUuid() = runTest { 236 | // Usamos coEvery para poder usar corutinas 237 | coEvery { repo.findByUuid(any()) } returns flowOf(representante) 238 | coEvery { repo.deleteById(any()) } returns Unit 239 | 240 | // Llamamos al método 241 | repository.deleteByUuid(representante.uuid) 242 | 243 | coVerify { repo.findByUuid(any()) } 244 | } 245 | 246 | @Test 247 | fun deleteByUuidNotFound() = runTest { 248 | // Usamos coEvery para poder usar corutinas 249 | coEvery { repo.findByUuid(any()) } returns flowOf() 250 | 251 | // Llamamos al método 252 | repository.deleteByUuid(representante.uuid) 253 | 254 | coVerify { repo.findByUuid(any()) } 255 | } 256 | 257 | @Test 258 | fun delete() = runTest { 259 | // Usamos coEvery para poder usar corutinas 260 | coEvery { repo.findByUuid(any()) } returns flowOf(representante) 261 | coEvery { repo.deleteById(any()) } returns Unit 262 | 263 | // Llamamos al método 264 | repository.delete(representante) 265 | 266 | coVerify { repo.findByUuid(any()) } 267 | coVerify { repo.deleteById(any()) } 268 | } 269 | 270 | @Test 271 | fun deleteAll() = runTest { 272 | // Usamos coEvery para poder usar corutinas 273 | coEvery { repo.deleteAll() } returns Unit 274 | 275 | // Llamamos al método 276 | repository.deleteAll() 277 | 278 | coVerify { repo.deleteAll() } 279 | } 280 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /src/test/kotlin/es/joseluisgs/tenistasrestspringboot/repositories/tenistas/TenistasCachedRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package es.joseluisgs.tenistasrestspringboot.repositories.tenistas 2 | 3 | import es.joseluisgs.tenistasrestspringboot.models.Tenista 4 | import io.mockk.MockKAnnotations 5 | import io.mockk.coEvery 6 | import io.mockk.coVerify 7 | import io.mockk.impl.annotations.InjectMockKs 8 | import io.mockk.impl.annotations.MockK 9 | import io.mockk.junit5.MockKExtension 10 | import kotlinx.coroutines.flow.flowOf 11 | import kotlinx.coroutines.flow.toList 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.jupiter.api.Assertions.* 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import org.springframework.boot.test.context.SpringBootTest 17 | import org.springframework.data.domain.PageRequest 18 | import java.time.LocalDate 19 | import java.util.* 20 | 21 | // Voy a usar MocKK en lugar de Mockito 22 | // https://spring.io/guides/tutorials/spring-boot-kotlin/ 23 | // @ExtendWith(SpringExtension::class) 24 | @ExtendWith(MockKExtension::class) 25 | @SpringBootTest 26 | class TenistasCachedRepositoryImplTest { 27 | 28 | private val tenista = Tenista( 29 | id = 99L, 30 | uuid = UUID.fromString("91e0c247-c611-4ed2-8db8-a495f1f16fee"), 31 | nombre = "Test", 32 | ranking = 99, 33 | fechaNacimiento = LocalDate.parse("1981-01-01"), 34 | añoProfesional = 2000, 35 | altura = 188, 36 | peso = 83, 37 | manoDominante = Tenista.ManoDominante.DERECHA, 38 | tipoReves = Tenista.TipoReves.UNA_MANO, 39 | puntos = 3789, 40 | pais = "Suiza", 41 | raquetaId = UUID.fromString("b0b5b2a1-5b1f-4b0f-8b1f-1b2c2b3c4d5e") 42 | ) 43 | 44 | @MockK // @MockkBean 45 | lateinit var repo: TenistasRepository 46 | 47 | @InjectMockKs // @Autowired 48 | lateinit var repository: TenistasCachedRepositoryImpl 49 | 50 | 51 | init { 52 | MockKAnnotations.init(this) 53 | } 54 | 55 | @Test 56 | fun findAllPageable() = runTest { 57 | // Usamos coEvery para poder usar corutinas 58 | coEvery { repo.findByOrderByRankingAsc() } returns flowOf(tenista) 59 | 60 | // Llamamos al método 61 | val result = repository.findAll().toList() 62 | 63 | assertAll( 64 | { assertEquals(1, result.size) }, 65 | { assertEquals(tenista, result[0]) } 66 | ) 67 | 68 | coVerify(exactly = 1) { repo.findByOrderByRankingAsc() } 69 | } 70 | 71 | @Test 72 | fun findById() = runTest { 73 | // Usamos coEvery para poder usar corutinas 74 | coEvery { repo.findById(any()) } returns tenista 75 | 76 | // Llamamos al método 77 | val result = repository.findById(1L)!! 78 | 79 | assertAll( 80 | { assertEquals(tenista.nombre, result.nombre) }, 81 | { assertEquals(tenista.ranking, result.ranking) }, 82 | ) 83 | 84 | 85 | coVerify { repo.findById(any()) } 86 | } 87 | 88 | @Test 89 | fun findByIdNotFound() = runTest { 90 | // Usamos coEvery para poder usar corutinas 91 | coEvery { repo.findById(any()) } returns null 92 | 93 | // Llamamos al método 94 | val result = repository.findById(1L) 95 | 96 | assertNull(result) 97 | 98 | coVerify { repo.findById(any()) } 99 | } 100 | 101 | 102 | @Test 103 | fun findByUuid() = runTest { 104 | // Usamos coEvery para poder usar corutinas 105 | coEvery { repo.findByUuid(any()) } returns flowOf(tenista) 106 | 107 | // Llamamos al método 108 | val result = repository.findByUuid(tenista.uuid)!! 109 | 110 | assertAll( 111 | { assertEquals(tenista.nombre, result.nombre) }, 112 | { assertEquals(tenista.ranking, result.ranking) }, 113 | ) 114 | } 115 | 116 | @Test 117 | fun findByUuidNotFound() = runTest { 118 | // Usamos coEvery para poder usar corutinas 119 | coEvery { repo.findByUuid(any()) } returns flowOf() 120 | 121 | // Llamamos al método 122 | val result = repository.findByUuid(tenista.uuid) 123 | 124 | assertNull(result) 125 | } 126 | 127 | @Test 128 | fun findByMarca() = runTest { 129 | // Usamos coEvery para poder usar corutinas 130 | coEvery { repo.findByNombreContainsIgnoreCase(any()) } returns flowOf(tenista) 131 | 132 | // Llamamos al método 133 | val result = repository.findByNombre("Test").toList() 134 | 135 | assertAll( 136 | { assertEquals(1, result.size) }, 137 | { assertEquals(tenista, result[0]) } 138 | ) 139 | 140 | coVerify { repo.findByNombreContainsIgnoreCase(any()) } 141 | } 142 | 143 | @Test 144 | fun findByMarcaNotFound() = runTest { 145 | // Usamos coEvery para poder usar corutinas 146 | coEvery { repo.findByNombreContainsIgnoreCase(any()) } returns flowOf() 147 | 148 | // Llamamos al método 149 | val result = repository.findByNombre("Test").toList() 150 | 151 | assertAll( 152 | { assertEquals(0, result.size) }, 153 | ) 154 | } 155 | 156 | @Test 157 | fun save() = runTest { 158 | // Usamos coEvery para poder usar corutinas 159 | coEvery { repo.save(any()) } returns tenista 160 | 161 | // Llamamos al método 162 | val result = repository.save(tenista) 163 | 164 | assertAll( 165 | { assertEquals(tenista.nombre, result.nombre) }, 166 | { assertEquals(tenista.ranking, result.ranking) }, 167 | ) 168 | 169 | coVerify { repo.save(any()) } 170 | } 171 | 172 | 173 | @Test 174 | fun update() = runTest { 175 | // Usamos coEvery para poder usar corutinas 176 | coEvery { repo.findByUuid(any()) } returns flowOf(tenista) 177 | coEvery { repo.save(any()) } returns tenista 178 | 179 | // Llamamos al método 180 | val result = repository.update(tenista.uuid, tenista)!! 181 | 182 | assertAll( 183 | { assertEquals(tenista.nombre, result.nombre) }, 184 | { assertEquals(tenista.ranking, result.ranking) }, 185 | ) 186 | 187 | coVerify { repo.save(any()) } 188 | } 189 | 190 | @Test 191 | fun updateNotFound() = runTest { 192 | // Usamos coEvery para poder usar corutinas 193 | coEvery { repo.findByUuid(any()) } returns flowOf() 194 | 195 | // Llamamos al método 196 | val result = repository.update(tenista.uuid, tenista) 197 | 198 | assertNull(result) 199 | 200 | } 201 | 202 | @Test 203 | fun deleteById() = runTest { 204 | // Usamos coEvery para poder usar corutinas 205 | coEvery { repo.deleteById(any()) } returns Unit 206 | 207 | // Llamamos al método 208 | repository.deleteById(1L) 209 | 210 | coVerify { repo.deleteById(any()) } 211 | } 212 | 213 | @Test 214 | fun findAllPage() = runTest { 215 | // Usamos coEvery para poder usar corutinas 216 | coEvery { repo.findAllBy(any()) } returns flowOf(tenista) 217 | coEvery { repo.count() } returns 1L 218 | 219 | // Llamamos al método 220 | val result = repository.findAllPage(PageRequest.of(0, 10)).toList()[0].content 221 | 222 | assertAll( 223 | { assertEquals(1, result.size) }, 224 | { assertEquals(tenista, result[0]) } 225 | ) 226 | 227 | coVerify { repo.findAllBy(any()) } 228 | } 229 | 230 | @Test 231 | fun countAll() = runTest { 232 | // Usamos coEvery para poder usar corutinas 233 | coEvery { repo.count() } returns 1L 234 | 235 | // Llamamos al método 236 | val result = repository.countAll() 237 | 238 | assertEquals(1L, result) 239 | 240 | coVerify { repo.count() } 241 | 242 | } 243 | 244 | @Test 245 | fun deleteByUuid() = runTest { 246 | // Usamos coEvery para poder usar corutinas 247 | coEvery { repo.findByUuid(any()) } returns flowOf(tenista) 248 | coEvery { repo.deleteById(any()) } returns Unit 249 | 250 | // Llamamos al método 251 | repository.deleteByUuid(tenista.uuid) 252 | 253 | coVerify { repo.findByUuid(any()) } 254 | } 255 | 256 | @Test 257 | fun deleteByUuidNotFound() = runTest { 258 | // Usamos coEvery para poder usar corutinas 259 | coEvery { repo.findByUuid(any()) } returns flowOf() 260 | 261 | // Llamamos al método 262 | repository.deleteByUuid(tenista.uuid) 263 | 264 | coVerify { repo.findByUuid(any()) } 265 | } 266 | 267 | @Test 268 | fun delete() = runTest { 269 | // Usamos coEvery para poder usar corutinas 270 | coEvery { repo.findByUuid(any()) } returns flowOf(tenista) 271 | coEvery { repo.deleteById(any()) } returns Unit 272 | 273 | // Llamamos al método 274 | repository.delete(tenista) 275 | 276 | coVerify { repo.findByUuid(any()) } 277 | coVerify { repo.deleteById(any()) } 278 | } 279 | 280 | @Test 281 | fun deleteAll() = runTest { 282 | // Usamos coEvery para poder usar corutinas 283 | coEvery { repo.deleteAll() } returns Unit 284 | 285 | // Llamamos al método 286 | repository.deleteAll() 287 | 288 | coVerify { repo.deleteAll() } 289 | } 290 | } --------------------------------------------------------------------------------