Api REST de Tenistas con Ktor para Programación de Servicios y Procesos de 2º de DAM. Curso 2022/2023
- Tenistas REST Ktor
- Descripción
- Dominio
- Proyectos y documentación anteriores
- Arquitectura
- Endpoints
- Ktor
- Creando un proyecto
- Punto de Entrada
- Parametrizando la aplicación
- Usando Plugins
- Creando rutas
- Serialización y Content Negotiation
- Otros plugins
- Responses
- Requests
- Excepciones personalizadas
- Gestiones de Errores con Result
- WebSockets
- SSL y Certificados
- Autenticación y Autorización con JWT
- Testing
- Despliegue
- Documentación
- Reactividad
- Inmutabilidad
- Caché
- Notificaciones en tiempo real
- Proveedor de Dependencias
- Railway Oriented Programming
- Seguridad de las comunicaciones
- Testing
- Distribución y Despliegue
- Documentación
- Recursos
- Autor
- Licencia de uso
El siguiente proyecto es una API REST de Tenistas con Ktor para Programación de Servicios y Procesos de 2º de DAM. Curso 2022/2023. En ella se pretende crear un servicio completo para la gestión de tenistas, raquetas y representantes de marcas de raquetas.
El objetivo es que el alumnado aprenda a crear un servicio REST con Ktor, con las operaciones CRUD, securizar el servicio con JWT y usar un cliente para consumir el servicio. Se pretende que el servicio completo sea asíncrono y reactivo en lo máximo posible agilizando el servicio mediante una caché.
Además que permita escuchar cambios en tiempo real usando websocket
Se realizará inyección de dependencias y un sistema de logging.
Tendrá una página web de presentación como devolución de recursos estáticos.
Este proyecto tiene a su "gemelo" implementando en Ktor: tenistas-rest-springboot-2022-2023
Esta API REST no está pensada para ser usada en producción. Es un proyecto de aprendizaje y por tanto algunas cosas no se profundizan y otras están pensadas para poder realizarlas en clase de una manera más simple con el objetivo que el alumnado pueda entenderlas mejor. No se trata de montar la mejor arquitectura o el mejor servicio, sino de aprender a crear un servicio REST en el tiempo exigido por el calendario escolar.
Este proyecto está en constante evolución y se irán añadiendo nuevas funcionalidades y mejoras para el alumnado. De la misma manera se irá completando la documentación asociada.
Si quieres colaborar, puedes hacerlo contactando conmigo.
- Servidor Web: Ktor - Framework para crear servicios web en Kotlin asíncronos y multiplataforma.
- Autenticación: JWT - JSON Web Token para la autenticación y autorización.
- Encriptado: Bcrypt - Algoritmo de hash para encriptar contraseñas.
- Proveedor de dependencias: Koin - Framework para la inyección de dependencias.
- Asincronía: Coroutines - Librería de Kotlin para la programación asíncrona.
- Result: Railway Oriented Programming - Patrón de programación para el control de errores.
- Logger: Kotlin Logging - Framework para la gestión de logs.
- Caché: Cache4k - Versión 100% Kotlin asíncrona y multiplataforma de Caffeine.
- Base de datos: H2 - Base de datos relacional que te permite trabajar en memoria, fichero y servidor.
- Librería base de Datos: Kotysa - Librería para la gestión de bases de datos en Kotlin que te permite operar reactivamente bajo R2DBC.
- Notificaciones en tiempo real: Ktor WebSockets - Framework para la gestión de websockets.
- Testing: JUnit 5 - Framework para la realización de tests unitarios, Mockk librería de Mocks para Kotlin, así como las propias herramientas de Ktor.
- Cliente: Postman - Cliente para realizar peticiones HTTP.
- Contenedor: Docker - Plataforma para la creación y gestión de contenedores.
- Documentación: Dokka y Swagger - Herramienta para la generación de documentación y pruebas de API REST respectivamente mediante OpenAPI.
Gestionar tenistas, raquetas y representantes de marcas de raquetas. Sabemos que:
- Una raqueta tiene un representante y el representante es solo de una marca de raqueta (1-1). No puede haber raquetas sin representante y no puede haber representantes sin raquetas.
- Un tenista solo puede o no tener contrato con una raqueta y una raqueta o modelo de raqueta puede ser usada por varios tenistas (1-N). Puede haber tenistas sin raqueta y puede haber raquetas sin tenistas.
- Por otro lado tenemos usuarios con roles de administrador y usuarios que se pueden registrar, loguear consultar los datos y acceder a los datos de los usuarios (solo administradores).
Campo | Tipo | Descripción |
---|---|---|
id | UUID | Identificador único |
nombre | String | Nombre del representante |
String | Email del representante |
Campo | Tipo | Descripción |
---|---|---|
id | UUID | Identificador único |
marca | String | Marca de la raqueta |
precio | Double | Precio de la raqueta |
representante | Representante | Representante de la raqueta (no nulo) |
Campo | Tipo | Descripción |
---|---|---|
id | UUID | Identificador único |
nombre | String | Nombre del tenista |
ranking | Int | Ranking del tenista |
fechaNacimiento | LocalDate | Fecha de nacimiento del tenista |
añoProfesional | Int | Año en el que se convirtió en profesional |
altura | Double | Altura del tenista |
peso | Double | Peso del tenista |
manoDominante | String | Mano dominante del tenista (DERECHA/IZQUIERDA) |
tipoReves | String | Tipo de revés del tenista (UNA_MANO/DOS_MANOS) |
puntos | Int | Puntos del tenista |
pais | String | País del tenista |
raquetaID | UUID | Identificador de la raqueta (puede ser nulo) |
Campo | Tipo | Descripción |
---|---|---|
id | UUID | Identificador único |
nombre | String | Nombre del usuario |
String | Email del usuario | |
username | String | Rol del usuario |
password | String | Contraseña del usuario |
avatar | String | Avatar del usuario |
rol | Rol | Rol del usuario (ADMIN o USER) |
Parte de los contenidos a desarrollar en este proyecto se han desarrollado en proyectos anteriores. En este caso:
Para la parte de reactividad te recomiendo leer: "Ya no sé programar si no es reactivo"
Nos centraremos en la arquitectura de la API REST. Para ello, usaremos el patrón de diseño MVC (Modelo Vista Controlador) en capas.
Recuerda que puedes conectarte de forma segura:
- Para la API REST: http://localhost:6969/api y https://localhost:6963/api
- Para la página web estática: http://localhost:6969/web y https://localhost:6963/web
Los endpoints que vamos a usar a nivel de api, parten de /api/ y puedes usarlos con tu cliente favorito. En este caso, usaremos Postman:
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
GET | /representantes | No | Devuelve todos los representantes | 200 | JSON |
GET | /representantes?page=X&perPage=Y | No | Devuelve representantes paginados | 200 | JSON |
GET | /representantes/{id} | No | Devuelve un representante por su id | 200 | JSON |
POST | /representantes | No | Crea un nuevo representante | 201 | JSON |
PUT | /representantes/{id} | No | Actualiza un representante por su id | 200 | JSON |
DELETE | /representantes/{id} | No | Elimina un representante por su id | 204 | No Content |
GET | /representantes/find?nombre=X | No | Devuelve los representantes con nombre X | 200 | JSON |
WS | /updates/representantes | No | Websocket para notificaciones los cambios en los representantes en tiempo real | --- | JSON |
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
GET | /raquetas | No | Devuelve todas las raquetas | 200 | JSON |
GET | /raquetas?page=X&perPage=Y | No | Devuelve raquetas paginadas | 200 | JSON |
GET | /raquetas/{id} | No | Devuelve una raqueta por su id | 200 | JSON |
POST | /raquetas | No | Crea una nueva raqueta | 201 | JSON |
PUT | /raquetas/{id} | No | Actualiza una raqueta por su id | 200 | JSON |
DELETE | /raquetas/{id} | No | Elimina una raqueta por su id | 204 | No Content |
GET | /raquetas/find?marca=X | No | Devuelve las raquetas con marca X | 200 | JSON |
GET | /raquetas/{id}/representante | No | Devuelve el representante de la raqueta dado su id | 200 | JSON |
WS | /updates/raquetas | No | Websocket para notificaciones los cambios en las raquetas en tiempo real | --- | JSON |
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
GET | /tenistas | No | Devuelve todos los tenistas | 200 | JSON |
GET | /tenistas?page=X&perPage=Y | No | Devuelve tenistas paginados | 200 | JSON |
GET | /tenistas/{id} | No | Devuelve un tenista por su id | 200 | JSON |
POST | /tenistas | No | Crea un nuevo tenista | 201 | JSON |
PUT | /tenistas/{id} | No | Actualiza un tenista por su id | 200 | JSON |
DELETE | /tenistas/{id} | No | Elimina un tenista por su id | 204 | No Content |
GET | /tenistas/find?nombre=X | No | Devuelve los tenistas con nombre X | 200 | JSON |
GET | /tenistas/{id}/raqueta | No | Devuelve la raqueta del tenista dado su id | 200 | JSON |
GET | /tenistas/ranking/{ranking} | No | Devuelve el tenista con ranking X | 200 | JSON |
WS | /updates/tenistas | No | Websocket para notificaciones los cambios en los tenistas en tiempo real | --- | JSON |
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
POST | /users/login | No | Login de un usuario, Token | 200 | JSON |
POST | /users/register | No | Registro de un usuario | 201 | JSON |
GET | /users/me | JWT | Datos del usuario del token | 200 | JSON |
PUT | /users/me | JWT | Actualiza datos del usuario: nombre, e-mail y username | 200 | JSON |
PATCH | /users/me | JWT | Actualiza avatar del usuario como multipart | 200 | JSON |
GET | /users/list | JWT | Devuelve todos los usuarios, si el token pertenece a un admin | 200 | JSON |
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
GET | /storage/check | NO | Info del servicio | 200 | JSON |
POST | /storage | No | Envía un fichero como stream de bytes | 201 | JSON |
GET | /storage/{fileName} | No | Descarga un fichero por su nombre | 200 | JSON |
DELETE | /storage/{fileName} | JWT | Elimina un fichero por su nombre | 204 | No Content |
Método | Endpoint (/api) | Auth | Descripción | Status Code (OK) | Content |
---|---|---|---|---|---|
GET | /test?texto | No | Devuelve un JSON con datos de prueba, y el texto de query opcional | 200 | JSON |
GET | /test/{id} | No | Devuelve un JSON con datos de prueba por su id | 200 | JSON |
POST | /test | No | Crea un nuevo JSON con datos de prueba | 201 | JSON |
PUT | /test/{id} | No | Actualiza un JSON con datos de prueba por su id | 200 | JSON |
PATCH | /test/{id} | No | Actualiza un JSON con datos de prueba por su id | 200 | JSON |
DELETE | /test/{id} | No | Elimina un JSON con datos de prueba por su id | 204 | No Content |
Ktor es el framework para desarrollar servicios y clientes asincrónicos. Es 100% Kotlin y se ejecuta en usando Coroutines. Admite proyectos multiplataforma, lo que significa que puede usarlo para cualquier proyecto dirigido a JVM, Android, iOS, nativo o Javascript. En este proyecto aprovecharemos Ktor para crear un servicio web para consumir una API REST. Además, aplicaremos Ktor para devolver páginas web.
Ktor trabaja con un sistema de plugins que lo hacen muy flexible y fácil de configurar. Además, Ktor es un framework donde trabajamos con DSL (Domain Specific Language) que nos permite crear código de forma más sencilla y legible.
Además, permite adaptar su estructura en base a funciones de extensión.
Podemos crear un proyecto Ktor usando el plugin IntelliJ, desde su web. Con estos asistentes podemos crear un proyecto Ktor con las opciones que queramos (plugins), destacamos el routing, el uso de json, etc.
El servidor tiene su entrada y configuración en la clase Application. Esta lee la configuración en base al fichero de configuración y a partir de aquí se crea una instancia de la clase Application en base a la configuración de module().
Podemos parametrizar la aplicación usando el fichero de configuración. En este caso, usaremos el fichero de configuración .conf y puede ser en distintos formatos, como JSON, YAML o HOCON. En este caso, usaremos HOCON. En este fichero de configuración podemos definir distintas propiedades, como el puerto de escucha, el host, el tiempo de expiración del token JWT, o el modo Auto-Reload, etc. En este caso, usaremos el siguiente fichero de configuración:
ktor {
## Para el puerto
deployment {
port = 6969
port = ${?PORT}
}
## Para la clase principal
application {
modules = [ joseluisgs.es.ApplicationKt.module ]
}
## Modo de desarrollo, se dispara cuando detecta cambios
## development = true
deployment {
## Directorios a vigilar
watch = [ classes, resources ]
}
## Modo de ejecución
environment = dev
environment = ${?KTOR_ENV}
}
Ktor se puede extender y ampliar usando plugins. Estos plugins se "instalan" y configuran configuran según las necesidades. Los más recomendados para hacer una Api Rest son:
- Routing: Para definir las rutas de la API
- Serialization: Para serializar y deserializar objetos, por ejemplo en JSON
- ContentNegotiation: Para definir el tipo de contenido que se va a usar en la API, por ejemplo JSON
fun Application.configureSerialization() {
install(ContentNegotiation) {
// Lo ponemos bonito :)
json(Json {
prettyPrint = true
isLenient = true
})
}
}
Las rutas se definen creando una función de extensión sobre Route. A su vez, usando DSL se definen las rutase en base a las petición HTTP sobre ella. Podemos responder a la petición usando call.respondText(), para texto; call.respondHTML(), para contenido HTML usando Kotlin HTML DSL; o call.respond() para devolver una respuesta en formato JSON o XML. finalmente asignamos esas rutas a la instancia de Application, es decir, dentro del método module(). Un ejemplo de ruta puede ser:
routing {
// Entrada en la api
get("/") {
call.respondText("👋 Hola Kotlin REST Service con Kotlin-Ktor")
}
}
Ktor te permite hacer Type-Safe Routing, es decir, que puedes definir una clase que represente una ruta y que tenga las operaciones a realizar.
También podemos crear rutas de manera tipada con Locations, pero esta siendo sustituida por Type-Safe Routing.
Ktor soporta Content Negotiation, es decir, que puede aceptar peticiones y respuestas distintos tipos de contenido, como JSON, XML, HTML, etc. En este caso, usaremos JSON. Para ello, usaremos la librería Kotlinx Serialization
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
Nos permite configurar los encabezados Cache-Control y Expires utilizados para el almacenamiento en caché de HTTP. Puede configurar el almacenamiento en caché de las siguientes maneras: globales, particulares a nivel de ruta o llamada, activando o desactivando esta opción para determinados tipos de contenidos.
Ktor proporciona la capacidad de comprimir contenido saliente usando diferentes algoritmos de compresión, incluidos gzip y deflate, y con ello, especificar las condiciones requeridas para comprimir datos (como un tipo de contenido o tamaño de respuesta) o incluso comprimir datos en función de parámetros de solicitud específicos.
Si se supone que su servidor debe manejar solicitudes de origen cruzado (CORS), debe instalar y configurar el complemento CORS Ktor. Este complemento le permite configurar hosts permitidos, métodos HTTP, encabezados establecidos por el cliente, etc.
Por defecto, el plugin de CORS permite los métodos GET, POST y HEAD
Lo ideal es que aprendas a configurarlo según tus necesidades, pero aquí tienes un ejemplo de configuración básica:
install(CORS) {
// podemos permitir algún host específico
anyHost() // cualquier host, quitar en produccion
allowHost("client-host")
allowHost("client-host:8081")
allowHost("client-host", subDomains = listOf("en", "de", "es"))
allowHost("client-host", schemes = listOf("http", "https"))
// Podemos permitir contenido
allowHeader(HttpHeaders.ContentType) // Permitimos el tipo de contenido
allowHeader(HttpHeaders.Authorization) // Permitimos autorithachion
// Si queremos permitir otros métodos
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Delete)
}
En Ktor podemos mandar distintos tipos de respuesta, así como distintos códigos de estado.
call.respondText("👋 Hola Kotlin REST Service con Kotlin-Ktor")
call.respond(HttpStatusCode.OK, "👋 Hola Kotlin REST Service con Kotlin-Ktor")
call.respond(HttpStatusCode.NotFound, "No encontrado")
Simplemente usa una data class y la función call.respond() para enviar datos serializados. En este caso, usaremos la librería Kotlinx Serialization
@Serializable
data class Customer(val id: Int, val firstName: String, val lastName: String)
get("/customer") {
call.respond(Customer(1, "José Luis", "García Sánchez"))
}
En Ktor podemos recibir distintos tipos de peticiones.
Podemos obtener los parámetros del Path, con parameters, como en el siguiente ejemplo, siempre y cuando estén definidos en la ruta {param}:
get("/hello/{name}") {
val name = call.parameters["name"]
call.respondText("Hello $name!")
}
Podemos obtener los parámetros de la Query, con queryParameters, si tenemos por ejemplo la siguiente ruta: /products?price=asc&category=1:
get("/products") {
val price = call.request.queryParameters["price"]
val category = call.request.queryParameters["category"]
call.respondText("Price: $price, Category: $category")
}
Para recibir datos serializados, usa la función call.receive() y la data class que representa el tipo de datos que se espera recibir con la que casteamos el body de la petición. En este caso, usaremos la librería Kotlinx Serialization
@Serializable
data class Customer(val id: Int, val firstName: String, val lastName: String)
post("/customer") {
val customer = call.receive<Customer>()
call.respondText("Customer: $customer")
}
Ktor soporta peticiones con formularios, es decir, que podemos enviar datos de un formulario.
post("/signup") {
val formParameters = call.receiveParameters()
val username = formParameters["username"].toString()
call.respondText("The '$username' account is created")
}
Ktor soporta peticiones multipartes, es decir, que podemos enviar ficheros, imágenes, etc.
post("/upload") {
// multipart data (suspending)
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
val fileName = part.originalFileName as String
var fileBytes = part.streamProvider().readBytes()
File("uploads/$fileName").writeBytes(fileBytes)
part.dispose()
}
call.respondText("$fileName is uploaded to 'uploads/$fileName'")
}
Ktor soporta subida de información, es decir, que podemos enviar ficheros, imágenes, etc. Podemos hacerlo con recieve o receiveChannel() (raw). Para el caso de ficheros se puede mandar así si sabemos cómo almacenarlos, si no podemos enviar ficheros usando el sistema de petición multipart.
post("/upload") {
val file = File("uploads/ktor_logo.png")
call.receiveChannel().copyAndClose(file.writeChannel())
call.respondText("A file is uploaded")
}
Ktor tiene una API de validación que nos permite validar los datos del body de una petición. En este caso lanzando RequestValidationException si no es correcto.
install(RequestValidation) {
validate<Customer> { customer ->
if (customer.id <= 0)
ValidationResult.Invalid("A customer ID should be greater than 0")
else ValidationResult.Valid
}
}
Ktor nos ofrece poder personalizar las páginas de error que se muestran al usuario.
De esta manera, a la hora de trabajar con las excepciones podemos desviarlas a este sistema y ofrecer una respuesta con su código de estado de error correspondiente y un mensaje personalizado.
install(StatusPages) {
exception<AuthenticationException> { cause ->
call.respond(HttpStatusCode.Unauthorized, "Not Authenticated")
}
exception<AuthorizationException> { cause ->
call.respond(HttpStatusCode.Forbidden, "Not Authorized")
}
exception<UserException.NotFound> { cause ->
call.respond(HttpStatusCode.NotFound, cause.message)
}
}
Aunque no es la mejor técnica, pues hay otras mejores como Railway Oriented Programming, podemos usar excepciones personalizadas para controlar los errores de nuestra aplicación.
Podemos lanzarlas con throw, y capturarlas con try/catch, o podemos usar la Status Pages para capturarlas y devolver una respuesta predeterminada
sealed class RaquetaException(message: String) : RuntimeException(message) {
class NotFound(message: String) : RaquetaException(message)
class BadRequest(message: String) : RaquetaException(message)
class ConflictIntegrity(message: String) : RaquetaException(message)
class RepresentanteNotFound(message: String) : RaquetaException(message)
}
override suspend fun findById(id: UUID): Raqueta {
logger.debug { "findById: Buscando raqueta en servicio con id: $id" }
return repository.findById(id)
?: throw RaquetaException.NotFound("No se ha encontrado la raqueta con id: $id")
}
Para evitar que las excepciones se propaguen por la aplicación, podemos usar el patrón Result, que nos permite devolver un valor o un error, siguiendo la filosofía de Railway Oriented Programming. De esta manera, podemos controlar los errores en la capa de servicio, y devolver un valor o un error, que será gestionado en la capa de controladores. De esta manera tendremos un control de errores centralizado, y evitaremos que las excepciones se propaguen por la aplicación.
Además, tener una jerarquía de errores nos permite tener un control de errores más granular, y poder devolver un código de error más específico.
sealed class RaquetaError(val message: String) {
class NotFound(message: String) : RaquetaError(message)
class BadRequest(message: String) : RaquetaError(message)
class ConflictIntegrity(message: String) : RaquetaError(message)
class RepresentanteNotFound(message: String) : RaquetaError(message)
}
override suspend fun findByUuid(uuid: UUID): Result<Raqueta, RaquetaError> {
logger.debug { "Servicio de raquetas findByUuid con uuid: $uuid" }
return raquetasRepository.findByUuid(uuid)
?.let { Ok(it) }
?: Err(RaquetaError.NotFound("No se ha encontrado la raqueta con uuid: $uuid"))
}
@GetMapping("/{id}")
suspend fun findById(@PathVariable id: UUID): ResponseEntity<RaquetaDto> {
logger.info { "GET By ID Raqueta con id: $id" }
raquetasService.findByUuid(id).mapBoth(
success = {
return ResponseEntity.ok(
it.toDto(
raquetasService.findRepresentante(it.representanteId).get()!!
)
)
},
failure = { return handleErrors(it) }
)
}
Ktor soporta WebSockets para crear aplicaciones que hagan uso de ellos. Los WebSockets permiten crear aplicaciones que requieren transferencia de datos en tiempo real desde y hacia el servidor ya que que hace posible abrir una sesión de comunicación interactiva entre el navegador del usuario y un servidor. Con esta API, puede enviar mensajes a un servidor y recibir respuestas controladas por eventos sin tener que consultar al servidor para una respuesta.
webSocket("/echo") {
send("Please enter your name")
for (frame in incoming) {
frame as? Frame.Text ?: continue
val receivedText = frame.readText()
if (receivedText.equals("bye", ignoreCase = true)) {
close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
} else {
send(Frame.Text("Hi, $receivedText!"))
}
}
}
Aunque lo normal, es que nuestros servicios estén detrás de un Proxy Inverso, podemos configurar Ktor para que soporte SSL y certificados. Para ello, debemos añadir la librería de soporte para TSL, y configurar el puerto y el certificado en el fichero application.conf.
ktor {
## Para el puerto
deployment {
## Si no se especifica el puerto, se usa el 8080, si solo queremos SSL quitar el puerto normal
port = 6969
port = ${?PORT}
## Para SSL, si es necesario poner el puerto
sslPort = 6963
sslPort = ${?SSL_PORT}
}
## Para la clase principal
application {
modules = [ joseluisgs.es.ApplicationKt.module ]
}
## Para SSL/TSL configuración del llavero y certificado
security {
ssl {
keyStore = ./cert/server_keystore.p12
keyAlias = serverKeyPair
keyStorePassword = 1234567
privateKeyPassword = 1234567
}
}
}
Ktor tiene una API de autenticación que nos permite autenticar usuarios y autorizar peticiones. En este caso, usaremos JWT para la autenticación y autorización. Para ello, debemos añadir la librería de soporte para Ktor JWT y configurar sus opciones.
Gracias a ella podemos crear un interceptor (middleware) que se ejecutará antes de cada petición y que nos permitirá validar el token JWT y añadirlo a la petición para que podamos usarlo en el resto de la aplicación. En este caso, usaremos el token para añadir el usuario autenticado a la petición y poder usarlo en el resto de la aplicación.
// Instalamos el interceptor de autenticación
install(Authentication) {
jwt("auth-jwt") {
validate { credential ->
if (credential.payload.getClaim("username").asString() != "") {
JWTPrincipal(credential.payload)
} else {
null
}
}
}
}
// Añadimos el interceptor a todas las rutas
routing {
authenticate("auth-jwt") {
get("/hello") {
val principal = call.principal<JWTPrincipal>() // Leemos el token
val username = principal!!.payload.getClaim("username").asString()
val expiresAt = principal.expiresAt?.time?.minus(System.currentTimeMillis())
call.respondText("Hello, $username! Token is expired at $expiresAt ms.")
}
}
}
Ktor tiene una API de testing que nos permite testear nuestras aplicaciones. Para ello, debemos añadir la librería de soporte para Ktor Test y configurar sus opciones.
Podemos usar la función testApplication para configurar una instancia configurada de nuestro servicio de prueba que se ejecuta localmente. Con ello podemos usar la instancia de cliente HTTP de Ktor dentro de una aplicación de prueba para realizar una solicitud a su servidor, recibir una respuesta y testear resultados.
fun registerUserTest() = testApplication {
// Configuramos el entorno de test
environment { config }
val client = createClient {
install(ContentNegotiation) {
json()
}
}
// Lanzamos la consulta
val response = client.post("/api/users/register") {
contentType(ContentType.Application.Json)
setBody(userDto)
}
// Comprobamos que la respuesta y el contenido es correcto
assertEquals(response.status, HttpStatusCode.Created)
// Tambien podemos comprobar el contenido
val res = json.decodeFromString<UserDto>(response.bodyAsText())
assertAll(
{ assertEquals(res.nombre, userDto.nombre) },
{ assertEquals(res.email, userDto.email) },
{ assertEquals(res.username, userDto.username) },
{ assertEquals(res.avatar, userDto.avatar) },
{ assertEquals(res.role, userDto.role) },
)
}
Podemos distribuir nuestra app de distintas maneras
Podemos crear un JAR con nuestra aplicación y ejecutarla con el comando java -jar. Para ello, debemos añadir la librería de soporte para Ktor JAR y configurar sus opciones en Gradle.
- buildFatJar: construye un JAR combinado de un proyecto y dependencias, como *-all.jar en el directorio build/libs cuando se complete esta compilación.
- runFatJar: construye un JAR del proyecto y lo ejecuta.
Podemos crear una aplicación y ejecutarla gracias a Gradle. Para ello, debemos añadir la librería de soporte para Ktor Application y configurar sus opciones.
Esta opción nos proporciona varias formas de empaquetar la aplicación, por ejemplo, la tarea installDist instala la aplicación con todas las dependencias de tiempo de ejecución y los scripts de inicio. Para crear archivos de distribución completos.
Ktor tiene una API de Docker que nos permite crear una imagen de Docker con nuestra aplicación.
- buildImage: construye la imagen de Docker de un proyecto en un tarball. Esta tarea genera un archivo jib-image.tar en el directorio de compilación. Puede cargar esta imagen en un demonio de Docker con el comando de carga de Docker: docker load < build/jib-image.tar
- publishImageToLocalRegistry: compila y publica la imagen de Docker de un proyecto en un registro local.
- runDocker: crea la imagen de un proyecto en un demonio Docker y lo ejecuta. Ejecutar esta tarea iniciará el servidor Ktor, respondiendo en http://0.0.0.0:8080 por defecto. Si su servidor está configurado para usar otro puerto, puede ajustar la asignación de puertos.
- publishImage: compila y publica la imagen de Docker de un proyecto en un registro externo, como Docker Hub o Google Container Registry. Tenga en cuenta que debe configurar el registro externo mediante la propiedad ktor.docker.externalRegistry para esta tarea.
ktor {
docker {
jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_11)
localImageName.set("sample-docker-image")
imageTag.set("0.0.1-preview")
portMappings.set(
listOf(
io.ktor.plugin.features.DockerPortMapping(
80,
8080,
io.ktor.plugin.features.DockerPortMappingProtocol.TCP
)
)
)
}
}
A la hora de documentar nuestro código hemos hecho uso de Dokka el cual haciendo uso de KDoc nos va a permitir comentar nuestro código y ver dicha documentación en html. Puedes ver un ejemplo completo en todo lo relacionado con Representantes (modelos, repositorios y/o servicios) y consultar la documentación en /build/dokka/html/index.html
Por otro lado se ha usado Swagger con OpenAPI para la documentación de la API. En vez de las librerías ofrecidas por el equipo de Ktor (OpenAPI y Swagger) hemos usado Ktor Swagger-UI la cual extiende el DSL de Ktor para añadir la documentación de Swagger-UI a nuestra aplicación sobre la marcha.
Puedes ver un ejemplo completo en todo lo relacionado con endpoint de Test (modelos, repositorios y/o servicios). Lo he hecho así para no llenar el proyecto de código y ser un proyecto didáctico. Puedes consultar swagger en: http://xxx/swagger/
// Put -> /{id}
put("{id}", {
description = "Put By Id: Mensaje de prueba"
request {
pathParameter<String>("id") {
description = "Id del mensaje de prueba"
required = true // Opcional
}
body<TestDto> {
description = "Mensaje de prueba de actualización"
}
}
response {
default {
description = "Respuesta de prueba"
}
HttpStatusCode.OK to {
description = "Mensaje de prueba modificado"
body<TestDto> { description = "Mensaje de test modificado" }
}
}
}) {
logger.debug { "PUT /test/{id}" }
val id = call.parameters["id"]
val input = call.receive<TestDto>()
val dto = TestDto("TEST OK PUT $id : ${input.message}")
call.respond(HttpStatusCode.OK, dto)
}
Como todo concepto que aunque complicado de conseguir implica una serie de condiciones. La primera de ellas es asegurar la asincronía en todo momento. Cosa que se ha hecho mediante Ktor y el uso de corrutinas.
Por otro lado el acceso de la base de datos no debe ser bloqueante, por lo que no se ha usado la librería Exposed de Kotlin para acceder a la base de datos y que trabaja por debajo con el driver JDBC. Sabemos que esto se puede podemos acercarnos a la Asincronía pura usando corrutinas y el manejo de contexto de transaccion asíncrono.
En cualquier caso, hemos decidido usar el driver R2DBC con el objetivo que el acceso a la base de datos sea no bloqueante y así poder aprovechar el uso de Flows en Kotlin y así poder usar la reactividad total en la base de datos con las corrutinas y Ktor.
Programación reactiva: programación asíncrona de flujos observables
Programar reactivamente una api comienza desde observar y procesar las colecciones existentes de manera asíncrona desde la base de datos hasta la respuesta que se ofrezca.
Es importante que los datos sean inmutables, es decir, que no se puedan modificar una vez creados en todo el proceso de las capas de nuestra arquitectura. Esto nos permite tener un código más seguro y predecible. En Kotlin, por defecto, podemos hacer que una clase sea inmutable, añadiendo el modificador val a sus propiedades.
Para los POKOS (Plain Old Kotlin Objects) usaremos Data Classes, que son clases inmutables por defecto y crearemos objetos nuevos con las modificaciones que necesitemos con la función copy().
La caché es una forma de almacenar datos en memoria/disco para que se puedan recuperar rápidamente. Además de ser una forma de optimizar el rendimiento, también es una forma de reducir el coste de almacenamiento de datos y tiempo de respuesta pues los datos se almacenan en memoria y no en disco o base de datos que pueden estar en otro servidor y con ello aumentar el tiempo de respuesta.
Además la caché nos ofrece automáticamente distintos mecanismos de actuación, como por ejemplo, que los elementos en cache tenga un tiempo de vida máximo y se eliminen automáticamente cuando se cumpla. Lo que nos permite tener datos actualizados Y/o los más usados en memoria y eliminar los que no se usan.
En nuestro proyecto tenemos dos repositorios, uno para la caché y otro para la base de datos. Para ello todas las consultas usamos la caché y si no está, se consulta a la base de datos y se guarda en la caché. Además, podemos tener un proceso en background que actualice la caché cada cierto tiempo solo si así lo configuramos, de la misma manera que el tiempo de refresco.
Además, hemos optimizado las operaciones con corrutinas para que se ejecuten en paralelo actualizando la caché y la base de datos.
El diagrama seguido es el siguiente
Por otro lado también podemos configurar la Caché de Header a nivel de rutas o tipo de ficheros como se ha indicado.
Para este proyecto hemos usado Cache4K. Cache4k proporciona un caché de clave-valor en memoria simple para Kotlin Multiplatform, con soporte para ivalidar items basados en el tiempo ( caducidad) y en el tamaño.
Las notificaciones en tiempo real son una forma de comunicación entre el servidor y el cliente que permite que el servidor envíe información al cliente sin que el cliente tenga que solicitarla. Esto permite que el servidor pueda enviar información al cliente cuando se produzca un evento sin que el cliente tenga que estar constantemente consultando al servidor.
Para ello usaremos WebSockets. A partir de aquñi tenemos dos opciones que te he dejado en el código.
Aplicar el patrón Observer para que el servidor pueda enviar información al cliente cuando se produzca un evento sin que el cliente tenga que estar constantemente consultando al servidor. Para ello, una vez el cliente se conecta al servidor, se le asigna un ID de sesión y se guarda en una lista de clientes conectados. Cuando se produce un evento, se recorre la lista de clientes conectados y se envía la información a cada uno de ellos, ejecutando la función de callback que se le ha pasado al servidor. El patrón Observer es una buena opción cuando tienes una lista de suscriptores que necesita recibir notificaciones en tiempo real y deseas mantener un registro de los suscriptores activos. En este caso, cuando ocurre un cambio, deberás recorrer la lista de suscriptores y notificar a cada uno de ellos individualmente. Esto puede ser adecuado si la lista de suscriptores no es muy grande y no hay una gran cantidad de cambios que se produzcan con frecuencia. En esta solución te he dejado los Representantes.
Además, podemos hacer uso de las funciones de serialización para enviar objetos complejos como JSON.
La siguiente opción es usar estados reactivos con StateFlow o con SharedFlow. El uso de StateFlow/SharedFlow en Kotlin puede ser beneficioso cuando deseas tener un flujo de datos reactivo. StateFlow/SharedFlow es una implementación de la interfaz Flow de Kotlin que te permite emitir cambios de estado de manera asincrónica. Puedes observar los cambios en el flujo y reaccionar solo cuando hay un cambio de estado relevante. Esto evita tener que recorrer manualmente una lista de suscriptores en cada cambio, ya que StateFlow/SharedFlow se encargará de notificar automáticamente a los observadores a través del websocket interesados cuando se produzca un cambio en el estado. Siguiendo esta forma te he dejado Tenistas y Raquetas.
La elección entre ambos enfoques depende de tus necesidades específicas. Si la lista de suscriptores es pequeña y los cambios ocurren con poca frecuencia, el patrón Observer puede ser una solución simple y adecuada. Sin embargo, si deseas aprovechar las capacidades reactivas de Kotlin y tener un flujo de datos que notifique automáticamente a los observadores cuando ocurra un cambio relevante, entonces StateFlow/SharedFlow podría ser una mejor opción.
Gracias al principio de inversión de dependencias (SOLID), podemos hacer que el código que es el núcleo de nuestra aplicación no dependa de los detalles de implementación, como pueden ser el framework que utilices, la base de datos, cache...Todos estos aspectos se especificarán mediante interfaces, y el núcleo no tendrá que conocer cuál es la implementación real para funcionar.
La Inyección de dependencias es un patrón de diseño que permite que las dependencias de una clase se pasen como parámetros en el constructor de la clase (principalmente). Esto nos permite que las dependencias de una clase sean independientes de la clase y que puedan ser reemplazadas por otras dependencias que implementen la misma interfaz y con ello conseguir un código no acoplado, que se adapte a cada situación y que sea fácil de testear y con ello podemos cumplir el principio de inversión de control.
Para ello usaremos Koin que es un framework de inyección de dependencias para Kotlin Multiplatform. Koin nos permite definir los módulos de inyección de dependencias y las dependencias que queremos inyectar en cada clase. En este caso hemos usado sus extensiones para Ktor y sus anotaciones para hacerlo mucho más directo.
Railway Oriented Programming es un patrón de diseño que nos permite escribir código más limpio y mantenible. Este patrón se basa en el concepto de programación funcional y en el uso de monadas.
Es una técnica de programación funcional que nos permite manejar errores de forma más sencilla y segura. En lugar de usar excepciones, se usan valores de retorno o tipos de error para indicar si una operación ha tenido éxito o no. En el caso de que la operación haya fallado, se devuelve un valor que indica el error.
Se van encadenando operaciones que pueden fallar, y en caso de que alguna de ellas falle, se devuelve el error. De esta forma, se evita el uso de excepciones, que pueden ser difíciles de manejar. Tampoco tenemos que esperar que se ejecuten todas las operaciones para saber si ha fallado alguna, sino que en cuanto una operación falle, se devuelve el error.
Para la seguridad de las comunicaciones usaremos SSL/TLS que es un protocolo de seguridad que permite cifrar las comunicaciones entre el cliente y el servidor. Para ello usaremos un certificado SSL que nos permitirá cifrar las comunicaciones entre el cliente y el servidor.
De esta manera, conseguiremos que los datos viajen cifrados entre el cliente y el servidor y que no puedan ser interceptados por terceros de una manera sencilla.
Esto nos ayudará, a la hora de hacer el login de un usuario, a que la contraseña no pueda ser interceptada por terceros y que el usuario pueda estar seguro de que sus datos están protegidos.
Para la seguridad de las comunicaciones usaremos JWT que es un estándar abierto (RFC 7519) que define una forma compacta y autónoma de transmitir información entre partes como un objeto JSON. Esta información puede ser verificada y confiada porque está firmada digitalmente. Las firmas también se pueden usar para asegurar la integridad de los datos.
El funcionamiento de JWT es muy sencillo. El cliente hace una petición para autenticarse la primera vez. El servidor genera un token que contiene la información del usuario y lo envía al cliente. El cliente lo guarda y lo envía en cada petición al servidor. El servidor verifica el token y si es correcto, permite la petición al recurso.
Para la seguridad de las comunicaciones usaremos CORS que es un mecanismo que usa cabeceras HTTP adicionales para permitir que un user agent obtenga permiso para acceder a recursos seleccionados desde un servidor, en un origen distinto (dominio) al que pertenece.
Para la seguridad de las comunicaciones usaremos Bcrypt que es un algoritmo de hash de contraseñas diseñado por Niels Provos y David Mazières, destinado a ser un método de protección contra ataques de fuerza bruta. Con este algoritmo, se puede almacenar una contraseña en la base de datos de forma segura, ya que no se puede obtener la contraseña original a partir de la contraseña almacenada.
Para testear se ha usado JUnit y MocKK como librerías de apoyo. Además, Hemos usado la propia api de Ktor para testear las peticiones. Con ello podemos simular un Postman para testear las peticiones de manera local, con una instancia de prueba de nuestro servicio.
Para probar con un cliente nuestro servicio usaremos Postman que es una herramienta de colaboración para el desarrollo de APIs. Permite a los usuarios crear y compartir colecciones de peticiones HTTP, así como documentar y probar sus APIs.
El fichero para probar nuestra api lo tienes en la carpera postman y puedes importarlo en tu Postman para probar el resultado.
Para la distribución de la aplicación usaremos Docker con su Dockerfile. Además, podemos usar Docker Compose con docker-compose.yml que es una herramienta para definir y ejecutar aplicaciones Docker de múltiples contenedores.
Por otro lado, podemos usar JAR o Aplicaciones de sistema tal y como hemos descrito en el apartado de Despliegue.
Recuerda: Si haces una imagen Docker mete todos los certificados y recursos que necesites que use adicionalmente nuestra aplicación (directorios), si no no funcionará, pues así los usas en tu fichero de configuración. Recuerda lo que usa tu fichero de configuración para incluirlo.
La documentación sobre los métodos se pueden consultar en HTML realizada con Dokka.
La documentación de los endpoints se puede consultar en HTML realizada con Swagger.
- Twitter: https://twitter.com/JoseLuisGS_
- GitHub: https://github.com/joseluisgs
- Web: https://joseluisgs.github.io
- Discord del módulo: https://discord.gg/RRGsXfFDya
- Aula DAMnificad@s: https://discord.gg/XT8G5rRySU
Codificado con 💖 por José Luis González Sánchez
Cualquier cosa que necesites házmelo saber por si puedo ayudarte 💬.
Este repositorio y todo su contenido está licenciado bajo licencia Creative Commons, si desea saber más, vea la LICENSE. Por favor si compartes, usas o modificas este proyecto cita a su autor, y usa las mismas condiciones para su uso docente, formativo o educativo y no comercial.
JoseLuisGS
by
José Luis González Sánchez is licensed under
a Creative Commons
Reconocimiento-NoComercial-CompartirIgual 4.0 Internacional License.
Creado a partir de la obra
en https://github.com/joseluisgs.