Utilizando o Ktor para criar um CRUD e REST API com Kotlin

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:

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:

Página de gerador de projeto Ktor, inicialmente, na seção setting, é adicionado o nome do projeto e configurações de estrutura, ao clicar em add plugins são adicionados os plugins necessários. Por fim, ao clicar Generate project, o produto com as configurações realizadas é baixado como um zip.

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.
  • 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:
Tela de plugins do gerador de projeto Ktor, apresentando 4 plugins adicionados, sendo o Content Negotiation e o kotlinx.serialization os 2 novos que vieram junto com o exposed.
  • 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.

Banner promocional da Alura, com um design futurista em tons de azul, apresentando dois blocos de texto, no qual o bloco esquerdo tem os dizeres:

Abrindo o projeto Ktor no IntelliJ IDEA Community

Com acesso ao projeto, abrimos o mesmo a partir da opção “Open” do IntelliJ IDEA:

Tela de launcher do IntelliJ IDEA Community. Ao clicar em Open, apresenta o explorar de arquivos e busca o projeto baixado já extraído do arquivo zip. Ao encontrá-lo, selecioná-lo e clicar em OK; a IDE começa o processo para abrir o projeto.

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:

Tela do IntelliJ exibindo a aba de projeto com os arquivos contidos no projeto.

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:

Tela do navegador acessando o endereço http://localhost:8080 e apresentando a mensagem Hello World!

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) ou 127.0.0.1;
      • module → determina onde os módulos do Ktor estão configurados, nesse caso, na função de extensão Application.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 o true para wait.
  • 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 plugin ContentNegotiation e permite realizar a configuração via lambda;
    • json() → função de configuração do plugin ContentNegotiation 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 tipo Table 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 seja UUID, no modelo de resposta utilizei String. 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.

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:
Realizando a requisição POST no endereço http://localhost:8080/notes. É enviada uma nota via corpo da requisição e retorna a nota criada com o código 201.
  • Busque todas as notas:
Realiza a requisição GET no endereço http://localhost:8080/notes. Não são enviados dados via corpo da requisição e retorna uma lista de notas com o código 200.
  • Busque uma nota específica:
Realiza a requisição GET no endereço http://localhost:8080/notes/dba5834f-de4b-4b54-91e5-cc1a2b162188. Não são enviados dados via corpo da requisição e retorna uma nota e código 200.
  • Altere uma nota existe ou salvando:
Realiza requisição PUT no endereço http://localhost:8080/notes/dba5834f-de4b-4b54-91e5-cc1a2b162188. É enviada uma nota com informações que deseja alterar via corpo da requisição e retorna a nota alterada com o código 200.
  • Remova nota:
Realiza a requisição DELETE no endereço http://localhost:8080/notes/dba5834f-de4b-4b54-91e5-cc1a2b162188. Não são enviados dados via corpo da requisição e retorna apenas o código 200.

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!

Alex Felipe
Alex Felipe

Alex é instrutor e desenvolvedor e possui experiência em Java, Kotlin, Android. Atualmente cria conteúdo no canal https://www.youtube.com/@AlexFelipeDev.

Veja outros artigos sobre Programação