Kotlin no es solo para Android: en el servidor se usa con Spring Boot y con Ktor, el framework de JetBrains. Aquí montamos una pequeña API de productos con Ktor: rutas, JSON de entrada y salida, parámetros y códigos de estado, probada petición por petición con curl.
También te puede interesar
El proyecto
Ktor es el framework web de JetBrains, escrito en Kotlin y pensado para corrutinas: cada petición se atiende en una función suspend, así que esperar a una base de datos u otro servicio no bloquea un hilo. Necesita varias bibliotecas, así que lo cómodo es un proyecto de Gradle. Este es el build.gradle.kts que corresponde al ejemplo:
plugins {
kotlin("jvm") version "2.4.21"
kotlin("plugin.serialization") version "2.4.21"
application
}
repositories { mavenCentral() }
dependencies {
implementation("io.ktor:ktor-server-core:3.6.1")
implementation("io.ktor:ktor-server-netty:3.6.1")
implementation("io.ktor:ktor-server-content-negotiation:3.6.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.1")
implementation("org.slf4j:slf4j-simple:2.0.17")
}
application { mainClass = "ServidorKt" }Cada pieza tiene su función: ktor-server-core trae las rutas, ktor-server-netty el motor que acepta las conexiones (Netty, el mismo de muchos servidores Java), content-negotiation y kotlinx-json convierten tus clases en JSON y de vuelta, y slf4j-simple imprime el registro. El plugin serialization del compilador genera el código de @Serializable. Se arranca con gradle run.
El servidor: rutas, JSON y códigos de estado
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@Serializable
data class Producto(val id: Int, val nombre: String, val precio: Double)
@Serializable
data class ProductoNuevo(val nombre: String, val precio: Double)
@Serializable
data class Error(val error: String)
val productos = ConcurrentHashMap<Int, Producto>()
val siguienteId = AtomicInteger(1)
fun agregar(nombre: String, precio: Double): Producto {
val p = Producto(siguienteId.getAndIncrement(), nombre, precio)
productos[p.id] = p
return p
}
fun Application.modulo() {
install(ContentNegotiation) { json() }
routing {
get("/") {
call.respondText("Tienda de ejemplo: prueba GET /productos")
}
get("/productos") {
call.respond(productos.values.sortedBy { it.id })
}
get("/productos/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val producto = id?.let { productos[it] }
if (producto == null) {
call.respond(HttpStatusCode.NotFound, Error("no existe el producto ${call.parameters["id"]}"))
} else {
call.respond(producto)
}
}
post("/productos") {
val nuevo = call.receive<ProductoNuevo>()
if (nuevo.nombre.isBlank() || nuevo.precio <= 0) {
call.respond(HttpStatusCode.BadRequest, Error("nombre vacío o precio no positivo"))
return@post
}
call.respond(HttpStatusCode.Created, agregar(nuevo.nombre, nuevo.precio))
}
}
}
fun main() {
agregar("Café molido", 89.50)
agregar("Té verde", 54.90)
embeddedServer(Netty, port = 8080, module = Application::modulo).start(wait = true)
}Lo esencial:
install(ContentNegotiation) { json() }hace quecall.respond(objeto)responda JSON y quecall.receive<Tipo>()lea JSON del cuerpo. Las clases tienen que llevar@Serializable.get("/productos/{id}")declara un parámetro de ruta, que se lee concall.parameters["id"]. Llega como texto (onull):toIntOrNull()convierte sin explotar si alguien pide/productos/abc.- El código de estado va como primer argumento:
HttpStatusCode.NotFound(404),BadRequest(400),Created(201). Sin él, 200. - Los datos viven en un
ConcurrentHashMapporque varias peticiones pueden llegar a la vez. En un servicio real irían a una base de datos.
Probarlo con curl
Con el servidor en marcha, desde otra terminal. Esta es la salida real de cada petición (quitamos solo la cabecera Date, que cambia cada vez) y, al final, el registro del servidor:
salida$ curl -i localhost:8080/productos
HTTP/1.1 200 OK
Content-Length: 92
Content-Type: application/json
[{"id":1,"nombre":"Café molido","precio":89.5},{"id":2,"nombre":"Té verde","precio":54.9}]
$ curl -i localhost:8080/productos/2
HTTP/1.1 200 OK
Content-Length: 43
Content-Type: application/json
{"id":2,"nombre":"Té verde","precio":54.9}
$ curl -i localhost:8080/productos/99
HTTP/1.1 404 Not Found
Content-Length: 36
Content-Type: application/json
{"error":"no existe el producto 99"}
$ curl -i -X POST localhost:8080/productos -H 'Content-Type: application/json' -d '{"nombre":"Cacao puro","precio":120.0}'
HTTP/1.1 201 Created
Content-Length: 45
Content-Type: application/json
{"id":3,"nombre":"Cacao puro","precio":120.0}
$ curl -i -X POST localhost:8080/productos -H 'Content-Type: application/json' -d '{"nombre":"","precio":10}'
HTTP/1.1 400 Bad Request
Content-Length: 46
Content-Type: application/json
{"error":"nombre vacío o precio no positivo"}
$ curl -i -X POST localhost:8080/productos -H 'Content-Type: application/json' -d '{"nombre":"Miel"}'
HTTP/1.1 400 Bad Request
Content-Length: 53
Content-Type: text/plain; charset=UTF-8
Failed to convert request body to class ProductoNuevo
$ curl -i localhost:8080/productos
HTTP/1.1 200 OK
Content-Length: 138
Content-Type: application/json
[{"id":1,"nombre":"Café molido","precio":89.5},{"id":2,"nombre":"Té verde","precio":54.9},{"id":3,"nombre":"Cacao puro","precio":120.0}]
--- registro del servidor
INFO io.ktor.server.Application - Autoreload is disabled because the development mode is off.
INFO io.ktor.server.Application - Application started in N seconds.
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::loadLibrary has been called by io.netty.util.internal.NativeLibraryUtil in an unnamed module (file:/lib-kt/ktor/netty-common-4.2.17.Final.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
INFO io.ktor.server.Application - Responding at http://0.0.0.0:8080Fíjate en las tres respuestas de error. La 404 la decidimos nosotros. La primera 400 también: el JSON es correcto, pero el nombre está vacío. La segunda 400 la da Ktor solo: al JSON le falta precio, que no tiene valor por omisión en ProductoNuevo, así que receive no puede crear el objeto y responde «Failed to convert request body…» en texto plano sin que escribamos nada. Si prefieres que todos los errores salgan en JSON con tu formato, el plugin StatusPages de Ktor permite capturar esa excepción y responder lo que quieras. En el registro quitamos además una línea de Netty que imprime bytes al azar porque el contenedor de prueba no tenía red.
--enable-native-access=ALL-UNNAMED; en Gradle, application { applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED") }.El error típico: olvidar @Serializable
Este servidor compila perfectamente, pero la clase que responde no lleva @Serializable:
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
data class Producto(val id: Int, val nombre: String) // sin @Serializable
fun main() {
embeddedServer(Netty, port = 8081) {
install(ContentNegotiation) { json() }
routing { get("/producto") { call.respond(Producto(1, "Café molido")) } }
}.start(wait = true)
}salida$ curl -i localhost:8081/producto HTTP/1.1 500 Internal Server Error Content-Length: 155 Content-Type: text/plain; charset=UTF-8 Serializer for class 'Producto' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied. --- registro del servidor (líneas con el error) kotlinx.serialization.SerializationException: Serializer for class 'Producto' is not found. at kotlinx.serialization.SerializersKt__SerializersKt.serializer(Serializers.kt:327)
El compilador no puede saberlo, porque respond acepta cualquier objeto; el fallo aparece en la primera petición, como un error 500 y una excepción en el registro. Si ves «Serializer for class … is not found», revisa dos cosas: que la clase tenga @Serializable y que el plugin kotlin("plugin.serialization") esté en el build.gradle.kts; sin el plugin, la anotación no genera nada.
- ParaEscribe
- Responder texto
call.respondText("...") - Responder JSON
call.respond(objeto)(clase con@Serializable) - Otro código de estado
call.respond(HttpStatusCode.NotFound, objeto) - Parámetro de ruta
get("/x/{id}")ycall.parameters["id"] - Parámetro de consulta (?q=)
call.request.queryParameters["q"] - Leer el cuerpo JSON
call.receive<Tipo>()
Si vienes de Java: Spring Boot o Ktor
En el servidor, Kotlin se usa mucho con Spring Boot, que lo soporta oficialmente: si tu equipo ya trabaja con Spring en Java, puedes escribir los nuevos controladores en Kotlin dentro del mismo proyecto. Ktor es más ligero y más «de Kotlin»: sin anotaciones ni reflexión, todo es código. Compara con un servidor web en Java.
Todo el código de esta página se compiló con Kotlin 2.4.21 y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.