├── settings.gradle.kts ├── postman ├── user.jpg ├── racket.jpg └── Ktor-Hyperskill.postman_collection.json ├── cert ├── server_keystore.jks ├── server_keystore.p12 └── keys.sh ├── clean-docker.sh ├── src ├── test │ ├── resources │ │ ├── user.jpg │ │ └── racket.jpg │ └── kotlin │ │ └── joseluisgs │ │ └── dev │ │ ├── ApplicationTest.kt │ │ ├── repositories │ │ ├── users │ │ │ └── UsersRepositoryImplTest.kt │ │ └── rackets │ │ │ └── RacketsRepositoryImplTest.kt │ │ ├── mappers │ │ └── RacketMapperKtTest.kt │ │ ├── routes │ │ ├── RacketsRoutesKtTest.kt │ │ └── UsersRoutesKtTest.kt │ │ └── services │ │ ├── rackets │ │ └── RacketsServiceImplTest.kt │ │ └── users │ │ └── UsersServiceImplTest.kt └── main │ ├── kotlin │ └── joseluisgs │ │ └── dev │ │ ├── errors │ │ ├── racket │ │ │ └── RacketErrors.kt │ │ ├── storage │ │ │ └── StorageErrors.kt │ │ └── user │ │ │ └── UserError.kt │ │ ├── plugins │ │ ├── Serialization.kt │ │ ├── Compression.kt │ │ ├── Koin.kt │ │ ├── Validation.kt │ │ ├── WebSockets.kt │ │ ├── Routing.kt │ │ ├── Cors.kt │ │ ├── Security.kt │ │ ├── StatusPages.kt │ │ └── Swagger.kt │ │ ├── repositories │ │ ├── rackets │ │ │ ├── RacketsRepository.kt │ │ │ └── RacketsRepositoryImpl.kt │ │ ├── users │ │ │ ├── UsersRepository.kt │ │ │ └── UsersRepositoryImpl.kt │ │ └── base │ │ │ └── CrudRepository.kt │ │ ├── services │ │ ├── storage │ │ │ ├── StorageService.kt │ │ │ └── StorageServiceImpl.kt │ │ ├── users │ │ │ ├── UsersService.kt │ │ │ └── UsersServiceImpl.kt │ │ ├── rackets │ │ │ ├── RacketsService.kt │ │ │ └── RacketsServiceImpl.kt │ │ ├── cache │ │ │ └── CacheService.kt │ │ ├── tokens │ │ │ └── TokensService.kt │ │ └── database │ │ │ └── DataBaseService.kt │ │ ├── config │ │ └── AppConfig.kt │ │ ├── dto │ │ ├── NotificationDto.kt │ │ ├── RacketDto.kt │ │ └── UsersDto.kt │ │ ├── data │ │ ├── UsersDemoData.kt │ │ └── RacketsDemoData.kt │ │ ├── serializers │ │ └── KotlinSerializers.kt │ │ ├── Application.kt │ │ ├── models │ │ ├── Racket.kt │ │ └── User.kt │ │ ├── validators │ │ ├── RacketValidator.kt │ │ └── UserValidator.kt │ │ ├── entities │ │ ├── UserTable.kt │ │ └── RacketTable.kt │ │ ├── mappers │ │ ├── UserMapper.kt │ │ └── RacketMapper.kt │ │ └── routes │ │ ├── UsersRoutes.kt │ │ └── RacketsRoutes.kt │ └── resources │ ├── logback.xml │ └── application.conf ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── docker-compose.yml ├── gradle.properties ├── .gitignore ├── Dockerfile ├── gradlew.bat ├── README.md └── gradlew /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ktor-reactive-rest-hyperskill" -------------------------------------------------------------------------------- /postman/user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/postman/user.jpg -------------------------------------------------------------------------------- /postman/racket.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/postman/racket.jpg -------------------------------------------------------------------------------- /cert/server_keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/cert/server_keystore.jks -------------------------------------------------------------------------------- /cert/server_keystore.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/cert/server_keystore.p12 -------------------------------------------------------------------------------- /clean-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker-compose down 3 | docker system prune -f -a --volumes 4 | # docker system prune -f --volumes -------------------------------------------------------------------------------- /src/test/resources/user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/src/test/resources/user.jpg -------------------------------------------------------------------------------- /src/test/resources/racket.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/src/test/resources/racket.jpg -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/ktor-reactive-rest-hyperskill/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | ktor-api-rest: 4 | build: . 5 | container_name: ktor-api-rest 6 | ports: 7 | - "8080:8080" 8 | - "8083:8083" 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.5.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/errors/racket/RacketErrors.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.errors.racket 2 | 3 | /** 4 | * Racket Errors 5 | */ 6 | sealed class RacketError(val message: String) { 7 | class NotFound(message: String) : RacketError(message) 8 | class BadRequest(message: String) : RacketError(message) 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/errors/storage/StorageErrors.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.errors.storage 2 | 3 | /** 4 | * Storage Errors 5 | */ 6 | sealed class StorageError(val message: String) { 7 | class NotFound(message: String) : StorageError(message) 8 | class BadRequest(message: String) : StorageError(message) 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Serialization.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.serialization.kotlinx.json.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.plugins.contentnegotiation.* 6 | 7 | /** 8 | * Configure the serialization of our application based on JSON 9 | */ 10 | fun Application.configureSerialization() { 11 | install(ContentNegotiation) { 12 | json() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cert/keys.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Server KeyStore: Private Key + Public Certificate (PKCS12) 4 | keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype PKCS12 -keystore server_keystore.p12 -storepass 1234567 5 | 6 | ## Server KeyStore: Private Key + Public Certificate (JKS) 7 | keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype JKS -keystore server_keystore.jks -storepass 1234567 -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Compression.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.plugins.compression.* 5 | 6 | 7 | fun Application.configureCompression() { 8 | // We can configure compression here 9 | install(Compression) { 10 | gzip { 11 | // The minimum size of a response that will be compressed 12 | minimumSize(512) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/repositories/rackets/RacketsRepository.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.rackets 2 | 3 | import joseluisgs.dev.models.Racket 4 | import joseluisgs.dev.repositories.base.CrudRepository 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface RacketsRepository : CrudRepository { 8 | suspend fun findAllPageable(page: Int = 0, perPage: Int = 10): Flow 9 | suspend fun findByBrand(brand: String): Flow 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/repositories/users/UsersRepository.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.users 2 | 3 | import joseluisgs.dev.models.User 4 | import joseluisgs.dev.repositories.base.CrudRepository 5 | 6 | interface UsersRepository : CrudRepository { 7 | suspend fun findByUsername(username: String): User? 8 | fun hashedPassword(password: String): String 9 | suspend fun checkUserNameAndPassword(username: String, password: String): User? 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Koin.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.server.application.* 4 | import org.koin.ksp.generated.defaultModule 5 | import org.koin.ktor.plugin.Koin 6 | import org.koin.logger.slf4jLogger 7 | 8 | fun Application.configureKoin() { 9 | install(Koin) { 10 | slf4jLogger() // Logger 11 | defaultModule() // Default module with Annotations 12 | // modules(appModule) // Our module, without dependencies 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/errors/user/UserError.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.errors.user 2 | 3 | /** 4 | * User Errors 5 | */ 6 | sealed class UserError(val message: String) { 7 | class NotFound(message: String) : UserError(message) 8 | class BadRequest(message: String) : UserError(message) 9 | class BadCredentials(message: String) : UserError(message) 10 | class BadRole(message: String) : UserError(message) 11 | class Unauthorized(message: String) : UserError(message) 12 | class Forbidden(message: String) : UserError(message) 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/storage/StorageService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.storage 2 | 3 | import com.github.michaelbull.result.Result 4 | import joseluisgs.dev.errors.storage.StorageError 5 | import java.io.File 6 | 7 | interface StorageService { 8 | suspend fun saveFile( 9 | fileName: String, 10 | fileUrl: String, 11 | fileBytes: ByteArray 12 | ): Result, StorageError> 13 | 14 | suspend fun getFile(fileName: String): Result 15 | suspend fun deleteFile(fileName: String): Result 16 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ktor_version=2.3.1 2 | kotlin_version=1.8.21 3 | logback_version=1.2.11 4 | kotlin.code.style=official 5 | # Logger 6 | micrologging_version=3.0.4 7 | logbackclassic_version=1.4.5 8 | # Database 9 | kotysa_version=3.0.1 10 | h2_r2dbc_version=1.0.0.RELEASE 11 | # Testing 12 | junit_version=5.9.2 13 | coroutines_test_version=1.6.4 14 | mockk_version=1.13.5 15 | # Cache 4K 16 | cache_version=0.11.0 17 | # Result 18 | result_version=1.1.17 19 | # Koin 20 | koin_ktor_version=3.4.1 21 | koin_ksp_version=1.2.1 22 | # Bcrypt 23 | bcrypt_version=1.0.9 24 | #Swagger UI 25 | ktor_swagger_ui_version=1.6.1 26 | 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | bin/ 16 | !**/src/main/**/bin/ 17 | !**/src/test/**/bin/ 18 | 19 | ### IntelliJ IDEA ### 20 | .idea 21 | *.iws 22 | *.iml 23 | *.ipr 24 | out/ 25 | !**/src/main/**/out/ 26 | !**/src/test/**/out/ 27 | 28 | ### NetBeans ### 29 | /nbproject/private/ 30 | /nbbuild/ 31 | /dist/ 32 | /nbdist/ 33 | /.nb-gradle/ 34 | 35 | ### VS Code ### 36 | .vscode/ 37 | /uploads/ 38 | 39 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/repositories/base/CrudRepository.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.base 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * Define the CRUD operations of our application 7 | * based on a generic type T and ID 8 | * @param T Type of our entity 9 | * @param ID Type of our ID 10 | */ 11 | interface CrudRepository { 12 | suspend fun findAll(): Flow 13 | suspend fun findById(id: ID): T? 14 | suspend fun save(entity: T): T 15 | suspend fun delete(entity: T): T 16 | suspend fun deleteAll() 17 | suspend fun saveAll(entities: Iterable): Flow 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Validation.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.plugins.requestvalidation.* 5 | import joseluisgs.dev.validators.racketValidation 6 | import joseluisgs.dev.validators.userValidation 7 | 8 | /** 9 | * Configure the validation plugin 10 | * https://ktor.io/docs/request-validation.html 11 | * We extend the validation with our own rules in separate file in validators package 12 | * like routes 13 | */ 14 | fun Application.configureValidation() { 15 | install(RequestValidation) { 16 | racketValidation() // Racket validation 17 | userValidation() // User validation 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/WebSockets.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.serialization.kotlinx.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.websocket.* 6 | import kotlinx.serialization.json.Json 7 | 8 | fun Application.configureWebSockets() { 9 | install(WebSockets) { 10 | // Configure WebSockets 11 | // Serializer for WebSockets 12 | contentConverter = KotlinxWebsocketSerializationConverter(Json { 13 | prettyPrint = true 14 | isLenient = true 15 | }) 16 | 17 | // Remeber it will close the connection if you don't send a ping in 15 seconds 18 | // https://ktor.io/docs/websocket.html#configure 19 | } 20 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/ApplicationTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.client.statement.* 5 | import io.ktor.http.* 6 | import io.ktor.server.testing.* 7 | import joseluisgs.dev.plugins.configureRouting 8 | import org.junit.jupiter.api.Assertions.assertEquals 9 | import org.junit.jupiter.api.Test 10 | 11 | class ApplicationTest { 12 | @Test 13 | fun testRoot() = testApplication { 14 | application { 15 | configureRouting() 16 | } 17 | client.get("/").apply { 18 | assertEquals(HttpStatusCode.OK, status) 19 | assertEquals("\uD83D\uDC4B Hello HyperSkill Reactive API REST!", bodyAsText()) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/config/AppConfig.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.config 2 | 3 | import io.ktor.server.config.* 4 | import org.koin.core.annotation.Singleton 5 | 6 | /** 7 | * Application Configuration to encapsulate our configuration 8 | * from application.conf or from other sources 9 | */ 10 | @Singleton 11 | class AppConfig { 12 | val applicationConfiguration: ApplicationConfig = ApplicationConfig("application.conf") 13 | 14 | // We can set here all the configuration we want from application.conf or from other sources 15 | // val applicationName: String = applicationConfiguration.property("ktor.application.name").getString() 16 | // val applicationPort: Int = applicationConfiguration.property("ktor.application.port").getString().toInt() 17 | 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Routing.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.response.* 5 | import io.ktor.server.routing.* 6 | import joseluisgs.dev.routes.racketsRoutes 7 | import joseluisgs.dev.routes.usersRoutes 8 | 9 | /** 10 | * Define the routing of our application based a DSL 11 | * https://ktor.io/docs/routing-in-ktor.html 12 | * we can define our routes in separate files like routes package 13 | */ 14 | fun Application.configureRouting() { 15 | routing { 16 | get("/") { 17 | call.respondText("\uD83D\uDC4B Hello HyperSkill Reactive API REST!") 18 | } 19 | } 20 | 21 | // Add our routes 22 | racketsRoutes() // Rackets routes 23 | usersRoutes() // Users routes 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/dto/NotificationDto.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.dto 2 | 3 | import joseluisgs.dev.serializers.LocalDateTimeSerializer 4 | import kotlinx.serialization.Serializable 5 | import java.time.LocalDateTime 6 | 7 | 8 | /** 9 | * Notification class 10 | * @param T Data type of the notification 11 | */ 12 | @Serializable 13 | data class NotificacionDto( 14 | val entity: String, 15 | val type: NotificationType, 16 | val id: Long?, 17 | val data: T?, 18 | @Serializable(with = LocalDateTimeSerializer::class) 19 | val createdAt: LocalDateTime = LocalDateTime.now() 20 | ) { 21 | enum class NotificationType { CREATE, UPDATE, DELETE } 22 | } 23 | 24 | // My notifications types 25 | typealias RacketNotification = NotificacionDto // Racket Notification 26 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/data/UsersDemoData.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.data 2 | 3 | import com.toxicbakery.bcrypt.Bcrypt 4 | import joseluisgs.dev.models.User 5 | 6 | 7 | fun userDemoData(): MutableMap = mutableMapOf( 8 | 1L to User( 9 | id = 1L, 10 | name = "Pepe Perez", 11 | username = "pepe", 12 | email = "pepe@perez.com", 13 | password = Bcrypt.hash("pepe1234", 12).decodeToString(), 14 | avatar = User.DEFAULT_IMAGE, 15 | role = User.Role.ADMIN 16 | ), 17 | 2L to User( 18 | id = 2L, 19 | name = "Ana Lopez", 20 | username = "ana", 21 | email = "ana@lopez.com", 22 | password = Bcrypt.hash("ana1234", 12).decodeToString(), 23 | avatar = User.DEFAULT_IMAGE, 24 | role = User.Role.USER 25 | ) 26 | ) 27 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/serializers/KotlinSerializers.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.serializers 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.descriptors.PrimitiveKind 5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 6 | import kotlinx.serialization.encoding.Decoder 7 | import kotlinx.serialization.encoding.Encoder 8 | import java.time.LocalDateTime 9 | 10 | object LocalDateTimeSerializer : KSerializer { 11 | override val descriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) 12 | 13 | override fun deserialize(decoder: Decoder): LocalDateTime { 14 | return LocalDateTime.parse(decoder.decodeString()) 15 | } 16 | 17 | override fun serialize(encoder: Encoder, value: LocalDateTime) { 18 | encoder.encodeString(value.toString()) 19 | } 20 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # With this file we create a Docker image that contains the application 2 | FROM gradle:7-jdk17 AS build 3 | # We create a directory for the application and copy the build.gradle file 4 | COPY --chown=gradle:gradle . /home/gradle/src 5 | WORKDIR /home/gradle/src 6 | RUN gradle buildFatJar --no-daemon 7 | 8 | # We create a new image with the application 9 | FROM openjdk:17-jdk-slim-buster 10 | EXPOSE 8080:8080 11 | EXPOSE 8083:8082 12 | # Directory to store the application 13 | RUN mkdir /app 14 | # Copy the certificate to the container (if it is necessary) 15 | RUN mkdir /cert 16 | COPY --from=build /home/gradle/src/cert/* /cert/ 17 | # Copy the jar file to the container 18 | COPY --from=build /home/gradle/src/build/libs/ktor-reactive-rest-hyperskill-all.jar /app/ktor-reactive-rest-hyperskill.jar 19 | # Run the application 20 | ENTRYPOINT ["java","-jar","/app/ktor-reactive-rest-hyperskill.jar"] -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/users/UsersService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.users 2 | 3 | import com.github.michaelbull.result.Result 4 | import joseluisgs.dev.errors.user.UserError 5 | import joseluisgs.dev.models.User 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface UsersService { 9 | suspend fun findAll(): Flow 10 | suspend fun findById(id: Long): Result 11 | suspend fun findByUsername(username: String): Result 12 | suspend fun checkUserNameAndPassword(username: String, password: String): Result 13 | suspend fun save(user: User): Result 14 | suspend fun update(id: Long, user: User): Result 15 | suspend fun delete(id: Long): Result 16 | suspend fun isAdmin(id: Long): Result 17 | suspend fun updateImage(id: Long, image: String): Result 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/rackets/RacketsService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.rackets 2 | 3 | import com.github.michaelbull.result.Result 4 | import joseluisgs.dev.dto.RacketNotification 5 | import joseluisgs.dev.errors.racket.RacketError 6 | import joseluisgs.dev.models.Racket 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.SharedFlow 9 | 10 | /** 11 | * Rackets Service to our Rackets 12 | * Define the CRUD operations of our application 13 | */ 14 | interface RacketsService { 15 | suspend fun findAll(): Flow 16 | suspend fun findAllPageable(page: Int = 0, perPage: Int = 10): Flow 17 | suspend fun findByBrand(brand: String): Flow 18 | suspend fun findById(id: Long): Result 19 | suspend fun save(racket: Racket): Result 20 | suspend fun update(id: Long, racket: Racket): Result 21 | suspend fun delete(id: Long): Result 22 | suspend fun updateImage(id: Long, image: String): Result 23 | 24 | // Notifications state 25 | val notificationState: SharedFlow 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/Application.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev 2 | 3 | import io.ktor.server.application.* 4 | import joseluisgs.dev.plugins.* 5 | 6 | /** 7 | * Main function of our application 8 | */ 9 | fun main(args: Array): Unit = 10 | io.ktor.server.netty.EngineMain.main(args) 11 | 12 | 13 | /** 14 | * Configure our application with the plugins 15 | */ 16 | @Suppress("unused") // application.conf references the main function. This annotation prevents the IDE from marking it as unused. 17 | fun Application.module() { 18 | configureKoin() // Configure the Koin plugin to inject dependencies 19 | configureSecurity() // Configure the security plugin with JWT 20 | configureWebSockets() // Configure the websockets plugin 21 | configureSerialization() // Configure the serialization plugin 22 | configureRouting() // Configure the routing plugin 23 | configureValidation() // Configure the validation plugin 24 | configureStatusPages() // Configure the status pages plugin 25 | configureCompression() // Configure the compression plugin 26 | configureCors() // Configure the CORS plugin 27 | configureSwagger() // Configure the Swagger plugin 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Cors.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.plugins.cors.routing.* 6 | 7 | fun Application.configureCors() { 8 | install(CORS) { 9 | anyHost() // Allow from any host 10 | allowHeader(HttpHeaders.ContentType) // Allow Content-Type header 11 | allowHeader(HttpHeaders.Authorization) 12 | allowHost("client-host") // Allow requests from client-host 13 | 14 | // We can also specify options 15 | /*allowHost("client-host") // Allow requests from client-host 16 | allowHost("client-host:8081") // Allow requests from client-host on port 8081 17 | allowHost( 18 | "client-host", 19 | subDomains = listOf("en", "de", "es") 20 | ) // Allow requests from client-host on subdomains en, de and es 21 | allowHost("client-host", schemes = listOf("http", "https")) // Allow requests from client-host on http and https 22 | 23 | // or methods 24 | allowMethod(HttpMethod.Put) // Allow PUT method 25 | allowMethod(HttpMethod.Delete) // Allow DELETE method*/ 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/models/Racket.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.models 2 | 3 | import java.time.LocalDateTime 4 | 5 | /** 6 | * Racket Model 7 | * @param id Racket ID 8 | * @param brand Racquet brand 9 | * @param model Racquet model 10 | * @param price Racquet price 11 | * @param numberTenisPlayers Number of tennis players who have used it 12 | * @param image Racquet image URL 13 | * @param createdAt Creation date 14 | * @param updatedAt Update date 15 | * @param isDeleted Is deleted 16 | */ 17 | data class Racket( 18 | val id: Long = NEW_RACKET, 19 | val brand: String, 20 | val model: String, 21 | val price: Double, 22 | val numberTenisPlayers: Int = 0, 23 | val image: String = DEFAULT_IMAGE, 24 | val createdAt: LocalDateTime = LocalDateTime.now(), 25 | val updatedAt: LocalDateTime = LocalDateTime.now(), 26 | val isDeleted: Boolean = false 27 | ) { 28 | /** 29 | * Companion object 30 | * @property NEW_RACKET New racket ID 31 | * @property DEFAULT_IMAGE Default image URL 32 | */ 33 | companion object { 34 | const val NEW_RACKET = -1L 35 | const val DEFAULT_IMAGE = "https://i.imgur.com/AsZ2xYS.jpg" 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/validators/RacketValidator.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.validators 2 | 3 | import io.ktor.server.plugins.requestvalidation.* 4 | import joseluisgs.dev.dto.RacketRequest 5 | 6 | /** 7 | * A RequestValidationConfig is a class that allows you to add custom validation rules 8 | * to the validation plugin. 9 | */ 10 | fun RequestValidationConfig.racketValidation() { 11 | // We add a validation rule for the RacquetRequest class 12 | // We can add as many as we want or need in your domain 13 | validate { racket -> 14 | if (racket.brand.isBlank() || racket.brand.length < 3) { 15 | ValidationResult.Invalid("Brand must be at least 3 characters long") 16 | } else if (racket.model.isBlank()) { 17 | ValidationResult.Invalid("Model must not be empty") 18 | } else if (racket.price < 0.0) { 19 | ValidationResult.Invalid("Price must be positive value or zero") 20 | } else if (racket.numberTenisPlayers < 0) { 21 | ValidationResult.Invalid("Number of tenis players must be positive number or zero") 22 | } else { 23 | // Everything is ok! 24 | ValidationResult.Valid 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/cache/CacheService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.cache 2 | 3 | import io.github.reactivecircus.cache4k.Cache 4 | import joseluisgs.dev.config.AppConfig 5 | import joseluisgs.dev.models.Racket 6 | import joseluisgs.dev.models.User 7 | import org.koin.core.annotation.Singleton 8 | import kotlin.time.Duration.Companion.seconds 9 | 10 | /** 11 | * Cache Service 12 | * @property myConfig AppConfig Configuration of our service 13 | */ 14 | 15 | @Singleton 16 | class CacheService( 17 | private val myConfig: AppConfig, 18 | ) { 19 | // Configure the Cache with the options of every entity in the cache 20 | val rackets by lazy { 21 | Cache.Builder() 22 | .expireAfterAccess( 23 | (myConfig.applicationConfiguration.property("cache.expireAfterAccess").getString() 24 | .toLongOrNull())?.seconds ?: 86400.seconds 25 | ) 26 | .maximumCacheSize( 27 | myConfig.applicationConfiguration.property("cache.maximumCacheSize").getString().toLongOrNull() ?: 1000 28 | ) 29 | .build() 30 | } 31 | 32 | // by default 33 | val users by lazy { 34 | Cache.Builder().build() 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/dto/RacketDto.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.dto 2 | 3 | import joseluisgs.dev.models.Racket.Companion.DEFAULT_IMAGE 4 | import kotlinx.serialization.Serializable 5 | 6 | 7 | /** 8 | * With DTO we can hide some fields from the model 9 | * or add some new ones o change the name of the fields or types 10 | * For example we can omit the Serializables for LocalDateTime using String (updatedAt) 11 | */ 12 | 13 | /** 14 | * Racket DTO for request 15 | */ 16 | @Serializable 17 | data class RacketRequest( 18 | val brand: String, 19 | val model: String, 20 | val price: Double, 21 | val numberTenisPlayers: Int = 0, 22 | val image: String = DEFAULT_IMAGE, 23 | ) 24 | 25 | /** 26 | * Racket DTO for response 27 | */ 28 | @Serializable 29 | data class RacketResponse( 30 | val id: Long, 31 | val brand: String, 32 | val model: String, 33 | val price: Double, 34 | val numberTenisPlayers: Int, 35 | val image: String, 36 | val createdAt: String, 37 | val updatedAt: String, 38 | val isDeleted: Boolean = false 39 | ) 40 | 41 | /** 42 | * Racket DTO for response with pagination 43 | */ 44 | @Serializable 45 | data class RacketPage( 46 | val page: Int, 47 | val perPage: Int, 48 | val data: List 49 | ) 50 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/data/RacketsDemoData.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.data 2 | 3 | import joseluisgs.dev.models.Racket 4 | import joseluisgs.dev.models.Racket.Companion.DEFAULT_IMAGE 5 | import java.time.LocalDateTime 6 | 7 | 8 | fun racketsDemoData(): MutableMap = mutableMapOf( 9 | 1L to Racket( 10 | 1L, 11 | "Babolat", 12 | "Pure Drive", 13 | 200.0, 14 | 10, 15 | DEFAULT_IMAGE, 16 | LocalDateTime.parse("2021-05-05T00:00:00"), 17 | LocalDateTime.parse("2021-05-05T00:00:00") 18 | ), 19 | 2L to Racket( 20 | 2L, 21 | "Babolat", 22 | "Pure Aero", 23 | 225.0, 24 | 8, 25 | DEFAULT_IMAGE, 26 | LocalDateTime.parse("2021-05-05T00:00:00"), 27 | LocalDateTime.parse("2021-05-05T00:00:00") 28 | ), 29 | 3L to Racket( 30 | 3L, 31 | "Head", 32 | "Speed", 33 | 250.25, 34 | 15, 35 | DEFAULT_IMAGE, 36 | LocalDateTime.parse("2021-05-05T00:00:00"), 37 | LocalDateTime.parse("2021-05-05T00:00:00") 38 | ), 39 | 4L to Racket( 40 | 4L, 41 | "Wilson", 42 | "Pro Staff", 43 | 300.0, 44 | 12, 45 | DEFAULT_IMAGE, 46 | LocalDateTime.parse("2021-05-05T00:00:00"), 47 | LocalDateTime.parse("2021-05-05T00:00:00") 48 | ), 49 | ) -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/dto/UsersDto.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.dto 2 | 3 | import joseluisgs.dev.models.User 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * User DTO for response 8 | */ 9 | @Serializable 10 | data class UserDto( 11 | val id: Long, 12 | val name: String, 13 | val email: String, 14 | val username: String, 15 | val avatar: String, 16 | val role: User.Role, 17 | val createdAt: String, 18 | val updatedAt: String, 19 | val isDeleted: Boolean = false 20 | ) 21 | 22 | /** 23 | * User DTO for request to create a new user 24 | */ 25 | @Serializable 26 | data class UserCreateDto( 27 | val name: String, 28 | val email: String, 29 | val username: String, 30 | val password: String, 31 | val avatar: String? = null, 32 | val role: User.Role? = User.Role.USER, 33 | ) 34 | 35 | /** 36 | * User DTO for request to update a user 37 | */ 38 | @Serializable 39 | data class UserUpdateDto( 40 | val name: String, 41 | val email: String, 42 | val username: String, 43 | ) 44 | 45 | /** 46 | * User DTO for request to login a user 47 | */ 48 | @Serializable 49 | data class UserLoginDto( 50 | val username: String, 51 | val password: String 52 | ) 53 | 54 | /** 55 | * User DTO for response with token 56 | */ 57 | @Serializable 58 | data class UserWithTokenDto( 59 | val user: UserDto, 60 | val token: String 61 | ) -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/models/User.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.models 2 | 3 | import joseluisgs.dev.models.User.Role.ADMIN 4 | import joseluisgs.dev.models.User.Role.USER 5 | import java.time.LocalDateTime 6 | 7 | /** 8 | * User Model 9 | * @param id User ID 10 | * @param name User name 11 | * @param email User email 12 | * @param username User username 13 | * @param password User password 14 | * @param avatar User avatar URL 15 | * @param role User role 16 | * @param createdAt Creation date 17 | * @param updatedAt Update date 18 | * @param deleted Is deleted 19 | */ 20 | data class User( 21 | val id: Long = NEW_USER, 22 | val name: String, 23 | val email: String, 24 | val username: String, 25 | val password: String, 26 | val avatar: String = DEFAULT_IMAGE, 27 | val role: Role = USER, 28 | val createdAt: LocalDateTime = LocalDateTime.now(), 29 | val updatedAt: LocalDateTime = LocalDateTime.now(), 30 | val deleted: Boolean = false 31 | ) { 32 | 33 | /** 34 | * Companion object 35 | * @property NEW_USER New user ID 36 | * @property DEFAULT_IMAGE Default image URL 37 | */ 38 | companion object { 39 | const val NEW_USER = -1L 40 | const val DEFAULT_IMAGE = "https://i.imgur.com/fIgch2x.png" 41 | } 42 | 43 | /** 44 | * User roles 45 | * @property USER Normal user 46 | * @property ADMIN Administrator user 47 | */ 48 | enum class Role { 49 | USER, ADMIN 50 | } 51 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Security.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.auth.* 5 | import io.ktor.server.auth.jwt.* 6 | import joseluisgs.es.services.tokens.TokenException 7 | import joseluisgs.es.services.tokens.TokensService 8 | import org.koin.ktor.ext.inject 9 | 10 | // Seguridad en base a JWT 11 | fun Application.configureSecurity() { 12 | 13 | // Inject the token service 14 | val jwtService: TokensService by inject() 15 | 16 | 17 | authentication { 18 | jwt { 19 | // Load the token verification config 20 | verifier(jwtService.verifyJWT()) 21 | // With realm we can get the token from the request 22 | realm = jwtService.realm 23 | validate { credential -> 24 | // If the token is valid, it also has the indicated audience, 25 | // and has the user's field to compare it with the one we want 26 | // return the JWTPrincipal, otherwise return null 27 | if (credential.payload.audience.contains(jwtService.audience) && 28 | credential.payload.getClaim("username").asString().isNotEmpty() 29 | ) 30 | JWTPrincipal(credential.payload) 31 | else null 32 | } 33 | 34 | challenge { defaultScheme, realm -> 35 | throw TokenException.InvalidTokenException("Invalid or expired token") 36 | } 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/entities/UserTable.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.entities 2 | 3 | import joseluisgs.dev.models.User 4 | import org.ufoss.kotysa.h2.H2Table 5 | import java.time.LocalDateTime 6 | 7 | /** 8 | * User Table 9 | */ 10 | object UserTable : H2Table("users") { 11 | // Autoincrement and primary key 12 | val id = autoIncrementBigInt(UserEntity::id).primaryKey() 13 | 14 | // Other fields 15 | val name = varchar(UserEntity::name) 16 | val email = varchar(UserEntity::email) 17 | val username = varchar(UserEntity::username) 18 | val password = varchar(UserEntity::password) 19 | val avatar = varchar(UserEntity::avatar) 20 | val role = varchar(UserEntity::role) 21 | 22 | // metadata 23 | val createdAt = timestamp(UserEntity::createdAt, "created_at") 24 | val updatedAt = timestamp(UserEntity::updatedAt, "updated_at") 25 | val deleted = boolean(UserEntity::deleted) 26 | } 27 | 28 | /** 29 | * User Entity 30 | * We can use this class to map from Entity Row to Model and viceversa 31 | * We use it because we can't use the same class for both (avoid id nullable) 32 | * Or adapt some fields type to the database 33 | */ 34 | data class UserEntity( 35 | // Id 36 | val id: Long?, // 37 | 38 | // data 39 | val name: String, 40 | val email: String, 41 | val username: String, 42 | val password: String, 43 | val avatar: String = User.DEFAULT_IMAGE, 44 | val role: String = User.Role.USER.name, 45 | val createdAt: LocalDateTime = LocalDateTime.now(), 46 | val updatedAt: LocalDateTime = LocalDateTime.now(), 47 | val deleted: Boolean = false 48 | ) -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/entities/RacketTable.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.entities 2 | 3 | import joseluisgs.dev.models.Racket.Companion.DEFAULT_IMAGE 4 | import org.ufoss.kotysa.h2.H2Table 5 | import java.time.LocalDateTime 6 | 7 | /** 8 | * Racket Entity 9 | */ 10 | object RacketTable : H2Table() { 11 | // Autoincrement and primary key 12 | val id = autoIncrementBigInt(RacketEntity::id).primaryKey() 13 | 14 | // Other fields 15 | val brand = varchar(RacketEntity::brand) 16 | val model = varchar(RacketEntity::model) 17 | val price = doublePrecision(RacketEntity::price) 18 | val numberTenisPlayers = integer(RacketEntity::numberTenisPlayers, "number_tenis_players") 19 | val image = varchar(RacketEntity::image, "image") 20 | 21 | // metadata 22 | val createdAt = timestamp(RacketEntity::createdAt, "created_at") 23 | val updatedAt = timestamp(RacketEntity::updatedAt, "updated_at") 24 | val isDeleted = boolean(RacketEntity::isDeleted, "is_deleted") 25 | } 26 | 27 | 28 | /** 29 | * Racket Entity 30 | * We can use this class to map from Entity Row to Model and viceversa 31 | * We use it because we can't use the same class for both (avoid id nullable) 32 | * Or adapt some fields type to the database 33 | */ 34 | 35 | data class RacketEntity( 36 | val id: Long?, // 37 | val brand: String, 38 | val model: String, 39 | val price: Double, 40 | val numberTenisPlayers: Int = 0, 41 | val image: String = DEFAULT_IMAGE, 42 | val createdAt: LocalDateTime = LocalDateTime.now(), 43 | val updatedAt: LocalDateTime = LocalDateTime.now(), 44 | val isDeleted: Boolean = false 45 | ) 46 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/StatusPages.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.plugins.requestvalidation.* 6 | import io.ktor.server.plugins.statuspages.* 7 | import io.ktor.server.response.* 8 | import joseluisgs.es.services.tokens.TokenException 9 | 10 | /** 11 | * Configure the Status Pages plugin and configure it 12 | * https://ktor.io/docs/status-pages.html 13 | * We use status pages to respond with expected exceptions 14 | */ 15 | fun Application.configureStatusPages() { 16 | // Install StatusPages plugin and configure it 17 | install(StatusPages) { 18 | 19 | // This is a custom exception we use to respond with a 400 if a validation fails, Bad Request 20 | exception { call, cause -> 21 | call.respond(HttpStatusCode.BadRequest, cause.reasons.joinToString()) 22 | } 23 | 24 | // When we try to convert a string to a number and it fails we respond with a 400 Bad Request 25 | exception { call, cause -> 26 | call.respond(HttpStatusCode.BadRequest, "${cause.message}. The input param is not a valid number") 27 | } 28 | 29 | // When try to send a bad request we respond with a 400 Bad Request 30 | exception { call, cause -> 31 | call.respond(HttpStatusCode.BadRequest, "${cause.message}") 32 | } 33 | 34 | // Token is not valid or expired 35 | exception { call, cause -> 36 | call.respond(HttpStatusCode.Unauthorized, cause.message.toString()) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/plugins/Swagger.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.plugins 2 | 3 | import io.github.smiley4.ktorswaggerui.SwaggerUI 4 | import io.github.smiley4.ktorswaggerui.dsl.AuthScheme 5 | import io.github.smiley4.ktorswaggerui.dsl.AuthType 6 | import io.ktor.server.application.* 7 | 8 | fun Application.configureSwagger() { 9 | // https://github.com/SMILEY4/ktor-swagger-ui/wiki/Configuration 10 | // http://xxx/swagger/ 11 | install(SwaggerUI) { 12 | swagger { 13 | swaggerUrl = "swagger" 14 | forwardRoot = false 15 | } 16 | info { 17 | title = "Ktor Hyperskill Reactive API REST" 18 | version = "latest" 19 | description = "Example of a Ktor API REST using Kotlin and Ktor" 20 | contact { 21 | name = "José Luis González Sánchez" 22 | url = "https://github.com/joseluisgs" 23 | } 24 | license { 25 | name = "Creative Commons Attribution-ShareAlike 4.0 International License" 26 | url = "https://joseluisgs.dev/docs/license/" 27 | } 28 | } 29 | 30 | schemasInComponentSection = true 31 | examplesInComponentSection = true 32 | automaticTagGenerator = { url -> url.firstOrNull() } 33 | // We can filter paths and methods 34 | pathFilter = { method, url -> 35 | url.contains("rackets") 36 | //(method == HttpMethod.Get && url.firstOrNull() == "api") 37 | // || url.contains("test") 38 | } 39 | 40 | // We can add security 41 | securityScheme("JWT-Auth") { 42 | type = AuthType.HTTP 43 | scheme = AuthScheme.BEARER 44 | bearerFormat = "jwt" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | # Configure the application based on the environment variables 2 | ktor { 3 | deployment { 4 | port = 8080 5 | port = ${?PORT} 6 | ## SSL, you need to enable it 7 | sslPort = 8083 8 | sslPort = ${?SSL_PORT} 9 | } 10 | 11 | # Configure the main module 12 | application { 13 | modules = [ joseluisgs.dev.ApplicationKt.module ] 14 | } 15 | 16 | ## Development mode 17 | # Enable development mode. Recommended to set it via -Dktor.deployment.environment=development 18 | # development = true 19 | deployment { 20 | ## Watch for changes in this directory and automatically reload the application if any file changes. 21 | watch = [ classes, resources ] 22 | } 23 | 24 | ## Modo de ejecución 25 | environment = dev 26 | environment = ${?KTOR_ENV} 27 | 28 | ## To enable SSL, you need to generate a certificate and configure it here 29 | security { 30 | ssl { 31 | keyStore = cert/server_keystore.p12 32 | keyAlias = "serverKeyPair" 33 | keyStorePassword = "1234567" 34 | privateKeyPassword = "1234567" 35 | } 36 | } 37 | } 38 | 39 | # Configure the database 40 | database { 41 | driver = "h2" 42 | protocol ="mem" 43 | user = "sa" 44 | user = ${?DATABASE_USER} 45 | password = "" 46 | password = ${?DATABASE_PASSWORD} 47 | database = "r2dbc:h2:mem:///rackets;DB_CLOSE_DELAY=-1" 48 | database = ${?DATABASE_NAME} 49 | ## Init database data 50 | initDatabaseData = true 51 | } 52 | 53 | # Configure Cache 54 | cache { 55 | maximumCacheSize = 1000 56 | expireAfterAccess = 86400 57 | } 58 | 59 | # Storage 60 | storage { 61 | uploadDir = "uploads" 62 | endpoint = api/storage 63 | } 64 | 65 | # JWT 66 | jwt { 67 | secret = "IL0v3L34rn1ngKt0rWithJ0s3Lu1sGS4ndHyp3r$k1ll" 68 | realm = "rackets-ktor" 69 | ## Expiration time: 3600s (1 hour) 70 | expiration = "3600" 71 | issuer = "rackets-ktor" 72 | audience = "rackets-ktor-auth" 73 | } 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/mappers/UserMapper.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.mappers 2 | 3 | import joseluisgs.dev.dto.UserCreateDto 4 | import joseluisgs.dev.dto.UserDto 5 | import joseluisgs.dev.entities.UserEntity 6 | import joseluisgs.dev.models.User 7 | 8 | 9 | /** 10 | * Mapper for User Model to User DTO 11 | * @return UserDto 12 | * @see UserDto 13 | */ 14 | fun User.toDto(): UserDto { 15 | return UserDto( 16 | id = this.id, 17 | name = this.name, 18 | email = this.email, 19 | username = this.username, 20 | avatar = this.avatar, 21 | role = this.role, 22 | createdAt = this.createdAt.toString(), 23 | updatedAt = this.updatedAt.toString(), 24 | isDeleted = this.deleted 25 | ) 26 | } 27 | 28 | /** 29 | * Mapper for User Model to User DTO 30 | * @return User 31 | * @see UserDto 32 | */ 33 | fun UserCreateDto.toModel(): User { 34 | return User( 35 | name = this.name, 36 | email = this.email, 37 | username = this.username, 38 | password = this.password, 39 | avatar = this.avatar ?: User.DEFAULT_IMAGE, 40 | role = this.role ?: User.Role.USER 41 | ) 42 | } 43 | 44 | /** 45 | * Mapper for User Entity to User DTO 46 | * @return User 47 | * @see UserEntity 48 | */ 49 | fun UserEntity.toModel(): User { 50 | return User( 51 | id = this.id ?: User.NEW_USER, 52 | name = this.name, 53 | email = this.email, 54 | username = this.username, 55 | password = this.password, 56 | avatar = this.avatar, 57 | role = User.Role.valueOf(this.role), 58 | createdAt = this.createdAt, 59 | updatedAt = this.updatedAt, 60 | deleted = this.deleted 61 | ) 62 | } 63 | 64 | /** 65 | * Mapper for User Model to User Entity 66 | * @return UserEntity 67 | * @see User 68 | */ 69 | fun User.toEntity(): UserEntity { 70 | return UserEntity( 71 | id = if (this.id == User.NEW_USER) null else this.id, 72 | name = this.name, 73 | email = this.email, 74 | username = this.username, 75 | password = this.password, 76 | avatar = this.avatar, 77 | role = this.role.name, 78 | createdAt = this.createdAt, 79 | updatedAt = this.updatedAt, 80 | deleted = this.deleted 81 | ) 82 | } 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/validators/UserValidator.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.validators 2 | 3 | import io.ktor.server.plugins.requestvalidation.* 4 | import joseluisgs.dev.dto.UserCreateDto 5 | import joseluisgs.dev.dto.UserLoginDto 6 | import joseluisgs.dev.dto.UserUpdateDto 7 | 8 | 9 | fun RequestValidationConfig.userValidation() { 10 | // We can add as many as we want or need in your domain 11 | validate { user -> 12 | if (user.name.isBlank()) { 13 | ValidationResult.Invalid("The name cannot be empty") 14 | } else if (user.email.isBlank()) { 15 | ValidationResult.Invalid("The email cannot be empty") 16 | } else if (!user.email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))) { 17 | ValidationResult.Invalid("The email is not valid") 18 | } else if (user.username.isBlank() && user.username.length < 3) { 19 | ValidationResult.Invalid("The username cannot be empty or less than 3 characters") 20 | } else if (user.password.isBlank() || user.password.length < 7) { 21 | ValidationResult.Invalid("The password cannot be empty or less than 7 characters") 22 | } else { 23 | ValidationResult.Valid 24 | } 25 | } 26 | 27 | validate { user -> 28 | if (user.name.isBlank()) { 29 | ValidationResult.Invalid("The name cannot be empty") 30 | } else if (user.email.isBlank()) { 31 | ValidationResult.Invalid("The email cannot be empty") 32 | } else if (!user.email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)\$"))) { 33 | ValidationResult.Invalid("The email is not valid") 34 | } else if (user.username.isBlank() && user.username.length < 3) { 35 | ValidationResult.Invalid("The username cannot be empty or less than 3 characters") 36 | } else { 37 | ValidationResult.Valid 38 | } 39 | } 40 | 41 | validate { user -> 42 | if (user.username.isBlank()) { 43 | ValidationResult.Invalid("The username cannot be empty or less than 3 characters") 44 | } else if (user.password.isBlank() || user.password.length < 7) { 45 | ValidationResult.Invalid("The password cannot be empty or less than 7 characters") 46 | } else { 47 | ValidationResult.Valid 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/mappers/RacketMapper.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.mappers 2 | 3 | import joseluisgs.dev.dto.RacketRequest 4 | import joseluisgs.dev.dto.RacketResponse 5 | import joseluisgs.dev.entities.RacketEntity 6 | import joseluisgs.dev.models.Racket 7 | 8 | /** 9 | * Mapper for Racquet 10 | * With this we can map from DTO to Model and viceversa 11 | * In Kotlin we can use extension functions 12 | */ 13 | 14 | /** 15 | * RacketRequest to Racket Model 16 | * @return Racket 17 | * @see Racket 18 | */ 19 | fun RacketRequest.toModel() = Racket( 20 | brand = this.brand, 21 | model = this.model, 22 | price = this.price, 23 | numberTenisPlayers = this.numberTenisPlayers, 24 | image = this.image 25 | ) 26 | 27 | /* 28 | @JvmName("fromRacketRequestListToModel") 29 | fun List.toModel() = this.map { it.toModel() } 30 | */ 31 | 32 | /** 33 | * Racket to RacketResponse 34 | * @return RacketResponse 35 | * @see RacketResponse 36 | */ 37 | fun Racket.toResponse() = RacketResponse( 38 | id = this.id, 39 | brand = this.brand, 40 | model = this.model, 41 | price = this.price, 42 | numberTenisPlayers = this.numberTenisPlayers, 43 | image = this.image, 44 | createdAt = this.createdAt.toString(), 45 | updatedAt = this.updatedAt.toString(), 46 | isDeleted = this.isDeleted 47 | ) 48 | 49 | /** 50 | * List to List 51 | * @return List 52 | * @see RacketResponse 53 | */ 54 | fun List.toResponse() = this.map { it.toResponse() } 55 | 56 | /** 57 | * RacketEntity to Racket 58 | * @return Racket 59 | * @see Racket 60 | */ 61 | fun RacketEntity.toModel() = Racket( 62 | id = this.id ?: Racket.NEW_RACKET, 63 | brand = this.brand, 64 | model = this.model, 65 | price = this.price, 66 | numberTenisPlayers = this.numberTenisPlayers, 67 | image = this.image, 68 | createdAt = this.createdAt, 69 | updatedAt = this.updatedAt, 70 | isDeleted = this.isDeleted 71 | ) 72 | 73 | //@JvmName("fromRacketEntityListToModel") 74 | /** 75 | * List to List 76 | * @return List 77 | * @see Racket 78 | */ 79 | fun List.toModel() = this.map { it.toModel() } 80 | 81 | /** 82 | * Racket to RacketEntity 83 | * @return RacketEntity 84 | * @see RacketEntity 85 | */ 86 | fun Racket.toEntity() = RacketEntity( 87 | id = if (this.id == Racket.NEW_RACKET) null else this.id, 88 | brand = this.brand, 89 | model = this.model, 90 | price = this.price, 91 | numberTenisPlayers = this.numberTenisPlayers, 92 | image = this.image, 93 | createdAt = this.createdAt, 94 | updatedAt = this.updatedAt, 95 | isDeleted = this.isDeleted 96 | ) 97 | 98 | //fun List.toEntity() = this.map { it.toEntity() } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/tokens/TokensService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.es.services.tokens 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.JWTVerifier 5 | import com.auth0.jwt.algorithms.Algorithm 6 | import joseluisgs.dev.config.AppConfig 7 | import joseluisgs.dev.models.User 8 | import mu.KotlinLogging 9 | import org.koin.core.annotation.Single 10 | import java.util.* 11 | 12 | private val logger = KotlinLogging.logger {} 13 | 14 | /** 15 | * Token Exception 16 | * @property message String 17 | */ 18 | sealed class TokenException(message: String) : RuntimeException(message) { 19 | class InvalidTokenException(message: String) : TokenException(message) 20 | } 21 | 22 | 23 | @Single 24 | class TokensService( 25 | private val myConfig: AppConfig 26 | ) { 27 | 28 | val audience by lazy { 29 | myConfig.applicationConfiguration.propertyOrNull("jwt.audience")?.getString() ?: "jwt-audience" 30 | } 31 | val realm by lazy { 32 | myConfig.applicationConfiguration.propertyOrNull("jwt.realm")?.getString() ?: "jwt-realm" 33 | } 34 | private val issuer by lazy { 35 | myConfig.applicationConfiguration.propertyOrNull("jwt.issuer")?.getString() ?: "jwt-issuer" 36 | } 37 | private val expiresIn by lazy { 38 | myConfig.applicationConfiguration.propertyOrNull("jwt.tiempo")?.getString()?.toLong() ?: 3600 39 | } 40 | private val secret by lazy { 41 | myConfig.applicationConfiguration.propertyOrNull("jwt.secret")?.getString() ?: "jwt-secret" 42 | } 43 | 44 | init { 45 | logger.debug { "Init tokens service with audience: $audience" } 46 | } 47 | 48 | /** 49 | * Generate a token JWT 50 | * @param user User 51 | * @return String 52 | */ 53 | fun generateJWT(user: User): String { 54 | return JWT.create() 55 | .withAudience(audience) 56 | .withIssuer(issuer) 57 | .withSubject("Authentication") 58 | // user claims and other data to store 59 | .withClaim("username", user.username) 60 | .withClaim("usermail", user.email) 61 | .withClaim("userId", user.id.toString()) 62 | // expiration time from currentTimeMillis + (tiempo times in seconds) * 1000 (to millis) 63 | .withExpiresAt(Date(System.currentTimeMillis() + expiresIn * 1000L)) 64 | // sign with secret 65 | .sign( 66 | Algorithm.HMAC512(secret) 67 | ) 68 | } 69 | 70 | /** 71 | * Verify a token JWT 72 | * @return JWTVerifier 73 | * @throws TokenException.InvalidTokenException 74 | */ 75 | fun verifyJWT(): JWTVerifier { 76 | 77 | return try { 78 | JWT.require(Algorithm.HMAC512(secret)) 79 | .withAudience(audience) 80 | .withIssuer(issuer) 81 | .build() 82 | } catch (e: Exception) { 83 | throw TokenException.InvalidTokenException("Invalid token") 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /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%" == "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%"=="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 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/storage/StorageServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.storage 2 | 3 | 4 | import com.github.michaelbull.result.Err 5 | import com.github.michaelbull.result.Ok 6 | import com.github.michaelbull.result.Result 7 | import joseluisgs.dev.config.AppConfig 8 | import joseluisgs.dev.errors.storage.StorageError 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import mu.KotlinLogging 12 | import org.koin.core.annotation.Singleton 13 | import java.io.File 14 | import java.nio.file.Files 15 | import java.nio.file.Path 16 | import java.time.LocalDateTime 17 | 18 | private val logger = KotlinLogging.logger {} 19 | 20 | /** 21 | * Storage Service to manage our files 22 | * @property myConfig AppConfig Configuration of our service 23 | */ 24 | @Singleton 25 | class StorageServiceImpl( 26 | private val myConfig: AppConfig 27 | ) : StorageService { 28 | 29 | private val uploadDir by lazy { 30 | myConfig.applicationConfiguration.propertyOrNull("upload.dir")?.getString() ?: "uploads" 31 | } 32 | 33 | init { 34 | logger.debug { " Starting Storage Service in $uploadDir" } 35 | initStorageDirectory() 36 | } 37 | 38 | /** 39 | * Inits the storage directory 40 | * If not exists, creates it 41 | * If exists, clean it if dev 42 | */ 43 | private fun initStorageDirectory() { 44 | // Create upload directory if not exists (or ignore if exists) 45 | // and clean if dev 46 | Files.createDirectories(Path.of(uploadDir)) 47 | if (myConfig.applicationConfiguration.propertyOrNull("ktor.environment")?.getString() == "dev") { 48 | logger.debug { "Cleaning storage directory in $uploadDir" } 49 | File(uploadDir).listFiles()?.forEach { it.delete() } 50 | } 51 | } 52 | 53 | /** 54 | * Saves a file in our storage 55 | * @param fileName String Name of the file 56 | * @param fileUrl String URL of the file 57 | * @param fileBytes ByteArray Bytes of the file 58 | * @return Result, StorageError> Map if Ok, StorageError if not 59 | */ 60 | override suspend fun saveFile( 61 | fileName: String, 62 | fileUrl: String, 63 | fileBytes: ByteArray 64 | ): Result, StorageError> = 65 | withContext(Dispatchers.IO) { 66 | logger.debug { "Saving file in: $fileName" } 67 | return@withContext try { 68 | File("${uploadDir}/$fileName").writeBytes(fileBytes) 69 | Ok( 70 | mapOf( 71 | "fileName" to fileName, 72 | "createdAt" to LocalDateTime.now().toString(), 73 | "size" to fileBytes.size.toString(), 74 | "url" to fileUrl, 75 | ) 76 | ) 77 | } catch (e: Exception) { 78 | Err(StorageError.BadRequest("Error saving file: $fileName")) 79 | } 80 | } 81 | 82 | /** 83 | * Retrieves a file from our storage 84 | * @param fileName String Name of the file 85 | * @return Result File if Ok, StorageError if not 86 | */ 87 | override suspend fun getFile(fileName: String): Result = withContext(Dispatchers.IO) { 88 | logger.debug { "Get file: $fileName" } 89 | return@withContext if (!File("${uploadDir}/$fileName").exists()) { 90 | Err(StorageError.NotFound("File Not Found in storage: $fileName")) 91 | } else { 92 | Ok(File("${uploadDir}/$fileName")) 93 | } 94 | } 95 | 96 | /** 97 | * Deletes a file from our storage 98 | * @param fileName String Name of the file 99 | * @return Result String if Ok, StorageError if not 100 | */ 101 | override suspend fun deleteFile(fileName: String): Result = withContext(Dispatchers.IO) { 102 | logger.debug { "Remove file: $fileName" } 103 | Files.deleteIfExists(Path.of("${uploadDir}/$fileName")) 104 | Ok(fileName) 105 | } 106 | 107 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/database/DataBaseService.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.database 2 | 3 | import io.r2dbc.spi.ConnectionFactories 4 | import io.r2dbc.spi.ConnectionFactoryOptions 5 | import joseluisgs.dev.config.AppConfig 6 | import joseluisgs.dev.data.racketsDemoData 7 | import joseluisgs.dev.data.userDemoData 8 | import joseluisgs.dev.entities.RacketTable 9 | import joseluisgs.dev.entities.UserTable 10 | import joseluisgs.dev.mappers.toEntity 11 | import joseluisgs.dev.models.Racket 12 | import joseluisgs.dev.models.User 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.runBlocking 16 | import kotlinx.coroutines.withContext 17 | import mu.KotlinLogging 18 | import org.koin.core.annotation.Singleton 19 | import org.ufoss.kotysa.H2Tables 20 | import org.ufoss.kotysa.r2dbc.coSqlClient 21 | import org.ufoss.kotysa.tables 22 | 23 | private val logger = KotlinLogging.logger {} 24 | 25 | /** 26 | * DataBase Service to connect to our database 27 | * @property myConfig AppConfig Configuration of our service 28 | */ 29 | 30 | @Singleton 31 | class DataBaseService( 32 | private val myConfig: AppConfig, 33 | ) { 34 | 35 | private val connectionFactory by lazy { 36 | val options = ConnectionFactoryOptions.builder() 37 | .option( 38 | ConnectionFactoryOptions.DRIVER, 39 | myConfig.applicationConfiguration.propertyOrNull("database.driver")?.getString() ?: "h2" 40 | ) 41 | .option( 42 | ConnectionFactoryOptions.PROTOCOL, 43 | myConfig.applicationConfiguration.propertyOrNull("database.protocol")?.getString() ?: "mem" 44 | ) 45 | .option( 46 | ConnectionFactoryOptions.USER, 47 | myConfig.applicationConfiguration.propertyOrNull("database.user")?.getString() ?: "sa" 48 | ) 49 | .option( 50 | ConnectionFactoryOptions.PASSWORD, 51 | myConfig.applicationConfiguration.propertyOrNull("database.password")?.getString() ?: "" 52 | ) 53 | .option( 54 | ConnectionFactoryOptions.DATABASE, 55 | myConfig.applicationConfiguration.propertyOrNull("database.database")?.getString() 56 | ?: "r2dbc:h2:mem:///test;DB_CLOSE_DELAY=-1" 57 | ) 58 | .build() 59 | ConnectionFactories.get(options) 60 | } 61 | 62 | private val initDatabaseData by lazy { 63 | myConfig.applicationConfiguration.propertyOrNull("database.initDatabaseData")?.getString()?.toBoolean() ?: false 64 | } 65 | 66 | // Our client 67 | val client = connectionFactory.coSqlClient(getTables()) 68 | 69 | init { 70 | logger.debug { "Init DataBaseService" } 71 | initDatabase() 72 | } 73 | 74 | // Our tables 75 | private fun getTables(): H2Tables { 76 | // Return tables 77 | return tables().h2(RacketTable, UserTable) 78 | } 79 | 80 | private fun initDatabase() = runBlocking { 81 | logger.debug { "Init DatabaseService" } 82 | createTables() 83 | // Init data 84 | if (initDatabaseData) { 85 | initDataBaseDataDemo() 86 | } 87 | } 88 | 89 | // demo data 90 | suspend fun initDataBaseDataDemo() { 91 | clearDataBaseData() 92 | initDataBaseData() 93 | } 94 | 95 | // Create tables if not exists 96 | private suspend fun createTables() = withContext(Dispatchers.IO) { 97 | logger.debug { "Creating the tables..." } 98 | launch { 99 | client createTableIfNotExists RacketTable 100 | client createTableIfNotExists UserTable 101 | } 102 | } 103 | 104 | // Clear all data 105 | private suspend fun clearDataBaseData() = withContext(Dispatchers.IO) { 106 | logger.debug { "Deleting data..." } 107 | launch { 108 | client deleteAllFrom RacketTable 109 | client deleteAllFrom UserTable 110 | } 111 | } 112 | 113 | // Init data 114 | private suspend fun initDataBaseData() = withContext(Dispatchers.IO) { 115 | logger.debug { "Saving demo data..." } 116 | launch { 117 | logger.debug { "Saving demo rackets..." } 118 | racketsDemoData().forEach { 119 | client insert it.value.copy(id = Racket.NEW_RACKET).toEntity() 120 | } 121 | logger.debug { "Saving demo users..." } 122 | userDemoData().forEach { 123 | client insert it.value.copy(id = User.NEW_USER).toEntity() 124 | } 125 | 126 | } 127 | } 128 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/repositories/users/UsersRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.users 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.config.* 5 | import joseluisgs.dev.config.AppConfig 6 | import joseluisgs.dev.models.User 7 | import joseluisgs.dev.services.database.DataBaseService 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.flow.take 10 | import kotlinx.coroutines.flow.toList 11 | import kotlinx.coroutines.test.runTest 12 | import org.junit.jupiter.api.Assertions.* 13 | import org.junit.jupiter.api.BeforeEach 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.TestInstance 16 | import java.util.* 17 | 18 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 19 | @OptIn(ExperimentalCoroutinesApi::class) 20 | class UsersRepositoryImplTest { 21 | val dataBaseService = DataBaseService(AppConfig()) 22 | val repository = UsersRepositoryImpl(dataBaseService) 23 | 24 | @BeforeEach 25 | fun setUp() = runTest { 26 | // Clean and restore database with data 27 | dataBaseService.initDataBaseDataDemo() 28 | } 29 | 30 | @Test 31 | fun findAll() = runTest { 32 | val result = repository.findAll().take(1).toList() 33 | 34 | assertAll( 35 | { assertEquals(1, result.size) }, 36 | { assertEquals("Pepe Perez", result[0].name) }, 37 | { assertEquals("pepe", result[0].username) }, 38 | { assertEquals("pepe@perez.com", result[0].email) } 39 | ) 40 | } 41 | 42 | @Test 43 | fun checkUserNameAndPassword() = runTest { 44 | val result = repository.checkUserNameAndPassword("pepe", "pepe1234") 45 | 46 | assertAll( 47 | { assertEquals("Pepe Perez", result?.name) }, 48 | { assertEquals("pepe", result?.username) }, 49 | { assertEquals("pepe@perez.com", result?.email) } 50 | ) 51 | } 52 | 53 | @Test 54 | fun checkUserNameAndPasswordNotFound() = runTest { 55 | val result = repository.checkUserNameAndPassword("caca", "caca1234") 56 | 57 | assertNull(result) 58 | } 59 | 60 | @Test 61 | fun findById() = runTest { 62 | val test = User( 63 | name = "Test", 64 | username = "test", 65 | email = "test@test.com", 66 | password = "test1234", 67 | ) 68 | 69 | val newUser = repository.save(test) 70 | 71 | val user = repository.findById(newUser.id)!! 72 | 73 | assertAll( 74 | { assertEquals(test.name, user.name) }, 75 | { assertEquals(test.username, user.username) }, 76 | { assertEquals(test.email, user.email) } 77 | ) 78 | } 79 | 80 | @Test 81 | fun findByIdNotFound() = runTest { 82 | val result = repository.findById(-1) 83 | 84 | assertNull(result) 85 | } 86 | 87 | @Test 88 | fun findByUsername() = runTest { 89 | val result = repository.findByUsername("pepe") 90 | 91 | assertAll( 92 | { assertEquals("Pepe Perez", result?.name) }, 93 | { assertEquals("pepe", result?.username) }, 94 | { assertEquals("pepe@perez.com", result?.email) } 95 | ) 96 | } 97 | 98 | @Test 99 | fun findByUsernameNotFound() = runTest { 100 | val result = repository.findByUsername("caca") 101 | 102 | assertNull(result) 103 | } 104 | 105 | @Test 106 | fun saveNewUser() = runTest { 107 | val user = User( 108 | name = "Test", 109 | username = "test", 110 | email = "test@test.com", 111 | password = "test1234", 112 | ) 113 | 114 | val res = repository.save(user) 115 | 116 | assertAll( 117 | { assertEquals(user.name, res.name) }, 118 | { assertEquals(user.username, res.username) }, 119 | { assertEquals(user.email, res.email) }, 120 | ) 121 | } 122 | 123 | 124 | @Test 125 | fun saveUpdateUser() = runTest { 126 | val user = User( 127 | id = 2, 128 | name = "Test", 129 | username = "test", 130 | email = "test@test.com", 131 | password = "test1234", 132 | ) 133 | 134 | val res = repository.save(user) 135 | 136 | assertAll( 137 | { assertEquals(user.name, res.name) }, 138 | { assertEquals(user.username, res.username) }, 139 | { assertEquals(user.email, res.email) }, 140 | ) 141 | } 142 | 143 | @Test 144 | fun delete() = runTest { 145 | val user = User( 146 | name = "Test", 147 | username = "test", 148 | email = "test@test.com", 149 | password = "test1234", 150 | ) 151 | 152 | val res = repository.save(user) 153 | repository.delete(user) 154 | val exists = repository.findById(user.id) 155 | 156 | assertAll( 157 | { assertEquals(user.name, res.name) }, 158 | { assertEquals(user.username, res.username) }, 159 | { assertEquals(user.email, res.email) }, 160 | { assertNull(exists) } 161 | ) 162 | } 163 | 164 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/repositories/rackets/RacketsRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.rackets 2 | 3 | import io.ktor.server.config.* 4 | import joseluisgs.dev.config.AppConfig 5 | import joseluisgs.dev.data.racketsDemoData 6 | import joseluisgs.dev.models.Racket 7 | import joseluisgs.dev.services.database.DataBaseService 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.flow.toList 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.jupiter.api.Assertions.* 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.TestInstance 15 | 16 | 17 | /** 18 | * Test our Rackects Repository 19 | */ 20 | 21 | @OptIn(ExperimentalCoroutinesApi::class) 22 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 23 | class RacketsRepositoryImplTest { 24 | 25 | val dataBaseService = DataBaseService(AppConfig()) 26 | val repository = RacketsRepositoryImpl(dataBaseService) 27 | 28 | 29 | @BeforeEach 30 | fun setUp() = runTest { 31 | // Clean and restore database with data 32 | dataBaseService.initDataBaseDataDemo() 33 | } 34 | 35 | @Test 36 | fun findAll() = runTest { 37 | val rackets = repository.findAll().toList() 38 | assertAll( 39 | { assertEquals(4, rackets.size) }, 40 | { assertEquals("Babolat", rackets[0].brand) }, 41 | { assertEquals("Babolat", rackets[1].brand) }, 42 | { assertEquals("Head", rackets[2].brand) }, 43 | { assertEquals("Wilson", rackets[3].brand) } 44 | ) 45 | } 46 | 47 | @Test 48 | suspend fun findById() = runTest { 49 | val test = Racket(brand = "Test Brand", model = "Test Model", price = 100.0) 50 | val newRacket = repository.save(test) 51 | 52 | val racket = repository.findById(newRacket.id)!! 53 | assertAll( 54 | { assertEquals(test.brand, racket.brand) }, 55 | { assertEquals(test.model, racket.model) }, 56 | { assertEquals(test.price, racket.price) } 57 | ) 58 | } 59 | 60 | @Test 61 | fun findByIdNotFound() = runTest { 62 | val racket = repository.findById(-100) 63 | assertNull(racket) 64 | } 65 | 66 | @Test 67 | fun findAllPageable() = runTest { 68 | val rackets = repository.findAllPageable(1, 2).toList() 69 | assertAll( 70 | { assertEquals(2, rackets.size) }, 71 | { assertEquals("Head", rackets[0].brand) }, 72 | { assertEquals("Wilson", rackets[1].brand) } 73 | ) 74 | } 75 | 76 | @Test 77 | fun findByBrand() = runTest { 78 | val rackets = repository.findByBrand("Babolat").toList() 79 | assertAll( 80 | { assertEquals(2, rackets.size) }, 81 | { assertEquals("Babolat", rackets[0].brand) }, 82 | { assertEquals("Babolat", rackets[1].brand) } 83 | ) 84 | } 85 | 86 | @Test 87 | fun saveNewRacket() = runTest { 88 | val racket = Racket(brand = "Test Brand", model = "Test Model", price = 100.0) 89 | val newRacket = repository.save(racket) 90 | assertAll( 91 | { assertEquals("Test Brand", newRacket.brand) }, 92 | { assertEquals("Test Model", newRacket.model) }, 93 | { assertEquals(100.0, newRacket.price) } 94 | ) 95 | } 96 | 97 | @Test 98 | fun saveUpdateRacket() = runTest { 99 | val racket = Racket(id = 1, brand = "Test Brand", model = "Test Model", price = 100.0) 100 | val newRacket = repository.save(racket) 101 | assertAll( 102 | { assertEquals(1, newRacket.id) }, 103 | { assertEquals("Test Brand", newRacket.brand) }, 104 | { assertEquals("Test Model", newRacket.model) }, 105 | { assertEquals(100.0, newRacket.price) } 106 | ) 107 | } 108 | 109 | @Test 110 | fun delete() = runTest { 111 | // Save a new racket 112 | val racket = Racket(brand = "Test Brand", model = "Test Model", price = 100.0) 113 | val newRacket = repository.save(racket) 114 | 115 | val deleted = repository.delete(newRacket) 116 | val exists = repository.findById(newRacket.id) 117 | assertAll( 118 | { assertEquals("Test Brand", deleted.brand) }, 119 | { assertEquals("Test Model", deleted.model) }, 120 | { assertEquals(100.0, deleted.price) }, 121 | { assertNull(exists) } 122 | ) 123 | } 124 | 125 | @Test 126 | fun deleteAll() = runTest { 127 | repository.deleteAll() 128 | val rackets = repository.findAll().toList() 129 | assertEquals(0, rackets.size) 130 | } 131 | 132 | @Test 133 | fun saveAll() = runTest { 134 | val rackets = racketsDemoData() 135 | val result = repository.saveAll(rackets.values).toList() 136 | assertAll( 137 | { assertEquals(rackets.size, result.size) }, 138 | { assertEquals("Babolat", result[0].brand) }, 139 | { assertEquals("Babolat", result[1].brand) }, 140 | { assertEquals("Head", result[2].brand) }, 141 | { assertEquals("Wilson", result[3].brand) } 142 | ) 143 | } 144 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ktor Reactive REST API for Hyperskill 2 | 3 | Example of a Reactive REST API with Ktor based on Hyperskill tracks 4 | 5 | [![Kotlin](https://img.shields.io/badge/Code-Kotlin-blueviolet)](https://kotlinlang.org/) 6 | [![LICENSE](https://img.shields.io/badge/Lisence-CC-%23e64545)](https://joseluisgs.dev/docs/license/) 7 | ![GitHub](https://img.shields.io/github/last-commit/joseluisgs/ktor-reactive-rest-hyperskill) 8 | 9 | ![imagen](https://static.tildacdn.com/tild3637-6466-4835-a263-373534663862/Hyperskill_sharing_c.png) 10 | 11 | - [Ktor Reactive REST API for Hyperskill](#ktor-reactive-rest-api-for-hyperskill) 12 | - [About this project](#about-this-project) 13 | - [Parts of the tutorial and Repositories](#parts-of-the-tutorial-and-repositories) 14 | - [Author](#author) 15 | - [Contact](#contact) 16 | - [Coffee?](#coffee) 17 | - [License](#license) 18 | 19 | ## About this project 20 | This is an example of a Reactive REST API with Ktor and Kotlin using techniques tha you can learn on the different Hyperskill tracks. 21 | 22 | This tutorials have been made to learn how to create a reactive service with a Reactive REST API with [Ktor](https://ktor.io/) with [Kotlin](https://kotlinlang.org/) technologies. 23 | 24 | You can find the following tutorials in the [Hyperskill Medium blog](https://medium.com/hyperskill). 25 | 26 | - [Part I: Set-up and your first end-point](https://medium.com/hyperskill/creating-your-reactive-rest-api-with-kotlin-and-ktor-part-i-f217be55c0bf) 27 | - [Part II: Reactive Database, DTOs, Validation and Test](#) 28 | 29 | You can also follow us on social media to stay up-to-date with our latest articles and projects. We are on [Reddit](https://www.reddit.com/r/Hyperskill/), [LinkedIn](https://www.linkedin.com/company/hyperskill/), [Twitter](https://twitter.com/yourhyperskill) and [Facebook](https://www.facebook.com/myhyperskill). 30 | 31 | ![image](https://miro.medium.com/v2/resize:fit:1400/format:webp/1*6sqcDNSinKG2uQb7UrJB7A.png) 32 | 33 | ### Parts of the tutorial and Repositories 34 | You can find the following parts of the tutorial in the following repository: [Ktor Reactive REST API for Hyperskill](https://github.com/joseluisgs/ktor-reactive-rest-hyperskill). 35 | 36 | In this repository you can find the following parts of the tutorial in each commit and the end result in the last commit and tag: 37 | - [Part I: Set-up and your first end-point](https://github.com/joseluisgs/ktor-reactive-rest-hyperskill/releases/tag/0.0.1) 38 | - [Part II: Reactive Database, DTOs, Validation and Test](https://github.com/joseluisgs/ktor-reactive-rest-hyperskill/releases/tag/0.0.2) 39 | 40 | ## Author 41 | Coded with :sparkling_heart: by [José Luis González Sánchez](https://twitter.com/joseluisgonsan). Member of the Kotlin Team in Hyperskill. 42 | 43 | [![Twitter](https://img.shields.io/twitter/follow/JoseLuisGS_?style=social)](https://twitter.com/joseluisgonsan) 44 | [![GitHub](https://img.shields.io/github/followers/joseluisgs?style=social)](https://github.com/joseluisgs) 45 | [![GitHub](https://img.shields.io/github/stars/joseluisgs?style=social)](https://github.com/joseluisgs) 46 | ### Contact 47 |

