Utilizando o Ktor para criar um CRUD e REST API com Kotlin
Existem diversas ferramentas capazes de criar uma aplicação Web, como, por exemplo, Node.js, Django, Spring Framework, Ruby on Rails etc.
Embora exista muitas opções, para você que aprendeu Kotlin, é natural utilizar ferramentas que deem suporte para linguagens interoperáveis, como, por exemplo, o Spring Framework para Java.
Inclusive, já desenvolvi algumas REST APIs aqui na Alura com o Spring Boot utilizando o Kotlin e se você já fez os cursos de Android, muito provavelmente já usou alguma delas. 👀
Por mais que seja uma opção válida, existem outras opções para criar aplicações Web com o Kotlin, dentre elas, temos o Ktor, um framework desenvolvido pela Jetbrains totalmente em Kotlin e baseado em Coroutines. O principal objeto do Ktor é criar aplicações assíncronas, seja cliente ou servidora, de uma maneira fácil e idiomática ao Kotlin.
E agora que tivemos uma introdução do que é o Ktor, neste artigo, eu vou te mostrar como criar uma aplicação servidora e implementar um CRUD, bora? 😎
Criando o projeto com o Ktor
Para criar um projeto com o Ktor, podemos considerar as seguintes opções:
- IntelliJ IDEA Ultimate (versão paga);
- Gerador de projeto Ktor.
Neste artigo, vamos considerar o uso do gerador de projeto Ktor, pois é uma ferramenta gratuita que permite utilizar uma IDE de nossa preferência que dê suporte ao Ktor, nesse caso, vou considerar o IntelliJ IDEA Community.
Você pode baixar qualquer IDE da Jetbrains pelo Toolbox. Recomendo que utilize um instalador de ferramentas da Jetbrains com o objetivo de facilitar o download e configuração. 😉
Para nosso exemplo, vou criar o projeto Ceep, um App de notas com título e descrição:
No momento que este artigo foi escrito, o Ktor estava na versão 2.2.4, ou seja, consequentemente, vão surgir novas versões e pode ser que o processo de criação de projeto tenha etapas diferentes, seja pelo visual do site ou outros plugins necessários etc.
Agora, vamos entender o que aconteceu no gif:
- O nome do projeto foi Ceep;
- Ao clicar em Adjusting project settings, acessamos as opções específicas do projeto:
- Build system → definição de build tool, por padrão temos o
Gradle Kotlin
; - Website → (
alura.com.br
) vai determinar o pacote e artefato (br.com.alura.ceep
) utilizado; - Ktor version → versão do Ktor (
2.2.4
); - Engine → definição de quem será responsável por gerenciar a conexão entre o servidor e cliente. Nessa amostra, o
Netty
é o padrão, mas você pode consultar outras opções de Engines suportadas e escolher a de sua preferência; - Configuration in → definição da escrita de configuração, sendo a
Code
em código Kotlin, e as demais em YAML ou HOCON. Aqui, vou manter a opção em código Kotlin; - Add sample code → essa opção é interessante para o projeto ter uma amostra de configuração inicial.
- Build system → definição de build tool, por padrão temos o
- Depois dos ajustes do projeto, clicamos em Add plugins para adicionar ferramentas do Ktor:
- Routing → permite fazer a configuração de rotas para acessar a aplicação servidora;
- Exposed → adiciona a lib Exposed que é uma abstração em Kotlin para comunicar com o banco de dados. Um detalhe importante é que ao adicionar o Exposed, mais 2 plugins foram adicionados:
- Content Negotiation → adiciona conversão automática de acordo com Content-Type, como por exemplo, JSON ou XML;
- kotlinx.serialization → conversor de JSON para objetos feito em Kotlin.
Depois de adicionar todas essas configurações, é só clicar em Generate project, baixar o zip e extrair em algum local onde você mantém seus projetos.
Abrindo o projeto Ktor no IntelliJ IDEA Community
Com acesso ao projeto, abrimos o mesmo a partir da opção “Open” do IntelliJ IDEA:
A partir desse momento, o IntelliJ vai realizar algumas tarefas para baixar as dependências, indexar arquivos etc, e então temos o seguinte resultado ou similar:
Nesta amostra estou usando a nova interface de usuário do IntelliJ IDEA.
Agora, podemos acessar o arquivo src\main\kotlin\br\com\alura\Application.kt
e ter acesso ao código inicial da aplicação:
package br.com.alura
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import br.com.alura.plugins.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureSerialization()
configureDatabases()
configureRouting()
}
Rodando a aplicação Ktor
Basicamente, o Application.kt
é o ponto de partida da aplicação com o Ktor, basta executar a função main()
e aguardar o log apresentar uma mensagem similar a esta:
[main] INFO ktor.application - Autoreload is disabled because the development mode is off.
[main] DEBUG Exposed - SELECT SETTING_VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE SETTING_NAME = 'MODE'
[main] DEBUG Exposed - CREATE TABLE IF NOT EXISTS USERS (ID INT AUTO_INCREMENT PRIMARY KEY, "NAME" VARCHAR(50) NOT NULL, AGE INT NOT NULL)
[main] INFO ktor.application - Application started in 1.056 seconds.
[DefaultDispatcher-worker-1] INFO ktor.application - Responding at http://127.0.0.1:8080
Embora tenha algumas informações de inicialização, como autoreload ou criação de tabela no banco, para esse momento, é importante notar a mensagem de inicialização e que a aplicação está respondendo no endereço [http://127.0.0.1:8080](http://127.0.0.1:8080)
, ou seja, é só acessar esse endereço pelo navegador, ou então, [http://localhost:8080](http://localhost:8080)
se preferir:
A aplicação retorna Hello World!
por padrão! Embora o código seja simples, não é claro em qual ponto do código essa configuração foi feita, concorda? Sendo assim, a seguir, vamos analisar o código pronto e entender o que está acontecendo.
Conhecendo os códigos do Ktor
Para entender o processo que foi realizado anteriormanete, preciso fazer uma análise mais detalhada do código, começando pelo código do Application.kt
:
main()
→ ponto de partida da aplicação, assim como qualquer outra aplicação Kotlin;embbededServer()
→ cria um servidor embutido com base em um factory, nesse caso, o da Engine Netty;port
→ indica a porta de execução;host
→ configura o endereço de execução da aplicação, sendo"0.0.0.0"
o famoso[localhost](http://localhost)
ou127.0.0.1
;module
→ determina onde os módulos do Ktor estão configurados, nesse caso, na função de extensãoApplication.module()
;start(wait = true)
→ inicia a aplicação Ktor e a mantém em execução até que seja encerrada, por isso é necessário enviar otrue
parawait
.
Application.module()
→ função para adicionar e configurar todos os plugins do projeto:configureSerialization()
→ serialização de objetos;configureDatabases()
→ banco de dados;configureRouting()
→ mapeamento de rotas.
Agora que sabemos o que cada código faz, vamos explorar a implementação das configurações contidas na Application.module()
, pois são os códigos que nós iremos personalizar com base na nossa regra de negócio.
Configuração de serialização
Na configuração de serialização, temos o seguinte código:
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
routing {
get("/json/kotlinx-serialization") {
call.respond(mapOf("hello" to "world"))
}
}
}
Perceba que se trata de uma outra extensão de Application, isso acontece pois a Application
é a referência central do Ktor, ou seja, todas as configurações ou requisições são de responsabilidade dela. Seguindo com o código, podemos compreender o seguinte:
install(ContentNegotiation)
→ instala plugins ao Ktor, nesse caso o pluginContentNegotiation
e permite realizar a configuração via lambda;json()
→ função de configuração do pluginContentNegotiation
que registra a aceitação de JSON durante a comunicação HTTP, o famoso Content-Type.
Instalações de plugins geralmente vão oferecer uma lambda para realizar as configurações.
routing
→ instala o plugin de mapeamento;get("/json/kotlinx-serialization")
→ mapeia uma requisição para GET com o endereço[http://localhost:8080/json/kotlinx-serialization](http://localhost:8080/json/kotlinx-serialization)
;call.respond(mapOf("hello" to "world"))
→ configura a resposta para a chamada GET e retorna um JSON:
Veja que apenas com essa configuração temos uma requisição GET que devolve um JSON.
Configuração de mapeamento de rotas
Embora a configuração de banco de dados seja a segunda etapa, ela é a mais complexa. Por isso, a próxima que iremos tratar será a de mapeamento de rotas:
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
Observe que o código não tem tanto segredo, considerando a parte de serialização. É necessário instalar o plugin de roteamento e definir o end-point inicial com o texto "Hello World!"
. É por meio dessa configuração que vimos o texto ao acessar o App na primeira execução!
Configuração de banco de dados
Agora, vamos para a configuração mais complexa e que apresenta a maior parte de detalhes de como iremos implementar a nossa aplicação:
fun Application.configureDatabases() {
val database = Database.connect(
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
user = "root",
driver = "org.h2.Driver",
password = ""
)
val userService = UserService(database)
routing {
// Create user
post("/users") {
val user = call.receive<User>()
val id = userService.create(user)
call.respond(HttpStatusCode.Created, id)
}
// Read user
get("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = userService.read(id)
if (user != null) {
call.respond(HttpStatusCode.OK, user)
} else {
call.respond(HttpStatusCode.NotFound)
}
}
// Update user
put("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<User>()
userService.update(id, user)
call.respond(HttpStatusCode.OK)
}
// Delete user
delete("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
userService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}
Agora já temos bem mais código, não é mesmo? Então, bora entender as novidades:
Database.connect()
→ abre a conexão com um banco de dados a partir do endereço, usuário, senha e driver de conexão;
A amostra utiliza o h2 como banco de dados, mas poderia ser outros bancos que possuem drivers suportados pelo jdbc.
UserService
→ código de abstração para a comunicação com o banco de dados.
Esse código não tem relação com o Ktor, ou seja, é uma camada de abstração totalmente personalizável para a nossa regra de negócio e por isso o analisaremos por último.
Novamente, temos o plugin de mapeamento de rotas, mas a diferença é que já temos uma representação de CRUD, ou seja, uma requisição para ações de:
- inserção (
post
); - busca (
get
); - alteração (
put
); - remoção (
delete
).
Note que os código são similares, a grande diferença está em:
- Rotas diferentes, algumas fixas ou com variações que podem receber parâmetros, como é o caso do id;
- Chamadas ao service para realizar a operação no banco de dados;
- Retornos que podem ser fixos ou condicionais, como por exemplo, na busca de usuário pode não existir o usuário esperado e é retornado um
HttpStatusCode.NotFound
.
Pronto! Conhecemos os códigos do Ktor e sabemos o que fazem, mas ainda precisamos analisar o código do service para compreender o que iremos personalizar para implementar o nosso CRUD. Vamos lá?
Analisando o código do service
No UserService
, a seguinte implementação inicial é feita:
@Serializable
data class User(val name: String, val age: Int)
class UserService(private val database: Database) {
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", length = 50)
val age = integer("age")
override val primaryKey = PrimaryKey(id)
}
init {
transaction(database) {
SchemaUtils.create(Users)
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun create(user: User): Int = dbQuery {
Users.insert {
it[name] = user.name
it[age] = user.age
}[Users.id]
}
suspend fun read(id: Int): User? {
return dbQuery {
Users.select { Users.id eq id }
.map { User(it[Users.name], it[Users.age]) }
.singleOrNull()
}
}
suspend fun update(id: Int, user: User) {
dbQuery {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[age] = user.age
}
}
}
suspend fun delete(id: Int) {
dbQuery {
Users.deleteWhere { Users.id.eq(id) }
}
}
}
Repare que além do service, foi implementado o objeto que representa o modelo, requisição e resposta, o User
.
A anotação
@Serializable
indica que esse objeto pode realizar a conversão de JSON para objeto e vice-versa a partir da lib do Kotlin de serialização.
E então, temos o restante do código:
object Users : Table()
→ representa a tabela no exposed;transaction(database)
→ abre uma transação no banco de dados e permite realizar operações via lambda;SchemaUtils.create(Users)
→ cria a tabela baseada no objeto do tipoTable
do exposed, que nesse caso é a tabela de usuários;
dbQuery()
→ encapsula o código de criação de transação via coroutines com o escopo de IO.
Os demais métodos, basicamente, fazem as ações de CRUD esperada, ou seja, vai abrir uma transação com coroutines, enviar os valores e obter um retorno esperado. Pronto! Fizemos a análise necessária do código de amostra e podemos começar o nosso!
Criando os modelos de nota
Vamos começar com o modelo para a nossa nota:
//Note.kt
package br.com.alura.models
import br.com.alura.responses.NoteResponse
import java.util.*
class Note(
val id: UUID = UUID.randomUUID(),
val title: String,
val message: String
)
fun Note.toNoteResponse(): NoteResponse {
return NoteResponse(
id = id.toString(),
title = title,
message = message
)
}
Diferente da amostra inicial, vamos utilizar UUID para identificar os modelos, mantê-lo num pacote específico (models
) e o modelo será diferente dos objetos de requisição e resposta.
Também aproveitei para implementar o conversor de resposta a partir de uma extensão do modelo. Agora, vamos para a requisição e resposta:
//NoteRequest.kt
package br.com.alura.requests
import br.com.alura.models.Note
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
class NoteRequest(
val title: String,
val message: String
)
fun NoteRequest.toNote(
id: UUID = UUID.randomUUID()
): Note {
return Note(
id = id,
title = title,
message = message
)
}
Também adicionei uma função de extensão de requisição para o modelo de nota. E a resposta ficou assim:
//NoteResponse.kt
package br.com.alura.responses
import kotlinx.serialization.Serializable
@Serializable
class NoteResponse(
val id: String,
val title: String,
val message: String
)
Embora o
id
do modelo de nota sejaUUID
, no modelo de resposta utilizeiString
. O motivo dessa decisão é para facilitar a implementação, pois para tipos não primitivos é necessário realizar configurações extras com o serializador do Kotlin.
No caso da resposta, não há a necessidade de um conversor. Se você preferir, também pode utilizar data class nas implementações de modelos.
Implementando o service de notas
Agora vamos implementar o NoteService
:
//NoteService.kt
package br.com.alura.services
import br.com.alura.models.Note
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
class NoteService(database: Database) {
private object Notes : Table() {
val id = uuid("id")
val title = varchar("title", 255)
val message = text("message")
override val primaryKey = PrimaryKey(id)
}
init {
transaction(database) {
SchemaUtils.create(Notes)
}
}
private suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun findAll(): List<Note> = dbQuery {
Notes.selectAll()
.map { row -> row.toNote() }
}
suspend fun findById(id: UUID): Note? {
return dbQuery {
Notes.select { Notes.id eq id }
.map { row -> row.toNote() }
.singleOrNull()
}
}
suspend fun save(note: Note): Note = dbQuery {
Notes.insertIgnore {
it[id] = note.id
it[title] = note.title
it[message] = note.message
}.let {
Note(
id = it[Notes.id],
title = it[Notes.title],
message = it[Notes.message]
)
}
}
suspend fun delete(id: UUID) {
dbQuery {
Notes.deleteWhere { Notes.id.eq(id) }
}
}
private fun ResultRow.toNote() = Note(
id = this[Notes.id],
title = this[Notes.title],
message = this[Notes.message]
)
}
Embora o código seja relativamente grande, ele realiza os mesmos comportamentos do UserService
, com a adição do método findAll()
que faz a busca de todas as notas existentes no banco de dados.
Além disso, ao invés de ter um método para criar e outro para alterar, criei apenas o save()
, que cria uma nota nova caso ela não exista no banco ou a altera se ela existir, baseada na chave primária (o id).
Por fim, implementei a função de extensão ResultRow.toNote()
para reutilizar a conversão de uma linha para o modelo de nota.
Mapeamento os end-points para as notas
Após a implementação, podemos começar com o mapeamentos dos end-points das notas. E para isso, vamos criar o nosso módulo de routing:
//NoteRouting.kt
package br.com.alura.modules
import br.com.alura.models.toNoteResponse
import br.com.alura.requests.NoteRequest
import br.com.alura.requests.toNote
import br.com.alura.services.NoteService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.util.*
fun Application.configureNoteRouting(
service: NoteService
) {
routing {
get("/notes") {
val response = service.findAll().map {
it.toNoteResponse()
}
call.respond(HttpStatusCode.OK, response)
}
get("/notes/{id}") {
val id = UUID.fromString(call.parameters["id"])
service.findById(id)?.let { note ->
val response = note.toNoteResponse()
call.respond(HttpStatusCode.OK, response)
} ?: call.respond(HttpStatusCode.NotFound)
}
post("/notes") {
val note = call.receive<NoteRequest>().toNote()
val response = service.save(note).toNoteResponse()
call.respond(HttpStatusCode.Created, response)
}
put("/notes/{id}") {
val id = UUID.fromString(call.parameters["id"])
val note = call.receive<NoteRequest>().toNote(id)
val response = service.save(note).toNoteResponse()
call.respond(HttpStatusCode.OK, response)
}
delete("/notes/{id}") {
val id = UUID.fromString(call.parameters["id"])
service.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}
Pronto, agora falta apenas integrar o nosso código com a Application
do Kotlin.
Configurando o Ktor com a regra de negócio
Na função Application.module()
, fazemos a seguintes modificações:
package br.com.alura
import br.com.alura.modules.configureNoteRouting
import br.com.alura.services.NoteService
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 org.jetbrains.exposed.sql.Database
fun main() {
embeddedServer(
Netty,
port = 8080,
host = "0.0.0.0",
module = Application::module
).start(wait = true)
}
fun Application.module() {
install(ContentNegotiation) {
json()
}
val database = Database.connect(
url = "jdbc:h2:file:./database/db",
user = "root",
driver = "org.h2.Driver",
password = ""
)
val service = NoteService(database)
configureNoteRouting(service)
}
Veja que temos algumas diferenças:
- Não utilizamos mais nenhuma função de configuração que veio na amostra inicial;
- As instalações de plugins, configuração de banco de dados e criação do service são feitas antes de chamar o método de configuração de mapeamento;
- Agora, o banco de dados é configurado para criar um arquivo no diretório database (
file:./database/db
) no local onde o projeto é executado, dessa forma, os dados são mantidos mesmo que a aplicação seja reiniciada.
- Agora, o banco de dados é configurado para criar um arquivo no diretório database (
Ao rodar a aplicação, temos o seguinte resultado via log:
[main] INFO ktor.application - Autoreload is disabled because the development mode is off.
[main] DEBUG Exposed - SELECT SETTING_VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE SETTING_NAME = 'MODE'
[main] DEBUG Exposed - CREATE TABLE IF NOT EXISTS NOTES (ID UUID PRIMARY KEY, TITLE VARCHAR(255) NOT NULL, MESSAGE TEXT NOT NULL)
[main] INFO ktor.application - Application started in 0.808 seconds.
[DefaultDispatcher-worker-1] INFO ktor.application - Responding at http://127.0.0.1:8080
Veja que a aplicação ainda roda sem apresentar problemas e, ao invés de criar a tabela de usuário, é criada a de notas! E agora que a nossa API está funcionando, podemos fazer os testes com um cliente HTTP, como por exemplo, o Postman.
Testando a API com o Postman
Caso deseje, você pode baixar a coleção do Postman para fazer os testes. E para realizar o testes, siga a sequência:
- Crie uma nota:
- Busque todas as notas:
- Busque uma nota específica:
- Altere uma nota existe ou salvando:
- Remova nota:
Pronto! Realizando todos esses testes, teremos o O CRUD de notas em Ktor funcionando corretamente!
O Ktor é uma ferramenta capaz de implementar uma aplicação servidora, porém, também é uma ferramenta que atua no lado do cliente realizando requisições HTTP. Se você tem interesse em como fazer isso em uma aplicação Android, confira o artigo Consumindo REST API no Android com o Ktor, aqui da Alura.
Conclusão
Neste artigo aprendemos o básico para criar uma REST API simples com o Ktor, vimos como é possível criar um projeto com códigos de amostra para mapeamento de rotas, banco de dados e serialização. Também vimos como podemos personalizar o código para implementar um CRUD de notas e fizemos o teste da implementação a partir do Postman.
Se você tem interesse no projeto, pode consultar o repositório no GitHub para mais detalhes, ou então, o código fonte.
Aproveite esse momento para praticar e implementar a sua própria API. Compartilhe suas impressões com a gente nas redes sociais ou Discord. Dessa forma, entendemos o quão relevante é o conteúdo e aumentam as chances de produzirmos mais conteúdos sobre o Ktor. 😉
Bons estudos e até mais!