Criando tarefas
O primeiro passo para criar nossa tarefa será entender o que é uma tarefa. A ideia de uma tarefa é conter informações sobre ela e que outras pessoas do time tenham visibilidade do que se trata a tarefa. Assim vamos começar por modelar o domínio de entrada e saída. Usaremos a struct
do Rust para modelar:
Assim, nossa struct principal é a TodoCard
, que possui os campos String
title
e description
, correspondentes ao título da tarefa e a sua descrição. Depois disso, podemos ver que existe um campo do tipo Uuid
(inclua a crate uuid
com as features
serde
e v4
ativadas em seu Cargo.toml
), que é um owner
, ou seja, a pessoa dona da tarefa. Cada tarefa possui um conjunto de subtarefas a fazer, que podem estar completas ou não. Essas subtarefas são chamadas de Task
, e é uma struct que possui um título, title
, e um estado booleano que chamamos de is_done
. Em seguida temos o estado da tarefa no fluxo de cards, state
, que corresponde ao enum State
, com os campos Todo
, Doing
e Done
. O primeiro passo do serviço será algo bastante simples, receber um POST
JSON com o TodoCard
e respondermos um TodoCardId
:
Uuid
A crate Uuid possui várias configurações, mas para o que vamos utilizar precisamos de compatibilidade com
Serde
e a versão 4. Serde para garantir que ela é serializável e desserializável para JSON, e versão 4, pois é o formato que vamos utilizar. Assim, essas configurações são adicionadas ao[dependencies]
doCargo.toml
comouuid = { version = "0.7", features = ["serde", "v4"] }
.
Um exemplo de POST
em json de uma TodoCard
seria:
{
"title": "This is a card",
"description": "This is the description of the card",
"owner": "ae75c4d8-5241-4f1c-8e85-ff380c041442",
"tasks": [
{
"title": "title 1",
"is_done": true
},
{
"title": "title 2",
"is_done": true
},
{
"title": "title 3",
"is_done": false
}
],
"state": "Doing"
}
Agora que modelamos nosso TodoCard
podemos começar sua implementação com testes.
Criando o primeiro teste de TodoCard
O novo teste envolve uma série de alterações no código, como criar um novo scope
para rotas de api
, enviar payloads
e responder objetos JSON. Assim, a estratégia desse teste vai envolver enviar um TodoCard
para ser criado e termos como resposta um JSON contendo o id desse TodoCard
. Lembrando que agora que vamos manipular JSON, precisamos poder serializar e desserializar eles, e para isso devemos incluir a biblioteca serde
no Cargo.toml:
// ...
[dependencies]
actix-web = "2.0"
actix-rt = "1.0"
uuid = { version = "0.7", features = ["serde", "v4"] }
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
serde_derive = "1.0.104"
num_cpus = "1.0"
[dev-dependencies]
bytes = "0.5.3"
actix-service = "1.0.5"
Assim, podemos escrever nosso teste sem conflito de dependências em tests/todo_api_web/controller.rs
:
Duas características já se destacam, controller::todo::create_todo
e model::TodoIdResponse
, que correspondem ao recursos que de fato estão sendo testados. TodoIdResponse
corresponde ao Json com o id de criação da TodoCard
, sua definição é a seguinte:
Perceba que utilizamos as macros de Serialize, Deserialize
para sua fácil conversão entre JSON e String. Além disso, TodoIdResponse
possui um campo id
que é do tipo Uuid
, um Uuid
do tipo v4
, conforme definimos no Cargo.toml
. Agora temos também o controller create_todo
, que receberá um POST
do tipo JSON, fará sua inserção no banco de dados e retornará seu id. Felizmente, para este primeiro momento, não precisamos fazer a inserção no banco, pois o teste espera somente um tipo de retorno id
.
Outro ponto importante é o uso da biblioteca use serde_json::from_str;
. Essa função em especial serve para converter uma &str
em uma das structs serializáveis, conforme a linha let id: TodoIdResponse = from_str(&String::from_utf8(resp.to_vec()).unwrap()).unwrap();
. Note que como a função não sabe para qual struct deve converter a resposta resp
, tivemos de definir seu tipo na declaração do valor id, id: TodoIdResponse
. O JSON do payload
do POST
está definido como uma string na função auxiliar de teste post_todo
.
A seguir possuímos a definição do teste e o uso do runtime do actix, seguidos da definição do App
, que vamos utilizar para mockar o serviço e suas rotas:
Note duas mudanças na definição do App
: nossa rota possui um padrão diferente /api/create
e o controller create_todo
está sendo passado para um método service()
. Outro detalhe é que estamos utilizando mais recursos na criação do request:
Veja que TestRequest
agora instancia um tipo POST
antes de adicionar informações ao seu builder, TestRequest::post()
. As duas outras mudanças são a adição das funções header
e set_payload
, .header("Content-Type", ContentType::json()).set_payload(post_todo().as_bytes().to_owned())
. header
define o tipo de conteúdo que estamos enviando e sua ausência nesse caso pode implicar em uma resposta com o status 400
. set_payload
recebe um array de bytes com o conteúdo do payload
, ou seja post_todo
.
Depois podemos ler a resposta normalmente, let resp = test::call_service(&mut app, req).await;
, obter o body da response em bytes let body = resp.into_body();let bytes = body::to_bytes(body).await.unwrap();
e transformar essa resposta em uma struct conhecida pelo serviço, from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap();
. O último passo é garantir que a resposta contendo o TodoIdResponse
seja de fato um id válido e para isso utilizamos a macro assert!
em assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok());
. Note a função auxiliar get_id
, se nosso teste estivesse dentro do nosso módulo em vez de na pasta de testes de integração, seria possível anotar ela com #[cfg(test)]
e economizar espaço no executável e tempo de compilação. Eu optei por deixá-la visível e testar o controller nos testes de integração, mas a escolha é sua:
Implementando o controller do teste anterior
Agora, com o teste implementado, precisamos entender quais são as coisas que necessitamos implementar:
- Controller
create_todo
. - O controller recebe um Json do tipo
TodoCard
, que precisa ser deserializável com a macro#[derive(Deserialize)]
. - Um struct
TodoIdResponse
que precisa ser serializável com#[derive(Serialize)]
.
Como os itens 2 e 3 já foram mencionados na seção anterior, vou mostrar como eles ficaram com as macros de serialização e desserialização. Além disso, inclui a macro de Debug
, pois pode ser útil durante o desenvolvimento, se você achar necessário retirá-la no futuro pode ajudar a economizar espaço do binário.
- Para utilizar as macros de serde, lembre-se de incluir
#[macro_use] extern crate serde;
emlib.rs
e emmain.rs
(versões mais antigas do Rust).
Para o item 1, create_todo
controller, devemos novamente criar uma função async
, que tem como tipo de resposta uma implementação da trait Responder
, a impl Responder
, como fizemos com pong
e readiness
:
As primeiras coisas que podemos perceber são a create de Uuid
para gerar novos uuids
com Uuid::new_v4()
, e os tipos de entrada e de saída, TodoCard, TodoIdResponse
, respectivamente. O actix possui uma forma interna de desserializar objetos JSON que é definido no módulo web
com web::Json<T>
e é em T
que vamos incluir nossa struct TodoCard
. Veja que o tipo de retorno TodoIdResponse
está sendo serializado pelo serde_json
e retornado ao body
. Note também que adicionamos o header Content-type
através da função .content_type(ContentType::json())
. Assim já seria suficiente para nosso teste passar, mas se quisermos testar essa rota com um curl
é preciso adicionar ao App
de main.rs
:
Refatorando as rotas
No nosso teste anterior, percebemos que nossas rotas da main.rs
são desconectadas das rotas do teste (tests/todo_api_web/controller
), pois iniciamos um servidor de teste (test::init_service
) que pode possuir uma rota aleatória, já que iniciamos um novo App
dentro dele. Assim, basta direcionarmos a rota a um controller correto e fazer o request ser direcionado para essa rota que tudo ocorrerá bem. Para resolver isso, a sugestão é refatorarmos o App
de forma que suas rotas sejam configuradas em um único lugar e possam ser utilizadas tanto na main.rs
quanto nos testes. Para isso, vamos refatorar nosso main
para extrair todo o web::scope
de forma que as configurações venham de um módulo de rotas. Assim, devemos criar um módulo de rotas em src/todo_api_web/routes.rs
e adicionar o seguinte código:
Basicamente estamos extraindo todas as rotas para uma nova função que alterará o está do configuração do serviço dentro de App
com a função config.service
. Isso impacta também nosso main
, pois agora somente vamos precisar declarar a função app_routes
:
// main.rs
mod todo_api_web;
use todo_api_web::routes::app_routes;
use actix_web::{App, HttpServer};
use num_cpus;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().configure(app_routes)
})
.workers(num_cpus::get() + 2)
.bind(("localhost", 4004))
.unwrap()
.run()
.await
}
Agora podemos fazer a mesma refatoração nos testes, tests/todo_api_web/controller
:
Com estas alterações podemos perceber que um teste falha ao executarmos cargo test
, esse é o teste test todo_api_web::controller::ping_readiness::test_readiness_ok
, que falha com um 404
. Isso se deve ao fato de que a rota que estamos enviando o request de readiness
estava errada esse tempo todo, pois escrevemos /readiness
, enquanto a rota real é /~/ready
:
Nosso próximo passo é incluir nosso TodoCard
em nossa base de dados.
Configurando a base de dados
A base de dados que vamos utilizar agora é o DyanmoDB. O objetivo de utilizar essa base de dados é salvar as TodoCards
para podermos buscá-las no futuro, assim o primeiro passo é configurar e modelar a base de dados para que nosso servidor a reconheça. A instância que vamos utilizar é derivada de um contêiner docker cuja imagem é amazon/dynamodb-local
e pode ser executada com docker run -p 8000:8000 amazon/dynamodb-local
. Observe que a porta que o DynamoDB está expondo é a 8000
. Eu gosto muito de utilizar Makefiles, pois eles facilitam a vida quando precisamos rodar vários comandos, especialmente em serviços diferentes. Assim, criei o seguinte Makefile para executar o DynamoDB:
db:
docker run -p 8000:8000 amazon/dynamodb-local
Escrevendo no banco de dados
Como essa primeira feature envolve exploração, primeiro vou apresentar a lógica de como fazemos para depois escrever os testes e generalizações. O próximo passo para termos a lógica do banco de dados é criar um novo módulo em lib.rs
(e no main.rs
) chamado todo_api
, que por sua vez possuirá o módulo db
, que vai gerenciar todas as relações com o DynamoDB. Antes de seguir com o servidor em si, vou comentar a atual função main
e substituir por outra simples que sera descrita posteriormente, que utiliza somente o módulo todo_api
para executar a criação de uma TodoCard
no banco de dados, depois disso podemos conectar as partes novamente.
Para podermos nos comunicar facilmente com o DynamoDB em Rust, existem a biblioteca oferecida pela AWS, chamada aws-sdk-dynamodb
. Basta adicioná-las às dependências no Cargo.toml
. (Atualmente a sdk Rust da AWS está em Developer Preview e não deve ser usada em produção).
[dependencies]
actix-web = "2.0"
actix-rt = "1.0"
actix-http = "1.0.1"
uuid = { version = "0.7", features = ["serde", "v4"] }
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
serde_derive = "1.0.104"
num_cpus = "1.0"
aws-config = "0.49.0"
aws-sdk-dynamodb = "0.19.0"
[dev-dependencies]
bytes = "0.5.3"
actix-service = "1.0.5"
Com a biblioteca aws-sdk-dynamodb
disponível, podemos começar a pensar em como nos comunicar com o DynamoDB. Podemos fazer isso adicionando um módulo helpers
dentro de todo_api/db
e criando uma função que retorna o cliente:
Nota: Para utilizar o dynamodb localmente, deve ser criado um arquivo de configuração contendo uma região e credenciais (que não precisam ser validas) da AWS em ~/.aws/config
contendo:
[profile localstack]
region=us-east-1
aws_access_key_id=AKIDLOCALSTACK
aws_secret_access_key=localstacksecret
Agora precisamos criar uma tabela, para nosso caso não vou utilizar uma migracão pois acredito que em um cenário real este banco de dados será configurado por outro serviço, algo mais próximo a um ambiente cloud. Assim, vamos criar a função create_table
em todo_api/db/helpers.rs
, que fará a configuração da tabela para nós:
Para testar precisamos executar o comando make db
. Iremos seguir a documentação do aws-sdk-rust para configurar o DynamoDB local. Em outro terminal, precisamos setar uma variavel de ambiente para a aws-config
utilizar o profile localstack
que adicionamos em ~/.aws/config
, para isso usamos export AWS_PROFILE=localstack
(no osx ou linux). Depois atualizamos a main com o cdigo abaixo e executamos em seguida, no mesmo terminal aonde setamos a variavel de ambiente AWS_PROFILE
executamos cargo build && cargo run
.
// main.rs
use todo_api::db::helpers::create_table;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
create_table();
}
Executando esta sequência de comandos, recebemos o seguinte output:
Tabela criada! Mas se executarmos cargo run
de novo, receberemos um erro dizendo que não é possível criar uma tabela que já existe:
Para corrigir esse erro, sugiro modificar o método create_table
para verificar se existem tabelas com a função client.list_tables().send()
. Para isso, fazemos a seguinte modificação:
Note que, quando verificamos as listas existentes na tabela, surgiram várias situações possíveis e para facilitar a criação da tabela, extraímos sua lógica para create_table_input
. A primeira situação é Err
, que possivelmente representa algum problema de listagem de tabelas na base, indicando ausência de tabelas, que nos permite criar tabelas. O segundo caso, dentro do Ok
é um None
, que pode significar os mais diversos problemas. Depois disso obtemos a listagem em Some
, mas esta listagem pode estar vazia, sendo um caso para criar tabela, o else
, e se a listagem for maior que zero, não criamos a tabela.
Inserindo conteúdo na tabela
Para inserirmos a tabela, vamos precisar de uma struct de rusoto_dynamo
chamada PutItemInput
, que nos permitirá inserir o JSON que recebemos na tabela, porém o JSON que recebemos em TodoCard
não possui o id do card. Para podermos utilizar o PutItemInput
como definimos na tabela, vamos criar um model
que possua um id.
Vamos criar uma função que permita transformar um TodoCard
em um TodoCardDb
, em src/todo_api/model/mod.rs
:
Agora, podemos fazer nosso controller
momentaneamente gerenciar todas as ações com o banco de dados:
Veja que nosso controller ficou muito mais funcional agora. Ele recebe um JSON do tipo TodoCard
, transforma esse JSON em um TodoCardDb
e envia para a função put_todo
inserir no banco de dados. Caso ocorra algum problema com a inserção fazemos pattern matching com o None
e retornamos algo como HttpResponse::BadRequest()
ou HttpResponse::InternalServerError()
, mas caso o retorno seja um id em Some
, retornamos um JSON contendo TodoIdResponse
. Note que foi necessário adicionar a função body
ao HttpResponse::BadRequest()
para garantir que os dois pattern matchings tivessem o mesmo tipo de retorno Response
, em vez de ResponseBuilder
.
Se você estiver utilizando o rust-analyzer
do rust, vai perceber que o into
de item: todo_card.clone().into(),
está destacado, isso se deve ao fato de que precisamos implementar a função into
para o tipo TodoCardDB
de forma que retorne HashMap<String, AttributeValue>
. Para isso, utilizamos a declaração impl Into<HashMap<String, AttributeValue>> for TodoCardDb
com a seguinte implementação:
Se você está utilizando rls
vai perceber que o state.to_string()
e o task_to_db_val
estão destacados como errados, assim como a macro val!
. Vamos falar do val!
logo, mas primeiro vamos entender como funciona a criação do tipo AttributeValue
para ser inserido dentro do banco. A função into
espera como retorno um tipo HashMap<String, AttributeValue>
, no qual AttributeValue
é uma struct com a seguinte estrutura:
AttributeValue
Os tipos
T
dentro doOption<T>
são os tipos possíveis dentro do DynamoDB. Veja que alguns tipos são bem fáceis de perceber comobool
,Vec<AttributeValue>
eHashMap<String, AttributeValue>
, isto é, um valor booleano, um vetor de atributos do dynamo e um mapa com keys strings e valores como atributos, respectivamente. Outros valores podem ser confusos, como as chavess
,ss
,n
ens
. As chaves dos tiposb
ebs
são para valores binários como"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"
, além disso o tipon
serve para representar um tipo numérico, enquanto o tipos
serve para tipos String. Os tiposss
ens
são as versões vetores des
e den
, respectivamente.
Para resovermos a falha de compilação em state.to_string()
precisamos implementar a trait std::fmt::Display
que nos permite transformar o valor de state
em uma String:
Agora vamos verificar a função task_to_db_val
, cujo objetivo é transformar um vetor do tipo TaskDb
em um vetor de AttributeValue
. Essa transformação nos permite inserir as tasks
como um único campo contendo um vetor de objetos, como se diria na linguagem JSON, TaskDB
. A função task_to_db_val
é bastante simples, pois recebe uma tasks
do tipo Vec<TaskDb>
e aplica um mapa sobre cada TaskDb
para substituí-las por um AttributeValue
da chave m
, Option<HashMap<String, AttributeValue>>
, e depois coleciona todos esses Option<HashMap<String, AttributeValue>>
em um vetor Vec<AttributeValue>
:
Ainda falta falarmos da val!
. val!
é uma macro criada para transformar os valores de nossa struct em valores do DynamoDB. Inseri essa macro em um novo módulo chamado adapter
:
Para que essa macro esteja disponível dentro do módulo todo_api
, precisamos utilizar #[macro_use]
na declaração dos módulos:
Agora tudo deve estar funcionando. Podemos executar make db
e cargo run
para fazer um curl
em http://localhost:4000/api/create
com o seguinte JSON:
{
"title": "title",
"description": "descrition",
"state": "Done",
"owner": "90e700b0-2b9b-4c74-9285-f5fc94764995",
"tasks": [
{
"is_done": true,
"title": "blob"
}
]
}
E vamos receber um Uuid
como resposta e o status 201
:
{
"id": "ae1cb12c-6c67-4337-bd7b-b557d7568c60"
}
Organizando nosso código
Nosso controller possui um conjunto de códigos que não fazem sentido dentro do contexto de controller, no caso a função put_todo
. A primeira coisa que vamos fazer é criar um módulo todo
dentro de todo_api/db
que conterá toda a lógica de banco de dados para o todo
:
E agora podemos simplificar muito nosso controller com:
Note que para declarar todos os módulos internos utilizei o use crate::{// ...}
, pois ajuda na organização. Além disso, na minha opinião, a função new
de TodoCardDb
é um adapter e pode estar mal localizada. Uma possível solução para isso seria mover e renomear a função new
para o módulo adapter com nome de todo_json_to_db
, mas isso implicaria em tornar todos os campos de TodoCardDb
públicos, assim como de TaskDb
. Por isso, essa parte da refatoração fica a seu critério de estilo, mas vou fazer para exemplificar:
A compilação falha pois StateDB
e TaskDB
são privados, assim como quase todos campos de TodoCardDb
, para isso modificamos o módulo todo_api/model
para:
Também precisamos mudar o controller para utilizar nossa nova função:
Uma última refatoração que podemos fazer é a função task_to_db_val
, já que sua função é essencialmente transformar TaskDb
em um tipo AttributeValue
. Assim, podemos implementar uma função que faça isso com TaskDb
:
Agora faltam alguns testes.
Aplicando testes a nosso endpoint
Creio que uma boa abordagem agora seja começar pelos testes mais unitários, por isso vamos começar pelo adapter. Nosso primeiro teste será com a função converts_json_to_db
:
Note que, para facilitar a testabilidade, mudamos a assinatura da função para receber um id
, todo_json_to_db(json, id)
. Isso se deve ao fato de que gerar id randomicamente não ajuda os testes e testar campo a campo não parece uma boa solução. Além disso, adicionamos a macro PartialEq
nas structs StateDb
, TaskDb
e TodoCardDb
para fins de comparabilidade. Agora precisamos testar a função to_db_val
de TaskDb
:
A lógica do teste task_db_to_db_val
é basicamente a mesma que a implementação da função, mas já vale como um simples teste unitário. Agora podemos testar a função into
, que também teria a mesma implementação da própria função, note que estamos utilizando apenas um id:
Se executarmos cargo test
enquanto o make db
roda, teremos duas situações: uma em que a base de dados já está configurada e tudo ocorre normalmente e outra em que ela não está configurada e o teste falha. Para resolvermos esse problema, bastaria adicionar o create_table
ao cenário de teste assim:
É bem claro para mim que um teste que precisa executar o contêiner do banco de dados para passar é bastante frágil. Assim vamos precisar fazer algumas modificações para tornar o teste passável. A mudança que vamos fazer é, na minha opinião, uma forma mais elegante de fazer mocks em rust, pois ela não necessita criar uma trait e uma struct para mockar uma função específica, basta definirmos que para modo de compilação em test, #[cfg(test)]
, a função terá outro comportamento, geralmente evitando efeitos colaterais com base de dados. Agora, o que vai mudar é que nosso teste de controller deixará de estar presente na pasta tests
e passará a ser um módulo #[cfg(test)]
junto ao controller:
Assim, agora precisamos fazer com que nossa interação com o banco de dados seja "mockada", para isso reescrevi o módulo src/todo_api/db/todo.rs
para conter duas formas de compilacão "com testes" e "sem testes":
Veja que put_todo
com cfg(test)
ativado pula a etapa match client.put_item().table_name(TODO_CARD_TABLE.to_string()).set_item(Some(todo_card.clone().into())).send().await
e simplesmente retorna um Option<Uuid>
.
Outro modo de fazer esse teste, utilizando cfg
, é utilizar features
, mas por ser um pouco mais sensível deixei para apresentar depois. Neste repositório, vamos utilizar features
para testar os controllers, o que deixará o código mais limpo, porém mais difícil de gerenciar, podendo fazer com que uma feature indesejada suba para a produção. Assim, recomendo fortemente que os builds de produção utilizem a flag --release
e que os cfg
mapeie corretamente isso. Para utilizar essa feature, uma boa prática é adicioná-la ao campo [features]
do Cargo.toml
:
[package]
name = "todo-server"
version = "0.1.0"
authors = ["Julia Naomi <jnboeira@outlook.com>"]
edition = "2018"
[features]
dynamo = []
// ...
Além disso, precisamos gerar a nova função, muito semelhante ao cfg(test)
de antes:
E movemos novamente nosso teste para a pasta tests
. Para executar todos os testes corretamente usamos cargo teste --features "dynamo"
, é sempre bom adicionar este comando a um Makefile.
db:
docker run -p 8000:8000 amazon/dynamodb-local
test:
cargo test --features "dynamo"
O último passo para nossos testes é gerar ums função de teste que nos permita retirar a grosseria que é a função de teste post_todo
. Assim, faremos uma função que le um arquivo json
e retorna uma string contendo seu conteúdo. Vamos chamá-la de read_json
e vai receber como argumento uma string com o nome do arquivo. A primeira mudança que faremos é adicionar mod helpers
no arquivo tests/lib.rs
. Depois vamos criar o módulo tests/helpers.rs
e adicionar a função read_json
:
Com a função read_json
pronta, podemos adicionar o Json post_todo.json
na pasta (que vamos criar junto) dev-resources
do projeto:
{
"title": "This is a card",
"description": "This is the description of the card",
"owner": "ae75c4d8-5241-4f1c-8e85-ff380c041442",
"tasks": [
{
"title": "title 1",
"is_done": true
},
{
"title": "title 2",
"is_done": true
},
{
"title": "title 3",
"is_done": false
}
],
"state": "Doing"
}
Agora, podemos remover a função post_todo()
do módulo create_todo
encontrado no módulo tests/todo_api_web/controller.rs
e adicionar o use
da função read_json
:
No próximo capítulo vamos aprender a obter todos os TodoCard
que criamos na base de dados para depois podermos melhorar as configurações do serviço, por exemplo logs.