48 | Let me know if there's anything you need help with 💬. 49 |

50 |

51 | 52 | 54 |    55 | 56 | 58 |    59 | 60 | 62 |    63 | 64 | 66 |    67 | 68 | 70 |    71 | 72 | 74 |    75 | 76 | 78 | 79 |

80 | 81 | ### Coffee? 82 |

joseluisgs




83 | 84 | ## License 85 | This repository and all its contents are licensed under the **Creative Commons** license. If you want to know more, see the [LICENSE](https://joseluisgs.dev/docs/license/). Please cite the author if you share, use or modify this project, and use the same conditions for its educational, formative, or non-commercial use. 86 | 87 | Creative Commons License
88 | JoseLuisGS 89 | by 90 | José Luis González Sánchez is licensed under 91 | a Creative Commons 92 | Attribution-NonCommercial-ShareAlike 4.0 International License.
Based on a work at 93 | https://github.com/joseluisgs. -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/repositories/rackets/RacketsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.rackets 2 | 3 | import joseluisgs.dev.entities.RacketTable 4 | import joseluisgs.dev.mappers.toEntity 5 | import joseluisgs.dev.mappers.toModel 6 | import joseluisgs.dev.models.Racket 7 | import joseluisgs.dev.services.database.DataBaseService 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.filter 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.withContext 13 | import mu.KotlinLogging 14 | import org.koin.core.annotation.Singleton 15 | import java.time.LocalDateTime 16 | 17 | private val logger = KotlinLogging.logger {} 18 | 19 | /** 20 | * Repository of Racquets with CRUD operations 21 | * @property dataBaseService DataBaseService 22 | */ 23 | 24 | @Singleton 25 | class RacketsRepositoryImpl( 26 | private val dataBaseService: DataBaseService 27 | ) : RacketsRepository { 28 | 29 | /** 30 | * Find all Rackets 31 | * @return Flow Flow of Rackets 32 | */ 33 | override suspend fun findAll(): Flow = withContext(Dispatchers.IO) { 34 | logger.debug { "findAll" } 35 | 36 | return@withContext (dataBaseService.client selectFrom RacketTable) 37 | .fetchAll().map { it.toModel() } 38 | } 39 | 40 | /** 41 | * Find by ID, if not exists return null 42 | * @param id Long ID 43 | * @return Racket? Racket or null 44 | */ 45 | override suspend fun findById(id: Long): Racket? = withContext(Dispatchers.IO) { 46 | logger.debug { "findById: $id" } 47 | 48 | return@withContext (dataBaseService.client selectFrom RacketTable 49 | where RacketTable.id eq id) 50 | .fetchFirstOrNull()?.toModel() 51 | } 52 | 53 | /** 54 | * Find all Rackets with pagination 55 | * @param page Int Page to show 56 | * @param perPage Int Number of elements per page 57 | * @return Flow Flow of Rackets 58 | */ 59 | override suspend fun findAllPageable(page: Int, perPage: Int): Flow = withContext(Dispatchers.IO) { 60 | logger.debug { "findAllPageable: $page, $perPage" } 61 | 62 | val myLimit = if (perPage > 100) 100L else perPage.toLong() 63 | val myOffset = (page * perPage).toLong() 64 | 65 | return@withContext (dataBaseService.client selectFrom RacketTable 66 | limit myLimit offset myOffset) 67 | .fetchAll().map { it.toModel() } 68 | 69 | } 70 | 71 | /** 72 | * Find by brand 73 | * @param brand String Brand to search 74 | * @return Flow Flow of Rackets 75 | */ 76 | override suspend fun findByBrand(brand: String): Flow = withContext(Dispatchers.IO) { 77 | logger.debug { "findByBrand: $brand" } 78 | return@withContext (dataBaseService.client selectFrom RacketTable) 79 | .fetchAll() 80 | .filter { it.brand.contains(brand, true) } 81 | .map { it.toModel() } 82 | } 83 | 84 | /** 85 | * Save a Racket, if exists update, else create 86 | * @param entity Racket Racket to save 87 | * @return Racket Racket saved or updated 88 | */ 89 | override suspend fun save(entity: Racket): Racket = withContext(Dispatchers.IO) { 90 | logger.debug { "save: $entity" } 91 | 92 | if (entity.id == Racket.NEW_RACKET) { 93 | create(entity) 94 | } else { 95 | update(entity) 96 | } 97 | } 98 | 99 | /** 100 | * Create a Racket 101 | * @param entity Racket to save 102 | * @return Racket Racket saved 103 | */ 104 | private suspend fun create(entity: Racket): Racket { 105 | val newEntity = entity.copy(createdAt = LocalDateTime.now(), updatedAt = LocalDateTime.now()) 106 | .toEntity() 107 | logger.debug { "create: $newEntity" } 108 | return (dataBaseService.client insertAndReturn newEntity).toModel() 109 | } 110 | 111 | /** 112 | * Update a Racket 113 | * @param entity Racket to update 114 | * @return Racket Racket updated 115 | */ 116 | private suspend fun update(entity: Racket): Racket { 117 | logger.debug { "update: $entity" } 118 | val updateEntity = entity.copy(updatedAt = LocalDateTime.now()).toEntity() 119 | 120 | (dataBaseService.client update RacketTable 121 | set RacketTable.brand eq updateEntity.brand 122 | set RacketTable.model eq updateEntity.model 123 | set RacketTable.price eq updateEntity.price 124 | set RacketTable.numberTenisPlayers eq updateEntity.numberTenisPlayers 125 | set RacketTable.image eq updateEntity.image 126 | where RacketTable.id eq entity.id) 127 | .execute() 128 | return updateEntity.toModel() 129 | } 130 | 131 | /** 132 | * Delete a Racket 133 | * @param entity Racket to delete 134 | * @return Racket Racket deleted 135 | */ 136 | override suspend fun delete(entity: Racket): Racket { 137 | logger.debug { "delete: $entity" } 138 | (dataBaseService.client deleteFrom RacketTable 139 | where RacketTable.id eq entity.id) 140 | .execute() 141 | return entity 142 | } 143 | 144 | /** 145 | * Delete all Rackets 146 | */ 147 | override suspend fun deleteAll() { 148 | logger.debug { "deleteAll" } 149 | dataBaseService.client deleteAllFrom RacketTable 150 | } 151 | 152 | /** 153 | * Save all Rackets 154 | * @param entities Iterable Rackets to save 155 | * @return Flow Flow of Rackets 156 | */ 157 | override suspend fun saveAll(entities: Iterable): Flow { 158 | logger.debug { "saveAll: $entities" } 159 | entities.forEach { save(it) } 160 | return this.findAll() 161 | } 162 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/mappers/RacketMapperKtTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.mappers 2 | 3 | import joseluisgs.dev.dto.RacketRequest 4 | import joseluisgs.dev.entities.RacketEntity 5 | import joseluisgs.dev.models.Racket 6 | import org.junit.jupiter.api.Test 7 | import org.junit.jupiter.api.assertAll 8 | 9 | class RacketMapperKtTest { 10 | @Test 11 | fun fromRequestToModel() { 12 | val request = RacketRequest( 13 | brand = "Brand", 14 | model = "Model", 15 | price = 10.0, 16 | numberTenisPlayers = 2, 17 | image = "Image" 18 | ) 19 | val model = request.toModel() 20 | 21 | assertAll( 22 | { assert(model.brand == request.brand) }, 23 | { assert(model.model == request.model) }, 24 | { assert(model.price == request.price) }, 25 | { assert(model.numberTenisPlayers == request.numberTenisPlayers) }, 26 | { assert(model.image == request.image) } 27 | ) 28 | } 29 | 30 | @Test 31 | fun fromModelToResponse() { 32 | val racket = Racket( 33 | brand = "Brand", 34 | model = "Model", 35 | price = 10.0, 36 | numberTenisPlayers = 2, 37 | image = "Image" 38 | ) 39 | val response = racket.toResponse() 40 | 41 | assertAll( 42 | { assert(response.brand == racket.brand) }, 43 | { assert(response.model == racket.model) }, 44 | { assert(response.price == racket.price) }, 45 | { assert(response.numberTenisPlayers == racket.numberTenisPlayers) }, 46 | { assert(response.image == racket.image) } 47 | ) 48 | } 49 | 50 | @Test 51 | fun testListModelToResponse() { 52 | val rackets = listOf( 53 | Racket( 54 | brand = "Brand", 55 | model = "Model", 56 | price = 10.0, 57 | numberTenisPlayers = 2, 58 | image = "Image" 59 | ), 60 | Racket( 61 | brand = "Brand", 62 | model = "Model", 63 | price = 10.0, 64 | numberTenisPlayers = 2, 65 | image = "Image" 66 | ) 67 | ) 68 | 69 | val response = rackets.toResponse() 70 | 71 | assertAll( 72 | { assert(response[0].brand == rackets[0].brand) }, 73 | { assert(response[0].model == rackets[0].model) }, 74 | { assert(response[0].price == rackets[0].price) }, 75 | { assert(response[0].numberTenisPlayers == rackets[0].numberTenisPlayers) }, 76 | { assert(response[0].image == rackets[0].image) }, 77 | { assert(response[1].brand == rackets[1].brand) }, 78 | { assert(response[1].model == rackets[1].model) }, 79 | { assert(response[1].price == rackets[1].price) }, 80 | { assert(response[1].numberTenisPlayers == rackets[1].numberTenisPlayers) }, 81 | { assert(response[1].image == rackets[1].image) } 82 | ) 83 | } 84 | 85 | @Test 86 | fun fromRacketEntityToModel() { 87 | val entity = RacketEntity( 88 | id = null, 89 | brand = "Brand", 90 | model = "Model", 91 | price = 10.0, 92 | numberTenisPlayers = 2, 93 | image = "Image" 94 | ) 95 | 96 | val model = entity.toModel() 97 | 98 | assertAll( 99 | { assert(model.id == Racket.NEW_RACKET) }, 100 | { assert(model.brand == entity.brand) }, 101 | { assert(model.model == entity.model) }, 102 | { assert(model.price == entity.price) }, 103 | { assert(model.numberTenisPlayers == entity.numberTenisPlayers) }, 104 | { assert(model.image == entity.image) } 105 | ) 106 | } 107 | 108 | @Test 109 | fun ListEntityToModel() { 110 | val entities = listOf( 111 | RacketEntity( 112 | id = null, 113 | brand = "Brand", 114 | model = "Model", 115 | price = 10.0, 116 | numberTenisPlayers = 2, 117 | image = "Image" 118 | ), 119 | RacketEntity( 120 | id = 1, 121 | brand = "Brand", 122 | model = "Model", 123 | price = 10.0, 124 | numberTenisPlayers = 2, 125 | image = "Image" 126 | ) 127 | ) 128 | 129 | val models = entities.toModel() 130 | 131 | assertAll( 132 | { assert(models[0].id == Racket.NEW_RACKET) }, 133 | { assert(models[0].brand == entities[0].brand) }, 134 | { assert(models[0].model == entities[0].model) }, 135 | { assert(models[0].price == entities[0].price) }, 136 | { assert(models[0].numberTenisPlayers == entities[0].numberTenisPlayers) }, 137 | { assert(models[0].image == entities[0].image) }, 138 | { assert(models[1].id == entities[1].id) }, 139 | { assert(models[1].brand == entities[1].brand) }, 140 | { assert(models[1].model == entities[1].model) }, 141 | { assert(models[1].price == entities[1].price) }, 142 | { assert(models[1].numberTenisPlayers == entities[1].numberTenisPlayers) }, 143 | { assert(models[1].image == entities[1].image) } 144 | ) 145 | 146 | } 147 | 148 | @Test 149 | fun fromModelEntity() { 150 | val racket = Racket( 151 | brand = "Brand", 152 | model = "Model", 153 | price = 10.0, 154 | numberTenisPlayers = 2, 155 | image = "Image" 156 | ) 157 | 158 | val entity = racket.toEntity() 159 | 160 | assertAll( 161 | { assert(entity.id == null) }, 162 | { assert(entity.brand == racket.brand) }, 163 | { assert(entity.model == racket.model) }, 164 | { assert(entity.price == racket.price) }, 165 | { assert(entity.numberTenisPlayers == racket.numberTenisPlayers) }, 166 | { assert(entity.image == racket.image) } 167 | ) 168 | } 169 | 170 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/repositories/users/UsersRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.repositories.users 2 | 3 | import com.toxicbakery.bcrypt.Bcrypt 4 | import joseluisgs.dev.entities.UserTable 5 | import joseluisgs.dev.mappers.toEntity 6 | import joseluisgs.dev.mappers.toModel 7 | import joseluisgs.dev.models.User 8 | import joseluisgs.dev.services.database.DataBaseService 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.withContext 13 | import mu.KotlinLogging 14 | import org.koin.core.annotation.Singleton 15 | import java.time.LocalDateTime 16 | 17 | private val logger = KotlinLogging.logger {} 18 | private const val BCRYPT_SALT = 12 19 | 20 | /** 21 | * Users Repository 22 | * @property dataBaseService Database service 23 | */ 24 | @Singleton 25 | class UsersRepositoryImpl( 26 | private val dataBaseService: DataBaseService 27 | ) : UsersRepository { 28 | 29 | /** 30 | * Find all users in database 31 | * @return Flow Flow of users 32 | * @see User 33 | */ 34 | override suspend fun findAll(): Flow = withContext(Dispatchers.IO) { 35 | logger.debug { "findAll" } 36 | 37 | return@withContext (dataBaseService.client selectFrom UserTable).fetchAll() 38 | .map { it.toModel() } 39 | } 40 | 41 | /** 42 | * Get hashed password from a plain text password 43 | * @param password Plain text password 44 | * @return String Hashed password by Bcrypt algorithm 45 | */ 46 | override fun hashedPassword(password: String) = Bcrypt.hash(password, BCRYPT_SALT).decodeToString() 47 | 48 | /** 49 | * Check if username and password are correct 50 | * @param username Username 51 | * @param password Password 52 | * @return User? User if username and password are correct, null otherwise 53 | */ 54 | override suspend fun checkUserNameAndPassword(username: String, password: String): User? = 55 | withContext(Dispatchers.IO) { 56 | val user = findByUsername(username) 57 | return@withContext user?.let { 58 | if (Bcrypt.verify(password, user.password.encodeToByteArray())) { 59 | return@withContext user 60 | } 61 | return@withContext null 62 | } 63 | } 64 | 65 | /** 66 | * Find user by id 67 | * @param id User id 68 | * @return User? User if exists, null otherwise 69 | */ 70 | override suspend fun findById(id: Long): User? = withContext(Dispatchers.IO) { 71 | logger.debug { "findById: Buscando usuario con id: $id" } 72 | 73 | return@withContext (dataBaseService.client selectFrom UserTable 74 | where UserTable.id eq id 75 | ).fetchFirstOrNull()?.toModel() 76 | } 77 | 78 | /** 79 | * Find user by username 80 | * @param username User username 81 | * @return User? User if exists, null otherwise 82 | */ 83 | override suspend fun findByUsername(username: String): User? = withContext(Dispatchers.IO) { 84 | logger.debug { "findByUsername: Buscando usuario con username: $username" } 85 | 86 | return@withContext (dataBaseService.client selectFrom UserTable 87 | where UserTable.username eq username 88 | ).fetchFirstOrNull()?.toModel() 89 | } 90 | 91 | /** 92 | * Save or update user 93 | * @param entity User to save or update 94 | * @return User User saved or updated 95 | */ 96 | override suspend fun save(entity: User): User = withContext(Dispatchers.IO) { 97 | logger.debug { "save: $entity" } 98 | 99 | if (entity.id == User.NEW_USER) { 100 | create(entity) 101 | } else { 102 | update(entity) 103 | } 104 | } 105 | 106 | /** 107 | * Create user 108 | * @param entity User to create 109 | * @return User User created 110 | */ 111 | suspend fun create(entity: User): User { 112 | val newEntity = entity.copy( 113 | password = Bcrypt.hash(entity.password, 12).decodeToString(), 114 | createdAt = LocalDateTime.now(), 115 | updatedAt = LocalDateTime.now() 116 | ).toEntity() 117 | 118 | logger.debug { "create: $newEntity" } 119 | 120 | return (dataBaseService.client insertAndReturn newEntity).toModel() 121 | 122 | } 123 | 124 | /** 125 | * Update user 126 | * @param entity User to update 127 | * @return User User updated 128 | */ 129 | suspend fun update(entity: User): User { 130 | logger.debug { "update: $entity" } 131 | val updateEntity = entity.copy(updatedAt = LocalDateTime.now()).toEntity() 132 | 133 | (dataBaseService.client update UserTable 134 | set UserTable.name eq updateEntity.name 135 | set UserTable.username eq updateEntity.username 136 | set UserTable.password eq updateEntity.password 137 | set UserTable.email eq updateEntity.email 138 | set UserTable.avatar eq updateEntity.avatar 139 | set UserTable.role eq updateEntity.role 140 | set UserTable.updatedAt eq updateEntity.updatedAt 141 | where UserTable.id eq entity.id) 142 | .execute() 143 | return updateEntity.toModel() 144 | } 145 | 146 | /** 147 | * Delete user 148 | * @param entity User to delete 149 | * @return User User deleted 150 | */ 151 | override suspend fun delete(entity: User): User { 152 | logger.debug { "delete: $entity" } 153 | (dataBaseService.client deleteFrom UserTable 154 | where UserTable.id eq entity.id) 155 | .execute() 156 | return entity 157 | } 158 | 159 | /** 160 | * Delete all users 161 | * @return Unit 162 | */ 163 | override suspend fun deleteAll() { 164 | logger.debug { "deleteAll" } 165 | dataBaseService.client deleteAllFrom UserTable 166 | } 167 | 168 | /** 169 | * Save all users 170 | * @param entities Iterable Users to save 171 | * @return Flow Flow of users 172 | */ 173 | override suspend fun saveAll(entities: Iterable): Flow { 174 | logger.debug { "saveAll: $entities" } 175 | entities.forEach { save(it) } 176 | return this.findAll() 177 | } 178 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/users/UsersServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.users 2 | 3 | 4 | import com.github.michaelbull.result.* 5 | import joseluisgs.dev.errors.user.UserError 6 | import joseluisgs.dev.models.User 7 | import joseluisgs.dev.repositories.users.UsersRepository 8 | import joseluisgs.dev.services.cache.CacheService 9 | import kotlinx.coroutines.flow.Flow 10 | import mu.KotlinLogging 11 | import org.koin.core.annotation.Singleton 12 | 13 | private val logger = KotlinLogging.logger {} 14 | 15 | /** 16 | * Users Service to our User 17 | * Define the CRUD operations of our application with our Users using cache 18 | * @property usersRepository UsersRepository Repository of our Users 19 | * @property cacheService CacheService Cache Service to our Users 20 | */ 21 | @Singleton 22 | class UsersServiceImpl( 23 | private val usersRepository: UsersRepository, 24 | private val cacheService: CacheService 25 | ) : UsersService { 26 | 27 | /** 28 | * Find all users 29 | * @return Flow Flow of users 30 | * @see User 31 | */ 32 | override suspend fun findAll(): Flow { 33 | logger.debug { "findAll: search all users" } 34 | 35 | return usersRepository.findAll() 36 | } 37 | 38 | /** 39 | * Find by id 40 | * @param id Long Id of user 41 | * @return Result Result of user or error if not found 42 | */ 43 | override suspend fun findById(id: Long): Result { 44 | logger.debug { "findById: search user by id" } 45 | 46 | // find in cache if not found in repository 47 | return cacheService.users.get(id)?.let { 48 | logger.debug { "findById: found in cache" } 49 | Ok(it) 50 | } ?: run { 51 | usersRepository.findById(id)?.let { user -> 52 | logger.debug { "findById: found in repository" } 53 | cacheService.users.put(id, user) 54 | Ok(user) 55 | } ?: Err(UserError.NotFound("User with id $id not found")) 56 | } 57 | } 58 | 59 | /** 60 | * Find by username 61 | * @param username String Username of user 62 | * @return Result Result of user or error if not found 63 | */ 64 | override suspend fun findByUsername(username: String): Result { 65 | logger.debug { "findById: search user by username" } 66 | 67 | // find in cache if not found in repository 68 | return usersRepository.findByUsername(username)?.let { user -> 69 | logger.debug { "findById: found in repository" } 70 | cacheService.users.put(user.id, user) 71 | Ok(user) 72 | } ?: Err(UserError.NotFound("User with username: $username not found")) 73 | } 74 | 75 | /** 76 | * Check if username and password are valid 77 | * @param username String Username of user 78 | * @param password String Password of user 79 | * @return Result Result of user or error if not found 80 | */ 81 | override suspend fun checkUserNameAndPassword(username: String, password: String): Result { 82 | logger.debug { "checkUserNameAndPassword: check username and password" } 83 | 84 | return usersRepository.checkUserNameAndPassword(username, password)?.let { 85 | Ok(it) 86 | } ?: Err(UserError.BadCredentials("User password or username not valid")) 87 | } 88 | 89 | /** 90 | * Save user 91 | * @param user User User to save 92 | * @return Result Result of user or error if not found 93 | */ 94 | override suspend fun save(user: User): Result { 95 | logger.debug { "save: save user" } 96 | 97 | return findByUsername(user.username).onSuccess { 98 | return Err(UserError.BadRequest("Another user existe with this username: ${user.username}")) 99 | }.onFailure { 100 | return Ok(usersRepository.save(user).also { 101 | cacheService.users.put(it.id, it) 102 | }) 103 | } 104 | 105 | } 106 | 107 | /** 108 | * Update user 109 | * @param user User to update 110 | * @param id Long Id of user 111 | * @return Result Result of user or error if not found 112 | */ 113 | override suspend fun update(id: Long, user: User): Result { 114 | logger.debug { "update: update user" } 115 | 116 | // search if exists user with same username 117 | return findByUsername(user.username).onSuccess { 118 | // if exists, check if is the same user or not 119 | return if (user.id == id) { 120 | Ok(usersRepository.save(user).also { cacheService.users.put(it.id, it) }) 121 | } else { 122 | Err(UserError.BadRequest("Another user exists with username: ${user.username}")) 123 | } 124 | }.onFailure { 125 | // if not exists, update user 126 | return Ok(usersRepository.save(user).also { cacheService.users.put(it.id, it) }) 127 | } 128 | } 129 | 130 | /** 131 | * Delete user 132 | * @param id Long Id of user 133 | * @return Result Result of user or error if not found 134 | */ 135 | override suspend fun delete(id: Long): Result { 136 | logger.debug { "delete: delete" } 137 | 138 | // find, if exists delete in cache and repository and notify 139 | return findById(id).andThen { 140 | Ok(usersRepository.delete(it).also { 141 | cacheService.users.invalidate(id) 142 | }) 143 | } 144 | } 145 | 146 | /** 147 | * Check if user is admin 148 | * @param id Long Id of user 149 | * @return Result Result of user or error if not found 150 | */ 151 | override suspend fun isAdmin(id: Long): Result { 152 | logger.debug { "isAdmin: chek if user is admin" } 153 | return findById(id).andThen { 154 | if (it.role == User.Role.ADMIN) { 155 | cacheService.users.put(it.id, it) 156 | Ok(true) 157 | } else { 158 | Err(UserError.BadRole("User is not admin")) 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Update image of user 165 | * @param id Long Id of user 166 | * @param image String Image of user 167 | * @return Result Result of user or error if not found 168 | */ 169 | override suspend fun updateImage(id: Long, image: String): Result { 170 | logger.debug { "updateImage: update image user" } 171 | 172 | // find, if exists update in cache and repository and notify 173 | return findById(id).andThen { 174 | Ok(usersRepository.save( 175 | it.copy( 176 | avatar = image 177 | ) 178 | ).also { res -> 179 | cacheService.users.put(id, res) 180 | }) 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/services/rackets/RacketsServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.rackets 2 | 3 | import com.github.michaelbull.result.Err 4 | import com.github.michaelbull.result.Ok 5 | import com.github.michaelbull.result.Result 6 | import com.github.michaelbull.result.andThen 7 | import joseluisgs.dev.dto.NotificacionDto.NotificationType 8 | import joseluisgs.dev.dto.RacketNotification 9 | import joseluisgs.dev.errors.racket.RacketError 10 | import joseluisgs.dev.mappers.toResponse 11 | import joseluisgs.dev.models.Racket 12 | import joseluisgs.dev.repositories.rackets.RacketsRepository 13 | import joseluisgs.dev.services.cache.CacheService 14 | import kotlinx.coroutines.channels.BufferOverflow 15 | import kotlinx.coroutines.flow.Flow 16 | import kotlinx.coroutines.flow.MutableSharedFlow 17 | import kotlinx.coroutines.flow.asSharedFlow 18 | import mu.KotlinLogging 19 | import org.koin.core.annotation.Singleton 20 | 21 | private val logger = KotlinLogging.logger {} 22 | 23 | /** 24 | * Rackets Service to our Rackets 25 | * Define the CRUD operations of our application with our Rackets using cache 26 | * @property racketsRepository RacketsRepository Repository of our Rackets 27 | * @property cacheService CacheService Cache Service to our Rackets 28 | */ 29 | @Singleton 30 | class RacketsServiceImpl( 31 | private val racketsRepository: RacketsRepository, 32 | private val cacheService: CacheService 33 | ) : RacketsService { 34 | 35 | /** 36 | * Find all Rackets 37 | * @return Flow Rackets 38 | * @see Racket 39 | */ 40 | override suspend fun findAll(): Flow { 41 | logger.debug { "findAll: search all rackets" } 42 | 43 | return racketsRepository.findAll() 44 | } 45 | 46 | /** 47 | * Find all Rackets with pagination 48 | * @param page Int Page to search 49 | * @param perPage Int Number of elements per page 50 | * @return Flow Rackets 51 | */ 52 | override suspend fun findAllPageable(page: Int, perPage: Int): Flow { 53 | logger.debug { "findAllPageable: search all rackets with pagination" } 54 | 55 | return racketsRepository.findAllPageable(page, perPage) 56 | } 57 | 58 | /** 59 | * Find all Rackets by brand 60 | * @param brand String Brand to search 61 | * @return Flow Rackets 62 | */ 63 | override suspend fun findByBrand(brand: String): Flow { 64 | logger.debug { "findByBrand: search all rackets by brand" } 65 | 66 | return racketsRepository.findByBrand(brand) 67 | } 68 | 69 | /** 70 | * Find by id 71 | * @param id Long Id to search 72 | * @return Result Racket or error if not exists 73 | */ 74 | override suspend fun findById(id: Long): Result { 75 | logger.debug { "findById: search racket by id" } 76 | 77 | // find in cache if not found in repository 78 | return cacheService.rackets.get(id)?.let { 79 | logger.debug { "findById: found in cache" } 80 | Ok(it) 81 | } ?: run { 82 | racketsRepository.findById(id)?.let { racket -> 83 | logger.debug { "findById: found in repository" } 84 | cacheService.rackets.put(id, racket) 85 | Ok(racket) 86 | } ?: Err(RacketError.NotFound("Racket with id $id not found")) 87 | } 88 | } 89 | 90 | /** 91 | * Save a Racket 92 | * @param racket Racket Racket to save 93 | * @return Result Racket or error if not possible 94 | */ 95 | override suspend fun save(racket: Racket): Result { 96 | logger.debug { "save: save racket" } 97 | 98 | // return ok if we save in cache and repository and notify 99 | return Ok(racketsRepository.save(racket).also { 100 | cacheService.rackets.put(it.id, it) 101 | onChange(NotificationType.CREATE, it.id, it) 102 | }) 103 | } 104 | 105 | /** 106 | * Update a Racket 107 | * @param id Long Id to update 108 | * @param racket Racket Racket to update 109 | * @return Result Racket or error if not possible 110 | */ 111 | override suspend fun update(id: Long, racket: Racket): Result { 112 | logger.debug { "update: update racket" } 113 | 114 | // find, if exists update in cache and repository and notify 115 | return findById(id).andThen { 116 | // Copy the new values over the existing values. Return OK if the racket was updated in the database and cache 117 | Ok(racketsRepository.save( 118 | it.copy( 119 | brand = racket.brand, 120 | model = racket.model, 121 | price = racket.price, 122 | image = racket.image, 123 | numberTenisPlayers = racket.numberTenisPlayers 124 | ) 125 | ).also { res -> 126 | cacheService.rackets.put(id, res) 127 | onChange(NotificationType.UPDATE, id, res) 128 | }) 129 | } 130 | } 131 | 132 | /** 133 | * Delete a Racket 134 | * @param id Long Id to delete 135 | * @return Result Racket or error if not possible 136 | */ 137 | override suspend fun delete(id: Long): Result { 138 | logger.debug { "delete: delete racket" } 139 | 140 | // find, if exists delete in cache and repository and notify 141 | return findById(id).andThen { 142 | Ok(racketsRepository.delete(it).also { res -> 143 | cacheService.rackets.invalidate(id) 144 | onChange(NotificationType.DELETE, id, res) 145 | }) 146 | } 147 | } 148 | 149 | /** 150 | * Update a Racket image 151 | * @param id Long Id to update 152 | * @param image String Image to update 153 | * @return Result Racket or error if not possible 154 | */ 155 | override suspend fun updateImage(id: Long, image: String): Result { 156 | logger.debug { "updateImage: update image racket" } 157 | 158 | // find, if exists update in cache and repository and notify 159 | return findById(id).andThen { 160 | // Copy the new values over the existing values. Return OK if the racket was updated in the database and cache 161 | Ok(racketsRepository.save( 162 | it.copy( 163 | image = image 164 | ) 165 | ).also { res -> 166 | cacheService.rackets.put(id, res) 167 | onChange(NotificationType.UPDATE, id, res) 168 | }) 169 | } 170 | } 171 | 172 | // Real Time Notifications and WebSockets 173 | // We can notify use a reactive system with SharedFlow 174 | private val _notificationState: MutableSharedFlow = 175 | MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) 176 | 177 | override val notificationState = _notificationState.asSharedFlow() 178 | 179 | private suspend fun onChange(type: NotificationType, id: Long, data: Racket) { 180 | logger.debug { "onChange: Notification on Rackets: $type, notification updates to subscribers: $data" } 181 | // update notification state 182 | _notificationState.tryEmit( 183 | RacketNotification( 184 | entity = "RACKET", 185 | type = type, 186 | id = id, 187 | data = data.toResponse() 188 | ) 189 | ) 190 | } 191 | } -------------------------------------------------------------------------------- /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 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/routes/RacketsRoutesKtTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.routes 2 | 3 | import io.ktor.client.plugins.contentnegotiation.* 4 | import io.ktor.client.request.* 5 | import io.ktor.client.request.forms.* 6 | import io.ktor.client.statement.* 7 | import io.ktor.http.* 8 | import io.ktor.serialization.kotlinx.json.* 9 | import io.ktor.server.config.* 10 | import io.ktor.server.testing.* 11 | import io.ktor.util.* 12 | import io.ktor.utils.io.* 13 | import joseluisgs.dev.dto.RacketRequest 14 | import joseluisgs.dev.dto.RacketResponse 15 | import kotlinx.serialization.json.Json 16 | import org.junit.jupiter.api.* 17 | import org.junit.jupiter.api.Assertions.assertEquals 18 | import java.io.File 19 | import java.util.* 20 | 21 | 22 | private val json = Json { ignoreUnknownKeys = true } 23 | 24 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 25 | @TestMethodOrder(MethodOrderer.OrderAnnotation::class) 26 | class RacketsRoutesKtTest { 27 | // Load configuration from application.conf 28 | private val config = ApplicationConfig("application.conf") 29 | 30 | val racket = RacketRequest( 31 | brand = "Test", 32 | model = "Test", 33 | price = 10.0, 34 | numberTenisPlayers = 1, 35 | ) 36 | 37 | // New we can user it to test routes with Ktor 38 | @Test 39 | @Order(1) 40 | fun testGetAll() = testApplication { 41 | // Set up the test environment 42 | environment { config } 43 | 44 | // Launch the test 45 | val response = client.get("/api/rackets") 46 | 47 | // Check the response and the content 48 | assertEquals(HttpStatusCode.OK, response.status) 49 | // Check the content if we want 50 | // val result = response.bodyAsText() 51 | // val list = json.decodeFromString>(result) 52 | // .... 53 | 54 | } 55 | 56 | @Test 57 | @Order(2) 58 | fun testGetAllPageable() = testApplication { 59 | environment { config } 60 | 61 | val response = client.get("/api/rackets?page=1&perPage=10") 62 | 63 | assertEquals(HttpStatusCode.OK, response.status) 64 | } 65 | 66 | @Test 67 | @Order(3) 68 | fun testPost() = testApplication { 69 | environment { config } 70 | 71 | // Configure the client, as we are going to send JSON 72 | val client = createClient { 73 | install(ContentNegotiation) { 74 | json() 75 | } 76 | } 77 | 78 | // Launch the query to create it and that it is 79 | val response = client.post("/api/rackets") { 80 | contentType(ContentType.Application.Json) 81 | setBody(racket) 82 | } 83 | 84 | // Check that the response and the content is correct 85 | assertEquals(HttpStatusCode.Created, response.status) 86 | val result = response.bodyAsText() 87 | 88 | // We can check that the result is a JSON analyzing the string or deserializing it 89 | val dto = json.decodeFromString(result) 90 | assertAll( 91 | { assertEquals(racket.brand, dto.brand) }, 92 | { assertEquals(racket.model, dto.model) }, 93 | { assertEquals(racket.price, dto.price) }, 94 | { assertEquals(racket.numberTenisPlayers, dto.numberTenisPlayers) }, 95 | ) 96 | } 97 | 98 | @Test 99 | @Order(4) 100 | fun testPut() = testApplication { 101 | environment { config } 102 | 103 | val client = createClient { 104 | install(ContentNegotiation) { 105 | json() 106 | } 107 | } 108 | 109 | // Create 110 | var response = client.post("/api/rackets") { 111 | contentType(ContentType.Application.Json) 112 | setBody(racket) 113 | } 114 | 115 | // Take the id of the result 116 | var dto = json.decodeFromString(response.bodyAsText()) 117 | 118 | // Update 119 | response = client.put("/api/rackets/${dto.id}") { 120 | contentType(ContentType.Application.Json) 121 | setBody(racket.copy(brand = "TestBrand2", model = "TestModel2")) 122 | } 123 | 124 | // Check that the response and the content is correct 125 | assertEquals(HttpStatusCode.OK, response.status) 126 | val result = response.bodyAsText() 127 | dto = json.decodeFromString(result) 128 | assertAll( 129 | { assertEquals("TestBrand2", dto.brand) }, 130 | { assertEquals("TestModel2", dto.model) }, 131 | { assertEquals(racket.price, dto.price) }, 132 | { assertEquals(racket.numberTenisPlayers, dto.numberTenisPlayers) }, 133 | ) 134 | } 135 | 136 | @Test 137 | @Order(5) 138 | fun testPutNotFound() = testApplication { 139 | environment { config } 140 | 141 | val client = createClient { 142 | install(ContentNegotiation) { 143 | json() 144 | } 145 | } 146 | 147 | val response = client.put("/api/rackets/-1") { 148 | contentType(ContentType.Application.Json) 149 | setBody(racket.copy(brand = "TestBrand2", model = "TestModel2")) 150 | } 151 | 152 | assertEquals(HttpStatusCode.NotFound, response.status) 153 | } 154 | 155 | @Test 156 | @Order(6) 157 | fun testDelete() = testApplication { 158 | environment { config } 159 | 160 | val client = createClient { 161 | install(ContentNegotiation) { 162 | json() 163 | } 164 | } 165 | 166 | var response = client.post("/api/rackets") { 167 | contentType(ContentType.Application.Json) 168 | setBody(racket) 169 | } 170 | 171 | val dto = json.decodeFromString(response.bodyAsText()) 172 | 173 | response = client.delete("/api/rackets/${dto.id}") 174 | assertEquals(HttpStatusCode.NoContent, response.status) 175 | } 176 | 177 | @Test 178 | @Order(7) 179 | fun testDeleteNotFound() = testApplication { 180 | environment { config } 181 | 182 | val response = client.delete("/api/rackets/-1") 183 | 184 | assertEquals(HttpStatusCode.NotFound, response.status) 185 | } 186 | 187 | @Test 188 | @Order(8) 189 | fun testGetById() = testApplication { 190 | environment { config } 191 | 192 | val client = createClient { 193 | install(ContentNegotiation) { 194 | json() 195 | } 196 | } 197 | 198 | var response = client.post("/api/rackets") { 199 | contentType(ContentType.Application.Json) 200 | setBody(racket) 201 | } 202 | 203 | val result = response.bodyAsText() 204 | var dto = json.decodeFromString(result) 205 | 206 | response = client.get("/api/rackets/${dto.id}") 207 | dto = json.decodeFromString(result) 208 | 209 | assertEquals(HttpStatusCode.OK, response.status) 210 | assertAll( 211 | { assertEquals(racket.brand, dto.brand) }, 212 | { assertEquals(racket.model, dto.model) }, 213 | { assertEquals(racket.price, dto.price) }, 214 | { assertEquals(racket.numberTenisPlayers, dto.numberTenisPlayers) }, 215 | ) 216 | } 217 | 218 | @Test 219 | @Order(9) 220 | fun testGetByIdNotFound() = testApplication { 221 | environment { config } 222 | 223 | val response = client.get("/api/rackets/-1") 224 | 225 | assertEquals(HttpStatusCode.NotFound, response.status) 226 | } 227 | 228 | @OptIn(InternalAPI::class) 229 | @Test 230 | @Order(10) 231 | fun testPatchImage() = testApplication { 232 | environment { config } 233 | 234 | val client = createClient { 235 | install(ContentNegotiation) { 236 | json() 237 | } 238 | } 239 | 240 | var response = client.post("/api/rackets") { 241 | contentType(ContentType.Application.Json) 242 | setBody(racket) 243 | } 244 | 245 | val result = response.bodyAsText() 246 | val dto = json.decodeFromString(result) 247 | 248 | val boundary = "WebAppBoundary" 249 | response = client.patch("/api/rackets/${dto.id}") { 250 | setBody( 251 | MultiPartFormDataContent( 252 | formData { 253 | // Load file from resources folder 254 | append("file", File("src/test/resources/racket.jpg").readBytes(), Headers.build { 255 | append(HttpHeaders.ContentType, "image/jp") 256 | append(HttpHeaders.ContentDisposition, "filename=\"racket.jpg\"") 257 | }) 258 | }, 259 | boundary, 260 | ContentType.MultiPart.FormData.withParameter("boundary", boundary) 261 | ) 262 | ) 263 | } 264 | 265 | assertEquals(HttpStatusCode.OK, response.status) 266 | } 267 | 268 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/services/rackets/RacketsServiceImplTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.rackets 2 | 3 | import com.github.michaelbull.result.get 4 | import com.github.michaelbull.result.getError 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 io.mockk.just 11 | import io.mockk.runs 12 | import joseluisgs.dev.data.racketsDemoData 13 | import joseluisgs.dev.errors.racket.RacketError 14 | import joseluisgs.dev.repositories.rackets.RacketsRepositoryImpl 15 | import joseluisgs.dev.services.cache.CacheService 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.flow.flowOf 18 | import kotlinx.coroutines.flow.toList 19 | import kotlinx.coroutines.test.runTest 20 | import org.junit.jupiter.api.Assertions.* 21 | import org.junit.jupiter.api.Test 22 | import org.junit.jupiter.api.TestInstance 23 | import org.junit.jupiter.api.extension.ExtendWith 24 | 25 | @OptIn(ExperimentalCoroutinesApi::class) 26 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 27 | @ExtendWith(MockKExtension::class) 28 | class RacketsServiceImplTest { 29 | 30 | @MockK 31 | lateinit var repository: RacketsRepositoryImpl 32 | 33 | @MockK 34 | lateinit var cache: CacheService 35 | 36 | @InjectMockKs 37 | lateinit var service: RacketsServiceImpl 38 | 39 | val rackets = racketsDemoData().values 40 | 41 | @Test 42 | fun findAll() = runTest { 43 | // Given 44 | coEvery { repository.findAll() } returns flowOf(rackets.first()) 45 | // When 46 | val result = service.findAll().toList() 47 | // Then 48 | assertAll( 49 | { assertNotNull(result) }, 50 | { assertEquals(1, result.size) }, 51 | { assertEquals(rackets.first(), result.first()) } 52 | ) 53 | // Verifications 54 | coVerify(exactly = 1) { repository.findAll() } 55 | } 56 | 57 | @Test 58 | fun findAllPageable() = runTest { 59 | // Given 60 | coEvery { repository.findAllPageable(0, 10) } returns flowOf(rackets.first()) 61 | // When 62 | val result = service.findAllPageable(0, 10).toList() 63 | // Then 64 | assertAll( 65 | { assertNotNull(result) }, 66 | { assertEquals(1, result.size) }, 67 | { assertEquals(rackets.first(), result.first()) } 68 | ) 69 | // Verifications 70 | coVerify(exactly = 1) { repository.findAllPageable(0, 10) } 71 | } 72 | 73 | @Test 74 | fun findByBrand() = runTest { 75 | // Given 76 | coEvery { repository.findByBrand("Babolat") } returns flowOf(rackets.first()) 77 | // When 78 | val result = service.findByBrand("Babolat").toList() 79 | // Then 80 | assertAll( 81 | { assertNotNull(result) }, 82 | { assertEquals(1, result.size) }, 83 | { assertEquals(rackets.first(), result.first()) } 84 | ) 85 | // Verifications 86 | coVerify(exactly = 1) { repository.findByBrand("Babolat") } 87 | 88 | } 89 | 90 | @Test 91 | fun findByIdFoundButNotInCache() = runTest { 92 | // Given 93 | coEvery { cache.rackets.get(any()) } returns null 94 | coEvery { repository.findById(any()) } returns rackets.first() 95 | coEvery { cache.rackets.put(any(), rackets.first()) } just runs // returns Unit 96 | // When 97 | val result = service.findById(1) 98 | // Then 99 | assertAll( 100 | { assertNotNull(result) }, 101 | { assertEquals(rackets.first(), result.get()) } 102 | ) 103 | // Verifications 104 | coVerify { cache.rackets.get(any()) } 105 | coVerify { repository.findById(any()) } 106 | coVerify { cache.rackets.put(any(), rackets.first()) } 107 | } 108 | 109 | @Test 110 | fun findByIdFoundFromCache() = runTest { 111 | // Given 112 | coEvery { cache.rackets.get(any()) } returns rackets.first() 113 | coEvery { cache.rackets.put(any(), rackets.first()) } returns Unit // or just runs 114 | // When 115 | val result = service.findById(1) 116 | // Then 117 | assertAll( 118 | { assertNotNull(result) }, 119 | { assertEquals(rackets.first(), result.get()) } 120 | ) 121 | // Verifications 122 | coVerify { cache.rackets.get(any()) } 123 | coVerify { cache.rackets.put(any(), rackets.first()) } 124 | } 125 | 126 | @Test 127 | fun findByIdNotFound() = runTest { 128 | // Given 129 | coEvery { cache.rackets.get(any()) } returns null 130 | coEvery { repository.findById(any()) } returns null 131 | // When 132 | val result = service.findById(1) 133 | // Then 134 | assertAll( 135 | { assertNotNull(result) }, 136 | { assertNull(result.get()) }, 137 | { assertNotNull(result.getError()) }, 138 | { assertTrue(result.getError() is RacketError.NotFound) }, 139 | { assertEquals(result.getError()!!.message, "Racket with id 1 not found") } 140 | ) 141 | // Verifications 142 | coVerify { cache.rackets.get(any()) } 143 | coVerify { repository.findById(any()) } 144 | } 145 | 146 | @Test 147 | fun save() = runTest { 148 | // Given 149 | coEvery { repository.save(any()) } returns rackets.first() 150 | coEvery { cache.rackets.put(any(), rackets.first()) } just runs // returns Unit 151 | // When 152 | val result = service.save(rackets.first()) 153 | // Then 154 | assertAll( 155 | { assertNotNull(result) }, 156 | { assertEquals(rackets.first(), result.get()) } 157 | ) 158 | // Verifications 159 | coVerify { repository.save(any()) } 160 | coVerify { cache.rackets.put(any(), rackets.first()) } 161 | } 162 | 163 | @Test 164 | fun update() = runTest { 165 | // Given 166 | coEvery { cache.rackets.get(any()) } returns null 167 | coEvery { repository.findById(any()) } returns rackets.first() 168 | coEvery { repository.save(any()) } returns rackets.first() 169 | coEvery { cache.rackets.put(any(), rackets.first()) } just runs 170 | // When 171 | val result = service.update(1, rackets.first()) 172 | // Then 173 | assertAll( 174 | { assertNotNull(result) }, 175 | { assertEquals(rackets.first(), result.get()) } 176 | ) 177 | // Verifications 178 | coVerify { cache.rackets.get(any()) } 179 | coVerify { repository.findById(any()) } 180 | coVerify { repository.save(any()) } 181 | coVerify { cache.rackets.put(any(), rackets.first()) } 182 | } 183 | 184 | @Test 185 | fun updateNotFound() = runTest { 186 | // Given 187 | coEvery { cache.rackets.get(any()) } returns null 188 | coEvery { repository.findById(any()) } returns null 189 | // When 190 | val result = service.update(1, rackets.first()) 191 | // Then 192 | assertAll( 193 | { assertNotNull(result) }, 194 | { assertNull(result.get()) }, 195 | { assertNotNull(result.getError()) }, 196 | { assertTrue(result.getError() is RacketError.NotFound) }, 197 | { assertEquals(result.getError()!!.message, "Racket with id 1 not found") } 198 | ) 199 | // Verifications 200 | coVerify { cache.rackets.get(any()) } 201 | coVerify { repository.findById(any()) } 202 | } 203 | 204 | @Test 205 | fun delete() = runTest { 206 | // Given 207 | coEvery { cache.rackets.get(any()) } returns null 208 | coEvery { repository.findById(any()) } returns rackets.first() 209 | coEvery { cache.rackets.put(any(), rackets.first()) } just runs 210 | coEvery { repository.delete(any()) } returns rackets.first() 211 | coEvery { cache.rackets.invalidate(any()) } returns Unit 212 | // When 213 | val result = service.delete(1) 214 | // Then 215 | assertAll( 216 | { assertNotNull(result) }, 217 | { assertEquals(rackets.first(), result.get()) } 218 | ) 219 | // Verifications 220 | coVerify { cache.rackets.get(any()) } 221 | coVerify { repository.findById(any()) } 222 | coVerify { cache.rackets.put(any(), rackets.first()) } 223 | coVerify { repository.delete(any()) } 224 | coVerify { cache.rackets.invalidate(any()) } 225 | } 226 | 227 | @Test 228 | fun deleteNotFound() = runTest { 229 | // Given 230 | coEvery { cache.rackets.get(any()) } returns null 231 | coEvery { repository.findById(any()) } returns null 232 | // When 233 | val result = service.delete(1) 234 | // Then 235 | assertAll( 236 | { assertNotNull(result) }, 237 | { assertNull(result.get()) }, 238 | { assertNotNull(result.getError()) }, 239 | { assertTrue(result.getError() is RacketError.NotFound) }, 240 | { assertEquals(result.getError()!!.message, "Racket with id 1 not found") } 241 | ) 242 | // Verifications 243 | coVerify { cache.rackets.get(any()) } 244 | coVerify { repository.findById(any()) } 245 | } 246 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/services/users/UsersServiceImplTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.services.users 2 | 3 | import com.github.michaelbull.result.get 4 | import com.github.michaelbull.result.getError 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 io.mockk.just 11 | import io.mockk.runs 12 | import joseluisgs.dev.data.userDemoData 13 | import joseluisgs.dev.errors.user.UserError 14 | import joseluisgs.dev.repositories.users.UsersRepositoryImpl 15 | import joseluisgs.dev.services.cache.CacheService 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.flow.flowOf 18 | import kotlinx.coroutines.flow.toList 19 | import kotlinx.coroutines.test.runTest 20 | import org.junit.jupiter.api.Assertions.* 21 | import org.junit.jupiter.api.Test 22 | import org.junit.jupiter.api.TestInstance 23 | import org.junit.jupiter.api.extension.ExtendWith 24 | import java.util.* 25 | 26 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 27 | @ExtendWith(MockKExtension::class) 28 | @OptIn(ExperimentalCoroutinesApi::class) 29 | class UsersServiceImplTest { 30 | 31 | @MockK 32 | lateinit var repository: UsersRepositoryImpl 33 | 34 | @MockK 35 | lateinit var cache: CacheService 36 | 37 | @InjectMockKs 38 | lateinit var service: UsersServiceImpl 39 | 40 | val users = userDemoData().values 41 | 42 | 43 | @Test 44 | fun findAll() = runTest { 45 | // Given 46 | coEvery { repository.findAll() } returns flowOf(users.first()) 47 | // When 48 | val result = service.findAll().toList() 49 | // Then 50 | assertAll( 51 | { assertEquals(1, result.size) }, 52 | { assertEquals(users.first(), result.first()) } 53 | ) 54 | // Verifications 55 | coVerify { repository.findAll() } 56 | } 57 | 58 | @Test 59 | fun findById() = runTest { 60 | // Given 61 | coEvery { cache.users.get(any()) } returns null 62 | coEvery { repository.findById(any()) } returns users.first() 63 | coEvery { cache.users.put(any(), users.first()) } just runs // returns Unit 64 | // When 65 | val result = service.findById(1) 66 | // Then 67 | assertAll( 68 | { assertNotNull(result) }, 69 | { assertEquals(users.first(), result.get()) } 70 | ) 71 | // Verifications 72 | coVerify { cache.users.get(any()) } 73 | coVerify { repository.findById(any()) } 74 | coVerify { cache.users.put(any(), users.first()) } 75 | } 76 | 77 | @Test 78 | fun findByIdFoundFromCache() = runTest { 79 | // Given 80 | coEvery { cache.users.get(any()) } returns users.first() 81 | coEvery { cache.users.put(any(), users.first()) } returns Unit // or just runs 82 | // When 83 | val result = service.findById(1) 84 | // Then 85 | assertAll( 86 | { assertNotNull(result) }, 87 | { assertEquals(users.first(), result.get()) } 88 | ) 89 | // Verifications 90 | coVerify { cache.users.get(any()) } 91 | coVerify { cache.users.put(any(), users.first()) } 92 | } 93 | 94 | @Test 95 | fun findByIdNotFound() = runTest { 96 | // Given 97 | coEvery { cache.users.get(any()) } returns null 98 | coEvery { repository.findById(any()) } returns null 99 | // When 100 | val result = service.findById(1) 101 | // Then 102 | assertAll( 103 | { assertNotNull(result) }, 104 | { assertNull(result.get()) }, 105 | { assertNotNull(result.getError()) }, 106 | { assertTrue(result.getError() is UserError.NotFound) }, 107 | { assertEquals(result.getError()!!.message, "User with id 1 not found") } 108 | ) 109 | // Verifications 110 | coVerify { cache.users.get(any()) } 111 | coVerify { repository.findById(any()) } 112 | } 113 | 114 | @Test 115 | fun findByUsername() = runTest { 116 | // Given 117 | coEvery { repository.findByUsername(any()) } returns users.first() 118 | coEvery { cache.users.put(any(), users.first()) } returns Unit 119 | // When 120 | val result = service.findByUsername("Pepe") 121 | // Then 122 | assertAll( 123 | { assertNotNull(result) }, 124 | { assertEquals(users.first(), result.get()) } 125 | ) 126 | // Verifications 127 | coVerify { repository.findByUsername(any()) } 128 | coVerify { cache.users.put(any(), users.first()) } 129 | } 130 | 131 | @Test 132 | fun findByUsernameNotFound() = runTest { 133 | // Given 134 | coEvery { repository.findByUsername(any()) } returns null 135 | // When 136 | val result = service.findByUsername("Pepe") 137 | // Then 138 | assertAll( 139 | { assertNotNull(result) }, 140 | { assertNull(result.get()) }, 141 | { assertNotNull(result.getError()) }, 142 | { assertTrue(result.getError() is UserError.NotFound) }, 143 | { assertEquals(result.getError()!!.message, "User with username: Pepe not found") } 144 | ) 145 | // Verifications 146 | coVerify { repository.findByUsername(any()) } 147 | } 148 | 149 | 150 | @Test 151 | fun checkUserNameAndPassword() = runTest { 152 | // Given 153 | coEvery { repository.checkUserNameAndPassword(any(), any()) } returns users.first() 154 | // When 155 | val result = service.checkUserNameAndPassword("test", "test1234") 156 | // Then 157 | assertAll( 158 | { assertNotNull(result) }, 159 | { assertEquals(users.first(), result.get()) } 160 | ) 161 | // Verifications 162 | coVerify { repository.checkUserNameAndPassword(any(), any()) } 163 | } 164 | 165 | 166 | @Test 167 | fun checkUserNameAndPasswordNotFound() = runTest { 168 | // Given 169 | coEvery { repository.checkUserNameAndPassword(any(), any()) } returns null 170 | // When 171 | val result = service.checkUserNameAndPassword("Test", "test1234") 172 | // Then 173 | assertAll( 174 | { assertNotNull(result) }, 175 | { assertNull(result.get()) }, 176 | { assertNotNull(result.getError()) }, 177 | { assertTrue(result.getError() is UserError.BadCredentials) }, 178 | { assertEquals(result.getError()!!.message, "User password or username not valid") } 179 | ) 180 | // Verifications 181 | coVerify { repository.checkUserNameAndPassword(any(), any()) } 182 | } 183 | 184 | @Test 185 | fun save() = runTest { 186 | // Given 187 | coEvery { repository.save(any()) } returns users.first() 188 | coEvery { cache.users.put(any(), users.first()) } just runs 189 | coEvery { repository.hashedPassword(any()) } returns "test1234" 190 | // When 191 | val result = service.save(users.first()) 192 | // Then 193 | assertAll( 194 | { assertNotNull(result) }, 195 | { assertEquals(users.first(), result.get()) } 196 | ) 197 | // Verifications 198 | coVerify { repository.save(any()) } 199 | coVerify { cache.users.put(any(), users.first()) } 200 | } 201 | 202 | 203 | @Test 204 | fun update() = runTest { 205 | // Given 206 | coEvery { cache.users.get(any()) } returns null 207 | coEvery { repository.findById(any()) } returns users.first() 208 | coEvery { repository.save(any()) } returns users.first() 209 | coEvery { cache.users.put(any(), users.first()) } just runs 210 | // When 211 | val result = service.update(1, users.first()) 212 | // Then 213 | assertAll( 214 | { assertNotNull(result) }, 215 | { assertEquals(users.first(), result.get()) } 216 | ) 217 | // Verifications 218 | coVerify { cache.users.get(any()) } 219 | coVerify { repository.findById(any()) } 220 | coVerify { repository.save(any()) } 221 | coVerify { cache.users.put(any(), users.first()) } 222 | } 223 | 224 | @Test 225 | fun delete() = runTest { 226 | // Given 227 | coEvery { cache.users.get(any()) } returns null 228 | coEvery { repository.findById(any()) } returns users.first() 229 | coEvery { cache.users.put(any(), users.first()) } just runs 230 | coEvery { repository.delete(any()) } returns users.first() 231 | coEvery { cache.users.invalidate(any()) } returns Unit 232 | // When 233 | val result = service.delete(1) 234 | // Then 235 | assertAll( 236 | { assertNotNull(result) }, 237 | { assertEquals(users.first(), result.get()) } 238 | ) 239 | // Verifications 240 | coVerify { cache.users.get(any()) } 241 | coVerify { repository.findById(any()) } 242 | coVerify { cache.users.put(any(), users.first()) } 243 | coVerify { repository.delete(any()) } 244 | coVerify { cache.users.invalidate(any()) } 245 | } 246 | 247 | @Test 248 | fun deleteNotFound() = runTest { 249 | // Given 250 | coEvery { cache.users.get(any()) } returns null 251 | coEvery { repository.findById(any()) } returns null 252 | // When 253 | val result = service.delete(1) 254 | // Then 255 | assertAll( 256 | { assertNotNull(result) }, 257 | { assertNull(result.get()) }, 258 | { assertNotNull(result.getError()) }, 259 | { assertTrue(result.getError() is UserError.NotFound) }, 260 | { assertEquals(result.getError()!!.message, "User with id 1 not found") } 261 | ) 262 | // Verifications 263 | coVerify { cache.users.get(any()) } 264 | coVerify { repository.findById(any()) } 265 | } 266 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/routes/UsersRoutes.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.routes 2 | 3 | import com.github.michaelbull.result.andThen 4 | import com.github.michaelbull.result.mapBoth 5 | import com.github.michaelbull.result.onFailure 6 | import com.github.michaelbull.result.onSuccess 7 | import io.ktor.http.* 8 | import io.ktor.http.content.* 9 | import io.ktor.server.application.* 10 | import io.ktor.server.auth.* 11 | import io.ktor.server.auth.jwt.* 12 | import io.ktor.server.plugins.* 13 | import io.ktor.server.request.* 14 | import io.ktor.server.response.* 15 | import io.ktor.server.routing.* 16 | import io.ktor.util.pipeline.* 17 | import joseluisgs.dev.dto.UserCreateDto 18 | import joseluisgs.dev.dto.UserLoginDto 19 | import joseluisgs.dev.dto.UserUpdateDto 20 | import joseluisgs.dev.dto.UserWithTokenDto 21 | import joseluisgs.dev.errors.storage.StorageError 22 | import joseluisgs.dev.errors.user.UserError 23 | import joseluisgs.dev.mappers.toDto 24 | import joseluisgs.dev.mappers.toModel 25 | import joseluisgs.dev.services.storage.StorageService 26 | import joseluisgs.dev.services.users.UsersService 27 | import joseluisgs.es.services.tokens.TokensService 28 | import kotlinx.coroutines.flow.toList 29 | import mu.KotlinLogging 30 | import org.koin.ktor.ext.inject 31 | 32 | private val logger = KotlinLogging.logger {} 33 | 34 | private const val ENDPOINT = "api/users" // Endpoint 35 | 36 | fun Application.usersRoutes() { 37 | 38 | // Dependency injection by Koin 39 | val usersService: UsersService by inject() 40 | val tokenService: TokensService by inject() 41 | val storageService: StorageService by inject() 42 | 43 | routing { 44 | route("/$ENDPOINT") { 45 | 46 | // Register a new user --> POST /api/users/register 47 | post("/register") { 48 | logger.debug { "POST Register /$ENDPOINT/register" } 49 | 50 | val dto = call.receive().toModel() 51 | usersService.save(dto) 52 | .mapBoth( 53 | success = { call.respond(HttpStatusCode.Created, it.toDto()) }, 54 | failure = { handleUserError(it) } 55 | ) 56 | } 57 | 58 | // Login a user --> POST /api/users/login 59 | post("/login") { 60 | logger.debug { "POST Login /$ENDPOINT/login" } 61 | 62 | val dto = call.receive() 63 | usersService.checkUserNameAndPassword(dto.username, dto.password) 64 | .mapBoth( 65 | success = { user -> 66 | val token = tokenService.generateJWT(user) 67 | call.respond(HttpStatusCode.OK, UserWithTokenDto(user.toDto(), token)) 68 | }, 69 | failure = { handleUserError(it) } 70 | ) 71 | } 72 | 73 | // JWT Auth routes 74 | authenticate { 75 | // Get the user info --> GET /api/users/me (with token) 76 | get("/me") { 77 | logger.debug { "GET Me /$ENDPOINT/me" } 78 | 79 | // Token came with principal (authenticated) user in its claims 80 | // Be careful, it comes with quotes!!! 81 | val userId = call.principal() 82 | ?.payload?.getClaim("userId") 83 | .toString().replace("\"", "").toLong() 84 | 85 | usersService.findById(userId) 86 | .mapBoth( 87 | success = { call.respond(HttpStatusCode.OK, it.toDto()) }, 88 | failure = { handleUserError(it) } 89 | ) 90 | } 91 | 92 | // Update user info --> PUT /api/users/me (with token) 93 | put("/me") { 94 | logger.debug { "PUT Me /$ENDPOINT/me" } 95 | 96 | val userId = call.principal() 97 | ?.payload?.getClaim("userId") 98 | .toString().replace("\"", "").toLong() 99 | 100 | val dto = call.receive() 101 | 102 | usersService.findById(userId).andThen { 103 | usersService.update( 104 | userId, it.copy( 105 | name = dto.name, 106 | username = dto.username, 107 | email = dto.email, 108 | ) 109 | ) 110 | }.mapBoth( 111 | success = { call.respond(HttpStatusCode.OK, it.toDto()) }, 112 | failure = { handleUserError(it) } 113 | ) 114 | } 115 | 116 | // Update user Image --> PATCH /api/users/me (with token) 117 | patch("/me") { 118 | logger.debug { "PUT Me /$ENDPOINT/me" } 119 | 120 | // Token came with principal (authenticated) user in its claims 121 | val userId = call.principal() 122 | ?.payload?.getClaim("userId") 123 | .toString().replace("\"", "").toLong() 124 | 125 | val baseUrl = 126 | call.request.origin.scheme + "://" + call.request.host() + ":" + call.request.port() + "/$ENDPOINT/image/" 127 | val multipartData = call.receiveMultipart() 128 | multipartData.forEachPart { part -> 129 | if (part is PartData.FileItem) { 130 | val fileName = part.originalFileName as String 131 | val fileBytes = part.streamProvider().readBytes() 132 | val fileExtension = fileName.substringAfterLast(".") 133 | val newFileName = "${System.currentTimeMillis()}.$fileExtension" 134 | val newFileUrl = "$baseUrl$newFileName" 135 | // Buscar usuario, 136 | storageService.saveFile(newFileName, newFileUrl, fileBytes).andThen { 137 | // Actualizar usuario 138 | usersService.updateImage( 139 | id = userId, 140 | image = newFileUrl 141 | ) 142 | }.mapBoth( 143 | success = { call.respond(HttpStatusCode.OK, it.toDto()) }, 144 | failure = { handleUserError(it) } 145 | ) 146 | } 147 | } 148 | } 149 | 150 | // Get racket image --> GET /api/rackets/image/{image} 151 | get("image/{image}") { 152 | logger.debug { "GET IMAGE /$ENDPOINT/image/{image}" } 153 | 154 | call.parameters["image"]?.let { image -> 155 | storageService.getFile(image).mapBoth( 156 | success = { call.respondFile(it) }, 157 | failure = { handleUserError(it) } 158 | ) 159 | } 160 | } 161 | 162 | // Get all users --> GET /api/users/list (with token and only if you are admin) 163 | get("/list") { 164 | logger.debug { "GET Users /$ENDPOINT/list" } 165 | 166 | val userId = call.principal() 167 | ?.payload?.getClaim("userId") 168 | .toString().replace("\"", "").toLong() 169 | 170 | usersService.isAdmin(userId) 171 | .onSuccess { 172 | usersService.findAll().toList() 173 | .map { it.toDto() } 174 | .let { call.respond(HttpStatusCode.OK, it) } 175 | }.onFailure { 176 | handleUserError(it) 177 | } 178 | } 179 | 180 | // Delete user --> DELETE /api/users/delete/{id} (with token and only if you are admin) 181 | delete("delete/{id}") { 182 | logger.debug { "DELETE User /$ENDPOINT/{id}" } 183 | 184 | val userId = call.principal() 185 | ?.payload?.getClaim("userId") 186 | .toString().replace("\"", "").toLong() 187 | 188 | call.parameters["id"]?.toLong()?.let { id -> 189 | usersService.isAdmin(userId).andThen { 190 | usersService.findById(id).andThen { user -> 191 | usersService.delete(user.id).andThen { 192 | storageService.deleteFile(it.avatar.substringAfterLast("/")) 193 | } 194 | } 195 | }.mapBoth( 196 | success = { call.respond(HttpStatusCode.NoContent) }, 197 | failure = { handleUserError(it) } 198 | ) 199 | } 200 | } 201 | } 202 | } 203 | } 204 | } 205 | 206 | // Manejador de errores 207 | private suspend fun PipelineContext.handleUserError( 208 | error: Any 209 | ) { 210 | when (error) { 211 | // Users 212 | is UserError.BadRequest -> call.respond(HttpStatusCode.BadRequest, error.message) 213 | is UserError.NotFound -> call.respond(HttpStatusCode.NotFound, error.message) 214 | is UserError.Unauthorized -> call.respond(HttpStatusCode.Unauthorized, error.message) 215 | is UserError.Forbidden -> call.respond(HttpStatusCode.Forbidden, error.message) 216 | is UserError.BadCredentials -> call.respond(HttpStatusCode.BadRequest, error.message) 217 | is UserError.BadRole -> call.respond(HttpStatusCode.Forbidden, error.message) 218 | // Storage 219 | is StorageError.BadRequest -> call.respond(HttpStatusCode.BadRequest, error.message) 220 | is StorageError.NotFound -> call.respond(HttpStatusCode.NotFound, error.message) 221 | } 222 | } -------------------------------------------------------------------------------- /src/test/kotlin/joseluisgs/dev/routes/UsersRoutesKtTest.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.routes 2 | 3 | import io.ktor.client.plugins.auth.* 4 | import io.ktor.client.plugins.auth.providers.* 5 | import io.ktor.client.plugins.contentnegotiation.* 6 | import io.ktor.client.request.* 7 | import io.ktor.client.request.forms.* 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.json.* 11 | import io.ktor.server.config.* 12 | import io.ktor.server.testing.* 13 | import io.ktor.util.* 14 | import io.ktor.utils.io.* 15 | import joseluisgs.dev.dto.* 16 | import joseluisgs.dev.models.User 17 | import kotlinx.serialization.json.Json 18 | import org.junit.jupiter.api.* 19 | import org.junit.jupiter.api.Assertions.assertEquals 20 | import org.junit.jupiter.api.Assertions.assertNotNull 21 | import java.io.File 22 | import java.util.* 23 | 24 | 25 | private val json = Json { ignoreUnknownKeys = true } 26 | 27 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 28 | @TestMethodOrder(MethodOrderer.OrderAnnotation::class) 29 | class UsersRoutesKtTest { 30 | // Cargamos la configuración del entorno 31 | private val config = ApplicationConfig("application.conf") 32 | 33 | val userDto = UserCreateDto( 34 | name = "Test", 35 | email = "test@test.com", 36 | username = "test", 37 | password = "test12345", 38 | avatar = User.DEFAULT_IMAGE, 39 | role = User.Role.USER 40 | ) 41 | 42 | val userLoginDto = UserLoginDto( 43 | username = "test", 44 | password = "test12345" 45 | ) 46 | 47 | val userLoginAdminDto = UserLoginDto( 48 | username = "pepe", 49 | password = "pepe1234" 50 | ) 51 | 52 | @Test 53 | @Order(1) 54 | fun registerUserTest() = testApplication { 55 | // Set up the test environment 56 | environment { config } 57 | val client = createClient { 58 | install(ContentNegotiation) { 59 | json() 60 | } 61 | } 62 | 63 | // Launch the test 64 | val response = client.post("/api/users/register") { 65 | contentType(ContentType.Application.Json) 66 | setBody(userDto) 67 | } 68 | 69 | // Check the response and the content 70 | assertEquals(response.status, HttpStatusCode.Created) 71 | val res = json.decodeFromString(response.bodyAsText()) 72 | assertAll( 73 | { assertEquals(res.name, userDto.name) }, 74 | { assertEquals(res.email, userDto.email) }, 75 | { assertEquals(res.username, userDto.username) }, 76 | { assertEquals(res.avatar, userDto.avatar) }, 77 | { assertEquals(res.role, userDto.role) }, 78 | ) 79 | } 80 | 81 | 82 | @Test 83 | @Order(2) 84 | fun login() = testApplication { 85 | environment { config } 86 | val client = createClient { 87 | install(ContentNegotiation) { 88 | json() 89 | } 90 | } 91 | 92 | client.post("/api/users/register") { 93 | contentType(ContentType.Application.Json) 94 | setBody(userDto) 95 | } 96 | 97 | val responseLogin = client.post("/api/users/login") { 98 | contentType(ContentType.Application.Json) 99 | setBody(userLoginDto) 100 | } 101 | 102 | assertEquals(responseLogin.status, HttpStatusCode.OK) 103 | val res = json.decodeFromString(responseLogin.bodyAsText()) 104 | assertAll( 105 | { assertEquals(res.user.name, userDto.name) }, 106 | { assertEquals(res.user.email, userDto.email) }, 107 | { assertEquals(res.user.username, userDto.username) }, 108 | { assertEquals(res.user.avatar, userDto.avatar) }, 109 | { assertEquals(res.user.role, userDto.role) }, 110 | { assertNotNull(res.token) }, 111 | ) 112 | } 113 | 114 | @Test 115 | @Order(3) 116 | fun meInfoTest() = testApplication { 117 | environment { config } 118 | 119 | var client = createClient { 120 | install(ContentNegotiation) { 121 | json() 122 | } 123 | } 124 | 125 | var response = client.post("/api/users/register") { 126 | contentType(ContentType.Application.Json) 127 | setBody(userDto) 128 | } 129 | 130 | response = client.post("/api/users/login") { 131 | contentType(ContentType.Application.Json) 132 | setBody(userLoginDto) 133 | } 134 | 135 | assertEquals(response.status, HttpStatusCode.OK) 136 | 137 | val res = json.decodeFromString(response.bodyAsText()) 138 | // token 139 | client = createClient { 140 | install(ContentNegotiation) { 141 | json() 142 | } 143 | install(Auth) { 144 | bearer { 145 | loadTokens { 146 | // Load tokens from a local storage and return them as the 'BearerTokens' instance 147 | BearerTokens(res.token, res.token) 148 | } 149 | } 150 | } 151 | } 152 | 153 | response = client.get("/api/users/me") { 154 | contentType(ContentType.Application.Json) 155 | } 156 | 157 | assertEquals(response.status, HttpStatusCode.OK) 158 | val resUser = json.decodeFromString(response.bodyAsText()) 159 | assertAll( 160 | { assertEquals(resUser.name, userDto.name) }, 161 | { assertEquals(resUser.email, userDto.email) }, 162 | { assertEquals(resUser.username, userDto.username) }, 163 | { assertEquals(resUser.avatar, userDto.avatar) }, 164 | { assertEquals(resUser.role, userDto.role) }, 165 | ) 166 | } 167 | 168 | @Test 169 | @Order(4) 170 | fun meUpdateTest() = testApplication { 171 | environment { config } 172 | 173 | var client = createClient { 174 | install(ContentNegotiation) { 175 | json() 176 | } 177 | } 178 | 179 | var response = client.post("/api/users/register") { 180 | contentType(ContentType.Application.Json) 181 | setBody(userDto) 182 | } 183 | 184 | response = client.post("/api/users/login") { 185 | contentType(ContentType.Application.Json) 186 | setBody(userLoginDto) 187 | } 188 | 189 | assertEquals(response.status, HttpStatusCode.OK) 190 | 191 | val res = json.decodeFromString(response.bodyAsText()) 192 | // token 193 | client = createClient { 194 | install(ContentNegotiation) { 195 | json() 196 | } 197 | install(Auth) { 198 | bearer { 199 | loadTokens { 200 | // Load tokens from a local storage and return them as the 'BearerTokens' instance 201 | BearerTokens(res.token, res.token) 202 | } 203 | } 204 | } 205 | } 206 | 207 | response = client.put("/api/users/me") { 208 | contentType(ContentType.Application.Json) 209 | setBody( 210 | UserUpdateDto( 211 | name = "Test2", 212 | email = "test@test.com", 213 | username = "test", 214 | ) 215 | ) 216 | } 217 | 218 | assertEquals(response.status, HttpStatusCode.OK) 219 | val resUser = json.decodeFromString(response.bodyAsText()) 220 | assertAll( 221 | { assertEquals(resUser.name, "Test2") }, 222 | { assertEquals(resUser.email, userDto.email) }, 223 | { assertEquals(resUser.username, userDto.username) }, 224 | { assertEquals(resUser.avatar, userDto.avatar) }, 225 | { assertEquals(resUser.role, userDto.role) }, 226 | ) 227 | } 228 | 229 | @Test 230 | @Order(5) 231 | fun mePatchTest() = testApplication { 232 | environment { config } 233 | 234 | var client = createClient { 235 | install(ContentNegotiation) { 236 | json() 237 | } 238 | } 239 | 240 | var response = client.post("/api/users/register") { 241 | contentType(ContentType.Application.Json) 242 | setBody(userDto) 243 | } 244 | 245 | response = client.post("/api/users/login") { 246 | contentType(ContentType.Application.Json) 247 | setBody(userLoginDto) 248 | } 249 | 250 | assertEquals(response.status, HttpStatusCode.OK) 251 | 252 | val res = json.decodeFromString(response.bodyAsText()) 253 | client = createClient { 254 | install(ContentNegotiation) { 255 | json() 256 | } 257 | install(Auth) { 258 | bearer { 259 | loadTokens { 260 | BearerTokens(res.token, res.token) 261 | } 262 | } 263 | } 264 | } 265 | 266 | // Lanzamos la consulta 267 | val boundary = "WebAppBoundary" 268 | response = client.patch("/api/users/me") { 269 | setBody( 270 | MultiPartFormDataContent( 271 | formData { 272 | append("file", File("src/test/resources/user.jpg").readBytes(), Headers.build { 273 | append(HttpHeaders.ContentType, "image/png") 274 | append(HttpHeaders.ContentDisposition, "filename=\"ktor.png\"") 275 | }) 276 | }, 277 | boundary, 278 | ContentType.MultiPart.FormData.withParameter("boundary", boundary) 279 | ) 280 | ) 281 | } 282 | assertEquals(HttpStatusCode.OK, response.status) 283 | } 284 | 285 | @Test 286 | @Order(6) 287 | fun listAsAdminTestNot() = testApplication { 288 | environment { config } 289 | 290 | var client = createClient { 291 | install(ContentNegotiation) { 292 | json() 293 | } 294 | } 295 | 296 | var response = client.post("/api/users/register") { 297 | contentType(ContentType.Application.Json) 298 | setBody(userDto) 299 | } 300 | 301 | response = client.post("/api/users/login") { 302 | contentType(ContentType.Application.Json) 303 | setBody(userLoginDto) 304 | } 305 | 306 | assertEquals(response.status, HttpStatusCode.OK) 307 | 308 | val res = json.decodeFromString(response.bodyAsText()) 309 | client = createClient { 310 | install(ContentNegotiation) { 311 | json() 312 | } 313 | install(Auth) { 314 | bearer { 315 | loadTokens { 316 | BearerTokens(res.token, res.token) 317 | } 318 | } 319 | } 320 | } 321 | 322 | response = client.get("/api/users/list") { 323 | contentType(ContentType.Application.Json) 324 | } 325 | 326 | assertEquals(response.status, HttpStatusCode.Forbidden) 327 | } 328 | 329 | @Test 330 | @Order(7) 331 | fun deleteAsAdminTestNot() = testApplication { 332 | environment { config } 333 | 334 | var client = createClient { 335 | install(ContentNegotiation) { 336 | json() 337 | } 338 | } 339 | 340 | var response = client.post("/api/users/register") { 341 | contentType(ContentType.Application.Json) 342 | setBody(userDto) 343 | } 344 | 345 | response = client.post("/api/users/login") { 346 | contentType(ContentType.Application.Json) 347 | setBody(userLoginDto) 348 | } 349 | 350 | assertEquals(response.status, HttpStatusCode.OK) 351 | 352 | val res = json.decodeFromString(response.bodyAsText()) 353 | client = createClient { 354 | install(ContentNegotiation) { 355 | json() 356 | } 357 | install(Auth) { 358 | bearer { 359 | loadTokens { 360 | BearerTokens(res.token, res.token) 361 | } 362 | } 363 | } 364 | } 365 | 366 | response = client.delete("/api/users/delete/2") { 367 | contentType(ContentType.Application.Json) 368 | } 369 | 370 | assertEquals(response.status, HttpStatusCode.Forbidden) 371 | } 372 | 373 | @Test 374 | @Order(8) 375 | fun listAsAdminTestYes() = testApplication { 376 | environment { config } 377 | 378 | var client = createClient { 379 | install(ContentNegotiation) { 380 | json() 381 | } 382 | } 383 | 384 | 385 | var response = client.post("/api/users/login") { 386 | contentType(ContentType.Application.Json) 387 | setBody(userLoginAdminDto) 388 | } 389 | 390 | assertEquals(response.status, HttpStatusCode.OK) 391 | 392 | val res = json.decodeFromString(response.bodyAsText()) 393 | client = createClient { 394 | install(ContentNegotiation) { 395 | json() 396 | } 397 | install(Auth) { 398 | bearer { 399 | loadTokens { 400 | BearerTokens(res.token, res.token) 401 | } 402 | } 403 | } 404 | } 405 | 406 | response = client.get("/api/users/list") { 407 | contentType(ContentType.Application.Json) 408 | } 409 | 410 | assertEquals(response.status, HttpStatusCode.OK) 411 | } 412 | 413 | // You can add more tests if you want or need 414 | 415 | } -------------------------------------------------------------------------------- /postman/Ktor-Hyperskill.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "c26422f0-10df-4921-8ae9-75faff6dbc86", 4 | "name": "Ktor-Hyperskill", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "11271351" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Rackets", 11 | "item": [ 12 | { 13 | "name": "GET ALL", 14 | "request": { 15 | "method": "GET", 16 | "header": [], 17 | "url": { 18 | "raw": "http://0.0.0.0:8080/api/rackets", 19 | "protocol": "http", 20 | "host": [ 21 | "0", 22 | "0", 23 | "0", 24 | "0" 25 | ], 26 | "port": "8080", 27 | "path": [ 28 | "api", 29 | "rackets" 30 | ] 31 | } 32 | }, 33 | "response": [] 34 | }, 35 | { 36 | "name": "GET ALL PAGEABLE", 37 | "request": { 38 | "method": "GET", 39 | "header": [], 40 | "url": { 41 | "raw": "http://0.0.0.0:8080/api/rackets?page=1&perPage=2", 42 | "protocol": "http", 43 | "host": [ 44 | "0", 45 | "0", 46 | "0", 47 | "0" 48 | ], 49 | "port": "8080", 50 | "path": [ 51 | "api", 52 | "rackets" 53 | ], 54 | "query": [ 55 | { 56 | "key": "page", 57 | "value": "1" 58 | }, 59 | { 60 | "key": "perPage", 61 | "value": "2" 62 | } 63 | ] 64 | } 65 | }, 66 | "response": [] 67 | }, 68 | { 69 | "name": "GET BY ID", 70 | "request": { 71 | "method": "GET", 72 | "header": [], 73 | "url": { 74 | "raw": "http://0.0.0.0:8080/api/rackets/1", 75 | "protocol": "http", 76 | "host": [ 77 | "0", 78 | "0", 79 | "0", 80 | "0" 81 | ], 82 | "port": "8080", 83 | "path": [ 84 | "api", 85 | "rackets", 86 | "1" 87 | ] 88 | } 89 | }, 90 | "response": [] 91 | }, 92 | { 93 | "name": "GET BY ID BRAND", 94 | "request": { 95 | "method": "GET", 96 | "header": [], 97 | "url": { 98 | "raw": "http://0.0.0.0:8080/api/rackets/brand/babolat", 99 | "protocol": "http", 100 | "host": [ 101 | "0", 102 | "0", 103 | "0", 104 | "0" 105 | ], 106 | "port": "8080", 107 | "path": [ 108 | "api", 109 | "rackets", 110 | "brand", 111 | "babolat" 112 | ] 113 | } 114 | }, 115 | "response": [] 116 | }, 117 | { 118 | "name": "POST", 119 | "request": { 120 | "method": "POST", 121 | "header": [], 122 | "body": { 123 | "mode": "raw", 124 | "raw": "{\n \"brand\": \"New Racquet\",\n \"model\": \"New Model\",\n \"price\": 200.0,\n \"numberTenisPlayers\": 10\n}", 125 | "options": { 126 | "raw": { 127 | "language": "json" 128 | } 129 | } 130 | }, 131 | "url": { 132 | "raw": "http://0.0.0.0:8080/api/rackets", 133 | "protocol": "http", 134 | "host": [ 135 | "0", 136 | "0", 137 | "0", 138 | "0" 139 | ], 140 | "port": "8080", 141 | "path": [ 142 | "api", 143 | "rackets" 144 | ] 145 | } 146 | }, 147 | "response": [] 148 | }, 149 | { 150 | "name": "UPDATE", 151 | "request": { 152 | "method": "PUT", 153 | "header": [], 154 | "body": { 155 | "mode": "raw", 156 | "raw": "{\n \"brand\": \"Updated Racket\",\n \"model\": \"Updated Model\",\n \"price\": 200.0,\n \"numberTenisPlayers\": 10\n}", 157 | "options": { 158 | "raw": { 159 | "language": "json" 160 | } 161 | } 162 | }, 163 | "url": { 164 | "raw": "http://0.0.0.0:8080/api/rackets/5", 165 | "protocol": "http", 166 | "host": [ 167 | "0", 168 | "0", 169 | "0", 170 | "0" 171 | ], 172 | "port": "8080", 173 | "path": [ 174 | "api", 175 | "rackets", 176 | "5" 177 | ] 178 | } 179 | }, 180 | "response": [] 181 | }, 182 | { 183 | "name": "DELETE", 184 | "request": { 185 | "method": "DELETE", 186 | "header": [], 187 | "body": { 188 | "mode": "raw", 189 | "raw": "", 190 | "options": { 191 | "raw": { 192 | "language": "json" 193 | } 194 | } 195 | }, 196 | "url": { 197 | "raw": "http://0.0.0.0:8080/api/rackets/5", 198 | "protocol": "http", 199 | "host": [ 200 | "0", 201 | "0", 202 | "0", 203 | "0" 204 | ], 205 | "port": "8080", 206 | "path": [ 207 | "api", 208 | "rackets", 209 | "5" 210 | ] 211 | } 212 | }, 213 | "response": [] 214 | }, 215 | { 216 | "name": "PATCH IMAGE", 217 | "request": { 218 | "method": "PATCH", 219 | "header": [], 220 | "body": { 221 | "mode": "formdata", 222 | "formdata": [ 223 | { 224 | "key": "image", 225 | "type": "file", 226 | "src": "/home/joseluisgs/Proyectos/ktor-reactive-rest-hyperskill/postman/racket.jpg" 227 | } 228 | ] 229 | }, 230 | "url": { 231 | "raw": "http://0.0.0.0:8080/api/rackets/1", 232 | "protocol": "http", 233 | "host": [ 234 | "0", 235 | "0", 236 | "0", 237 | "0" 238 | ], 239 | "port": "8080", 240 | "path": [ 241 | "api", 242 | "rackets", 243 | "1" 244 | ] 245 | } 246 | }, 247 | "response": [] 248 | }, 249 | { 250 | "name": "GET IMAGE", 251 | "request": { 252 | "method": "GET", 253 | "header": [], 254 | "url": { 255 | "raw": "http://0.0.0.0:8080/api/rackets/image/1685114639748.jpg", 256 | "protocol": "http", 257 | "host": [ 258 | "0", 259 | "0", 260 | "0", 261 | "0" 262 | ], 263 | "port": "8080", 264 | "path": [ 265 | "api", 266 | "rackets", 267 | "image", 268 | "1685114639748.jpg" 269 | ] 270 | } 271 | }, 272 | "response": [] 273 | } 274 | ] 275 | }, 276 | { 277 | "name": "Users", 278 | "item": [ 279 | { 280 | "name": "POST Register", 281 | "request": { 282 | "method": "POST", 283 | "header": [], 284 | "body": { 285 | "mode": "raw", 286 | "raw": "{\n \"name\": \"create\",\n \"email\": \"create@create.com\",\n \"username\": \"create\",\n \"password\": \"create1234\"\n\n}", 287 | "options": { 288 | "raw": { 289 | "language": "json" 290 | } 291 | } 292 | }, 293 | "url": { 294 | "raw": "http://0.0.0.0:8080/api/users/register", 295 | "protocol": "http", 296 | "host": [ 297 | "0", 298 | "0", 299 | "0", 300 | "0" 301 | ], 302 | "port": "8080", 303 | "path": [ 304 | "api", 305 | "users", 306 | "register" 307 | ] 308 | } 309 | }, 310 | "response": [] 311 | }, 312 | { 313 | "name": "POST Login User", 314 | "request": { 315 | "method": "POST", 316 | "header": [], 317 | "body": { 318 | "mode": "raw", 319 | "raw": "{\n \"username\": \"ana\",\n \"password\": \"ana1234\"\n}", 320 | "options": { 321 | "raw": { 322 | "language": "json" 323 | } 324 | } 325 | }, 326 | "url": { 327 | "raw": "http://localhost:8080/api/users/login", 328 | "protocol": "http", 329 | "host": [ 330 | "localhost" 331 | ], 332 | "port": "8080", 333 | "path": [ 334 | "api", 335 | "users", 336 | "login" 337 | ] 338 | } 339 | }, 340 | "response": [] 341 | }, 342 | { 343 | "name": "POST Login Admin", 344 | "request": { 345 | "method": "POST", 346 | "header": [], 347 | "body": { 348 | "mode": "raw", 349 | "raw": "{\n \"username\": \"pepe\",\n \"password\": \"pepe1234\"\n}", 350 | "options": { 351 | "raw": { 352 | "language": "json" 353 | } 354 | } 355 | }, 356 | "url": { 357 | "raw": "http://localhost:8080/api/users/login", 358 | "protocol": "http", 359 | "host": [ 360 | "localhost" 361 | ], 362 | "port": "8080", 363 | "path": [ 364 | "api", 365 | "users", 366 | "login" 367 | ] 368 | } 369 | }, 370 | "response": [] 371 | }, 372 | { 373 | "name": "GET Me (auth)", 374 | "protocolProfileBehavior": { 375 | "disableBodyPruning": true 376 | }, 377 | "request": { 378 | "auth": { 379 | "type": "bearer", 380 | "bearer": [ 381 | { 382 | "key": "token", 383 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyYWNrZXRzLWt0b3ItYXV0aCIsImlzcyI6InJhY2tldHMta3RvciIsInN1YiI6IkF1dGhlbnRpY2F0aW9uIiwidXNlcm5hbWUiOiJwZXBlIiwidXNlcm1haWwiOiJwZXBlQHBlcmV6LmNvbSIsInVzZXJJZCI6IjEiLCJleHAiOjE2ODU4NzE2ODh9.FslU5FRx1zQpfSEG5nn1u6be-jgMaABt1FGCE_6gocye-Qty-nI-_4jz23OGPBjBpiKNwfnROAKHSq0WaaYy6w", 384 | "type": "string" 385 | } 386 | ] 387 | }, 388 | "method": "GET", 389 | "header": [], 390 | "body": { 391 | "mode": "raw", 392 | "raw": "", 393 | "options": { 394 | "raw": { 395 | "language": "json" 396 | } 397 | } 398 | }, 399 | "url": { 400 | "raw": "http://localhost:8080/api/users/me", 401 | "protocol": "http", 402 | "host": [ 403 | "localhost" 404 | ], 405 | "port": "8080", 406 | "path": [ 407 | "api", 408 | "users", 409 | "me" 410 | ] 411 | } 412 | }, 413 | "response": [] 414 | }, 415 | { 416 | "name": "PUT Me Update Info", 417 | "request": { 418 | "auth": { 419 | "type": "bearer", 420 | "bearer": [ 421 | { 422 | "key": "token", 423 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyYWNrZXRzLWt0b3ItYXV0aCIsImlzcyI6InJhY2tldHMta3RvciIsInN1YiI6IkF1dGhlbnRpY2F0aW9uIiwidXNlcm5hbWUiOiJhbmEiLCJ1c2VybWFpbCI6ImFuYUBsb3Blei5jb20iLCJ1c2VySWQiOiIyIiwiZXhwIjoxNjg1ODcxODcwfQ.fymnLr-ZG8hu7HV0jH7CdqWo5m9sBp8Y3asCcrkTFY322WEDf3gGCn1_QPfLyKqlx7YtW-933DYML9FYDn24kA", 424 | "type": "string" 425 | } 426 | ] 427 | }, 428 | "method": "PUT", 429 | "header": [], 430 | "body": { 431 | "mode": "raw", 432 | "raw": "{\n \"name\": \"Ana Lopez Updated\",\n \"email\": \"updated@lopez.com\",\n \"username\": \"anaupdated\"\n}", 433 | "options": { 434 | "raw": { 435 | "language": "json" 436 | } 437 | } 438 | }, 439 | "url": { 440 | "raw": "http://localhost:8080/api/users/me", 441 | "protocol": "http", 442 | "host": [ 443 | "localhost" 444 | ], 445 | "port": "8080", 446 | "path": [ 447 | "api", 448 | "users", 449 | "me" 450 | ] 451 | } 452 | }, 453 | "response": [] 454 | }, 455 | { 456 | "name": "PATCH Me Avatar", 457 | "request": { 458 | "auth": { 459 | "type": "bearer", 460 | "bearer": [ 461 | { 462 | "key": "token", 463 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyYWNrZXRzLWt0b3ItYXV0aCIsImlzcyI6InJhY2tldHMta3RvciIsInN1YiI6IkF1dGhlbnRpY2F0aW9uIiwidXNlcm5hbWUiOiJhbmEiLCJ1c2VybWFpbCI6ImFuYUBsb3Blei5jb20iLCJ1c2VySWQiOiIyIiwiZXhwIjoxNjg1ODcxODcwfQ.fymnLr-ZG8hu7HV0jH7CdqWo5m9sBp8Y3asCcrkTFY322WEDf3gGCn1_QPfLyKqlx7YtW-933DYML9FYDn24kA", 464 | "type": "string" 465 | } 466 | ] 467 | }, 468 | "method": "PATCH", 469 | "header": [], 470 | "body": { 471 | "mode": "formdata", 472 | "formdata": [ 473 | { 474 | "key": "file", 475 | "type": "file", 476 | "src": "/home/joseluisgs/Proyectos/ktor-reactive-rest-hyperskill/postman/user.jpg" 477 | }, 478 | { 479 | "key": "username", 480 | "value": "pepe", 481 | "type": "text", 482 | "disabled": true 483 | }, 484 | { 485 | "key": "", 486 | "type": "file", 487 | "src": [], 488 | "disabled": true 489 | } 490 | ] 491 | }, 492 | "url": { 493 | "raw": "http://localhost:8080/api/users/me", 494 | "protocol": "http", 495 | "host": [ 496 | "localhost" 497 | ], 498 | "port": "8080", 499 | "path": [ 500 | "api", 501 | "users", 502 | "me" 503 | ] 504 | } 505 | }, 506 | "response": [] 507 | }, 508 | { 509 | "name": "GET ALL Users (admin)", 510 | "protocolProfileBehavior": { 511 | "disableBodyPruning": true 512 | }, 513 | "request": { 514 | "auth": { 515 | "type": "bearer", 516 | "bearer": [ 517 | { 518 | "key": "token", 519 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyYWNrZXRzLWt0b3ItYXV0aCIsImlzcyI6InJhY2tldHMta3RvciIsInN1YiI6IkF1dGhlbnRpY2F0aW9uIiwidXNlcm5hbWUiOiJwZXBlIiwidXNlcm1haWwiOiJwZXBlQHBlcmV6LmNvbSIsInVzZXJJZCI6IjEiLCJleHAiOjE2ODU4NzE2ODh9.FslU5FRx1zQpfSEG5nn1u6be-jgMaABt1FGCE_6gocye-Qty-nI-_4jz23OGPBjBpiKNwfnROAKHSq0WaaYy6w", 520 | "type": "string" 521 | } 522 | ] 523 | }, 524 | "method": "GET", 525 | "header": [], 526 | "body": { 527 | "mode": "raw", 528 | "raw": "", 529 | "options": { 530 | "raw": { 531 | "language": "json" 532 | } 533 | } 534 | }, 535 | "url": { 536 | "raw": "http://localhost:8080/api/users/list", 537 | "protocol": "http", 538 | "host": [ 539 | "localhost" 540 | ], 541 | "port": "8080", 542 | "path": [ 543 | "api", 544 | "users", 545 | "list" 546 | ] 547 | } 548 | }, 549 | "response": [] 550 | }, 551 | { 552 | "name": "DELETE User", 553 | "request": { 554 | "auth": { 555 | "type": "bearer", 556 | "bearer": [ 557 | { 558 | "key": "token", 559 | "value": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyYWNrZXRzLWt0b3ItYXV0aCIsImlzcyI6InJhY2tldHMta3RvciIsInN1YiI6IkF1dGhlbnRpY2F0aW9uIiwidXNlcm5hbWUiOiJhbmEiLCJ1c2VybWFpbCI6ImFuYUBsb3Blei5jb20iLCJ1c2VySWQiOiIyIiwiZXhwIjoxNjg1ODcxODcwfQ.fymnLr-ZG8hu7HV0jH7CdqWo5m9sBp8Y3asCcrkTFY322WEDf3gGCn1_QPfLyKqlx7YtW-933DYML9FYDn24kA", 560 | "type": "string" 561 | } 562 | ] 563 | }, 564 | "method": "DELETE", 565 | "header": [], 566 | "url": { 567 | "raw": "http://localhost:8080/api/users/delete/3", 568 | "protocol": "http", 569 | "host": [ 570 | "localhost" 571 | ], 572 | "port": "8080", 573 | "path": [ 574 | "api", 575 | "users", 576 | "delete", 577 | "3" 578 | ] 579 | } 580 | }, 581 | "response": [] 582 | } 583 | ] 584 | } 585 | ] 586 | } -------------------------------------------------------------------------------- /src/main/kotlin/joseluisgs/dev/routes/RacketsRoutes.kt: -------------------------------------------------------------------------------- 1 | package joseluisgs.dev.routes 2 | 3 | import com.github.michaelbull.result.andThen 4 | import com.github.michaelbull.result.mapBoth 5 | import io.github.smiley4.ktorswaggerui.dsl.* 6 | import io.ktor.http.* 7 | import io.ktor.http.content.* 8 | import io.ktor.server.application.* 9 | import io.ktor.server.plugins.* 10 | import io.ktor.server.request.* 11 | import io.ktor.server.response.* 12 | import io.ktor.server.routing.* 13 | import io.ktor.server.websocket.* 14 | import io.ktor.util.pipeline.* 15 | import joseluisgs.dev.dto.RacketPage 16 | import joseluisgs.dev.dto.RacketRequest 17 | import joseluisgs.dev.dto.RacketResponse 18 | import joseluisgs.dev.errors.racket.RacketError 19 | import joseluisgs.dev.errors.storage.StorageError 20 | import joseluisgs.dev.mappers.toModel 21 | import joseluisgs.dev.mappers.toResponse 22 | import joseluisgs.dev.services.rackets.RacketsService 23 | import joseluisgs.dev.services.storage.StorageService 24 | import kotlinx.coroutines.flow.toList 25 | import mu.KotlinLogging 26 | import org.koin.ktor.ext.inject 27 | import java.io.File 28 | import org.koin.ktor.ext.get as koinGet 29 | 30 | private val logger = KotlinLogging.logger {} 31 | 32 | /** 33 | * Rackets routes for our API 34 | * We define the routes for our API based on the endpoint 35 | * to manage the rackets 36 | * We use the repository to manage the data to perform the CRUD operations 37 | */ 38 | private const val ENDPOINT = "api/rackets" 39 | 40 | fun Application.racketsRoutes() { 41 | 42 | // Dependency injection by Koin 43 | // Rackets Services with dependency injection by Koin lazy-loading 44 | //val racketsService: RacketsService by inject() 45 | // We can also use Koin get() no lazy-loading, we use a alias to avoid conflicts with Ktor get() 46 | val racketsService: RacketsService = koinGet() 47 | // Storage Service with dependency injection by Koin lazy-loading (we use it for images) 48 | val storageService: StorageService by inject() 49 | 50 | // Define routing based on endpoint 51 | routing { 52 | route("/$ENDPOINT") { 53 | 54 | // Get all racket --> GET /api/rackets 55 | get({ 56 | description = "Get All Rackets" 57 | request { 58 | queryParameter("page") { 59 | description = "page number" 60 | required = false // Optional 61 | } 62 | queryParameter("perPage") { 63 | description = "number of elements per page" 64 | required = false // Optional 65 | } 66 | } 67 | response { 68 | default { 69 | description = "List of Rackets" 70 | } 71 | HttpStatusCode.OK to { 72 | description = "List of Rackets" 73 | body> { description = "List of Rackets" } 74 | } 75 | } 76 | }) { 77 | 78 | // QueryParams: rackets?page=1&perPage=10 79 | call.request.queryParameters["page"]?.toIntOrNull()?.let { 80 | val page = if (it > 0) it else 0 81 | val perPage = call.request.queryParameters["perPage"]?.toIntOrNull() ?: 10 82 | 83 | logger.debug { "GET ALL /$ENDPOINT?page=$page&perPage=$perPage" } 84 | 85 | racketsService.findAllPageable(page, perPage) 86 | .toList() 87 | .run { 88 | call.respond(HttpStatusCode.OK, RacketPage(page, perPage, this.toResponse())) 89 | } 90 | 91 | } ?: run { 92 | logger.debug { "GET ALL /$ENDPOINT" } 93 | 94 | racketsService.findAll() 95 | .toList() 96 | .run { call.respond(HttpStatusCode.OK, this.toResponse()) } 97 | } 98 | } 99 | 100 | // Get one racket by id --> GET /api/rackets/{id} 101 | get("{id}", { 102 | description = "Get Racket by ID" 103 | request { 104 | pathParameter("id") { 105 | description = "Racket ID" 106 | } 107 | } 108 | response { 109 | HttpStatusCode.OK to { 110 | description = "Racket" 111 | body { description = "Racket" } 112 | } 113 | HttpStatusCode.NotFound to { 114 | description = "Racket not found" 115 | body { description = "Racket not found" } 116 | } 117 | } 118 | }) { 119 | logger.debug { "GET BY ID /$ENDPOINT/{id}" } 120 | 121 | call.parameters["id"]?.toLong()?.let { id -> 122 | racketsService.findById(id).mapBoth( 123 | success = { call.respond(HttpStatusCode.OK, it.toResponse()) }, 124 | failure = { handleRacketErrors(it) } 125 | ) 126 | } 127 | } 128 | 129 | // Get one racket by brand --> GET /api/rackets/brand/{brand} 130 | get("brand/{brand}", { 131 | description = "Get Racket by Brand" 132 | request { 133 | pathParameter("brand") { 134 | description = "Racket Brand" 135 | } 136 | } 137 | response { 138 | default { 139 | description = "List of Rackets" 140 | } 141 | HttpStatusCode.OK to { 142 | description = "List of Rackets" 143 | body> { description = "List of Rackets" } 144 | } 145 | } 146 | }) { 147 | logger.debug { "GET BY BRAND /$ENDPOINT/brand/{brand}" } 148 | 149 | call.parameters["brand"]?.let { 150 | racketsService.findByBrand(it) 151 | .toList() 152 | .run { call.respond(HttpStatusCode.OK, this.toResponse()) } 153 | } 154 | } 155 | 156 | // Create a new racket --> POST /api/rackets 157 | post({ 158 | description = "Create a new Racket" 159 | request { 160 | body { description = "Racket request" } 161 | } 162 | response { 163 | HttpStatusCode.Created to { 164 | description = "Racket created" 165 | body { description = "Racket created" } 166 | } 167 | HttpStatusCode.BadRequest to { 168 | description = "Racket errors" 169 | body { description = "Racket bad request petition" } 170 | } 171 | } 172 | }) { 173 | logger.debug { "POST /$ENDPOINT" } 174 | 175 | racketsService.save( 176 | racket = call.receive().toModel() 177 | ).mapBoth( 178 | success = { call.respond(HttpStatusCode.Created, it.toResponse()) }, 179 | failure = { handleRacketErrors(it) } 180 | ) 181 | } 182 | 183 | // Update a racket --> PUT /api/rackets/{id} 184 | put("{id}", { 185 | description = "Update a Racket" 186 | request { 187 | pathParameter("id") { 188 | description = "Racket ID" 189 | } 190 | body { description = "Racket request" } 191 | } 192 | response { 193 | HttpStatusCode.OK to { 194 | description = "Racket updated" 195 | body { description = "Racket updated" } 196 | } 197 | HttpStatusCode.BadRequest to { 198 | description = "Racket errors" 199 | body { description = "Racket bad request petition" } 200 | } 201 | HttpStatusCode.NotFound to { 202 | description = "Racket not found" 203 | body { description = "Racket not found" } 204 | } 205 | } 206 | }) { 207 | logger.debug { "PUT /$ENDPOINT/{id}" } 208 | 209 | call.parameters["id"]?.toLong()?.let { id -> 210 | racketsService.update( 211 | id = id, 212 | racket = call.receive().toModel() 213 | ).mapBoth( 214 | success = { call.respond(HttpStatusCode.OK, it.toResponse()) }, 215 | failure = { handleRacketErrors(it) } 216 | ) 217 | } 218 | } 219 | 220 | // Update a racket image --> PATCH /api/rackets/{id} 221 | patch("{id}", { 222 | description = "Update a Racket image" 223 | request { 224 | pathParameter("id") { 225 | description = "Racket ID" 226 | } 227 | multipartBody { 228 | part("file") 229 | } 230 | } 231 | response { 232 | HttpStatusCode.OK to { 233 | description = "Racket image updated" 234 | body { description = "Racket image updated" } 235 | } 236 | HttpStatusCode.BadRequest to { 237 | description = "Racket errors" 238 | body { description = "Racket bad request petition" } 239 | } 240 | HttpStatusCode.NotFound to { 241 | description = "Racket not found" 242 | body { description = "Racket not found" } 243 | } 244 | } 245 | }) { 246 | logger.debug { "PATCH /$ENDPOINT/{id}" } 247 | 248 | call.parameters["id"]?.toLong()?.let { id -> 249 | val baseUrl = 250 | call.request.origin.scheme + "://" + call.request.host() + ":" + call.request.port() + "/$ENDPOINT/image/" 251 | val multipartData = call.receiveMultipart() 252 | multipartData.forEachPart { part -> 253 | if (part is PartData.FileItem) { 254 | val fileName = part.originalFileName as String 255 | val fileBytes = part.streamProvider().readBytes() 256 | val fileExtension = fileName.substringAfterLast(".") 257 | val newFileName = "${System.currentTimeMillis()}.$fileExtension" 258 | val newFileUrl = "$baseUrl$newFileName" 259 | // Save the file 260 | storageService.saveFile(newFileName, newFileUrl, fileBytes).andThen { 261 | // Update the racket Image 262 | racketsService.updateImage( 263 | id = id, 264 | image = newFileUrl 265 | ) 266 | }.mapBoth( 267 | success = { call.respond(HttpStatusCode.OK, it.toResponse()) }, 268 | failure = { handleRacketErrors(it) } 269 | ) 270 | } 271 | part.dispose() 272 | } 273 | } 274 | } 275 | 276 | // Delete a racket --> DELETE /api/rackets/{id} 277 | delete("{id}", { 278 | description = "Delete a Racket" 279 | request { 280 | pathParameter("id") { 281 | description = "Racket ID" 282 | } 283 | } 284 | response { 285 | HttpStatusCode.NoContent to { 286 | description = "Racket deleted" 287 | } 288 | HttpStatusCode.NotFound to { 289 | description = "Racket not found" 290 | body { description = "Racket not found" } 291 | } 292 | } 293 | }) { 294 | logger.debug { "DELETE /$ENDPOINT/{id}" } 295 | 296 | call.parameters["id"]?.toLong()?.let { id -> 297 | racketsService.delete(id).andThen { 298 | // get the racket image and delete it 299 | storageService.deleteFile(it.image.substringAfterLast("/")) 300 | }.mapBoth( 301 | success = { call.respond(HttpStatusCode.NoContent) }, 302 | failure = { handleRacketErrors(it) } 303 | ) 304 | } 305 | } 306 | 307 | // Get racket image --> GET /api/rackets/image/{image} 308 | get("image/{image}", { 309 | description = "Get a Racket image" 310 | request { 311 | pathParameter("image") { 312 | description = "Racket image" 313 | } 314 | } 315 | response { 316 | HttpStatusCode.OK to { 317 | description = "Racket image" 318 | } 319 | } 320 | }) { 321 | logger.debug { "GET IMAGE /$ENDPOINT/image/{image}" } 322 | 323 | call.parameters["image"]?.let { image -> 324 | storageService.getFile(image).mapBoth( 325 | success = { call.respondFile(it) }, 326 | failure = { handleRacketErrors(it) } 327 | ) 328 | } 329 | } 330 | } 331 | 332 | // WebSockets Real Time Updates and Notifications 333 | webSocket("/$ENDPOINT/notifications") { 334 | 335 | sendSerialized("Notifications WS: Rackets - Rackets API") 336 | // Remeber it will autoclose the connection, see config 337 | // Now we can listen and react to the changes in the StateFlow 338 | racketsService.notificationState.collect { 339 | logger.debug { "notificationState: collect $it and sent to ${this.hashCode()}" } 340 | sendSerialized(it) // WS function to send the message to the client 341 | } 342 | } 343 | } 344 | } 345 | 346 | // Error handling for our API based on the error type and message 347 | private suspend fun PipelineContext.handleRacketErrors( 348 | error: Any, 349 | ) { 350 | // We can handle the errors in a different way 351 | when (error) { 352 | // Racket Errors 353 | is RacketError.NotFound -> call.respond(HttpStatusCode.NotFound, error.message) 354 | is RacketError.BadRequest -> call.respond(HttpStatusCode.BadRequest, error.message) 355 | // Storage Errors 356 | is StorageError.BadRequest -> call.respond(HttpStatusCode.BadRequest, error.message) 357 | is StorageError.NotFound -> call.respond(HttpStatusCode.NotFound, error.message) 358 | } 359 | } 360 | --------------------------------------------------------------------------------