Servicio web para API REST con Kotlin y Ktor.
- Kotlin Ktor REST Service
El proyecto consiste en realizar un servicio REST con Kotlin y Ktor. Para ello vamos a usar la tecnologías que nos propone Jetbrains para hacer todo el trabajo, desde la creación de la API REST, hasta la implementación de la misma, así como la serialización de objetos y/o acceso al almacenamiento de los mismos.
Para el almacenamiento de la información se ha usado una H2 Database donde la usamos gracias a la librería de Jetbrains Exposed.
Ktor es un nuevo 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 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.
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().
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")
}
}
Para serializar objetos a JSON, usamos la librería de serialización de Kotlin, especialmente para hacer la negociación de contenido en JSON.
Para ello, las clases POJO a serailizar son indicadas con @Serializable.
import kotlinx.serialization.Serializable
@Serializable
data class Customer(var id: String, val firstName: String, val lastName: String, val email: String)
Posteriormente, en nuestra Application de Ktor, instalamos como un plugin la negociación de contenido en JSON.
install(ContentNegotiation) {
json()
}
Podemos dejar el Json formateado, con el constructor de serialización Kotlin de Kotlin
install(ContentNegotiation) {
// Lo ponemos bonito :)
json(Json {
prettyPrint = true
isLenient = true
})
}
Dentro de un controlador de ruta, puedes obtener acceso a una solicitud utilizando la propiedad call.request. Esto devuelve la instancia de ApplicationRequest y proporciona acceso a varios parámetros de solicitud.
routing {
get("/") {
val uri = call.request.uri
call.respondText("Request uri: $uri")
}
}
Para obtener acceso a los valores de los parámetros de ruta mediante la propiedad call.parameters. Por ejemplo, call.parameters["login"] devolverá admin para la ruta /user/admin
get("/user/{login}") {
if (call.parameters["login"] == "admin") {
call.respondText("Request admin: ${call.parameters["login"]}")
}
}
Para obtener acceso a los parámetros de una cadena de consulta, puede usar la propiedad ApplicationRequest.queryParameters. Por ejemplo, si se realiza una solicitud a /products?price=asc, puede acceder al parámetro de consulta de precio.
get("/products") {
if (call.request.queryParameters["price"] == "asc") {
call.respondText("Request price: ${call.request.queryParameters["price"]}")
}
}
Ktor proporciona un complemento de negociación de contenido para negociar el tipo de medio de la solicitud y deserializar el contenido a un objeto de un tipo requerido. Para recibir y convertir contenido para una solicitud, llama al método de recepción que acepta una clase de datos como parámetro.
post("/customer") {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText("Customer stored correctly", status = HttpStatusCode.Created)
}
Si necesita recibir un archivo enviado como parte de una solicitud de varias partes, llame a la función receiveMultipart y luego recorra cada parte según sea necesario. En el siguiente ejemplo, PartData.FileItem se usa para recibir un archivo como flujo de bytes.
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'")
}
Podemos implementar métodos de autenticación y autorización variados con Ktor. Este ejemplo se ha procedido a usar JWT Tokens. Para ello se ha instalado las librerías necesarias para el procesamiento de tokens JWT. Los parámetros para generar el token se han configurado en el fichero de configuración. Debemos tener en cuenta algunos parámetros para proteger y verificar los tokens, así como su tiempo de vida. Posteriormente lo instalamos como un plugin más en la configuración de la aplicación. Podemos configurar su verificador y ademas validar el payload para analizar que el cuerpo del token es válido, tal y como se indica el la documentación de Ktor.
install(Authentication) {
jwt {
// Configure jwt authentication
}
}
Por otro lado, cuando nos logueamos, podemos generar el token y devolverlo al usuario, en base a los parámetros de configuración.
Para proteger ls rutas usamos la función athenticate. Cualquier ruta dentro de ella quedará protegida por la autenticación. Además si leemos en el Payload el usuario y administramos alguna política de permisos, podemos verificar que el usuario tiene permisos para acceder a la ruta.
routing {
authenticate("auth-jwt") {
get("/hello") {
val principal = call.principal<JWTPrincipal>()
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.")
}
}
}
Para el almacenamiento de la información se ha usado Exposed, el cual nos ofrece dos modos de operación. Hemos usado el modelo DAO para este ejemplo. Puedes ver más información al respecto en este ejemplo. Para ello trabajamos con unas tablas en la base de datos y unas clases DAO que mapean las operaciones con objetos.
Se ha seguido un patrón CRUD basado en repositorios para la mayoría de las operaciones. Para las relaciones se han usado las clases relacionadas.
// Tabla de orders
object OrdersTable : LongIdTable() {
//Indicamos los campos de la tabla
val customer = reference("customer_id", CustomersTable)
val createdAt = datetime("created_at")
}
// Clase que mapea la tabla de Order en Objetos DAO
class OrderDAO(id: EntityID<Long>) : LongEntity(id) {
// Sobre qué tabla me estoy trabajando para hacer los Bindigs del objeto con los elementos de la tabbla/fila
companion object : LongEntityClass<OrderDAO>(OrdersTable)
// Indicamos que este pedido tiene una relacion con cliente. 1 Pedido pertenece a 1 Cliente (1:M). Un cliente puede tener varios pedidos.
var customer by CustomerDAO referencedOn OrdersTable.customer
var createdAt by OrdersTable.createdAt
// Relación inversa donde soy referenciado. 1 Pedido tiene varios contenidos (1:M). Es opcional ponerlo, pero nos ayuda a mejorar las relaciones.
// evitando consultas y haciendo uso de los métodos.
val contents by OrderItemDAO referrersOn OrderItemsTable.order
}
Ktor ofrece un motor de test especial que no crea un servidor web, no se une a los sockets y no realiza ninguna solicitud HTTP real. En su lugar, se conecta directamente a los mecanismos internos y procesa una llamada de aplicación directamente. Esto da como resultado una ejecución de pruebas más rápida en comparación con la ejecución de un servidor web completo para la prueba. Además, puede configurar pruebas de extremo a extremo para probar los puntos finales del servidor utilizando el cliente HTTP de Ktor.
Para ello debemos crear nuestra aplicación testeable y luego procesar el endpoint con la petición indicada.
@Test
fun testGetCustomers() = withApplication(testEnv) {
with(handleRequest(HttpMethod.Get, "/rest//customers?limit=2")) {
assertEquals(HttpStatusCode.OK, response.status())
assertTrue(response.content!!.isNotEmpty())
assertTrue(response.content!!.contains("[email protected]"))
}
}
Además podemos testar punto a punto, usando el cliente HTTP de Ktor.
@Test
fun testGetCustomers() = runBlocking {
val httpResponse: HttpStatement = client.get("http://localhost:6969/rest/customers?limit=2")
val response: String = httpResponse.receive()
assertTrue(response.isNotEmpty())
assertTrue(response.contains("[email protected]"))
}
GET /rest/customers?limit={limit}
GET /rest/customers/{id}
PUT /rest/customers/{id}
DELETE /rest/customers/{id}
GET /rest/customers/{id}/orders
GET /rest/orders?limit={limit}
GET /rest/orders/{id}
PUT /rest/orders/{id}
DELETE /rest/orders/{id}
GET /rest/orders/{id}
GET /rest/orders/{id}/contents
GET /rest/orders/{id}/total
GET /rest/orders/{id}/customer
GET /rest/uploads/{fileName}
POST /rest/uploads/
DELETE /rest/uploads/{fileName}
<!-- Return a JWT Token -->
POST /rest/auth/login
POST /rest/auth/register
<!-- Needs a JWT Token -->
GET /rest/auth/me
<!-- Needs a JWT Token and ADMIN Role -->
GET /rest/auth/users
Puedes consumir el servicio REST con PostMan. Para ello solo debes importar la colección de ejemplo y ejecutar las mismas.
Codificado con 💖 por José Luis González Sánchez
Cualquier cosa que necesites házmelo saber por si puedo ayudarte 💬.
Este proyecto está licenciado bajo licencia MIT, si desea saber más, visite el fichero LICENSE para su uso docente y educativo.