Patrones de Servidor Ktor
Patrones completos de Ktor para construir servidores HTTP robustos y mantenibles con corrutinas de Kotlin.
Cuándo Activar
- Construcción de servidores HTTP Ktor
- Configuración de plugins de Ktor (Auth, CORS, ContentNegotiation, StatusPages)
- Implementación de APIs REST con Ktor
- Configuración de inyección de dependencias con Koin
- Escritura de pruebas de integración Ktor con testApplication
- Trabajo con WebSockets en Ktor
Estructura de la Aplicación
Diseño Estándar del Proyecto Ktor
src/main/kotlin/
├── com/example/
│ ├── Application.kt # Punto de entrada, configuración del módulo
│ ├── plugins/
│ │ ├── Routing.kt # Definiciones de rutas
│ │ ├── Serialization.kt # Configuración de negociación de contenido
│ │ ├── Authentication.kt # Configuración de autenticación
│ │ ├── StatusPages.kt # Manejo de errores
│ │ └── CORS.kt # Configuración CORS
│ ├── routes/
│ │ ├── UserRoutes.kt # Endpoints /users
│ │ ├── AuthRoutes.kt # Endpoints /auth
│ │ └── HealthRoutes.kt # Endpoints /health
│ ├── models/
│ │ ├── User.kt # Modelos de dominio
│ │ └── ApiResponse.kt # Envoltorios de respuesta
│ ├── services/
│ │ ├── UserService.kt # Lógica de negocio
│ │ └── AuthService.kt # Lógica de autenticación
│ ├── repositories/
│ │ ├── UserRepository.kt # Interfaz de acceso a datos
│ │ └── ExposedUserRepository.kt
│ └── di/
│ └── AppModule.kt # Módulos de Koin
src/test/kotlin/
├── com/example/
│ ├── routes/
│ │ └── UserRoutesTest.kt
│ └── services/
│ └── UserServiceTest.kt
Punto de Entrada de la Aplicación
// Application.kt
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
configureSerialization()
configureAuthentication()
configureStatusPages()
configureCORS()
configureDI()
configureRouting()
}
DSL de Enrutamiento
Rutas Básicas
// plugins/Routing.kt
fun Application.configureRouting() {
routing {
userRoutes()
authRoutes()
healthRoutes()
}
}
// routes/UserRoutes.kt
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "ID faltante")
val user = userService.getById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user)
}
put("/{id}") {
val id = call.parameters["id"]
?: return@put call.respond(HttpStatusCode.BadRequest, "ID faltante")
val request = call.receive<UpdateUserRequest>()
val user = userService.update(id, request)
?: return@put call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
delete("/{id}") {
val id = call.parameters["id"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "ID faltante")
val deleted = userService.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound)
}
}
}
Organización de Rutas con Rutas Autenticadas
fun Route.userRoutes() {
route("/users") {
// Rutas públicas
get { /* listar usuarios */ }
get("/{id}") { /* obtener usuario */ }
// Rutas protegidas
authenticate("jwt") {
post { /* crear usuario - requiere autenticación */ }
put("/{id}") { /* actualizar usuario - requiere autenticación */ }
delete("/{id}") { /* eliminar usuario - requiere autenticación */ }
}
}
}
Negociación de Contenido y Serialización
Configuración de kotlinx.serialization
// plugins/Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
}
Modelos Serializables
@Serializable
data class UserResponse(
val id: String,
val name: String,
val email: String,
val role: Role,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: String? = null,
) {
companion object {
fun <T> ok(data: T): ApiResponse<T> = ApiResponse(success = true, data = data)
fun <T> error(message: String): ApiResponse<T> = ApiResponse(success = false, error = message)
}
}
@Serializable
data class PaginatedResponse<T>(
val data: List<T>,
val total: Long,
val page: Int,
val limit: Int,
)
Serializadores Personalizados
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
Autenticación
Autenticación JWT
// plugins/Authentication.kt
fun Application.configureAuthentication() {
val jwtSecret = environment.config.property("jwt.secret").getString()
val jwtIssuer = environment.config.property("jwt.issuer").getString()
val jwtAudience = environment.config.property("jwt.audience").getString()
val jwtRealm = environment.config.property("jwt.realm").getString()
install(Authentication) {
jwt("jwt") {
realm = jwtRealm
verifier(
JWT.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtIssuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, ApiResponse.error<Unit>("Token inválido o expirado"))
}
}
}
}
// Extrayendo usuario del JWT
fun ApplicationCall.userId(): String =
principal<JWTPrincipal>()
?.payload
?.getClaim("userId")
?.asString()
?: throw AuthenticationException("No hay userId en el token")
Rutas de Autenticación
fun Route.authRoutes() {
val authService by inject<AuthService>()
route("/auth") {
post("/login") {
val request = call.receive<LoginRequest>()
val token = authService.login(request.email, request.password)
?: return@post call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("Credenciales inválidas"),
)
call.respond(ApiResponse.ok(TokenResponse(token)))
}
post("/register") {
val request = call.receive<RegisterRequest>()
val user = authService.register(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
authenticate("jwt") {
get("/me") {
val userId = call.userId()
val user = authService.getProfile(userId)
call.respond(ApiResponse.ok(user))
}
}
}
}
Páginas de Estado (Manejo de Errores)
// plugins/StatusPages.kt
fun Application.configureStatusPages() {
install(StatusPages) {
exception<ContentTransformationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("Cuerpo de solicitud inválido: ${cause.message}"),
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>(cause.message ?: "Solicitud incorrecta"),
)
}
exception<AuthenticationException> { call, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("Autenticación requerida"),
)
}
exception<AuthorizationException> { call, _ ->
call.respond(
HttpStatusCode.Forbidden,
ApiResponse.error<Unit>("Acceso denegado"),
)
}
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ApiResponse.error<Unit>(cause.message ?: "Recurso no encontrado"),
)
}
exception<Throwable> { call, cause ->
call.application.log.error("Excepción no manejada", cause)
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse.error<Unit>("Error interno del servidor"),
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ApiResponse.error<Unit>("Ruta no encontrada"))
}
}
}
Configuración CORS
// plugins/CORS.kt
fun Application.configureCORS() {
install(CORS) {
allowHost("localhost:3000")
allowHost("example.com", schemes = listOf("https"))
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowCredentials = true
maxAgeInSeconds = 3600
}
}
Inyección de Dependencias con Koin
Definición del Módulo
// di/AppModule.kt
val appModule = module {
// Base de datos
single<Database> { DatabaseFactory.create(get()) }
// Repositorios
single<UserRepository> { ExposedUserRepository(get()) }
single<OrderRepository> { ExposedOrderRepository(get()) }
// Servicios
single { UserService(get()) }
single { OrderService(get(), get()) }
single { AuthService(get(), get()) }
}
// Configuración de la aplicación
fun Application.configureDI() {
install(Koin) {
modules(appModule)
}
}
Uso de Koin en Rutas
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(ApiResponse.ok(users))
}
}
}
Koin para Pruebas
class UserServiceTest : FunSpec(), KoinTest {
override fun extensions() = listOf(KoinExtension(testModule))
private val testModule = module {
single<UserRepository> { mockk() }
single { UserService(get()) }
}
private val repository by inject<UserRepository>()
private val service by inject<UserService>()
init {
test("getUser devuelve usuario") {
coEvery { repository.findById("1") } returns testUser
service.getById("1") shouldBe testUser
}
}
}
Validación de Solicitudes
// Validar datos de solicitud en rutas
fun Route.userRoutes() {
val userService by inject<UserService>()
post("/users") {
val request = call.receive<CreateUserRequest>()
// Validar
require(request.name.isNotBlank()) { "El nombre es obligatorio" }
require(request.name.length <= 100) { "El nombre debe tener 100 caracteres o menos" }
require(request.email.matches(Regex(".+@.+\\..+"))) { "Formato de correo electrónico inválido" }
val user = userService.create(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
}
// O usar una extensión de validación
fun CreateUserRequest.validate() {
require(name.isNotBlank()) { "El nombre es obligatorio" }
require(name.length <= 100) { "El nombre debe tener 100 caracteres o menos" }
require(email.matches(Regex(".+@.+\\..+"))) { "Formato de correo electrónico inválido" }
}
WebSockets
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = 64 * 1024 // 64 KiB — aumentar solo si tu protocolo requiere tramas más grandes
masking = false // Las tramas de servidor a cliente no están enmascaradas según RFC 6455; las de cliente a servidor siempre están enmascaradas por Ktor
}
}
fun Route.chatRoutes() {
val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
webSocket("/chat") {
val thisConnection = Connection(this)
connections += thisConnection
try {
send("¡Conectado! Usuarios en línea: ${connections.size}")
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
val message = ChatMessage(thisConnection.name, text)
// Instantánea bajo bloqueo para evitar ConcurrentModificationException
val snapshot = synchronized(connections) { connections.toList() }
snapshot.forEach { conn ->
conn.session.send(Json.encodeToString(message))
}
}
} catch (e: Exception) {
logger.error("Error de WebSocket", e)
} finally {
connections -= thisConnection
}
}
}
data class Connection(val session: DefaultWebSocketSession) {
val name: String = "Usuario-${counter.getAndIncrement()}"
…
[truncado para traducción]
Pruebas de Integración con testApplication
// Test de ruta con testApplication y Koin
class UserRoutesTest {
private val testModule = module {
single<UserRepository> { mockk() }
single { UserService(get()) }
}
@Test
fun `GET /users devuelve usuarios`() = testApplication {
application {
install(Koin) { modules(testModule) }
configureRouting()
}
val response = client.get("/users")
assertEquals(HttpStatusCode.OK, response.status)
}
@Test
fun `POST /users crea usuario`() = testApplication {
application {
install(Koin) { modules(testModule) }
install(ContentNegotiation) { json() }
configureRouting()
}
val request = CreateUserRequest("Juan", "[email protected]")
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(request)
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
Mejores Prácticas
Uso de Extensiones de Ruta para Organización
Cada grupo de rutas debe ser una función de extensión en Route.
Secrets de Configuración
Siempre carga configuración sensible desde variables de entorno o archivos HOCON (application.conf).
Manejo de Errores Centralizado
Configura un único StatusPages y mapea todas las excepciones esperadas de tu dominio a respuestas HTTP adecuadas.
Separar Autenticación de Autorización
Usa authenticate para probar credenciales, y verifica permisos explícitamente dentro de los handlers de ruta.
Usar Koin para Pruebas
Sustituye dependencias reales por simulaciones (mocks) fácilmente usando módulos de Koin en testApplication.