Padrões de Servidor Ktor
Padrões abrangentes para construir servidores HTTP robustos e maintaináveis com Kotlin e corotinas.
Quando Ativar
- Construindo servidores HTTP Ktor
- Configurando plugins Ktor (Auth, CORS, ContentNegotiation, StatusPages)
- Implementando APIs REST com Ktor
- Configurando injeção de dependência com Koin
- Escrevendo testes de integração Ktor com testApplication
- Trabalhando com WebSockets no Ktor
Estrutura da Aplicação
Layout Padrão de Projeto Ktor
src/main/kotlin/
├── com/example/
│ ├── Application.kt # Ponto de entrada, configuração do módulo
│ ├── plugins/
│ │ ├── Routing.kt # Definições de rotas
│ │ ├── Serialization.kt # Configuração de negociação de conteúdo
│ │ ├── Authentication.kt # Configuração de autenticação
│ │ ├── StatusPages.kt # Tratamento de erros
│ │ └── CORS.kt # Configuração CORS
│ ├── routes/
│ │ ├── UserRoutes.kt # Endpoints /users
│ │ ├── AuthRoutes.kt # Endpoints /auth
│ │ └── HealthRoutes.kt # Endpoints /health
│ ├── models/
│ │ ├── User.kt # Modelos de domínio
│ │ └── ApiResponse.kt # Envelopes de resposta
│ ├── services/
│ │ ├── UserService.kt # Lógica de negócio
│ │ └── AuthService.kt # Lógica de autenticação
│ ├── repositories/
│ │ ├── UserRepository.kt # Interface de acesso a dados
│ │ └── ExposedUserRepository.kt
│ └── di/
│ └── AppModule.kt # Módulos Koin
src/test/kotlin/
├── com/example/
│ ├── routes/
│ │ └── UserRoutesTest.kt
│ └── services/
│ └── UserServiceTest.kt
Ponto de Entrada da Aplicação
// 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 Roteamento
Rotas 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, "Missing id")
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, "Missing id")
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, "Missing id")
val deleted = userService.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound)
}
}
}
Organização de Rotas com Rotas Autenticadas
fun Route.userRoutes() {
route("/users") {
// Rotas públicas
get { /* listar usuários */ }
get("/{id}") { /* obter usuário */ }
// Rotas protegidas
authenticate("jwt") {
post { /* criar usuário - requer autenticação */ }
put("/{id}") { /* atualizar usuário - requer autenticação */ }
delete("/{id}") { /* deletar usuário - requer autenticação */ }
}
}
}
Negociação de Conteúdo e Serialização
Configuração kotlinx.serialization
// plugins/Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
}
Modelos Serializáveis
@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())
}
Autenticação
Autenticação 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 ou expirado"))
}
}
}
}
// Extraindo usuário do JWT
fun ApplicationCall.userId(): String =
principal<JWTPrincipal>()
?.payload
?.getClaim("userId")
?.asString()
?: throw AuthenticationException("Nenhum userId no token")
Rotas de Autenticação
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>("Credenciais 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 Status (Tratamento de Erros)
// plugins/StatusPages.kt
fun Application.configureStatusPages() {
install(StatusPages) {
exception<ContentTransformationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("Corpo da requisição inválido: ${cause.message}"),
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>(cause.message ?: "Requisição inválida"),
)
}
exception<AuthenticationException> { call, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("Autenticação necessária"),
)
}
exception<AuthorizationException> { call, _ ->
call.respond(
HttpStatusCode.Forbidden,
ApiResponse.error<Unit>("Acesso negado"),
)
}
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ApiResponse.error<Unit>(cause.message ?: "Recurso não encontrado"),
)
}
exception<Throwable> { call, cause ->
call.application.log.error("Exceção não tratada", cause)
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse.error<Unit>("Erro interno do servidor"),
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ApiResponse.error<Unit>("Rota não encontrada"))
}
}
}
Configuração 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
}
}
Injeção de Dependência com Koin
Definição do Módulo
// di/AppModule.kt
val appModule = module {
// Banco de dados
single<Database> { DatabaseFactory.create(get()) }
// Repositórios
single<UserRepository> { ExposedUserRepository(get()) }
single<OrderRepository> { ExposedOrderRepository(get()) }
// Serviços
single { UserService(get()) }
single { OrderService(get(), get()) }
single { AuthService(get(), get()) }
}
// Configuração da aplicação
fun Application.configureDI() {
install(Koin) {
modules(appModule)
}
}
Usando Koin nas Rotas
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(ApiResponse.ok(users))
}
}
}
Koin para Testes
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 retorna usuário") {
coEvery { repository.findById("1") } returns testUser
service.getById("1") shouldBe testUser
}
}
}
Validação de Requisições
// Validar dados da requisição nas rotas
fun Route.userRoutes() {
val userService by inject<UserService>()
post("/users") {
val request = call.receive<CreateUserRequest>()
// Validar
require(request.name.isNotBlank()) { "Nome é obrigatório" }
require(request.name.length <= 100) { "Nome deve ter 100 caracteres ou menos" }
require(request.email.matches(Regex(".+@.+\\..+"))) { "Formato de email inválido" }
val user = userService.create(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
}
// Ou usar uma extensão de validação
fun CreateUserRequest.validate() {
require(name.isNotBlank()) { "Nome é obrigatório" }
require(name.length <= 100) { "Nome deve ter 100 caracteres ou menos" }
require(email.matches(Regex(".+@.+\\..+"))) { "Formato de email inválido" }
}
WebSockets
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = 64 * 1024 // 64 KiB — aumente apenas se seu protocolo exigir quadros maiores
masking = false // Quadros servidor-para-cliente são desmascarados conforme RFC 6455; cliente-para-servidor são sempre mascarados pelo Ktor
}
}
fun Route.chatRoutes() {
val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
webSocket("/chat") {
val thisConnection = Connection(this)
connections += thisConnection
try {
send("Conectado! Usuários online: ${connections.size}")
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
val message = ChatMessage(thisConnection.name, text)
// Snapshot sob bloqueio para evitar ConcurrentModificationException
val snapshot = synchronized(connections) { connections.toList() }
snapshot.forEach { conn ->
conn.session.send(Json.encodeToString(message))
}
}
} catch (e: Exception) {
logger.error("Erro no WebSocket", e)
} finally {
connections -= thisConnection
}
}
}
data class Connection(val session: DefaultWebSocketSession) {
val name: String = "User-${counter.getAndIncrement()}"
companion object {
private val counter = AtomicInteger(1)
}
}
@Serializable
data class ChatMessage(
val sender: String,
val text: String,
@Serializable(with = InstantSerializer::class)
val timestamp: Instant = Clock.System.now(),
)
Testes de Integração
Teste básico com testApplication
// src/test/kotlin/com/example/routes/UserRoutesTest.kt
class UserRoutesTest {
@Test
fun `test get users endpoint`() = testApplication {
application {
module()
}
client.get("/users").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
@Test
fun `test create user endpoint`() = testApplication {
application {
module()
}
val request = CreateUserRequest("John Doe", "[email protected]")
client.post("/users") {
contentType(ContentType.Application.Json)
setBody(request)
}.apply {
assertEquals(HttpStatusCode.Created, status)
}
}
}
Teste com injeção de dependência e mock
class UserRoutesTest : KoinTest {
private val userRepository = mockk<UserRepository>()
private val testModule = module {
single<UserRepository> { userRepository }
single { UserService(get()) }
}
@Test
fun `test get user by id`() = testApplication {
environment {
config = MapApplicationConfig(
"jwt.secret" to "test-secret",
"jwt.issuer" to "test",
"jwt.audience" to "test",
"jwt.realm" to "test",
)
}
application {
modules(testModule)
configureSerialization()
configureRouting()
}
coEvery { userRepository.findById("1") } returns testUser
client.get("/users/1").apply {
assertEquals(HttpStatusCode.OK, status)
val response = body<UserResponse>()
assertEquals("John Doe", response.name)
}
}
}
Teste de autenticação
@Test
fun `test protected endpoint without auth`() = testApplication {
application {
modules(testModule)
configureSerialization()
configureAuthentication()
configureRouting()
}
client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("John", "[email protected]"))
}.apply {
assertEquals(HttpStatusCode.Unauthorized, status)
}
}
@Test
fun `test protected endpoint with valid auth`() = testApplication {
application {
modules(testModule)
configureSerialization()
configureAuthentication()
configureRouting()
}
val token = generateTestToken("1", "test-secret")
client.post("/users") {
contentType(ContentType.Application.Json)
bearerAuth(token)
setBody(CreateUserRequest("John", "[email protected]"))
}.apply {
assertEquals(HttpStatusCode.Created, status)
}
}
Configuração de Ambiente
HOCON (application.conf)
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
jwt {
secret = "minha-chave-secreta"
secret = ${?JWT_SECRET}
issuer = "myapp"
issuer = ${?JWT_ISSUER}
audience = "myapp-users"
audience = ${?JWT_AUDIENCE}
realm = "myapp"
realm = ${?JWT_REALM}
}
Variáveis de Ambiente com dotenv ou acesso programático
// Carregar variáveis de ambiente usando o config embutido do Ktor
val jwtSecret = environment.config.property("jwt.secret").getString()
// Ou usando uma biblioteca como ktor-server-config-yaml
Tratamento Avançado de Erros e Validação
Resposta de Erro Estruturada
@Serializable
data class ValidationError(
val field: String,
val message: String,
)
@Serializable
data class ErrorResponse(
val success: Boolean = false,
val message: String,
val errors: List<ValidationError>? = null,
)
// Usar no controlador
fun Route.userRoutes() {
val userService by inject<UserService>()
post("/users") {
val request = call.receive<CreateUserRequest>()
val errors = mutableListOf<ValidationError>()
if (request.name.isBlank()) {
errors.add(ValidationError("name", "Nome é obrigatório"))
}
if (!request.email.matches(Regex(".+@.+\\..+"))) {
errors.add(ValidationError("email", "Formato de email inválido"))
}
if (errors.isNotEmpty()) {
return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse(
message = "Falha na validação",
errors = errors,
))
}
val user = userService.create(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
}
Geração de Resposta de Sucesso Padronizada
// Resposta de sucesso padronizada
fun <T> ApplicationCall.respondOk(data: T) =
respond(ApiResponse.ok(data))
fun <T> ApplicationCall.respondCreated(data: T) =
respond(HttpStatusCode.Created, ApiResponse.ok(data))
fun ApplicationCall.respondNoContent() =
respond(HttpStatusCode.NoContent)
// Uso
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respondOk(users)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respondCreated(user)
}
delete("/{id}") {
val id = call.parameters["id"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing id")
val deleted = userService.delete(id)
if (deleted) call.respondNoContent()
else call.respond(HttpStatusCode.NotFound)
}
}
}