Desenvolvimento Web com Rust
Por Julia Naomi Boeira.
Tecnologias:
- Actix
- Yew
- Wasm
- Graphql
- Rest
- Redis
- DynamoDB
Sobre a autora
Julia Naomi Boeira é uma engenheira de software com experiência em programação funcional e concorrente, games e sistemas distribuídos. Atualmente trabalha com open source em Rust e C++. Algumas empresas em seu currículo são Ubisoft, Creditas, Chorus One, Nubank, Thoughtworks, Latam e Globo.com.
Por este livro, agradeço a Eva Pace, ao Bruno Tavares e ao pessoal da Rust in Poa (Julio Biason, Douglas, Ruan Nunes e a todos os exercícios do Exercism.io que fizemos).
Co-autora
Isabela Goes é co-autora dos capítulos referentes a parte 1 tendo sendo responsável por sua atualização para a versão mais moderna do Actix. Isabela é engenheira de software na Ubisoft e já trabalhou com Julia em diversos projetos Rust.
Audiência
Este livro é para todas as pessoas que estão cansadas de lidar com projetos gigantes e arcaicos, com frameworks burocráticos e que têm muita dor de cabeça na hora de otimizar o código. Rust é uma linguagem de programação de baixo nível, como C, mas com abstrações de alto nível, como Ruby e JavaScript, que garante segurança na criação de processos e evita que threads diferentes concorram e mutem os mesmos dados ao mesmo tempo. Além disso, Rust cuida para ter duas características principais, uma comunidade inclusiva e uma linguagem de fácil entendimento, isso se mostra através dos milhares de livros completíssimos para aprender Rust e suas ferramentas. Infelizmente as partes de macros
, borrow checker
e lifetimes
não são tão simples de aprender — acredito que sejam as partes mais difíceis do Rust —, mas acredito que o borrow checker
será a única parte que você precisará conviver diariamente.
Existem alguns grandes casos de uso de Rust em produção, tirando a Mozilla, empresa criadora da linguagem e hoje uma das principais mantenedoras dela. Os maiores casos são:
- NPM - Escalando um serviço ligado a CPU com bilhões de requests. Rust foi escolhido entre C, C++, Java e Go. C e C++ foram descartados devido à insegurança a nível de memória, Java por conta da necessidade de deployar a JVM e Go por ter tido uma performance pior que Rust com muito mais trabalho em relação a ferramental básico.
- Tilde - Tilde fez seu MVP para monitoramento do espaço com Ruby on Rails, mas infelizmente a quantidade brutal de memória consumida fazia com que eles não tivessem competitividade do produto. Avaliando uma solução em C++, perceberam que a quantidade de crashes podia aumentar exponencialmente e que treinar uma equipe rubysta para manter um código C++ era bem complicado. Foi aí que entrou o Rust, a ausência de coletor de lixo e a segurança em memória garantiram a performance que o projeto queria.
- Dropbox - Para a Dropbox, o diferencial do uso de Rust foi desenvolver um serviço que aumentasse a velocidade de entrega e que diminuísse o espaço consumido. Rust com seu runtime quase mínimo e sua performance excepcional foi a solução preferida.
- Discord - Quando o Elixir para de performar bem para a quantidade de dados que você precisa processar, está na hora de delegar esse processamento para quem é bom nisso. No caso do Discord foi o Rust.
Ou seja, Rust é ideal para quem quer produzir serviços com performance excepcional, uma linguagem simples, pouco consumo de memória e muita felicidade. Acredito que seja um livro para pessoas que pelo menos já brincaram um ou dois dias com Rust, mas caso seja necessário, recomendo conferir os livros da documentação oficial e os maravilhosos livros que a Casa do Código possui sobre Rust antes de ler este.
Como este livro é organizado
Nos capítulos de introdução teremos um guia de instalação e um exercício básico. Nosso primeiro projeto será um servidor de gerenciamento de tarefas baseado em Actix Web. Actix Web é um framework de desenvolvimento web em Rust que tem feito muito barulho por suas constantes primeiras colocações em benchmarks de performance, principalmente da TechEmpower, e por algumas confusões pelo uso de unsafe
. Acredito que o framework seja bastante simples de entender e muito completo, mais informações sobre os benchmarks no apêndice A. Vamos utilizar diversas ferramentas da stack Actix para garantir um serviço completo e algumas ferramentas clássicas da comunidade Rust, como Serde para serialização de JSON e clientes de bancos de dados. Para este projeto vamos utilizar DynamoDB como banco de dados via biblioteca Rusoto para salvar os dados de gerenciamento de tarefas e Postgres com Diesel para salvar informações de autorização. O padrão de arquitetura de software utilizado para esta parte é inspirado no framework Phoenix do Elixir.
Na segunda parte, vamos modelar um serviço que retorna valores e rotas de passagens aéreas via queries GraphQL. Esse serviço realizará requests para serviços reais e será desenvolvido no padrão de arquitetura Hexagonal, ou, como também é conhecido, Cebola, que é inspirado no livro Clean Architecture de Robert C. Martin. A idéia central deste padrão é separarmos funções puras na parte mais interna da arquitetura e funções impuras na parte mais externa. Na terceira parte do livro, que está associada à parte anterior, construiremos um front-end utilizando a Yew Stack, que é baseada em WebAssembly, para a representação das opções de passagens. Lembrando que HTML e CSS serão mantidos de forma primitiva por não serem o foco deste livro.
- Para a parte 2 do livro, recomendo conhecer os conceitos de GraphQL. Livros e tutoriais estão indicados na bibliografia.
- Para a parte 3 do livro, recomendo conhecer os conceitos de WebAssembly. O livro oficial gratuito está indicado na bibliografia.
Por que utilizar o DynamoDB
Existem dois motivos para eu ter escolhido utilizar o DynamoDB para nosso serviço.
- Existem centenas de ótimos exemplos utilizando o banco Postgres com a crate Diesel. Inclusive integrados com AWS e em português. Além do mais, vamos utilizar o Postgres com Diesel, só que não como nosso principal banco de dados, já que é bastante comum aplicações diferentes possuirem mais de um tipo de banco de dados. A escolha do Diesel para o middleware de autorização não tem nenhuma relação com segurança ou performance, foi somente o recurso que me ocorreu usar no momento.
- Em um mundo cada vez mais voltado para cloud, escolher uma tecnologia nativa de cloud parece uma boa solução. Agora, isso não quer dizer que o DynamoDB seja o banco que modela perfeitamente nosso domínio ou as relações entre ele, mas é um banco com uma performance excepcional que permite muita flexibilidade ao modelar domínios. Alguns limites associados a transações e tipos no DynamoDB:
- O tamanho máximo de uma String é limitada a 400KB, assim como para binários.
- Uma String de expressão pode ter no máximo 4KB.
- Uma transação não pode conter mais de 25 itens únicos, assim como não pode conter mais de 25MB de dados.
- É possível ter até 50 requests simultâneos para criar, atualizar ou deletar tabelas.
BatchGetItem
, buscar um conjunto de itens pode trazer no máximo 100 itens e um total de 16MB de dados.BatchWriteItem
, comoPutItem
eDeleteItem
, pode conter até 25 itens e um total de 16MB de dados.Query
eScan
tem um limite de 1MB por chamada.
Phoenix MVC
O que eu gosto no modelo que o Phoenix utiliza para organizar seus módulos é a divisão entre a lógica web e a lógica core, ou seja, ele separa a camada de comunicação com o mundo da camada de comunicação interna. Por exemplo, uma API chamada de TodoApi
vai possuir módulos com os nomes todo_api_web
para a lógica web e todo_api
para a lógica core. O formato interno do todo_api_web
é bastante comum e geralmente utiliza a nomenclatura do MVC, na qual seus modelos representando o domínio estão dentro de um módulo chamado models
; seus operadores entre camadas, geralmente sem lógica, ou controllers, estão em um módulo chamado controllers
, e as visualizações das telas estão em um módulo chamado views
. Caso você esteja utilizando GraphQL, a divisão do MVC pode ficar um pouco diferente, como queries
e mutations
em um módulo de schemas
, seus objetos de entrada e saída em um módulo chamado models
e seus resolvers em um módulo resolvers
. Seria esse padrão o MRS
(Models Resolvers Schema)?
Quanto ao módulo todo_api
, ele estaria organizado em um módulo para gerenciar a fonte dos dados, geralmente denominado repo
ou db
, e em um outro módulo para organizar as estruturas de dados correspondentes chamado de models
. Aqui é comum existir uma camada que adapta os modelos de todo_api
para todo_api_web
, geralmente chamado de adapters
. Para serviços que se comunicam por mensagens, é comum um módulo message
aqui também. Resumindo graficamente seria:
todo_api
main
|-> todo_api
|-> adapters
|-> db (ou repo)
|-> message
|-> models
|-> todo_api_web
|-> controllers
|-> http (configurações do sistema e middlewares)
|-> models
|-> routes (rotas do sistema)
|-> views
Hexagonal
O modelo hexagonal é mais simples em organização, e talvez mais fácil para quem estiver começando a trabalhar em um sistema, mas talvez menos prático para quem já conhece o sistema. Ele consiste em algumas camadas que vão das camadas impuras com efeitos colaterais chamadas de boundaries
ou diplomat
até as camadas mais puras chamadas de core
ou logic
, assim como as camadas de modelagem. No módulo boundaries
vamos ter coisas como web
, db
e messaging
, ou seja, qualquer coisa que cause efeitos colaterais. Depois disso vamos ter uma camada que recebe esses efeitos colaterais e chama funções puras para lidar com eles, comumente chamado de controllers
. Os controllers
utilizam principalmente duas camadas para tratar os efeitos colaterais, a camada de adapters
, que transforma as entidades de boundaries
em entidades internas, e a camada de core
, que é dona de toda lógica do sistema, core
pode ainda possuir uma separação de negócio, business
, e outra computacional ou de apoio, compute
. Por último, a parte mais interna são os models
, que correspondem a estrutura de dados do sistema. Resumindo graficamente, em ordem de mais impura até mais pura, seria:
todo_api
main
|-> boundaries
|-> web
|-> db
|-> message
|-> controllers/resolvers
|-> adapters
|-> core
|-> business
|-> compute
|-> models/schemas
Considerações
-
Qual nomenclatura ou quais nomes específicos você vai dar para seus módulos é menos relevante do que a forma como as coisas estarão organizadas, a única coisa importante de lembrar é a necessidade de seu código e sua organização ser inteligível para todas as pessoas.
-
Este livro utiliza apenas o framework Actix para servidores web, mas exemplos com outros frameworks podem ser encontrados no livro
Programação Funcional e Concorrente em Rust
. Actix é o framework deactors
do Rust, e tem como framework web oactix-web
. -
A versão de Rust utilizada neste livro é a 1.40 da edição 2018, assim, caso a linguagem evolua mais rápido que o livro, você pode fazer Pull Request nos repositórios do livro. Só peço que explique no Pull Request a que parte ele se refere, qual a modificação, o porquê da modificação e caso ela derive de um erro preexistente, salientar o motivo. Algumas modificações na organização e renomeação de arquivos podem ser bastante interessantes também.
Instalando Rust
O primeiro passo para instalar Rust é a instalação do rustup
, uma ferramenta de linha de comando para gerenciar versões do Rust e todo ferramental a sua volta. Para fazer download do rustup
em Linux e macOS, basta digitar curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
em seu terminal e seguir as instruções. Para a instalação em Windows, basta ir ao site https://www.rust-lang.org/tools/install e garantir que você possui o C++ build tools for Visual Studio 2013
ou posterior instalado em sua máquina.
Para atualizar o rustup
, basta digitar rustup update
em seu terminal e, para desinstalar o Rust, rustup self uninstall
. Caso você precise saber a versão do Rust, basta digitar rustc --version
e você verá a resposta no formato rustc x.y.z (abcabcabc yyyy-mm-dd)
, na qual x.y.z
correspondente à versão, abcabcabc
ao commit da versão x.y.z
e yyyy-mm-dd
à data da versão x.y.z
. Se estiver desconectada da internet e quiser ver a documentação, basta digitar rustup doc
.
Não esqueça de experimentar a utilização do cargo
, gerenciador de pacotes e de build do Rust. Para ver se ele está bem em sua máquina, basta digitar cargo --version
. Para criar um pacote, você pode digitar cargo new <nome do pacote> --lib
e, para criar um executável, você pode digitar cargo new <nome do executável> --bin
. Caso você omita as opções --lib
e --bin
, o padrão atual é criar um executável.
A minha experiência de desenvolvimento Rust tem sido muito agradável com o Racer e o RLS configurados no VSCode ou no emacs. Para o VSCode, basta adicionar os plugins Rust
e Rust (rls)
. Caso seu path do cargo tenha algum problema, será necessário apontar o caminho para o racer
dentro do pacote do cargo
.
Pronto, agora podemos resolver um exercício básico.
Exercício Maior Produto de uma Série
Este exercício é retirado do site exercism.io da track de Rust, e em inglês é chamado de Largest Series Product. Ele consiste no seguinte problema:
Dada uma string de dígitos, calcular o maior produto contínuo de uma substring de tamanho
n
. Por exemplo, para a string"1027839564"
o maior produto comn = 3
seria9 * 5 * 6 = 270
, e o maior produto paran = 5
seria7 * 8 * 3 * 9 * 5 = 7560
.
Você pode utilizar a ferramenta do exercism.io para realizar as configurações deste exercício. Para isso, pule para o subcapítulo Resolvendo o primeiro teste. Bom, a primeira coisa que precisamos fazer é criar uma lib para rodar esses testes. Para isso, executamos em nosso terminal cargo new largest-series-product --lib && cd largest-series-product
. Abra em seu editor favorito e seu projeto deverá ser da seguinte forma:
Agora, precisamos criar uma pasta para conter todos os testes, a pasta tests
. O padrão em Rust é que os testes de integração fiquem na pasta tests
enquanto os testes unitários fiquem junto ao arquivo. Como o exercism já nos dispõe um conjunto bom de testes, podemos simplesmente colar eles no caminho tests/largest-series-product.rs
. Os testes são:
#![allow(unused)] fn main() { use largest_series_product::*; #[test] fn return_is_a_result() { assert!(lsp("29", 2).is_ok()); } #[test] #[ignore] fn find_the_largest_product_when_span_equals_length() { assert_eq!(Ok(18), lsp("29", 2)); } #[test] #[ignore] fn find_the_largest_product_of_two_with_numbers_in_order() { assert_eq!(Ok(72), lsp("0123456789", 2)); } #[test] #[ignore] fn find_the_largest_product_of_two_with_numbers_not_in_order() { assert_eq!(Ok(48), lsp("576802143", 2)); } #[test] #[ignore] fn find_the_largest_product_of_three_with_numbers_in_order() { assert_eq!(Ok(504), lsp("0123456789", 3)); } #[test] #[ignore] fn find_the_largest_product_of_three_with_numbers_not_in_order() { assert_eq!(Ok(270), lsp("1027839564", 3)); } #[test] #[ignore] fn find_the_largest_product_of_five_with_numbers_in_order() { assert_eq!(Ok(15120), lsp("0123456789", 5)); } #[test] #[ignore] fn span_of_six_in_a_large_number() { assert_eq!( Ok(23520), lsp("73167176531330624919225119674426574742355349194934", 6) ); } #[test] #[ignore] fn returns_zero_if_number_is_zeros() { assert_eq!(Ok(0), lsp("0000", 2)); } #[test] #[ignore] fn returns_zero_if_all_products_are_zero() { assert_eq!(Ok(0), lsp("99099", 3)); } #[test] #[ignore] fn a_span_is_longer_than_number_is_an_error() { assert_eq!(Err(Error::SpanTooLong), lsp("123", 4)); } // There may be some confusion about whether this should be 1 or error. // The reasoning for it being 1 is this: // There is one 0-character string contained in the empty string. // That's the empty string itself. // The empty product is 1 (the identity for multiplication). // Therefore LSP('', 0) is 1. // It's NOT the case that LSP('', 0) takes max of an empty list. // So there is no error. // Compare against LSP('123', 4): // There are zero 4-character strings in '123'. // So LSP('123', 4) really DOES take the max of an empty list. // So LSP('123', 4) errors and LSP('', 0) does NOT. #[test] #[ignore] fn an_empty_string_and_no_span_returns_one() { assert_eq!(Ok(1), lsp("", 0)); } #[test] #[ignore] fn a_non_empty_string_and_no_span_returns_one() { assert_eq!(Ok(1), lsp("123", 0)); } #[test] #[ignore] fn empty_string_and_non_zero_span_is_an_error() { assert_eq!(Err(Error::SpanTooLong), lsp("", 1)); } #[test] #[ignore] fn a_string_with_non_digits_is_an_error() { assert_eq!(Err(Error::InvalidDigit('a')), lsp("1234a5", 2)); } }
Vamos explicar rapidamente o que estamos vendo aqui. A primeira linha contém use largest_series_product::*;
, isso corresponde a uma diretiva de importar todas as funcionalidades (::*
) do pacote largest_series_product
. Poderíamos importar somente a diretiva lsp
com use largest_series_product::lsp;
ou mais de uma diretiva com use largest_series_product::{lsp, db::xps}
. Note que a diretiva xps
vem de um pacote interno chamado db
. Nas linhas seguintes, percebemos as anotações #[test]
e #[ignore]
, consideradas atributos que indicam como essa função deve se comportar. No caso do atributo #[test]
, a função descrita a seguir executará somente com a execução de testes no cargo test
, enquanto o atributo #[ignore]
, pulará esse teste. Depois disso, temos a declaração de uma função com o seguinte formato:
#![allow(unused)] fn main() { fn nome_da_funcao_em_snake_case() { //corpo da funcnao // ... } pub fn nome_da_funcao_em_snake_case(arg1: TArgs1, arg2: TArgs2, // ... argn: TArgsn) -> TResposta { //corpo da funcnao // ... } }
Em Rust, a declaração de uma função começa com a palavra-chave fn
seguida pelo nome da função em snake_case. Caso existam, os argumentos são separados como argumento: TipoDoArgument
e, caso a função retorne algum tipo, se adiciona a linha -> TipoDeRetorno
. A última linha da função, caso não tenha ;
no final é sempre retornada. Agora para o corpo da função de teste vemos assert!(lsp("29", 2).is_ok());
. assert!
e assert_eq!
são macros de teste de assertividade, isso quer dizer que assert!
retorna verdade caso o argumento dentro de seu corpo seja verdadeiro, como lsp de 29 e duas casas é do tipo Ok
(lsp("29", 2).is_ok()
), e assert_eq!
recebe dois argumentos, separados por vírgula e procura igualdade e identidade entre eles.
Resolvendo o primeiro teste
Vamos para a primeira função que temos e vamos tentar dissecá-la:
#![allow(unused)] fn main() { #[test] fn return_is_a_result() { assert!(lsp("29", 2).is_ok()); } }
Sabemos que é uma função de teste, #[test]
, e que existe uma chamada para função lsp
que recebe dois argumentos, "29"
(um &str
) e 2
(um inteiro
). Além disso, sabemos que retorna um tipo Result
, pois estamos esperando um resultado do tipo Ok
. Para este teste passar precisamos fazer muito pouco, assim a implementação dele passa a ser:
#![allow(unused)] fn main() { #[derive(Debug, PartialEq)] pub enum Error { SpanTooLong, InvalidDigit(char), } pub fn lsp(_: &str, _: usize) -> Result<u64, Error> { Ok(0u64) } }
Tanto faz o valor que retornamos para esse teste, pois somente queremos saber se é Ok()
. Agora removemos o #[ignore]
do teste a seguir e mudamos nosso Ok(0u64)
para Ok(18u64)
:
#![allow(unused)] fn main() { #[test] fn find_the_largest_product_when_span_equals_length() { assert_eq!(Ok(18), lsp("29", 2)); } }
O próximo teste nos exige um pouco mais:
#![allow(unused)] fn main() { #[test] fn find_the_largest_product_of_two_with_numbers_in_order() { assert_eq!(Ok(72), lsp("0123456789", 2)); } }
Para este teste podemos pegar os dois últimos números da string e multiplicá-los.
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, _: usize) -> Result<u64, Error> { let mut digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); digits.reverse(); Ok(digits.iter().take(2).fold(1u64, |acc, i| acc * i)) } }
Como sabemos que os dois últimos dígitos de ambos os casos são o maior produto, não precisamos nos preocupar muito com o resto. Assim, aplicamos a função split("")
ao valor de entrada, que gerará um vetor contendo cada um dos elementos, como vec!["2", "9"]
, para o caso do "29"
. Depois aplicamos um parse
deles para o tipo inferido em let mut digits: Vec<u64> =
, filtramos para evitar elementos que resultaram em Err
e assim podemos utilizar o unwrap
sem problemas. Coletamos tudo com o collect
e revertemos a lista para obter somente os dois primeiros elementos, que após o reverse
passaram de últimos a primeiros. Depois aplicamos o .fold(1u64, |acc, i| acc * i))
, que inicia a multiplicação com um 1u64
, e depois multiplicamos cada um deles pelo acumulador acc
. Tudo isso envolvido em um Ok()
. Existem formas mais simples de resolver esse problema em Rust, como o uso de slices
, mas acredito que seja uma boa solução para quem precisa revisar a linguagem.
Ao rodarmos o próximo teste, podemos perceber que nossa estratégia falha e que novas implementações são necessárias:
#![allow(unused)] fn main() { #[test] fn find_the_largest_product_of_two_with_numbers_not_in_order() { assert_eq!(Ok(48), lsp("576802143", 2)); } }
Felizmente parte de nossa solução, a variável digits
, já é bastante útil, pois converteu a &str
em um vetor de u64
. Agora precisamos de uma função que atue sobre os vetores e agrupe-os de dois em dois. Para poupar nosso tempo, a implementacão de Vec
em Rust já possui uma função assim, ela chama window
e recebe como argumento um self
e um span do tipo usize
, retornando um Window<T>
, no qual T
representa um genérico correspondente ao tipo do vetor. A struct Window<T>
corresponde a um iterável com valores internos do tipo slice com o tamanho de cada slice do valor span usize
, se fossemos comparar a um vetor seria um vec![ &["a", "b"], &["b", "c"], &["c", "d"], // ...]
para o afaltabeto separado 2usize
. Agora, precisamos de uma função que nos retorne o valor de cada Window
e ordene os valores de forma que o maior seja o primeiro ou o último. Chamei essa função de window_values
, e ela recebe como argumento o vetor que criamos anteriormente, digits: Vec<u64>
:
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, _: usize) -> Result<u64, Error> { let digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); Ok(window_values(digits) .first() .unwrap() .to_owned()) } fn window_values(digits: Vec<u64>) -> Vec<u64> { let mut window_values = digits .windows(2usize) .map(|w| w.to_vec()) .map(|v| v.iter().fold(1,|acc, i| acc * i)) .collect::<Vec<u64>>(); window_values.sort(); window_values.reverse(); window_values } }
Note que a função lsp
não mudou muito, o que mudou nela é que chamamos a função window_values
com o digits
, que deixou de ser mutável. Na função window_values
, estamos criando windows de tamanho 2usize
, depois aplicando map
para converter o tipo &[T;usize]
em vetor e, no map
seguinte, transformamos esse vetor gerado em um iterável que consome eles em um fold de multiplicação. Depois ordenamos a lista de maior para menor, e depois revertemos para termos o maior produto como primeiro elemento (podíamos deixar sem o reverse e aplicar um last
em vez de first
à solução da função). A chamada de função to_owned
ocorre porque o resultado do first é um borrow, ou seja &u64
e precisamos de um u64
.
O próximo teste inclui apenas uma diferença: o valor de span
deixa de ser 2
e passa a ser 3
. Para isso, precisamos passar span como argumento para window_values
.
#![allow(unused)] fn main() { #[test] fn find_the_largest_product_of_three_with_numbers_in_order() { assert_eq!(Ok(504), lsp("0123456789", 3)); } }
Agora a solução passa a ser (note o valor span
adicionado nas funções):
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, span: usize) -> Result<u64, Error> { let digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); Ok(window_values(digits, span) .first() .unwrap() .to_owned()) } fn window_values(digits: Vec<u64>, span: usize) -> Vec<u64> { let mut str_chunks = digits .windows(span) .map(|x| x.to_vec()) .map(|i| i.iter().fold(1,|acc, x| acc * x)) .collect::<Vec<u64>>(); str_chunks.sort(); str_chunks.reverse(); str_chunks } }
Com essas mudanças, os próximos três testes passam sem grandes esforços:
#![allow(unused)] fn main() { #[test] fn find_the_largest_product_of_three_with_numbers_not_in_order() { assert_eq!(Ok(270), lsp("1027839564", 3)); } #[test] fn find_the_largest_product_of_five_with_numbers_in_order() { assert_eq!(Ok(15120), lsp("0123456789", 5)); } #[test] fn span_of_six_in_a_large_number() { assert_eq!( Ok(23520), lsp("73167176531330624919225119674426574742355349194934", 6) ); } }
Os testes seguintes também passam, mas quis separá-los para chamar a atenção em relação aos 0
:
#![allow(unused)] fn main() { #[test] fn returns_zero_if_number_is_zeros() { assert_eq!(Ok(0), lsp("0000", 2)); } #[test] fn returns_zero_if_all_products_are_zero() { assert_eq!(Ok(0), lsp("99099", 3)); } }
Agora, o próximo teste já falha, pois apesar de termos a implementação do tipo Error
, não estamos usando o Error
. Note que o teste consiste em retornar um Resultwindow
ser maior que o tamanho total das strings:
#![allow(unused)] fn main() { #[test] fn a_span_is_longer_than_number_is_an_error() { assert_eq!(Err(Error::SpanTooLong), lsp("123", 4)); } }
Agora precisamos adicionar um if
que valida se o tamanho da string é maior que o tamanho da window
. span > string_digits.len()
e que caso verdadeiro retorne Err(Error::SpanTooLong)
:
#![allow(unused)] fn main() { pub enum Error { SpanTooLong, InvalidDigit(char), } pub fn lsp(string_digits: &str, span: usize) -> Result<u64, Error> { if span > string_digits.len() { return Err(Error::SpanTooLong); } let digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); Ok(window_values(digits, span) .first() .unwrap() .to_owned()) } // ... }
Os próximos dois testes se referem à mesma coisa. Se o valor do span
for zero, o resultado sempre será Ok(1u64)
:
#![allow(unused)] fn main() { #[test] fn an_empty_string_and_no_span_returns_one() { assert_eq!(Ok(1), lsp("", 0)); } #[test] fn a_non_empty_string_and_no_span_returns_one() { assert_eq!(Ok(1), lsp("123", 0)); } }
Para resolver esse teste basta adicionar mais um if
, if span == 0 { return Ok(1u64); }
:
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, span: usize) -> Result<u64, Error> { if span > string_digits.len() { return Err(Error::SpanTooLong); } else if span == 0 { return Ok(1u64); } let digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); Ok(window_values(digits, span) .first() .unwrap() .to_owned()) } // ... }
Além disso, o teste seguinte também passa:
#![allow(unused)] fn main() { #[test] fn empty_string_and_non_zero_span_is_an_error() { assert_eq!(Err(Error::SpanTooLong), lsp("", 1)); } }
O próximo e último teste traz um novo conceito: falha por conta de um dígito não válido, como um caractere alfabético. Vamos incluir este caractere como argumento do erro:
#![allow(unused)] fn main() { #[test] fn a_string_with_non_digits_is_an_error() { assert_eq!(Err(Error::InvalidDigit('a')), lsp("1234a5", 2)); } }
Para resolver esse teste precisamos fazer um match
por tipos alfabéticos e retornar o primeiro que falha. O if
que garante que existe uma falha é if string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>().len() > 0
e assim bastaria adicionar o seguinte código a lsp
:
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, span: usize) -> Result<u64, Error> { if span > string_digits.len() { return Err(Error::SpanTooLong); } else if span == 0 { return Ok(1u64); } else if string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>().len() > 0 { let digit = string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>(); return Err(Error::InvalidDigit(digit .first().unwrap() .to_owned() .to_string() .pop().unwrap())); } let digits: Vec<u64> = string_digits .split("") .map(|s| s.parse()) .filter(|s| match s { Ok(_) => true, Err(_) => false, }) .map(|s| s.unwrap()) .collect(); Ok(window_values(digits, span) .first() .unwrap() .to_owned()) } }
Assim, com o resultado de string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>()
podemos obter o primeiro com first
, e depois aplicar o pop
para retirar o valor de char
. Além disso, podemos perceber que a conta string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>()
está sendo executada duas vezes, assim podemos extrair para um valor, v_alphanumeric
, antes dos ifs/elses:
#![allow(unused)] fn main() { pub fn lsp(string_digits: &str, span: usize) -> Result<u64, Error> { let v_alphanumeric = string_digits.matches(char::is_alphabetic).collect::<Vec<&str>>(); if span > string_digits.len() { return Err(Error::SpanTooLong); } else if span == 0 { return Ok(1u64); } else if v_alphanumeric.len() > 0 { return Err(Error::InvalidDigit(v_alphanumeric .first().unwrap() .to_owned() .to_string() .pop().unwrap())); } // ... } }
Agora que revisamos Rust podemos iniciar nosso primeiro serviço.
Todo Server com Actix
Nesta primeira parte vamos desenvolver um Todo Server aplicando um modelo semelhante ao MVC do projeto Phoenix, da comunidade Elixir. Este modelo foi explicado no capítulo Como este livro é organizado
, mas agora vamos detalhar um pouco quais serão as características deste serviço.
Vamos criar um RESTful Todo Server que seria facilmente utilizado em produção pois contará com uma série de recursos como:
- Endpoints de monitoramento:
ping
que funciona comohealth check
.~/ready
que funciona como disponibilidade do servico,readiness probe
.
- Endpoints para salvar as informações dos
TODO
s,create
show
show-by-id
eupdate
. - Sistema de logs, headers padrão e middlewares de autenticação.
- Endpoints de autenticação, com
signup
,login
elogout
utilizando tokensJWT
e banco de dados Postgres via Diesel. - Bastion para tornar o sistema tolerante a falhas.
- Dockerização de todos os serviços.
- CI executando as pipelines de teste.
- Serde para serialização e deserialização de Json.
Configurando os primeiros endpoints
O nosso objetivo inicial é fazer nosso servidor responder pong
na rota /ping
e executar uma função de baixo custo na rota /~/ready
retornando 200 ou algum valor de status superior a 400, bastante simples. O objetivo disso é definir em nosso servidor endpoints que respondam se o servidor está disponível e saudável, /ping
, assim como responder se esta pronto para executar mais uma operação, /~/ready
. Para isso, precisamos criar nosso todo-server
com o cargo rodando o comando cargo new todo-server --bin
, que irá gerar os arquivos a seguir:
- todo-server/src/main.rs
- todo-server/Cargo.lock
- todo-server/Cargo.toml
O arquivo main.rs
é bastante simples, pois possui somente uma linha executando uma impressão no console de "Hello, world!"
da seguinte forma:
fn main() { println!("Hello, world!"); }
Já o arquivo Cargo.toml
possui todas as informações sobre o binário gerado:
[package]
name = "todo-server"
version = "0.1.0"
authors = ["Julia Naomi @naomijub"]
edition = "2018"
[dependencies]
Já o arquivo Cargo.lock
corresponde às configurações geradas para o Cargo.toml
com o registro de versões de dependências, assim como o package-lock.json
no Node.
Adicionando Actix
Nossa principal dependência é o Actix
, assim precisamos adicionar a dependência actix-web = "4.2.1"
à seção [dependencies]
do Cargo.toml
:
[package]
name = "todo-server"
version = "0.1.0"
authors = ["Julia Naomi @naomijub"]
edition = "2018"
[dependencies]
actix-web = "4.2.1"
Implementando o endpoint /ping
Este endpoint é comum a muitos serviços, mas em alguns casos é chamado de /healthy
, /healthcheck
ou /~/healthy
, digamos que seja um exemplo com aplicações práticas de um hello world
. Neste primeiro momento vamos apresentar primeiro a implementção do /ping
e depois explicar, pois acredito que neste caso seja importante ter visão do todo antes de entrar nos detalhes. Assim, uma implementação bem simples de /ping
seria:
use actix_web::{HttpServer, App}; use actix_web::{get, Responder, HttpResponse}; #[get("/ping")] pub async fn ping() -> impl Responder { HttpResponse::Ok().body("pong") } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(||{ App::new() .service(healthcheck) .service(ping) .default_service(web::to(||HttpResponse::NotFound())) }) .workers(6) .bind(("localhost", 4000)) .unwrap() .run() .await }
E seu funcionamento seria:
Agora podemos começar a descrever o endpoint /ping
:
- A primeira coisa que vemos é a diretiva
use
associada a libactix_web
. Essa diretiva nos permite disponibilizar no nosso código as funções e estruturas deactix_web
para uso posterior, assim a diretivause actix_web::HttpServer
disponibilizaria a estruturaHttpServer
para usarmos. - Depois vemos a função
async fn ping() -> impl Responder
. Essa função é uma função assíncrona, devido as palavras reservadasasync fn
, cujo nome éping
, recebe nenhum argumento()
e como tipo de resposta implementa a traitResponder
, que tem como tipo de retornoFuture<Output = Result<Response, Self::Error>>
. A resposta deping
é um status codeOk()
com umbody("pong")
, porém seria possível também implementar com a funçãowith_status
da traitResponder
, ficando"pong".with_status(StatusCode::Ok)
, que seria classificado como umCustomResponder
, ou umResponder
customizado. - A seguir encontramos a macro
#[actix_web::main]
, que é habilitada por padrão (https://docs.rs/actix-web/latest/actix_web/#crate-features). A função dessa macro é executar qualquer função marcada comoasync
no runtime de actix. - Agora temos a função de execução
main
comoasync fn main() -> std::io::Result<()>
. Assim, essa macro gera o código necessário para que nossa funçãomain
esteja conforme o padrão de funçõesmain
do Rust . - A linha
HttpServer::new(|| {..})
permite criar um servidor HTTP com umaapplication factory
, assim como permite configurar a instância do servidor, comoworkers
ebind
, que veremos a seguir. - A linha
App::new().service(..)
é umapplication builder
baseado no padrão builder para oApp
, que é uma struct correspondente a aplicação do actix-web, seu objetivo é configurar rotas e settings padrões. A funçãoservice
registra um serviço no servidor. - A rota do serviço
ping
é definida pela macro#[get("/ping")]
. - O módulo
web
possui uma série de funções auxiliares e e tipos auxiliares para o actix-web. - Depois disso, vemos
workers(6)
, uma função deHttpServer
que define a quantidade de threads trabalhadoras que estarão envolvidas nesse executável. Por padrão, o valor deworkers
é a quantidade de CPUs lógicas disponíveis. - Agora temos o
bind
, que recebe o IP e a porta a qual esse servidor se conectará. run
eawait
para executar o serviço e esperar peloasync
definido anteriormente.
É importante também implementarmos um teste para NOT_FOUND
. Esse teste consiste em um request para uma rota que não existe e um status NOT_FOUND
:
#![allow(unused)] fn main() { #[actix_web::test] async fn not_found_route() { let mut app = test::init_service( App::new() .service(healthcheck) .service(ping) .default_service(web::to(|| HttpResponse::NotFound())) ).await; let req = test::TestRequest::with_uri("/crazy-path").to_request(); let resp = app.call(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } }
Implementando o endpoint /~/ready
Este endpoint é comum especialmente em serviços kubernetes e sua execução é via kubectl
. Usualmente o kubectl
espera que o processo ocorra via HTTP, TCP-gRPC ou uma execução de comando no contêiner. Para um contexto simples de contêineres, ter esse endpoint permite um monitoramento mais elevado de serviços, como os Golden Signals (sinais dourados apresentados pelo Google no livro Engenharia de Confiabilidade de Sites). Assim, ele permite um pouco mais de informações além de saber se o servidor está vivo (/ping
), já que verifica se o serviço é capaz de realizar um pequeno processo. Outros endpoints comuns para esse tipo de prova são /readiness
ou /~/readiness
. O nosso endpoint vai executar um simples $ echo hello
e retornar accepted
para um resultado Ok
e internal server error
para um resultado Err
.
O primeiro passo para essa prova é definir a rota que vamos chamar, no caso /~/ready
:
#![allow(unused)] fn main() { App::new() .service(readiness) .service(healthcheck) .service(ping) .default_service(web::to(|| HttpResponse::NotFound())) }
Agora temos que implementar a função readiness
:
#![allow(unused)] fn main() { #[get("/~/ready")] pub async fn readiness() -> impl Responder { let process = std::process::Command::new("sh") .arg("-c") .arg("echo hello") .output(); match process { Ok(_) => HttpResponse::Accepted(), Err(_) => HttpResponse::InternalServerError() } } }
Note que criamos um valor chamado process
que é um comando executado pela crate de OS
std::process::Command
. Para o readiness
o comando que estamos executando é sh -c echo hello
, que imprime hello
no console. Depois disso fazemos pattern matching do resultado e se for Ok
retornamos um 2XX
ou retornamos 500
para um Err
.
Refatorando
Agora que nosso código está funcionando podemos começar a pensar em organizá-lo, já que nosso arquivo main
está com muitas funções. A ideia é seguir o padrão do framework Phoenix do Elixir, assim vamos separar o código em 3 conjuntos:
main.rs
, que contém todas as informações de configuração do servidor, ou seja, a própria instância do servidor.todo_api
, que contém todos os módulos responsáveis por lógica e banco de dados.todo_api_web
, que contém todos os módulos responsáveis pelo gerenciamento do conteúdo web, como views e controllers, no nosso caso somente controllers.
Assim, nossa primeira refatoração seria mover as funcões que implementam a trait Responder
para um módulo de controller, src/todo_api_web/controller/mod.rs
:
#![allow(unused)] fn main() { use actix_web::{get, Responder, HttpResponse}; #[get("/~/ready")] pub async fn readiness() -> impl Responder { let process = std::process::Command::new("sh") .arg("-c") .arg("echo hello") .output(); match process { Ok(_) => HttpResponse::Accepted(), Err(_) => HttpResponse::InternalServerError(), } } #[get("/ping")] pub async fn ping() -> impl Responder { HttpResponse::Ok().body("pong") } }
Além disso, nosso arquivo main.rs
agora consome nosso módulo:
pub mod todo_api_web; use actix_web::{ web, App, HttpResponse, HttpServer, }; use todo_api_web::*; #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service(readiness) .service(ping) .default_service(web::to(|| HttpResponse::NotFound())) }) .workers(6) .bind(("localhost", 4004)) .unwrap() .run() .await }
Note a presença do módulo todo_api_web
declarado como mod todo_api_web;
e importando as funções ping
e readiness
através de use todo_api_web::controller::{pong, readiness};
. Além disso, na imagem a seguir podemos perceber a presença de um arquivo lib.rs
no sistema de arquivos, esse arquivo serve para podermos exportar nossos módulos internos para testes de integração. Assim, atualmente o único módulo declarado em lib.rs
é pub mod todo_api_web
.
#![allow(unused)] fn main() { //src/todo_web_api/mod.rs pub mod controller; }
Outro ponto que creio ser interessante rafatorar é dar a capacidade de nosso servidor adaptar o número de workers
a quantidade de cores lógicos que a máquina hospedeira possui. Por exemplo, minha máquina pessoal possui 4 cores lógicos e decidi usar uma estratégia de leve estresse aos cores que geralmente se resume a número de cores lógicos + 2
, ela se torna uma opção segura pelo fato de estarmos utilizando async
no nosso serviço, ou seja, defini 6 workers
, mas se meu computador possuísse 8 cores lógicos, eu poderia estar utilizando 10 workers
. Para resolver este problema podemos utilizar uma lib conhecida como num_cpus
, basta adicionar ela ao [dependencies]
do Cargo.toml num_cpus = "1.0"
e substituir em nosso código da seguinte maneira:
#[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service(readiness) .service(ping) .default_service(web::to(|| HttpResponse::NotFound())) }) .workers(num_cpus::get() + 2) .bind(("localhost", 4004)) .unwrap() .run() .await }
Testando os endpoints
Uma coisa importante antes de continuarmos é criarmos testes para os endpoints implementados, especialmente agora que já aprendemos como funciona o a criação de rotas e controllers. No caso de rotas e controllers é mais eficiente começar com testes de integração, inclusive por já termos implementado as rotas anteriormente. Assim, precisamos criar alguns arquivos para executar nossos testes de integração. O primeiro arquivo que precisamos é o arquivo lib
dentro de tests
, tests/lib.rs
:
#![allow(unused)] fn main() { extern crate todo_server; mod todo_api_web; }
Além disso, agora precisamos criar o módulo todo_api_web
com um módulo interno controller
:
#![allow(unused)] fn main() { // tests/todo_api_web/mod.rs mod controller; }
Agora podemos começar a criar os testes de controller no arquivo tests/todo_api_web/controller.rs
. O primeiro teste que vamos escrever é a verificação se o conteúdo de texto da rota /ping
é pong
. Para isso, precisamos utilizar um módulo de suporte para testes do actix chamado actix_web::test
e incorporar como [dev-dependencies]
duas libs que nos apoiarão no uso de testes, a bytes = "0.5.3"
para processar os bytes da resposta gerada no endpoint, e a actix-service = "1.0.5"
, que apoia nos testes para chamar um mock de App
do actix na rota desejada. Sugiro isolar os testes dos controllers pong
e readiness
em um módulo conforme a seguir:
#![allow(unused)] fn main() { #[cfg(test)] mod ping_readiness { use todo_server::todo_api_web::controller::{ping}; use actix_web::{test, App}; #[actix_web::test] async fn test_ping_pong() { let mut app = test::init_service(App::new().service(ping)).await; let req = test::TestRequest::get().uri("/ping").to_request(); let resp = test::call_service(&mut app, req).await; let result = test::read_body(resp).await; assert_eq!(std::str::from_utf8(&result).unwrap(), "pong"); } } }
O teste apresentado possui uma macro de teste diferente do usual no Rust. Em vez de ser #[test]
, utilizamos uma macro de teste que disponibiliza o runtime de actix com #[actix_web::test]
. Além disso, note que agora nossa função de teste passa a ser async
e utilizamos vários await
dentro do teste.
Agora vamos explicar as partes do teste: test::init_service
disponibiliza um mock de serviço do Actix que recebe como argumento um tipo App
com a rota, /ping"
, e designa a essa rota um controller, .service(ping)
. Além disso, criamos uma instância de Request
para teste com test::TestRequest
utilizando o método get()
na uri("/ping")
. Depois disso, a resp
corresponde a ler a resposta que esse serviço app
daria para o Request
req
. Como a resposta de read_response
são bytes
, convertemos para string e comparamos com o resultado esperado pelo endpoint.
Com o teste de pong
implementado, podemos criar o teste de readiness
. No teste de readiness
não nos interessa saber o corpo da resposta, assim a sugestão é somente saber se a execução retornou um status Accepted
. Para esse teste, vamos utilizar o recurso da crate actix-service
, que nos possibilita fazer chamadas a um serviço através de <App>.call(<Request>).await
. Assim podemos utilizar o call
para retornar uma response, na qual podemos acessar o status()
. O bloco de testes fica assim:
#![allow(unused)] fn main() { #[cfg(test)] mod ping_readiness { use todo_server::todo_api_web::controller::{ping, readiness}; use actix_web::{test, App, http::StatusCode}; ... #[actix_web::test] async fn test_readiness() { let mut app = test::init_service(App::new().service(readiness)).await; let req = test::TestRequest::get().uri("/~/ready").to_request(); let resp = test::call_service(&mut app, req).await; assert_eq!(resp.status(), StatusCode::ACCEPTED); } } }
Com tudo testado, o próximo passo é configurar nosso serviço para criar uma tarefa todo.
- O código deste capítulo está na bibliografia e solicitações de mudança serão bem vindas para manter o código exemplo atualizado.
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:
#![allow(unused)] fn main() { struct Task { is_done: bool, title: String } enum State { Todo, Doing, Done, } struct TodoCard { title: String, description: String, owner: Uuid, tasks: Vec<Task>, state: State } }
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
:
#![allow(unused)] fn main() { struct TodoCardId { id: Uuid } }
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
:
#![allow(unused)] fn main() { mod create_todo { use todo_server::todo_api_web::{controller::todo::create_todo, model::todo::TodoIdResponse}; use actix_web::{http::header::CONTENT_TYPE, test, web, App, body}; use serde_json::from_str; fn post_todo() -> String { String::from( "{ \"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\" }", ) } #[actix_web::test] async fn valid_todo_post() { let mut app = test::init_service(App::new().service(create_todo)).await; let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(post_todo().as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); } } }
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:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] pub struct TodoIdResponse { id: Uuid, } }
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:
#![allow(unused)] fn main() { //definição do teste #[actix_web::test] async fn valid_todo_post() { }
#![allow(unused)] fn main() { // Definição do App let mut app = test::init_service( App::new().service(create_todo) ).await; }
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:
#![allow(unused)] fn main() { let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(post_todo().as_bytes().to_owned()) .to_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
.
#![allow(unused)] fn main() { let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); }
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:
#![allow(unused)] fn main() { impl TodoIdResponse { // #[cfg(test)] pub fn get_id(self) -> String { format!("{}", self.id) } } }
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).
#![allow(unused)] fn main() { // src/todo_api_web/model/mod.rs use uuid::Uuid; #[derive(Serialize, Deserialize, Debug)] struct Task { is_done: bool, title: String, } #[derive(Serialize, Deserialize, Debug)] enum State { Todo, Doing, Done, } #[derive(Serialize, Deserialize, Debug)] pub struct TodoCard { title: String, description: String, owner: Uuid, tasks: Vec<Task>, state: State, } #[derive(Serialize, Deserialize)] pub struct TodoIdResponse { id: Uuid, } impl TodoIdResponse { // #[cfg(test)] pub fn get_id(self) -> String { format!("{}", self.id) } } }
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
:
#![allow(unused)] fn main() { // src/todo_api_web/controller/todo.rs use crate::todo_api_web::model::todo::TodoIdResponse; use actix_web::{ http::header::ContentType, post, web, HttpResponse, Responder}; use uuid::Uuid; #[post("/api/create")] pub async fn create_todo(_payload: web::Payload) -> impl Responder { let new_id = Uuid::new_v4(); let str = serde_json::to_string(&TodoIdResponse::new(new_id)).unwrap(); HttpResponse::Created() .content_type(ContentType::json()) .body(str) } }
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
:
#![allow(unused)] fn main() { HttpServer::new(|| { App::new() .service(readiness) .service(ping) .service(create_todo) .default_service(web::to(|| HttpResponse::NotFound())) }) }
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:
#![allow(unused)] fn main() { use actix_web::{web, HttpResponse}; use crate::todo_api_web::controller::{ pong, readiness, todo::create_todo }; pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("/") .service( web::scope("api/") .route("create", web::post().to(create_todo)) ) .route("ping", web::get().to(pong)) .route("~/ready", web::get().to(readiness)) .route("", web::get().to(|| HttpResponse::NotFound())) ); } }
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
:
#![allow(unused)] fn main() { mod ping_readiness { use todo_server::todo_api_web::routes::app_routes; use actix_web::{body, http::StatusCode, test, web, App}; #[actix_web::test] async fn test_ping_pong() { let mut app = test::init_service(App::new().configure(app_routes)).await; let req = test::TestRequest::get().uri("/ping").to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); assert_eq!(bytes, web::Bytes::from_static(b"pong")); } #[actix_web::test] async fn test_readiness() { let mut app = test::init_service(App::new().configure(app_routes)).await; let req = test::TestRequest::get().uri("/~/ready").to_request(); let resp = test::call_service(&mut app, req).await; assert_eq!(resp.status(), StatusCode::ACCEPTED); } } mod create_todo { use todo_server::todo_api_web::{controller::todo::create_todo, model::todo::TodoIdResponse}; use actix_web::{body, http::header::CONTENT_TYPE, test, web, App}; use serde_json::from_str; fn post_todo() -> String { String::from( "{ \"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\" }", ) } #[actix_web::test] async fn valid_todo_post() { let mut app = test::init_service(App::new().service(create_todo)).await; let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(post_todo().as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); } } }
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
:
#![allow(unused)] fn main() { #[get("/~/ready")] pub async fn readiness() -> impl Responder { let process = std::process::Command::new("sh") .arg("-c") .arg("echo hello") .output(); match process { Ok(_) => HttpResponse::Accepted(), Err(_) => HttpResponse::InternalServerError(), } } }
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:
#![allow(unused)] fn main() { use actix_web::http::Uri; use aws_sdk_dynamodb::{ model::{ AttributeDefinition, KeySchemaElement, KeyType, ProvisionedThroughput, ScalarAttributeType, }, Client, Endpoint, }; pub static TODO_CARD_TABLE: &str = "TODO_CARDS"; pub async fn create_table() { let config = aws_config::load_from_env().await; let dynamodb_local_config = aws_sdk_dynamodb::config::Builder::from(&config) .endpoint_resolver( Endpoint::immutable(Uri::from_static("http://localhost:8000")), ) .build(); let client = Client::from_conf(dynamodb_local_config); let table_name = TODO_CARD_TABLE.to_string(); let ad = AttributeDefinition::builder() .attribute_name("id") .attribute_type(ScalarAttributeType::S) .build(); let ks = KeySchemaElement::builder() .attribute_name("id") .key_type(KeyType::Hash) .build(); let pt = ProvisionedThroughput::builder() .read_capacity_units(1) .write_capacity_units(1) .build(); match client .create_table() .table_name(table_name) .key_schema(ks) .attribute_definitions(ad) .provisioned_throughput(pt) .send() .await { Ok(output) => { println!("Output: {:?}", output); } Err(error) => { println!("Error: {:?}", error); } } } }
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:
#![allow(unused)] fn main() { Output: CreateTableOutput CreateTableOutput { table_description: Some(TableDescription { attribute_definitions: Some([AttributeDefinition { attribute_name: Some("id"), attribute_type: Some(S) }]), table_name: Some("TODO_CARDS"), key_schema: Some([KeySchemaElement { attribute_name: Some("id"), key_type: Some(Hash) }]), table_status: Some(Active), creation_date_time: Some(DateTime { seconds: 1665206254, subsecond_nanos: 461999893 }), provisioned_throughput: Some(ProvisionedThroughputDescription { last_increase_date_time: Some(DateTime { seconds: 0, subsecond_nanos: 0 }), last_decrease_date_time: Some(DateTime { seconds: 0, subsecond_nanos: 0 }), number_of_decreases_today: Some(0), read_capacity_units: Some(1), write_capacity_units: Some(1) }), table_size_bytes: 0, item_count: 0, table_arn: Some("arn:aws:dynamodb:ddblocal:000000000000:table/TODO_CARDS"), table_id: None, billing_mode_summary: None, local_secondary_indexes: None, global_secondary_indexes: None, stream_specification: None, latest_stream_label: None, latest_stream_arn: None, global_table_version: None, replicas: None, restore_summary: None, sse_description: None, archival_summary: None, table_class_summary: None }) } }
Tabela criada! Mas se executarmos cargo run
de novo, receberemos um erro dizendo que não é possível criar uma tabela que já existe:
#![allow(unused)] fn main() { Error: ServiceError { err: CreateTableError { kind: ResourceInUseException(ResourceInUseException { message: Some("Cannot create preexisting table") }), meta: Error { code: Some("ResourceInUseException"), message: Some("Cannot create preexisting table"), request_id: Some("543a624e-7f21-4dd2-80f2-520ae078152b"), extras: {} } }, raw: Response { inner: Response { status: 400, version: HTTP / 1.1, headers: { "date": "Sat, 08 Oct 2022 05:33:45 GMT", "content-type": "application/x-amz-json-1.0", "x-amzn-requestid": "543a624e-7f21-4dd2-80f2-520ae078152b", "content-length": "112", "server": "Jetty(9.4.48.v20220622)" }, body: SdkBody { inner: Once(Some(b "{\"__type\":\"com.amazonaws.dynamodb.v20120810#ResourceInUseException\",\"Message\":\"Cannot create preexisting table\"}")), retryable: true } }, properties: SharedPropertyBag(Mutex { data: PropertyBag, poisoned: false, .. }) } } }
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:
#![allow(unused)] fn main() { use actix_web::http::Uri; use aws_sdk_dynamodb::{ model::{ AttributeDefinition, KeySchemaElement, KeyType, ProvisionedThroughput, ScalarAttributeType, }, Client, Endpoint }; pub static TODO_CARD_TABLE: &str = "TODO_CARDS"; pub async fn create_table() { let config = aws_config::load_from_env().await; let dynamodb_local_config = aws_sdk_dynamodb::config::Builder::from(&config) .endpoint_resolver( Endpoint::immutable(Uri::from_static("http://localhost:8000")), ) .build(); let client = Client::from_conf(dynamodb_local_config); match client.list_tables().send().await { Ok(list) => { match list.table_names { Some(table_vec) => { if table_vec.len() > 0 { println!("Error: {:?}", "Table already exists"); } else { create_table_input(&client).await } } None => create_table_input(&client).await, }; } Err(_) => { create_table_input(&client).await; } } } fn build_key_schema() -> KeySchemaElement { KeySchemaElement::builder() .attribute_name("id") .key_type(KeyType::Hash) .build() } fn build_provisioned_throughput() -> ProvisionedThroughput { ProvisionedThroughput::builder() .read_capacity_units(1) .write_capacity_units(1) .build() } fn build_attribute_definition() -> AttributeDefinition { AttributeDefinition::builder() .attribute_name("id") .attribute_type(ScalarAttributeType::S) .build() } async fn create_table_input(client: &Client) { let table_name = TODO_CARD_TABLE.to_string(); let ad = build_attribute_definition(); let ks = build_key_schema(); let pt = build_provisioned_throughput(); match client .create_table() .table_name(table_name) .key_schema(ks) .attribute_definitions(ad) .provisioned_throughput(pt) .send() .await { Ok(output) => { println!("Output: {:?}", output); } Err(error) => { println!("Error: {:?}", error); } } } }
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.
#![allow(unused)] fn main() { // src/todo_api/model/mod.rs use rusoto_dynamodb::AttributeValue; use std::collections::HashMap; use uuid::Uuid; #[derive(Debug, Clone)] struct TaskDb { is_done: bool, title: String, } #[derive(Debug, Clone)] enum StateDb { Todo, Doing, Done, } #[derive(Debug, Clone)] pub struct TodoCardDb { id: Uuid, title: String, description: String, owner: Uuid, tasks: Vec<TaskDb>, state: StateDb, } }
Vamos criar uma função que permita transformar um TodoCard
em um TodoCardDb
, em src/todo_api/model/mod.rs
:
#![allow(unused)] fn main() { use crate::todo_api_web::model::{State, TodoCard}; use actix_web::web; impl TodoCardDb { pub fn new(card: web::Json<TodoCard>) -> Self { Self { id: Uuid::new_v4(), title: card.title.clone(), description: card.description.clone(), owner: card.owner, tasks: card .tasks .iter() .map(|t| TaskDb { is_done: t.is_done, title: t.title.clone(), }) .collect::<Vec<TaskDb>>(), state: match card.state { State::Doing => StateDb::Doing, State::Done => StateDb::Done, State::Todo => StateDb::Todo, }, } } } }
Agora, podemos fazer nosso controller
momentaneamente gerenciar todas as ações com o banco de dados:
#![allow(unused)] fn main() { use actix_web::{HttpResponse, web, Responder}; use uuid::Uuid; use crate::todo_api_web::model::{TodoCard, TodoIdResponse}; use crate::todo_api::model::{TodoCardDb}; pub async fn create_todo(info: web::Json<TodoCard>) -> impl Responder { let todo_card = TodoCardDb::new(info); let client = get_client().await; match put_todo(&client, todo_card).await { None => HttpResponse::BadRequest().body("Failed to create todo card"), Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoIdResponse::new(id)).expect("Failed to serialize todo card")) } } /// A partir daqui vamos extrair logo mais use aws_sdk_dynamodb::{Client}; use crate::{ todo_api::db::helpers::{TODO_CARD_TABLE}, }; use super::helpers::get_client; pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { match client.put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => { Some(todo_card.id) }, Err(_) => { None } } } }
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:
#![allow(unused)] fn main() { // src/todo_api/model/mod.rs use std::collections::HashMap; impl Into<HashMap<String, AttributeValue>> for TodoCardDb { fn into(self) -> HashMap<String, AttributeValue> { let mut todo_card = HashMap::new(); todo_card.insert("id".to_string(), val!(S => self.id.to_string())); todo_card.insert("title".to_string(), val!(S => self.title)); todo_card.insert("description".to_string(), val!(S => self.description)); todo_card.insert("owner".to_string(), val!(S => self.owner.to_string())); todo_card.insert("state".to_string(), val!(S => self.state.to_string())); todo_card.insert("tasks".to_string(), val!(L => task_to_db_val(self.tasks))); todo_card } } }
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:
#![allow(unused)] fn main() { pub struct AttributeValue { pub b: Option<Bytes>, pub bool: Option<bool>, pub bs: Option<Vec<Bytes>>, pub l: Option<Vec<AttributeValue>>, pub m: Option<HashMap<String, AttributeValue>>, pub n: Option<String>, pub ns: Option<Vec<String>>, pub null: Option<bool>, pub s: Option<String>, pub ss: Option<Vec<String>>, } }
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:
#![allow(unused)] fn main() { impl std::fmt::Display for StateDb { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{:?}", self) } } }
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>
:
#![allow(unused)] fn main() { fn task_to_db_val(tasks: Vec<TaskDb>) -> Vec<AttributeValue> { tasks .iter() .map(|t| { let mut tasks_hash = HashMap::new(); tasks_hash.insert("title".to_string(), val!(S => t.title.clone())); tasks_hash.insert("is_done".to_string(), val!(B => t.is_done)); val!(M => tasks_hash) }) .collect::<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
:
#![allow(unused)] fn main() { // src/todo_api/adapter/mod.rs #[macro_export] macro_rules! val { (B => $bval:expr) => {{ AttributeValue::Bool($bval) }}; (L => $val:expr) => {{ AttributeValue::L($val) }}; (S => $val:expr) => {{ AttributeValue::S($val) }}; (M => $val:expr) => {{ AttributeValue::M($val) }}; } }
Para que essa macro esteja disponível dentro do módulo todo_api
, precisamos utilizar #[macro_use]
na declaração dos módulos:
#![allow(unused)] fn main() { #[macro_use] pub mod adapter; pub mod db; pub mod model; }
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
:
#![allow(unused)] fn main() { use super::helpers::get_client; use crate::todo_api::db::helpers::TODO_CARD_TABLE; use aws_sdk_dynamodb::Client; pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { match client .put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => Some(todo_card.id), Err(_) => None, } } }
E agora podemos simplificar muito nosso controller com:
#![allow(unused)] fn main() { use crate::todo_api::db:: {helpers::get_client, todo::put_todo}; use crate::todo_api::model::TodoCardDb; use crate::todo_api_web::model::todo::{TodoCard, TodoIdResponse}; use actix_web::{http::header::ContentType, post, web, HttpResponse, Responder}; #[post("/api/create")] pub async fn create_todo(info: web::Json<TodoCard>) -> impl Responder { let todo_card = TodoCardDb::new(info); let client = get_client().await; match put_todo(&client, todo_card).await { None => HttpResponse::BadRequest().body("Failed to create todo card"), Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .body( serde_json::to_string(&TodoIdResponse::new(id)) .expect("Failed to serialize todo card"), ), } } }
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:
#![allow(unused)] fn main() { // src/todo_api/adapter/mod.rs // ... use actix_web::web; use uuid::Uuid; use crate::{ todo_api_web::model::{State, TodoCard}, todo_api::model::{StateDb, TodoCardDb, TaskDb} }; pub fn todo_json_to_db(card: web::Json<TodoCard>) -> TodoCardDb { TodoCardDb { id: Uuid::new_v4(), title: card.title.clone(), description: card.description.clone(), owner: card.owner, tasks: card .tasks .iter() .map(|t| TaskDb { is_done: t.is_done, title: t.title.clone(), }) .collect::<Vec<TaskDb>>(), state: match card.state { State::Doing => StateDb::Doing, State::Done => StateDb::Done, State::Todo => StateDb::Todo, }, } } }
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:
#![allow(unused)] fn main() { // ... #[derive(Debug, Clone)] pub struct TaskDb { pub is_done: bool, pub title: String, } #[derive(Debug, Clone)] pub enum StateDb { Todo, Doing, Done, } #[derive(Debug, Clone)] pub struct TodoCardDb { pub id: Uuid, pub title: String, pub description: String, pub owner: Uuid, pub tasks: Vec<TaskDb>, pub state: StateDb, } impl TodoCardDb { #[allow(dead_code)] pub fn get_id(self) -> Uuid { self.id } } // ... }
Também precisamos mudar o controller para utilizar nossa nova função:
#![allow(unused)] fn main() { use actix_web::{HttpResponse, web, Responder}; use crate::{ todo_api::{ db::todo::put_todo, adapter }, todo_api_web::model::{TodoCard, TodoIdResponse} }; pub async fn create_todo(info: web::Json<TodoCard>) -> impl Responder { let todo_card = adapter::todo_json_to_db(info); match put_todo(todo_card) { None => HttpResponse::BadRequest().body("Failed to create todo card"), Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoIdResponse::new(id)).expect("Failed to serialize todo card")) } } }
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
:
#![allow(unused)] fn main() { impl Into<HashMap<String, AttributeValue>> for TodoCardDb { fn into(self) -> HashMap<String, AttributeValue> { let mut todo_card = HashMap::new(); todo_card.insert("id".to_string(), val!(S => self.id.to_string())); todo_card.insert("title".to_string(), val!(S => self.title)); todo_card.insert("description".to_string(), val!(S => self.description)); todo_card.insert("owner".to_string(), val!(S => self.owner.to_string())); todo_card.insert("state".to_string(), val!(S => self.state.to_string())); todo_card.insert("tasks".to_string(), val!(L => self.tasks.into_iter().map(|t| t.to_db_val()).collect::<Vec<AttributeValue>>())); todo_card } } impl TaskDb { fn to_db_val(self) -> AttributeValue { let mut tasks_hash = HashMap::new(); tasks_hash.insert("title".to_string(), val!(S => self.title.clone())); tasks_hash.insert("is_done".to_string(), val!(B => self.is_done)); val!(M => tasks_hash) } } }
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
:
#![allow(unused)] fn main() { #[cfg(test)] mod test { use std::collections::HashMap; use super::*; use crate::{ todo_api::model::{StateDb, TaskDb, TodoCardDb}, todo_api_web::model::todo::{State, Task, TodoCard}, }; use actix_web::web::Json; #[test] fn converts_json_to_db() { let id = uuid::Uuid::new_v4(); let owner = uuid::Uuid::new_v4(); let json = Json(TodoCard { title: "title".to_string(), description: "description".to_string(), owner: owner, state: State::Done, tasks: vec![Task { is_done: true, title: "title".to_string(), }], }); let expected = TodoCardDb { id: id, title: "title".to_string(), description: "description".to_string(), owner: owner, state: StateDb::Done, tasks: vec![TaskDb { is_done: true, title: "title".to_string(), }], }; assert_eq!(todo_json_to_db(json, id), expected); } } }
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
:
#![allow(unused)] fn main() { #[cfg(test)] mod test { use super::*; #[test] fn task_db_to_db_val() { let actual = TaskDb { title: "blob".to_string(), is_done: true, } .to_db_val(); let mut tasks_hash = HashMap::new(); tasks_hash.insert("title".to_string(), val!(S => "blob".to_string())); tasks_hash.insert("is_done".to_string(), val!(B => true)); let expected = val!(M => tasks_hash); assert_eq!(actual, expected); } } }
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:
#![allow(unused)] fn main() { #[test] fn todo_card_db_to_db_val() { let id = uuid::Uuid::new_v4(); let actual: HashMap<String, AttributeValue> = TodoCardDb { id: id, title: "title".to_string(), description: "description".to_string(), owner: id, state: StateDb::Done, tasks: vec![TaskDb { is_done: true, title: "title".to_string(), }], } .into(); let mut expected = HashMap::new(); expected.insert("id".to_string(), val!(S => id.to_string())); expected.insert("title".to_string(), val!(S => "title".to_string())); expected.insert( "description".to_string(), val!(S => "description".to_string()), ); expected.insert("owner".to_string(), val!(S => id.to_string())); expected.insert("state".to_string(), val!(S => StateDb::Done.to_string())); expected.insert( "tasks".to_string(), val!(L => vec![TaskDb {is_done: true, title: "title".to_string()}.to_db_val()]), ); assert_eq!(actual, expected); } }
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:
#![allow(unused)] fn main() { #[actix_web::test] async fn valid_todo_post() { let mut app = test::init_service(App::new().configure(app_routes)).await; let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); } }
É 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:
#![allow(unused)] fn main() { #[cfg(test)] mod create_todo { use crate::todo_api_web::{ model::TodoIdResponse, routes::app_routes }; use actix_web::{ test, App, }; use serde_json::from_str; fn post_todo() -> String { // ... } #[actix_web::test] async fn valid_todo_post() { let mut app = test::init_service(App::new().configure(app_routes)).await; let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); } } }
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":
#![allow(unused)] fn main() { // ... #[cfg(not(test))] pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { use crate::todo_api::db::helpers::TODO_CARD_TABLE; match client .put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => Some(todo_card.id), Err(e) => { println!("{:?}", e); None } } } #[cfg(test)] pub async fn put_todo(_client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { Some(todo_card.id) } }
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:
#![allow(unused)] fn main() { use crate::todo_api::model::TodoCardDb; use aws_sdk_dynamodb::Client; #[cfg(feature = "dynamo")] pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { use crate::todo_api::db::helpers::TODO_CARD_TABLE; match client .put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => Some(todo_card.id), Err(e) => { println!("{:?}", e); None } } } #[cfg(not(feature = "dynamo"))] pub async fn put_todo(_client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { Some(todo_card.id) } }
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
:
#![allow(unused)] fn main() { use std::fs::File; use std::io::Read; pub fn read_json(file: &str) -> String { let path = String::from("dev-resources/") + file; let mut file = File::open(&path).unwrap(); let mut data = String::new(); file.read_to_string(&mut data).unwrap(); data } }
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
:
#![allow(unused)] fn main() { mod create_todo { // ... use crate::helpers::read_json; #[actix_web::test] async fn valid_todo_post() { ... let req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; let body = resp.into_body(); let bytes = body::to_bytes(body).await.unwrap(); let id = from_str::<TodoIdResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert!(uuid::Uuid::parse_str(&id.get_id()).is_ok()); } } }
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.
Obtendo todas as Todo Cards inseridas
Existem muitas abordagens para como vamos adicionar um novo endpoint no nosso sistema, mas a abordagem que eu gostaria de tratar aqui é a de começar de cima para baixo, ou seja, criamos um endpoint GET
que lista todas as TodoCard
e nos retorna elas no formato Json. Dessa vez vamos começar escrevendo um teste para este novo endpoint:
#![allow(unused)] fn main() { mod read_all_todos { use todo_server::todo_api_web::{ routes::app_routes }; use actix_web::{ test, App, http::StatusCode, }; #[actix_web::test] async fn test_todo_index_ok() { let mut app = test::init_service(App::new().configure(app_routes)).await; let req = test::TestRequest::get().uri("/api/index").to_request(); let resp = test::call_service(&mut app, req).await; assert_eq!(resp.status(), StatusCode::OK); } } }
Felizmente, nosso teste falha retornanto um NOT_FOUND
e nos obriga a implementar a nova rota, index
em src/todo_api_web/routes.rs
:
#![allow(unused)] fn main() { pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("") .service(ping) .service(readiness) .service(create_todo) .service(show_all_todo) .default_service(web::to(|| HttpResponse::NotFound())), ); } }
Note que agora estamos utilizando uma nova função controller chamada de show_all_todo
, ela precisa ser incorporada no escopo da função, fazemos isso através de use crate::todo_api_web::controller::todo::show_all_todo
e recebemos um aviso de que ela não existe, assim devemos implementá-la no módulo src/todo_api_web/controller/todo.rs
:
#![allow(unused)] fn main() { #[get("/api/index")] pub async fn show_all_todo() -> impl Responder { HttpResponse::Ok() } }
Como nosso teste checa apenas o retorno do status 200
, isso é suficiente. Nosso próximo passo é implementar um teste um pouco mais robusto. Esse teste consiste em garantir que o JSON recebido possua um vetor de tamanho 1 após um post em api/create
ser enviado:
#![allow(unused)] fn main() { mod read_all_todos { use serde_json::from_str; use todo_server::todo_api_web::{model::todo::TodoCardsResponse, routes::app_routes}; use actix_web::{body, http::StatusCode, test, App}; use crate::helpers::read_json; #[actix_web::test] async fn test_todo_index_ok() { // ... } #[actix_web::test] async fn test_todo_cards_count() { let mut app = test::init_service(App::new().configure(app_routes)).await; let post_req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let _ = test::call_service(&mut app, post_req).await; let get_req = test::TestRequest::get().uri("/api/index").to_request(); let resp_body = test::call_service(&mut app, get_req).await.into_body(); let bytes = body::to_bytes(resp_body).await.unwrap(); let todo_cards = from_str::<TodoCardsResponse>(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert_eq!(todo_cards.cards.len(), 1); } } }
Para fazer isso vamos criar uma struct serializável para o formato Json. Essa struct se encontrará em sr/todo_api_web/model/mod.rs
e se chamará TodoCardsResponse
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] pub struct TodoCardsResponse { pub cards: Vec<String> } }
Note que no momento não precisamos nos preocupar com o tipo de resposta, somente com a struct e seus campos. Agora precisamos fazer nosso controller retornar um vetor com uma String:
#![allow(unused)] fn main() { //src/todo_api_web/controller/todo.rs #[get("/api/index")] pub async fn show_all_todo() -> impl Responder { HttpResponse::Ok().content_type(ContentType::json()).body( serde_json::to_string(&TodoCardsResponse { cards: vec![String::from("test")], }) .expect("Failed to serialize todo cards") ) } }
Com este teste pronto, nosso próximo teste fica bastante simples, pois agora precisamos fazer um teste quase igual, mas que garanta que o retorno seja um TodoCard
com as informações que postamos. Note que como este teste conterá um mock da resposta do banco de dados, podemos simplesmente adicionar um Uuid
pré-determinado no mock. Vou criar uma função de teste, no módulo de helpers
que retorna um vetor com uma TodoCard
, mock_get_todos
.
Note que
TodoCard
não possui um id, assim temos duas opções: a primeira é criar umTodoCardResponse
, que contém um Id e a segunda é modificarmos aTodoCard
para conter um campoid: Option<Uuid>
. Nós vamos seguir a segunda abordagem, cuja única mudança será adicionarid: None,
no testeconverts_json_to_db
encontrado emsrc/todo_api/adapter/mod.rs
.
#![allow(unused)] fn main() { // ... use todo_server::todo_api_web::model::todo::{State, Task, TodoCard}; // ... pub fn mock_get_todos() -> Vec<TodoCard> { vec![TodoCard { id: Some(uuid::Uuid::from_str("be75c4d8-5241-4f1c-8e85-ff380c041664").unwrap()), title: String::from("This is a card"), description: String::from("This is the description of the card"), owner: uuid::Uuid::parse_str("ae75c4d8-5241-4f1c-8e85-ff380c041442").unwrap(), tasks: vec![ Task { title: String::from("title 1"), is_done: true, }, Task { title: String::from("title 2"), is_done: true, }, Task { title: String::from("title 3"), is_done: false, }, ], state: State::Doing, }] } }
Com nossa função implementada, podemos criar o novo cenário de teste no submódulo read_all_todos
:
#![allow(unused)] fn main() { #[actix_web::test] async fn test_todo_cards_with_value() { let mut app = test::init_service(App::new().configure(app_routes)).await; let post_req = test::TestRequest::post() .uri("/api/create") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let _ = test::call_service(&mut app, post_req).await; let req = test::TestRequest::with_uri("/api/index").to_request(); let resp_body = test::call_service(&mut app, req).await.into_body(); let bytes = body::to_bytes(resp_body).await.unwrap(); let todo_cards: TodoCardsResponse = from_str(&String::from_utf8(bytes.to_vec()).unwrap()).unwrap(); assert_eq!(todo_cards.cards, mock_get_todos()); } }
Veja que agora os tipos de todo_cards.cards, mock_get_todos()
são incompatíveis, assim, devemos modificar a a struct TodoCardsResponse
para:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, PartialEq)] pub struct TodoCardsResponse { pub cards: Vec<TodoCard>, } }
Também é necessário, para fins de teste, implementarmos a trait PartialEq
para todas as structs, e enums, derivadas de TodoCardsResponse
. Com essa mudança, precisamos modificar a lógica do nosso controller já que agora é necessário que ele busque TodoCard
s no banco. Faremos isso pela função get_todos
, que retornará Vec<TodoCard>
. Caso o match
retorne, não podemos enviar um erro 500
:
#![allow(unused)] fn main() { #[get("/api/index")] pub async fn show_all_todo() -> impl Responder { let client = get_client().await; let resp = get_todos(&client).await; match resp { None => HttpResponse::InternalServerError().body("Failed to read todo cards"), Some(cards) => HttpResponse::Ok() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoCardsResponse { cards }).expect(ERROR_SERIALIZE)), } } }
Agora precisamos implementar a função get_todos
, mas antes vamos implementar a versão de teste (feature = dynamo
) da função em src/todo_api/db/todo.rs
:
#![allow(unused)] fn main() { #[cfg(feature = "dynamo")] pub async fn get_todos(_client: &Client) -> Option<Vec<TodoCard>> { use crate::todo_api_web::model::todo::{State, Task}; Some(vec![TodoCard { id: Some(uuid::Uuid::parse_str("be75c4d8-5241-4f1c-8e85-ff380c041664").unwrap()), title: String::from("This is a card"), description: String::from("This is the description of the card"), owner: uuid::Uuid::parse_str("ae75c4d8-5241-4f1c-8e85-ff380c041442").unwrap(), tasks: vec![ Task { title: String::from("title 1"), is_done: true, }, Task { title: String::from("title 2"), is_done: true, }, Task { title: String::from("title 3"), is_done: false, }, ], state: State::Doing, }]) } }
Ao rodarmos o teste (comente o #[cfg(feature = "dynamo")]
), obtemos sucesso! Agora podemos partir para a leitura da base de dados de fato. Nossa função de get_todos
vai precisar de algumas mudanças como receber um client
e executar um scan
no banco de dados na tabela TODO_CARD_TABLE
. Em caso de Err
no match
retornamos None
e em caso de sucesso precisamos passar a função por um adapter
que transforma um scan_output
em um vetor de TodoCard
:
#![allow(unused)] fn main() { #[cfg(not(feature = "dynamo"))] pub async fn get_todos(client: &Client) -> Option<Vec<TodoCard>> { use crate::todo_api::adapter; let scan_output = client .scan() .table_name(TODO_CARD_TABLE.to_string()) .limit(100i32) .send() .await; match scan_output { Ok(dbitems) => Some(adapter::scanoutput_to_todocards( dbitems.items().unwrap().to_vec(), )), Err(_) => None, } } }
Note que limitamos o scan a 100i32
, isso se deve ao fato de que o Dynamo não vai responder mais de 100 itens. Se você precisar de mais, é importante realizar filtros no scan. Antes de implementarmos o adapter
, seria bom dar uma olhada em como é o resultado do scan:
#![allow(unused)] fn main() { [ { "id": S("7d9b9e38-199e-46e1-939c-80e0b10e1674"), "owner": S("90e700b0-2b9b-4c74-9285-f5fc94764995"), "description": S("descrition"), "title": S("title"), "tasks": L([M({"title": S("blob"), "is_done": Bool(true)})]), "state": S("Done") } ] }
Onde S, L e Bool sao do tipo aws_sdk_dynamodb::model::AttributeValue
. Agora podemos começar a implementar a função scanoutput_to_todocards
e, para isso, vamos escrever o primeiro teste com apenas um items
em src/todo_api/adapters/mod.rs
:
#![allow(unused)] fn main() { #[cfg(test)] mod scan_to_cards { use aws_sdk_dynamodb::model::AttributeValue; use super::scanoutput_to_todocards; use crate::todo_api_web::model::todo::{State, Task, TodoCard}; fn scan_with_one() -> Option<Vec<std::collections::HashMap<String, AttributeValue>>> { let tasks = vec![ ("is_done".to_string(), AttributeValue::Bool(true)), ("title".to_string(), AttributeValue::S("blob".to_string())), ]; let tasks_hash = HashMap::<String, AttributeValue>::from_iter(tasks); let values = vec![ ("title".to_string(), AttributeValue::S("title".to_string())), ( "description".to_string(), AttributeValue::S("description".to_string()), ), ( "owner".to_string(), AttributeValue::S("90e700b0-2b9b-4c74-9285-f5fc94764995".to_string()), ), ( "id".to_string(), AttributeValue::S("646b670c-bb50-45a4-ba08-3ab684bc4e95".to_string()), ), ("state".to_string(), AttributeValue::S("Done".to_string())), ( "tasks".to_string(), AttributeValue::L(vec![AttributeValue::M(tasks_hash)]), ), ]; let hash = HashMap::<String, AttributeValue>::from_iter(values); Some(vec![hash]) } #[test] fn scanoutput_has_one_item() { let scan = scan_with_one(); let todos = vec![TodoCard { title: "title".to_string(), description: "description".to_string(), state: State::Done, id: Some(uuid::Uuid::parse_str("646b670c-bb50-45a4-ba08-3ab684bc4e95").unwrap()), owner: uuid::Uuid::parse_str("90e700b0-2b9b-4c74-9285-f5fc94764995").unwrap(), tasks: vec![Task { is_done: true, title: "blob".to_string(), }], }]; assert_eq!(scanoutput_to_todocards(scan).unwrap(), todos) } } }
Agora podemos finalmente implementar nossa função scanoutput_to_todocards
para o caso de 1 items
:
#![allow(unused)] fn main() { pub fn scanoutput_to_todocards(scan: Vec<HashMap<String, AttributeValue>>) -> Vec<TodoCard> { let item = scan[0].to_owned(); let id = item.get("id").unwrap().as_s().unwrap(); let owner = item.get("owner").unwrap().as_s().unwrap(); let title = item.get("title").unwrap().as_s().unwrap(); let description = item.get("description").unwrap().as_s().unwrap(); let state = item.get("state").unwrap().as_s().unwrap(); let tasks = item.get("tasks").unwrap().as_l().unwrap(); vec![TodoCard { id: Some(uuid::Uuid::parse_str(id).unwrap()), owner: uuid::Uuid::parse_str(owner).unwrap(), title: title.to_string(), description: description.to_string(), state: State::from(state), tasks: tasks .iter() .map(|t| Task { title: t .as_m() .unwrap() .get("title") .unwrap() .as_s() .unwrap() .to_string(), is_done: *t.as_m().unwrap().get("is_done").unwrap().as_bool().unwrap(), }) .collect::<Vec<Task>>(), }] } }
Em scanoutput_to_todocards
, estamos navegando por dentro dos tipos de AttributeValue
e, quando o tipo é um HashMap
, utilizamos get
. Agora podemos testar o caso para um scan
com dois conjuntos de AttributeValue
. Para isso, vamos isolar a criação dos HashMap
em scan_with_one
:
#![allow(unused)] fn main() { fn attr_values() -> HashMap<String, AttributeValue> { let mut tasks_hash = HashMap::new(); tasks_hash.insert("is_done".to_string(), AttributeValue::Bool(true)); tasks_hash.insert("title".to_string(), AttributeValue::S("blob".to_string())); let mut hash = HashMap::new(); hash.insert("title".to_string(), AttributeValue::S("title".to_string())); hash.insert( "description".to_string(), AttributeValue::S("description".to_string()), ); hash.insert( "owner".to_string(), AttributeValue::S("90e700b0-2b9b-4c74-9285-f5fc94764995".to_string()), ); hash.insert( "id".to_string(), AttributeValue::S("646b670c-bb50-45a4-ba08-3ab684bc4e95".to_string()), ); hash.insert("state".to_string(), AttributeValue::S("Done".to_string())); hash.insert( "tasks".to_string(), AttributeValue::L(vec![AttributeValue::M(tasks_hash)]), ); hash } }
Assim a função scan_with_one
fica:
#![allow(unused)] fn main() { fn scan_with_one() -> ScanOutput { let hash = attr_values(); let mut output = ScanOutput::builder().build(); output.consumed_capacity = None; output.count = 1; output.items = Some(vec![hash]); output.scanned_count = 1; output.last_evaluated_key = None; output } }
E podemos fazer a scan_with_two
ser:
#![allow(unused)] fn main() { fn scan_with_two() -> ScanOutput { let hash = attr_values(); let mut output = ScanOutput::builder().build(); output.consumed_capacity = None; output.count = 2; output.items = Some(vec![hash.clone(), hash]); output.scanned_count = 2; output.last_evaluated_key = None; output } }
E assim já implementamos o seguinte teste (lembre-se de adicionar a trait Clone
a TodoCard
e seus derivados):
#![allow(unused)] fn main() { #[test] fn scanoutput_has_two_items() { let scan = scan_with_two(); let todo = TodoCard { title: "title".to_string(), description: "description".to_string(), state: State::Done, id: Some(uuid::Uuid::parse_str("646b670c-bb50-45a4-ba08-3ab684bc4e95").unwrap()), owner: uuid::Uuid::parse_str("90e700b0-2b9b-4c74-9285-f5fc94764995").unwrap(), tasks: vec![Task { is_done: true, title: "blob".to_string(), }], }; let todos = vec![todo.clone(), todo]; assert_eq!(scanoutput_to_todocards(scan).unwrap(), todos) } }
Nosso teste falha e agora nos permite modificar a função scanoutput_to_todocards
para retornar um vetor com todos os TodoCard
s contidos em um scan output:
#![allow(unused)] fn main() { pub fn scanoutput_to_todocards(output: ScanOutput) -> Option<Vec<TodoCard>> { Some( output .items()? .into_iter() .map(|item| { let id = item.get("id").unwrap().as_s().unwrap(); let owner = item.get("owner").unwrap().as_s().unwrap(); let title = item.get("title").unwrap().as_s().unwrap(); let description = item.get("description").unwrap().as_s().unwrap(); let state = item.get("state").unwrap().as_s().unwrap(); let tasks = item.get("tasks").unwrap().as_l().unwrap(); TodoCard { id: Some(uuid::Uuid::parse_str(id).unwrap()), owner: uuid::Uuid::parse_str(owner).unwrap(), title: title.to_string(), description: description.to_string(), state: State::from(state), tasks: tasks .iter() .map(|t| Task { title: t .as_m() .unwrap() .get("title") .unwrap() .as_s() .unwrap() .to_string(), is_done: *t.as_m().unwrap().get("is_done").unwrap().as_bool().unwrap(), }) .collect::<Vec<Task>>(), } }) .collect(), ) } }
A mudança que fizemos é bastante simples. Ela simplesmente consiste em transformar a variável item
em um argumento da closure de map
. Dessa forma, scan vira um iterável com scan.items.unwrap().into_iter()
e, depois do map
, colecionamos todos os valores com .collect::<Vec<TodoCard>>()
. Pronto, adapter
feito. Agora podemos utilizar esse adapter
na função get_todos
. Para testar a mudança, podemos executar a aplicação novamente e testar:
No próximo capítulo, vamos parar um pouco com a criação de endpoints e entender melhor como tornar nosso serviço mais viável para produção
Tornando nosso serviço mais realístico
Agora vamos aplicar uma série de mudanças em nosso servidor para deixá-lo mais robusto. Algumas dessas mudanças incluem sistemas de logs, conteinerizar a aplicação, tornar ela fault tolerante, headers padrões e mais. Para isso, vamos começar com o mais simples e indispensável, o sistema de logs.
Aplicando logs
O primeiro passo para começarmos a entender logs em Rust é darmos uma olhada na crate responsável por isso. A crate que vamos utilizar é a log = "0.4.8"
, que implementa sua lógica de logs de acordo com a ideia de que um log consiste em um alvo
, um nível
e um corpo
. O alvo é uma string que define o caminho do módulo no qual o requerimento do log é necessário. O nível é a severidade do log, error
, warn
, info
, debug
e trace
, e o corpo é o conteúdo que o log apresenta.
A crate que vamos utilizar nos disponibiliza cinco macros para isso: error!, warn!, info!, debug!, trace!
, dentre as quais error
é a mais severa e trace
a menos severa. As macros funcionam de forma muito similar ao println!
, assim a forma de utilizá-las é bastante intuitiva. Outra questão importante é que o sistema de logs deve ser inicializado apenas uma vez por outra crate, a mais comum delas é a env_logger = "0.9.0"
. Um exemplo rápido de como ficaria a combinação dessas duas é:
#[macro_use] extern crate log; fn main() { env_logger::init(); info!("starting up"); // ... }
Inicializando o sistema de Logs
Para inicializar nosso sistema de logs, precisamos adicionar a crate env_logger
ao nosso [dependencies]
do Cargo.toml
, o env_logger = "0.9.0"
. Com a crate disponível, podemos importar o env_logger
para o contexto do arquivo main.rs
com use env_logger;
e inicializá-lo com env_logger::init()
conforme o código a seguir:
// ... use env_logger; #[actix_rt::main] async fn main() -> std::io::Result<()> { env_logger::init(); // ... }
Com isso o código parece compilar, mas não conseguimos ver logs no console quando executamos um curl
. Isso se deve ao fato de que precisamos informar ao actix_web
que queremos que logs de algum nível sejam disponibilizados. Para isso, devemos incluir a linha std::env::set_var("RUST_LOG", "actix_web=info");
antes de env_logger::init();
na função main
para habilitar logs de error
a info
. Além disso, precisamos disponibilizar o middleware Logger
com a forma como queremos o log, note que o middleware pertence à crate actix_web
em use actix_web::middleware::Logger;
:
// ... use actix_web::middleware::Logger; use env_logger; // ... #[actix_web::main] async fn main() -> std::io::Result<()> { std::env::set_var("RUST_LOG", "actix_web=info"); env_logger::init(); create_table().await; HttpServer::new(|| { App::new() .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D")) .configure(app_routes) .default_service(web::to(|| HttpResponse::NotFound())) }) .workers(num_cpus::get() - 2) .bind(("localhost", 4004)) .unwrap() .run() .await }
Se fizermos um POST curl
agora no endpoint /api/create
vamos ver o seguinte log no terminal aonde o servidor está rodando:
[2020-02-08T01:41:32Z INFO actix_web::middleware::logger] IP:127.0.0.1:54089 DATETIME:2020-02-07T22:41:32-03:00 REQUEST:"POST /api/create HTTP/1.1" STATUS: 201 DURATION:33.976000
Note que o formato após os colchetes [...]
é igual ao que definimos no middleware de Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D")
, assim podemos entender alguns dos parâmetros que estamos passando:
%a
é o IP do request.%t
é o DateTime do request.%r
é o método (POST
no caso) seguido do endpoint (/api/create
) e o protocolo usado.%s
é o status de retorno do request.%D
é a duração total do request, em milisegundos.
Algumas outras variáveis disponíveis nesse middleware são:
%t
horário no qual o request começou a ser processado.%P
o ID do processo filho que serviu o request.%b
tamanho da resposta em bytes (inclui os headers).%T
duração do request em segundos com fração float de.06f
.%{FOO}i
headers[‘FOO’] do request.%{FOO}o
headers[‘FOO’] da response.%{FOO}e
valor da variável de ambienteFOO
,os.environ["FOO"]
. Algumas outras variávels disponíveis neste middleware são:
Adicionando logs
Para adicionar os logs ao nosso código, vamos utilizar duas macros error!
e debug!
. Para isso, precisamos adicionar log = "0.4"
ao nosso [dependencies]
no Cargo.toml
. A função de debug
deverá nos apoiar com resultados no ambiente de desenvolvimento, enquanto a função de error!
será exibir os erros no console. Para isso, usaremos o código use log::{error, debug};
. Um bom local para inicializar é no create_table
, a primeira função que nosso código executa. Para modo debug, utilize a env std::env::set_var("RUST_LOG", "debug");
:
#![allow(unused)] fn main() { use log::{debug, error}; // ... pub async fn create_table() { let client = get_client().await; match client.list_tables().send().await { Ok(list) => { match list.table_names { Some(table_vec) => { if table_vec.len() > 0 { error!("Table already exists and has more then one item"); } else { create_table_input(&client).await } } None => create_table_input(&client).await, }; } Err(_) => { create_table_input(&client).await; } } } async fn create_table_input(client: &Client) { let table_name = TODO_CARD_TABLE.to_string(); let ad = build_attribute_definition(); let ks = build_key_schema(); let pt = build_provisioned_throughput(); match client .create_table() .table_name(table_name) .key_schema(ks) .attribute_definitions(ad) .provisioned_throughput(pt) .send() .await { Ok(output) => { debug!("Table created {:?}", output); } Err(error) => { error!("Could not create table due to error: {:?}", error); } } } }
Outro lugar em que podemos aplicar logs é no arquivo src/todo_api/db/todo.rs
, pois as funções de put
e get
são bastante suscetíveis a erros. Assim podemos modificar o arquivo para:
#![allow(unused)] fn main() { // ... use log::{debug, error}; #[cfg(not(feature = "dynamo"))] pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { match client .put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => { debug!("item created with id {:?}", todo_card.id); Some(todo_card.id) } Err(e) => { error!("error when creating item {:?}", e); None } } } #[cfg(not(feature = "dynamo"))] pub async fn get_todos(client: &Client) -> Option<Vec<TodoCard>> { // ... match scan_output { Ok(dbitems) => { let res = adapter::scanoutput_to_todocards(dbitems)?.to_vec(); debug!("Scanned {:?} todo cards", dbitems); Some(res) } Err(e) => { error!("Could not scan todocards due to error {:?}", e); None } } } }
Note que nos casos de Err
agora estamos logando o motivo com e
. O último passo para este momento é adicionar logs aos controllers em src/todo_api_web/controllers/todo.rs
:
#![allow(unused)] fn main() { use log::{error}; // ... #[post("/api/create")] pub async fn create_todo(info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = get_client().await; match put_todo(&client, todo_card).await { None => { error!("Failed to create todo card"); HttpResponse::BadRequest().body(ERROR_CREATE) } Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoIdResponse::new(id)).expect(ERROR_SERIALIZE)), } } #[get("/api/index")] pub async fn show_all_todo() -> impl Responder { let client = get_client().await; let resp = get_todos(&client).await; match resp { None => { error!("Failed to read todo cards"); HttpResponse::InternalServerError().body(ERROR_READ) } Some(cards) => HttpResponse::Ok() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoCardsResponse { cards }).expect(ERROR_SERIALIZE)), } } }
Note que adicionamos somente a opção de error
já que o None => {...}
é a única resposta que pode conter diversas razões, pelo fato do Some
já estar mapeado em put_todo
e get_todos
.
Incluindo Docker
Como o foco deste livro não é docker e ele não é um requisito para entender o livro, vou mostrar o código e explicar um pouco o que está acontecendo. Assim vamos começar por um Dockerfile
extremamente simples.
FROM rustlang/rust:nightly
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY . /usr/src/app
CMD ["cargo", "build", "-q"]
A primeira diretiva, a FROM
, tem como objetivo definir a imagem base para nosso contêiner. Nesse caso, estamos utilizando uma versão nightly
do Rust, pois a versão stable não era compatível com a versão da minha máquina quando escrevi este livro. Depois disso, temos a diretiva RUN
, que executa algum comando, no nosso caso a criação da pasta /usr/src/app
, e já definimos essa pasta como o diretório que vamos utilizar com WORKDIR
. Depois disso, copiamos todo nosso código para nosso diretório com COPY
e executamos um comando do cargo, o build
, para construir nossa aplicação, cargo build -q
com CMD
. Outra opção de Dockerfile
com otimização para builds repetidos é:
FROM rust:latest
RUN mkdir -p /usr/src/
WORKDIR /usr/src/
RUN USER=root cargo new --bin app
WORKDIR /app
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml
COPY ./tests ./tests
RUN cargo build --release
RUN rm src/*.rs
COPY ./src ./src
CMD ["cargo", "build", "--release"]
O objetivo desse segundo Dockerfile
é diminuir o tempo de execução do contêiner ao cachear as dependências do app e somente atualizar o cache a partir do COPY ./src ./src
.
Com este container pronto, podemos começar a pensar em como utilizar os dois containers (DynamoDB e todo_server
) em conjunto. Faremos isso com docker-compose.yml
:
version: '3.8'
services:
dynamodb-local:
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
image: amazon/dynamodb-local
container_name: dynamodb-local
ports:
- "8000:8000"
volumes:
- "./docker/dynamodb:/home/dynamodblocal/data"
working_dir: /home/dynamodblocal
web:
build:
context: .
dockerfile: Dockerfile
command: cargo run
ports:
- "4000:4000"
depends_on:
- "dynamodb-local"
links:
- "dynamodb-local"
environment:
# Since we are using dynamodb local, the IAM authentication mechanism is not used at all.
# That is, whichever credentials you provide, it will be accepted
AWS_ACCESS_KEY_ID: 'MYID'
AWS_SECRET_ACCESS_KEY: 'MYSECRET'
AWS_REGION: 'us-east-1'
DYNAMODB_ENDPOINT: 'dynamodb-local'
Nosso docker-compose
precisa de duas chaves principais: version
, que corresponde à versão do compose, services
, que corresponde aos contêineres que vamos rodar. Em services
, precisamos declarar dois contêineres web
, os quais conterão nossa aplicação e o contêiner dynamodb
, que conterá a imagem do DynamoDB e veio (desse tutorial)[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html]. O contêiner dynamodb
possui as seguintes chaves:
container_name
: é o nome do contêiner, no nosso casodynamodb
.image
: a fonte da imagem que estamos utilizando, no caso do DynamoDB éamazon/dynamodb-local
.ports
: o mapeamento de portas de dentro do contêiner para fora,8000:8000
.volumes
: volumes disponíveis para o dynamo utilizar,dynamodata:/home/dynamodblocal/
.working_dir
: diretório no qual o dynamo executará,/home/dynamodblocal/
.command
: para inicializar o dynamo"-jar DynamoDBLocal.jar -sharedDb -dbPath ."
.
Depois disso temos o web
que irá rodar a todo API, que não vou repetir algumas chaves:
build
: o contexto de criação da imagem,context: .
. No caso, estamos passando um dockerfile chamadoDockerfile
dockerfile: Dockerfile
.command
: executamos o comandocargo run
para essa aplicação.environment
: para executar o DynamoDB dessa forma precisamos adicionar algumas variáveis de ambiente para que oclient
configure suas credenciais, de acordo com https://docs.aws.amazon.com/sdk-for-rust/latest/dg/dynamodb-local.html.AWS_ACCESS_KEY_ID=AKIDLOCALSTACK
AWS_SECRET_ACCESS_KEY=localstacksecret
AWS_REGION=us-east-1
DYNAMODB_ENDPOINT=dynamodb-local
Usaremos a variável de ambienteDYNAMODB_ENDPOINT
para saber qual address iremos usar quando inicializarmos o dynamodb client na nossa API. Faremos a seguinte mudança na funçãoget_client
:
#![allow(unused)] fn main() { // src/todo_api/db/helpers.rs pub async fn get_client() -> Client { let config = aws_config::load_from_env().await; let addr = if let Ok(db_endpoint) = std::env::var("DYNAMODB_ENDPOINT") { format!("http://{}:8000", db_endpoint) } else { "http://0.0.0.0:8000".to_string() }; let dynamodb_local_config = aws_sdk_dynamodb::config::Builder::from(&config) .endpoint_resolver(Endpoint::immutable(addr.parse().expect("Invalid URI"))) .build(); Client::from_conf(dynamodb_local_config) } }
depends_on
: define a ordem na qual os serviços devem ser inicializados, assimdynamodb
é inicializado antes deweb
links
: forma legada de fazer com que dois serviços estejam conectados, atualmente bastaria onetworks
, mas coloquei como exemplo. No caso delinks
enetworks
estarem definidos, é preciso que ambos estejam na mesma rede.
Se tivéssemos as configurações de produção, poderíamos criar a feature compose
para utilizar com o docker-compose
. Se executarmos o código agora com docker-compose up --build
e, em seguida, um curl
, tudo voltará a funcionar como antes. Outra coisa que podemos fazer agora é atualizar nosso Makefile para incluir o docker-compose
:
db:
docker run -p 8000:8000 amazon/dynamodb-local
test:
cargo test --features "dynamo"
run-local:
cargo run --features "dynamo"
run:
docker-compose up --build
down:
docker-compose down
Headers padrões
Outro ponto que acredito ser importante é o uso de headers para identificar os requests nos logs. Costumo ver o padrão de um header chamado x-request-id
cujo valor é um uuid
. Para implementarmos esse padrão com o actix, precisamos utilizar um middleware que felizmente a equipe do actix já disponibilizou para nós, o actix_web::middleware::DefaultHeaders
. Para isso, precisamos disponibilizá-lo no escopo com use
e depois passar essa informação para um wrap
. A forma de utilizar esses headers padrões é DefaultHeaders::new().header("X-Version", "0.2")
, isto é, criamos um novo header com DefaultHeaders::new()
e depois chamamos a função header
para adicionar um header com os argumentos-chave e valor do tipo string:
#![allow(unused)] fn main() { // src/main.rs // ... HttpServer::new(|| { App::new() .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new( "IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D", )) .configure(app_routes) }) // ... }
Além disso, precisamos definir o header no Logger
, para isso usamos a chave X-REQUEST-ID:%{x-request-id}o
após a DURATION
, pois somente assim o valor de x-request-id
será logado:
#![allow(unused)] fn main() { // ... HttpServer::new(|| { App::new() .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o")) .configure(app_routes) }) // ... }
Um exemplo de resposta seria:
[2020-02-08T23:10:58Z INFO actix_web::middleware::logger] IP:172.21.0.1:52686 DATETIME:2020-02-08T23:10:58Z REQUEST:"POST /api/create HTTP/1.1" STATUS: 201 DURATION:166.921700 X-REQUEST-ID=bd15de62-1ba6-4d43-89ca-4f89418
Adicionando o cliente ao estado da API
Nosso próximo passo vem de uma necessidade de refactor e preparação para o código futuro. Esse refactor consiste em retirar a declaração de let cliente = client();
de todos os códigos envolvendo banco de dados e passá-los como argumentos. Uma das vantagens disso é caso decidamos ter mais clientes de algum tipo de serviço como outros bancos de dados ou S3. Para fazermos isso, vamos criar uma nova struct chamada Clients
que conterá o campo dynamo
e depois a passaremos como argumento para o HttpServer
via função data
.
Assim, nosso primeiro passo é descrever a o modelo de Clients
em src/todo_api_web/model/http.rs
:
#![allow(unused)] fn main() { use aws_sdk_dynamodb::Client; use crate::todo_api::db::helpers::get_client; #[derive(Clone)] pub struct Clients { pub dynamo: Client, } impl Clients { pub async fn new() -> Self { Self { dynamo: get_client().await, } } } }
Agora podemos utilizar a função app_data
em HttpServer
para passar Clients como argumento. Fazemos isso com Clients::new()
:
// ... use todo_server::{ todo_api::db::helpers::create_table, todo_api_web::{model::http::Clients, routes::app_routes}, }; #[actix_web::main] async fn main() -> Result<(), std::io::Error> { std::env::set_var("RUST_LOG", "actix_web=info"); env_logger::init(); let client = web::Data::new(Clients::new().await); create_table(&client.dynamo.clone()).await; HttpServer::new(move|| { App::new() .app_data(client.clone()) .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o")) .configure(app_routes) }) .workers(num_cpus::get() - 2) .max_connections(30000) .bind(("0.0.0.0", 4000)) .unwrap() .run() .await } // ...
Com isso temos Clients
disponível no nos nossos controllers, para isso adicionamos o estado com state: web::Data<Clients>
:
#![allow(unused)] fn main() { // ... use crate::todo_api_web::model::http::Clients; #[post("/api/create")] pub async fn create_todo(state: web::Data<Clients>, info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = state.dynamo.clone(); //... } #[get("/api/index")] pub async fn show_all_todo(state: web::Data<Clients>) -> impl Responder { let client = state.dynamo.clone(); //... } }
As funcões put_todo
e get_todos
ja esperam um argumento do tipo aws_sdk_dynamodb::Client
então
não será preciso modificar elas.
Feito isso, devemos adicionar o novo client a todos os testes de integração, pois esse argumento é esperado nas funções de controller. Um exemplo seria:
#![allow(unused)] fn main() { #[actix_web::test] async fn test_todo_cards_count() { let client = web::Data::new(Clients::new().await); let mut app = test::init_service(App::new().app_data(client.clone()).configure(app_routes)).await; //... } }
Serializando o Response
Até o momento estávamos utilizando o formato de criação de HttpResponse
da seguinte maneira HttpResponse::Ok().content_type("application/json").body(serde_json::to_string(&struct).expect("Failed to serialize todo cards"))
, mas existe uma forma que pode simplificar nossa vida por nos permitir delegar a chamada de serde_json
. Esse formato substitui o .body(...)
por .json(...)
. A vantagem de se utilizar esse formato é que ele reduz a quantidade de código que nós devemos manter, delegando ao actix essa responsabilidade. Nos capítulos introdutórios do livro, falamos que o actix estava com muita vantagem em relação a outros frameworks nos benchmarks da TechEmpower, porém, no caso de serialização JSON, existem alguns frameworks C/C++ à sua frente, inclusive a crate hyper
. O Objetivo de body
é principalmente enviar mensagens sem dados estruturados ou estruturados em outros formatos como Edn.
Com esse pequeno refactor, nossos controllers de todo
serão modificados para o seguinte formato:
#![allow(unused)] fn main() { // src/todo_web_api/controller/todo.rs // ... #[post("/api/create")] pub async fn create_todo(state: web::Data<Clients>, info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = state.dynamo.clone(); match put_todo(&client, todo_card).await { None => { error!("Failed to create todo card {}", ERROR_CREATE); HttpResponse::BadRequest().body(ERROR_CREATE) } Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .json(TodoIdResponse::new(id)), } } #[get("/api/index")] pub async fn show_all_todo(state: web::Data<Clients>) -> impl Responder { let client = state.dynamo.clone(); let resp = get_todos(&client).await; match resp { None => { error!("Failed to read todo cards"); HttpResponse::InternalServerError().body(ERROR_READ) } Some(cards) => HttpResponse::Ok() .content_type(ContentType::json()) .json(TodoCardsResponse { cards }), } } }
Com isso, nosso código está pronto para receber novos clientes e nós podemos começar a pensar em autenticação.
Autenticação
Criaremos funções do serviço para registrar e para fazer login no nosso serviço. Além disso, implementaremos um middleware que protege nossos endpoints de usuários não autenticados. Para realizar isso vamos utilizar a crate Diesel
para lidar com a base de dados, que será o Postgres. Para isso precisamos seguir alguns passos (também em https://diesel.rs/guides/getting-started):
- Instale a
diesel_cli
, pois este binário ajuda a gerenciar o projeto. Utilizecargo install diesel_cli
para isso. Para compilar odiesel_cli
é preciso ter a liblibpq
, no MacOS podemos fazer isso combrew install postgresql
,brew install libpq
e depoiscargo install diesel_cli --no-default-features --features postgres
para instalar somente o conector depostgres
. - Ter um container disponível
docker run -i --rm --name auth-db -p 5432:5432 -e POSTGRES_USER=auth -e POSTGRES_PASSWORD=secret -d postgres
- Para utilizar o
diesel_cli
executamos o comandodiesel setup
, mas para isso precisamos da url do postgress em um arquivo.env
. Para isso precisamos executarecho DATABASE_URL=postgres://auth:secret@localhost/auth_db > .env
. Agora executamosdiesel setup --migration-dir src/migrations
para estabelecer a conexão. - Depois podemos criar nossas migrações com
diesel migration generate create_auth
, note a pastamigrations
com duas subpastas cada uma contendo umup.sql
e umdown.sql
. - Na segunda pasta vamos criar a tabela
auth_user
emup.sql
:
-- up.sql
CREATE TABLE auth_user (
email VARCHAR(100) NOT NULL PRIMARY KEY,
id UUID NOT NULL,
password VARCHAR(64) NOT NULL, --bcrypt hash
expires_at TIMESTAMP NOT NULL
);
--down.sql
DROP TABLE auth_user;
- Agora basta executar as migrations com
diesel migration run --migration-dir src/migrations
, caso você queira reverter as migrations basta executardiesel migration redo
. Note a criação de um arquivosrc/schema.rs
em nosso projeto:
#![allow(unused)] fn main() { table! { auth_user (email) { email -> Varchar, id -> Uuid, password -> Varchar, expires_at -> Timestamp, } } }
A macro table! gera código baseado no schema da base de dados que representes todas as tabelas e colunas.
- Tipicamente um schema não é criado na mão e sim gerado pelo binário
diesel
. Quando executamosdiesel setup
, um arquivodiesel.toml
é criado para indicar aoDiesel
para manter o arquivosrc/schema.rs
por nós.
Nota sobre Diesel em Produção
Quando em produção você talvez prefira executar suas migrações na inicialização da aplicação. Assim, a crate Diesel disponibiliza a macro
embed_migrations!
, permitindo embedar os scripts de migração como parte final do binário. Para usá-la, basta usar um snippet similar ao abaixo no início de suasmain
e as migrações serão executadas.#![allow(unused)] fn main() { use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations/postgresql"); fn run_migrations(connection: &mut impl MigrationHarness<DB>) -> Result<(), Box<dyn Error + Send + Sync + 'static>> { connection.run_pending_migrations(MIGRATIONS)?; Ok(()) } }
Configurando o Postgres com Rust
Agora podemos começar a evoluir a autenticação do nosso servidor, para isso devemos adicionar algumas crates ao [dependencies]
do Cargo.toml:
actix = "0.11.0"
chrono = { version = "0.4.23", features = ["serde"] }
diesel = {version = "2.0.2", features = ["chrono", "postgres", "r2d2", "uuid"]}
dotenv = "0.15.0"
r2d2 = "0.8.10"
A abordagem que vamos seguir aqui é diferente da apresentada no guia do diesel, consulte bibliografia para obter o link, pois vamos tentar tirar proveito do sistema de actors do actix (caso você queira, é um bom exercício aplicar a mesma estratégia ao Client
). Assim, em nosso módulo src/todo_api/db/helpers.rs
vamos criar uma struct DbExecutor
, com um tipo de conexão de pool, que vai implementar a trait Actor
do actix:
#![allow(unused)] fn main() { use actix::{Actor, SyncContext}; use diesel::pg::PgConnection; use diesel::r2d2::{ConnectionManager, Pool}; pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>); impl Actor for DbExecutor { type Context = SyncContext<Self>; } }
Actors
Actors se comunicam exclusivamente pela troca de mensagens. Assim, o actor que envia a mensagem irá, opcionalmente, esperar pela respostas. Além disso, actors não são referenciados diretamente, mas sim pelos seus endereços. Qualquer tipo no Rust pode se tornar um actor, o único requerimento é que implemente a trait
Actor
.
Depois disso precisamos adicionar a struct DbExecutor
ao nosso Clients
, porém nosso DbExecutor
vai precisar precisar ser envelopado em um Addr<T>
, que corresponde ao endereço do actor:
#![allow(unused)] fn main() { // src/todo_api_web/model/http.rs use actix::prelude::Addr; use crate::todo_api::db::helpers::{client, DbExecutor}; #[derive(Clone)] pub struct Clients { pub dynamo: aws_sdk_dynamodb::Client, pub postgres: Addr<DbExecutor>, } impl Clients { pub fn new(pg: Addr<DbExecutor>) -> Self { Self { dynamo: client(), postgres: pg } } } }
Agora precisamos de uma função que crie o Addr<DbExecutor>
para podemos enviar como argumento ao new
. Essa função se chamara db_executor_address
e estará localizada em src/todo_api/db/helpers.rs
:
#![allow(unused)] fn main() { use actix::{Actor, Addr, SyncContext, SyncArbiter}; // ... use diesel::{ r2d2::{ConnectionManager, Pool}, pg::PgConnection }; use std::env; // ... pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>); impl Actor for DbExecutor { type Context = SyncContext<Self>; } pub fn db_executor_address() -> Addr<DbExecutor> { let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let manager = ConnectionManager::<PgConnection>::new(database_url); let pool = r2d2::Pool::builder() .build(manager) .expect("Failed to create pool."); SyncArbiter::start(4, move || DbExecutor(pool.clone())) } }
Agora podemos modificar a função Clients::new
para que não seja preciso passar Addr<DbExecutor>
como argumento:
#![allow(unused)] fn main() { use crate::todo_api::db::helpers::{client, DbExecutor, db_executor_address}; use actix::prelude::Addr; use aws_sdk_dynamodb::Client; #[derive(Clone)] pub struct Clients { pub dynamo: aws_sdk_dynamodb::Client, pub postgres: Addr<DbExecutor>, } impl Clients { pub fn new() -> Self { Self { dynamo: client(), postgres: db_executor_address(), } } } }
Note que ao executar o código obtemos uma falha, pois DATABASE_URL
não está setada, agora precisamos utilizar as configurações do postgres para o docker-compose
:
# ...
services:
# ...
web:
environment:
# ...
DATABASE_URL: 'postgres://auth:secret@postgres:5432/auth_db'
postgres:
container_name: "postgres"
image: postgres
ports:
- "5432:5432"
environment:
- POSTGRES_USER=auth
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=auth_db
# ...
Para isso precisamos remover nosso env_logger
do nosso código e Cargo.toml. Além disso, a definição da variável de ambiente do log passa para o arquivo .env
:
DATABASE_URL=postgres://auth:secret/auth_db
RUST_LOG=actix_web=info
Agora precisamos executar as migrações no docker compose, para isso vamos utilizar embed_migrations!
migrations como falamos anteriormente. A macro embed_migrations!
está disponível na crate diesel_migrations = "2.0.0"
, adicione ela a seu [dependencies]
do Cargo.toml. E agora precisamos que o código execute a migração. Para isso adicionamos a função run_migrations
em create_table
, no módulo src/todo_api/db/helpers.rs
:
#![allow(unused)] fn main() { use actix::{Actor, Addr, SyncArbiter, SyncContext}; use diesel::{ prelude::*, r2d2::{ConnectionManager, Pool}, }; use diesel_migrations::run_pending_migrations; use log::{debug, error}; use std::env; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/migrations"); // ... pub async fn create_table(client: &Client) { let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let mut pg_conn = PgConnection::establish(&database_url) .expect(&format!("Error connecting to {}", database_url)); run_migrations(&mut pg_conn); match client.list_tables().send().await { Ok(list) => { match list.table_names { Some(table_vec) => { if table_vec.len() > 0 { println!("Error: {:?}", "Table already exists"); } else { create_table_input(&client).await } } None => create_table_input(&client).await, }; } Err(_) => { create_table_input(&client).await; } } } fn run_migrations(pg_conn: &mut PgConnection) { match pg_conn.run_pending_migrations(MIGRATIONS) { Ok(_) => debug!("auth database created"), Err(_) => error!("auth database creation failed"), }; } // ... }
- Cuidado pois esta configuração do docker-compose pode consumir muita memória. Pode ser interessante executar um
docker system prune --volumes
caso seu docker falhe.
Criando o endpoint de cadastro de usuários
Nosso próximo passo é modelar o domínio de autenticação em todo_api
, chamaremos nossa struct de User
e incluiremos no módulo src/todo_api/model/auth.rs
. A primeira coisa que devemos fazer é declarar mod schema
em main.rs
e lib.rs
, e por motivos de agilidade utilizar #[macro_use] extern crate diesel_migrations;
e #[macro_use] extern crate diesel;
para disponibilizar as macros utilizads em schema.rs
. Depois disso, podemos criar nossa struct User
:
#![allow(unused)] fn main() { use crate::schema::*; #[derive(Debug, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = auth_user)] pub struct User { email: String, id: uuid::Uuid, password: String, expires_at: chrono::NaiveDateTime, } }
Utilizamos a linha use crate::schema::*;
para disponibilizar a tabela auth_user
neste contexto para que a "anotação" table_name
funcione. Além disso, aplicamos as macros Queryable, Insertable
para que possamos utilizar nossa struct com o postgres. Agora sabemos que vamos receber 2 argumentos para criar um user, que serão email
e password
, com isso podemos presupor que vamos precisar implementar uma função que gere um tipo User
destes dois argumentos, algo como fn from(email: String, password: String) -> User
. Assim, podemos implementar um teste para esta funcão. Para este teste vamos ter que adicionar a crate regex = "1.3.4"
ao nosso [dev-dependencies]
do Cargo.toml:
#![allow(unused)] fn main() { #[cfg(test)] mod test { use super::*; use regex::Regex; #[test] fn user_is_correctly_created() { let user = User::from(String::from("email"), String::from("password")); let rx = Regex::new("[0-9]{4}-[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1} [0-2]{1}[0-9]{1}:[0-6]{1}[0-9]{1}:[0-6]{1}[0-9]{1}").unwrap(); assert_eq!(user.email, String::from("email")); assert_eq!(user.password, String::from("password")); assert!(uuid::Uuid::parse_str(&user.id.to_string()).is_ok()); assert!(rx.is_match(&format!("{}", user.expires_at.format("%Y-%m-%d %H:%M:%S")))); } } }
Neste teste estamos testando se email
e password
são exatamente como enviamos, se o Uuid
é gerado como Uuid
e se o formato da data está de acordo coma regex "[0-9]{4}-[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1} [0-2]{1}[0-9]{1}:[0-6]{1}[0-9]{1}:[0-6]{1}[0-9]{1}"
. Agora podemos implementar a funcão from
:
#![allow(unused)] fn main() { impl User { pub fn from(email: String, password: String) -> Self { use chrono::{DateTime, Duration, Utc}; let utc: DateTime<Utc> = Utc::now() + Duration::days(1); Self { email: email, id: uuid::Uuid::new_v4(), password: password, expires_at: utc.naive_utc(), } } } }
Note que está funcão começa com use chrono::
, isso se deve ao fato de estas structs ainda não serem necessárias em outras partes do código. Depois disso vemos a linha let utc: DateTime<Utc> = Utc::now() + Duration::days(1);
, que é depois inserida em expires_at
, ela referencia a ideia de que o token vai sobreviver apenas até este período, que é deste instante até mais um dia. Podemos ainda simplificar esta função para extrair o DateTime<Utc>
com one_day_from_now()
:
#![allow(unused)] fn main() { impl User { pub fn from(email: String, password: String) -> Self { let utc = crate::todo_api::db::helpers::one_day_from_now(); Self { email: email, id: uuid::Uuid::new_v4(), password: password, expires_at: utc.naive_utc(), } } } }
Agora one_day_from_now
está definida no módulo src/todo_api/db/helpers.rs
como:
#![allow(unused)] fn main() { use chrono::{DateTime, Duration, Utc}; // ... pub fn one_day_from_now() -> DateTime<Utc> { Utc::now() + Duration::days(1) } }
Adaptando o request para um modelo de banco de dados.
Agora precisamos de um modelo que represente o request HTTP de signup
. Iremos criar o módulo src/todo_api_web/model/auth.rs
com a struct SignUp
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct SignUp { pub email: String, pub password: String, } }
Note que ambos campos são pub
, isso é porque vamos precisar deles no adapter
. Felizmente não podemos guardar o password
como texto em nosso banco de dados, para isso vamos utilizar uma crate chamada bcrypt, adicionando bcrypt = "0.13"
ao nosso [dependencies]
do Cargo.toml (caso você se interesse por criptografia e queira outras opções, sugiro olhar também as crates argonautica
e libreauth
). Faremos está conversão no módulo src/todo_api/adapter/auth.rs
:
#![allow(unused)] fn main() { use crate::todo_api::model::auth::User; use crate::todo_api_web::model::auth::SignUp; use bcrypt::{hash, DEFAULT_COST}; pub fn signup_to_hash_user(su: SignUp) -> User { let hashed_pw = hash(su.password, DEFAULT_COST); User::from(su.email, hashed_pw.unwrap()) } }
Agora importamos duas coisas ao escopo, a função hash
e DEFAULT_COST
. bcrypt
possui 3 principais funções e um padrão de custo, que é DEFAULT_COST
e definido como 12u32
. As funções são:
hash
recebe um password do tipo genéricoP
, no nosso casopassword
do tipoString
e um custo, no casoDEFAULT_COST
.verify
verifica se opassword
enviado é igual ahash
enviada.bcrypt
é similar aohash
, porém o segundo argumento é umsalt
do tipo&[u8]
Quanto ao custo, quanto maior o valor de custo, mais lento o hashing. Existe um benchmark com diferentes custos que apresenta uma relação de custo por velocidade:
- Custo = 4: test bench_cost_4 ... bench: 1,197,414 ns/iter (+/- 112,856)
- Custo = 10: test bench_cost_10 ... bench: 73,629,975 ns/iter (+/- 4,439,106)
- Custo = 12: test bench_cost_default ... bench: 319,749,671 ns/iter (+/- 29,216,326)
- Custo = 14: test bench_cost_14 ... bench: 1,185,802,788 ns/iter (+/- 37,571,986)
Creio que podemos escrever um teste simples para signup_to_hash_user
como:
#![allow(unused)] fn main() { #[cfg(test)] mod test { use super::*; use crate::todo_api_web::model::auth::SignUp; #[test] fn asser_signup_becomes_user() { let email = "my@email.com"; let pass = "this Is a cr4zy p@ssw0rd"; let signup = SignUp { email: String::from(email), password: String::from(pass) }; let user = signup_to_hash_user(signup); user.is_user_valid(email, pass) } } }
Este teste que criamos funciona da seguinte maneira, ele cria um SignUp
com valores fixos e passa para a função adapter signup_to_hash_user
, depois disso validamos que os inputs passados para SignUp
formam um User
válido com .is_user_valid(email, pass)
. Agora a função is_user_valid
é um pouco diferente do que já vimos, pois ela é uma função que compila apenas para testes com #[cfg(test)]
, e possui asserts
internos. Os asserts foram movidos para o arquivo de src/todo_api/model/auth.rs
pois os campos de User
são privados:
#![allow(unused)] fn main() { impl User { // ... #[cfg(test)] pub fn is_user_valid(self, email: &str, password: &str) { use bcrypt::verify; assert_eq!(self.email, String::from(email)); assert!(verify(password, &self.password).unwrap()); assert!(self.id.to_string().len() == 36); } } }
Com está função de teste podemos testar os valores internos de 1 User, comparando se o email
interno é igual ao email
recebido, se a string de password
é um caso possível para user.password
e se o id
tem o tamanho de um Uuid
do tipo v4
.
Comunicando SignUp
com o banco
Agora temos SignUp
e podemos converter em User
com a função adapter signup_to_hash_user
, falta inserir User
no banco de dados para podermos criar nosso endpoint de signup
. O primeiro passo para isso é criarmos a função insert_new_user
em src/todo_api/db/auth.rs
:
#![allow(unused)] fn main() { use diesel::{PgConnection, prelude::*}; use crate::todo_api::model::auth::User; use crate::todo_api::db::error::DbError; pub fn insert_new_user(user: User, conn: &mut PgConnection) -> Result<(),DbError>{ use crate::schema::auth_user::dsl::*; let new_user = diesel::insert_into(auth_user) .values(&user) .execute(conn); match new_user { Ok(_) => Ok(()), Err(_) => Err(DbError::UserNotCreated) } } }
Na versão que estamos usando da lib diesel, connection tem que ser mutáveis
Vamos explicar o que se passa neste módulo. Precisamos de PgConnection
para disponibilizar uma conexão a nosso execute
, que é o executor da nossa query. prelude::*
serve para disponibilizar funções como execute
. Além disso, criamos um módulo para conter todos nossos erros de banco de dados em src/todo_api/db/error.rs
, que possui o enum DbError
que veremos a seguir. Depois disso, temos use crate::schema::auth_user::dsl::*;
, que disponibiliza a table auth_user
para utilizar em insert_into(auth_user)
. Temos, também, diesel::insert_into(auth_user).values(&user).execute(conn)
que insere na tabale auth_user
com insert_into
, define seus valores de inserção com values
, recebendo a struct User
, e executa a query com execute
, ou com get_result
caso você queria algum dos valores existente no banco após a inserção. Por último aplicamos um match
ao tipo Result
de new_user
, caso o tipo seja Ok
retornamos um sucesso, caso o tipo seja Err
, retornamos o erro DbError::UserNotCreated
. Agora vamos para a implementação da trait Error
em DbError
:
#![allow(unused)] fn main() { use std::error::Error; #[derive(Debug)] pub enum DbError { UserNotCreated } impl std::fmt::Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DbError::UserNotCreated => write!(f, "User could not be created") } } } impl Error for DbError { fn description(&self) -> &str { match self { DbError::UserNotCreated => "User could not be created, check for possible conflits" } } fn cause(&self) -> Option<&dyn Error> { Some(self) } } }
Por enquanto temos somente um item em DbError
, UserNotCreated
. E agora precisamos implementar a trait std::error::Error
para que nosso enum possa ser utilizado como um tipo erro em nosso projeto. A trait Error
exige duas funções description
e cause
. cause
é importante caso nosso erro receba algum argumento, pois nos permite retornar coisas específica com &dyn
, já description é o texto que veremos quando, por exemplo, logarmos o erro. Além disso, notamos que a trait Error
exige a implementação de std::fmt::Display
, que corresponde ao to_string()
. Agora podemos criar uma versão de teste desta função da seguinte forma:
#![allow(unused)] fn main() { // src/todo_api/db/auth.rs #[cfg(test)] mod test { use diesel::debug_query; use diesel::pg::Pg; use crate::schema::auth_user::dsl::*; #[test] fn insert_user_matches_url() { use crate::todo_api::model::auth::User; let user = User::from(String::from("email@my.com"), String::from("pswd")); let query = diesel::insert_into(auth_user).values(&user); let sql = String::from("INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\") VALUES ($1, $2, $3, $4) \ -- binds: [\"email@my.com\", ") + &user.id.to_string() + ", \"pswd\", " + &format!("{:?}", user.expires_at) +"]"; assert_eq!(sql, debug_query::<Pg, _>(&query).to_string()); } } }
Eu acredito que testes que comparam strings caractere a caractere é uma péssima ideia, mas a comunidade Diesel parece gostar, quando formos testar a nível de integração usaremos outra estratégia. Notamos a presença de debug_query
e Pg
, ambos são responsáveis por nos permitir debugar a query que que montamos com diesel::insert_into(auth_user).values(&user)
sem executá-la. Depois fazemos um assert de nossa query com let sql = String::from("INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\") VALUES ($1, $2, $3, $4) \ -- binds: [\"email@my.com\", ") + &user.id.to_string() + " \"pswd\", " + &format!("{:?}", user.expires_at) +"]";
que é uma string com o valor que esperamos para o sql. Note que estamos acessando os campos id
e expires_at
neste teste, para fazermos isso mudamos a implementação de User
um pouco:
#![allow(unused)] fn main() { // src/todo_api/model/auth.rs #[derive(Debug, Serialize, Deserialize, Queryable, Insertable)] #[table_name = "auth_user"] pub struct User { #[cfg(test)] pub email: String, #[cfg(not(test))] email: String, #[cfg(test)] pub id: uuid::Uuid, #[cfg(not(test))] id: uuid::Uuid, #[cfg(test)] pub password: String, #[cfg(not(test))] password: String, #[cfg(test)] pub expires_at: chrono::NaiveDateTime, #[cfg(not(test))] expires_at: chrono::NaiveDateTime, } // ... }
Fizemos com que os campos sejam públicos para teste e privado para todos os outros ambientes. Agora implementaremos o endpoint em si.
Formatando
expires_at
e a crate ChronoMuitas vezes é complicado acertar diretamente qual o formato que você quer que sua string contendo a data tenha, por isso, aqui está um bom referencial para o tipo
UTC
do Chrono:
assert_eq!(dt.format("%Y-%m-%d %H:%M:%S").to_string(), "2014-11-28 12:00:09");
assert_eq!(dt.format("%a %b %e %T %Y").to_string(), "Fri Nov 28 12:00:09 2014");
assert_eq!(dt.format("%a %b %e %T %Y").to_string(), dt.format("%c").to_string());
assert_eq!(dt.to_string(), "2014-11-28 12:00:09 UTC");
assert_eq!(dt.to_rfc2822(), "Fri, 28 Nov 2014 12:00:09 +0000");
assert_eq!(dt.to_rfc3339(), "2014-11-28T12:00:09+00:00");
assert_eq!(format!("{:?}", dt), "2014-11-28T12:00:09Z");
Definindo o endpoint
Nosso primeiro passo é definir um teste para este endpoint e a partir deste teste podemos implementar a solução:
#![allow(unused)] fn main() { mod auth { use actix_web::{ test, App, http::StatusCode, }; use actix_service::Service; use todo_server::todo_api_web::{ routes::app_routes }; use dotenv::dotenv; use crate::helpers::{read_json}; use todo_server::todo_api_web::model::http::Clients; #[actix_rt::test] async fn signup_returns_created_status() { dotenv().ok(); let app = test::init_service(App::new().app_data(Clients::new()).configure(app_routes)).await; let signup_req = test::TestRequest::post() .uri("/auth/signup") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("signup.json").as_bytes().to_owned()) .to_request(); let resp = app.call(signup_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } } }
Nosso teste define signup_req
como o request que vamos enviar para app.call(signup_req)
, mas este request possui uma nova URI
"/auth/signup"
e um novo arquivo Json com o conteúdo de post "signup.json"
. Precisamos então definir este arquivo em dev-resources
e implementar a rota. Note que o assert
neste caso é somente para verificar se o usuário foi criado. O arquivo signup.json
possui o seguinte formato:
{
"email": "my@email.com",
"password": "My cr4azy p@ssw0rd"
}
Reconfigurando os testes
Ao executarmos os testes agora termos um retorno de
InternalServerError
, isso se deve ao fato de queDbExecutor
não consegue encontrar aDATABASE_URL
que está associada ao banco. Isso se deve pelo fato de estarmos utilizando uma url diferente para o docker compose e outra para testes locais. Além disso, Postgres é mais complicado que DynamoDB no sentido de que o cliente realmente tenta estabelecer uma conexão para iniciar e para isso precisamos de uma base de dados falsa executando. Além disso, essa base deve estar migrada para as queries ocorrerem sem problemas. Assim, nossomake test
fica mais complicado:db: docker run -i --rm --name auth-db -p 5432:5432 -e POSTGRES_USER=auth -e POSTGRES_PASSWORD=secret -d postgres test: db diesel setup --migration-dir src/migrations diesel migration run --migration-dir src/migrations cargo test --features "dbtest" diesel migration redo clear-db: docker ps -a | awk '{ print $1,$2 }' | grep postgres | awk '{print $1 }' | xargs -I {} docker stop {}
Note que a partir da agora para rodar os testes precisamos de um container postgres configurado (
setup
emigration run
) para podermos executar nossos testes sem quebrar oDbExecutor
. Pode ser necessário adicionar umsleep 3
depois detest: db
para dar tempo do container executar. A última linha iniciada emdocker ps
serve para remover o container que executamos. Além disso,DbExecutor
depende dedotenv
, assim, devemos incluirdotenv().ok()
antes de executar os testes e incorporar odotenv
no escopo comuse dotenv::dotenv;
.
Agora que configuramos o teste, precisamos fazer a configuração de rotas, app_routes
passa a ser:
#![allow(unused)] fn main() { use crate::todo_api_web::controller::{ // ... auth::{signup_user} }; use actix_web::{web, HttpResponse}; pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("") .service(ping) .service(readiness) .service(create_todo) .service(show_all_todo) .service( web::scope("/auth") .service(signup_user) ) .default_service(web::to(|| HttpResponse::NotFound())), ); } }
Ainda precisamos implementar o controller signup_user
no módulo de controllers auth
, porém ao contrário do método que vinhamos utilizando para inserir User
no nosso banco de dados, que é o default do Diesel, vamos tirar proveito do sistema de actors do Actix e implementar um handler para permitir a comunição entre nosso serviço e o Diesel
por mensagens. Assim, devemos mudar nossa struct SignUp
para que ela implemente as traits Handler
e Message
, que vão nos permitir enviar mensagens para o DbExecutor
:
#![allow(unused)] fn main() { use actix::prelude::*; use crate::todo_api::{ db::{ error::DbError, helpers::DbExecutor, }, adapter }; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct SignUp { pub email: String, pub password: String, } impl Message for SignUp { type Result = Result<(), DbError>; } impl Handler<SignUp> for DbExecutor { type Result = Result<(), DbError>; fn handle(&mut self, msg: SignUp, _: &mut Self::Context) -> Self::Result { use crate::schema::auth_user::dsl::*; use crate::diesel::RunQueryDsl; let user = adapter::auth::signup_to_hash_user(msg); let new_user = diesel::insert_into(auth_user) .values(&user) .execute(&mut self.0.get().expect("Failed to open connection")); match new_user { Ok(_) => Ok(()), Err(_) => Err(DbError::UserNotCreated) } } } }
Para a trait Message
devemos implementar o tipo de retorna da comunicação, como no nosso caso não vamos retornar nada deixamos o ()
e caso ocorra um erro, retornamos o que já implementamos, DbError
. Depois disso implementamos o Handler
para DbExecutor
com o tipo de mensagem SignUp
, que possui a função handle
. O primeiro argumento de handle
é o prório DbExecutor
, que está implementado como struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>)
, o segundo argumento é a mensagem, no nosso caso SignUp
, e o terceiro argumento é o contexto do actix. Note que a função handle
é praticamente igual a insert_new_user
, mas em vez de passarmos um PgConnection
passamos um PooledConnection
, uma referência ao Pool
de conexões que criamos em DbExecutor
, e para isso precisamos adicionar use crate::diesel::RunQueryDsl;
que altera nosso execute
para poder realizar erstá operação. Com isto encaminhado, agora podemos criar o controller. Este controller será um pouco diferente do que usamos usualmente, pois o adapter se encontra dentro do Handler
e o controller simplesmente ficará responsável por fazer a comunicação via mensagem entre os actors:
#![allow(unused)] fn main() { use actix_web::{HttpResponse, web, Responder}; use log::{error}; use crate::{ todo_api_web::model::{ http::Clients, auth::SignUp, } }; pub async fn signup_user(state: web::Data<Clients>, info: web::Json<SignUp>) -> impl Responder { let signup = info.into_inner(); let resp = state.postgres .send(signup) .await; match resp { Ok(_) => HttpResponse::Created(), Err(e) => { error!("{:?}",e); HttpResponse::InternalServerError() }, } } }
Note que agora estamos fazendo a conversão do tipo web::Json<SignUp>
na nossa struct SignUp
com let signup = info.into_inner();
e enviando seu conteúdo para o DbExecutor
através de:
#![allow(unused)] fn main() { let resp = state.postgres .send(signup) .await; }
Se executarmos os testes agora veremos que eles falham, pois nosso teste tenta invocar o banco de dados de verdade, para isso, podemos utilizar a função que criamos anteriormente insert_new_user
dentro do Handler
para abstrair a lógica com o banco de dados e nos permitir utilizar features. Assim, a primeira mudança passa a ser o handle
que utiliza o insert_new_user
:
#![allow(unused)] fn main() { impl Handler<SignUp> for DbExecutor { type Result = Result<(), DbError>; fn handle(&mut self, msg: SignUp, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::insert_new_user; let user = adapter::auth::signup_to_hash_user(msg); insert_new_user(user, &mut self.0.get().expect("Failed to open connection")) } } }
Agora precisamos implementar uma solução de insert_new_user
que utilize a feature dynamo
:
#![allow(unused)] fn main() { use diesel::{PgConnection, prelude::*}; use crate::todo_api::model::auth::User; use crate::todo_api::db::error::DbError; #[cfg(not(feature = "dynamo"))] pub fn insert_new_user(user: User, conn: &mut PgConnection) -> Result<(),DbError>{ use crate::schema::auth_user::dsl::*; let new_user = diesel::insert_into(auth_user) .values(&user) .execute(conn); match new_user { Ok(_) => Ok(()), Err(_) => Err(DbError::UserNotCreated) } } #[cfg(feature = "dynamo")] pub fn insert_new_user(_user: User, _: &PgConnection) -> Result<(),DbError>{ use crate::schema::auth_user::dsl::*; use diesel::debug_query; use diesel::pg::Pg; let user = User::from(String::from("my@email.com"), String::from("My cr4azy p@ssw0rd")); let query = diesel::insert_into(auth_user).values(&user); let sql = "INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\") VALUES ($1, $2, $3, $4) \ -- binds: [\"my@email.com\", "; assert!(debug_query::<Pg, _>(&query).to_string().contains(sql)); assert!(debug_query::<Pg, _>(&query).to_string().contains("My cr4azy p@ssw0rd")); Ok(()) } }
Com o #[cfg(feature = "dynamo")]
fazemos uma query para diesel_query
com os valores de user
(não usamos _user
pois seus campos são privados), como fizemos no módulo de testes e depois fazemos um assert que a query retornada de debug_query::<Pg, _>(&query).to_string()
contém a substring sql
e que contém a substring de password "My cr4azy p@ssw0rd"
. Depois disso retornamos Ok(())
para conformar com o esperado do Result
.
Validando email e password
Agora vamos fazer algo pequeno, pois nosso objetivo é garantir que o email é no formato válido \w{1,}@\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?
(regex significando qualquer conjunto de caracteres com mais de 1 elemento entre letras, números e _
, seguido de @
, repete o primeiro, seguido de ponto e 2 ou 3 caracteres de letras, seguido pela possível existência de ponto e 2 ou 3 caracteres de letras). Além disso, vamos garantir que o password contém umais de 32 caracteres, com letras maiúsculas e minúsculas, números e alguns caracterés especiais.
No controller signup_user
adicionaremos uma validação da string de email com a crate Regex. Para isso definiremos nossa regex com Regex::new
e depois compararemos com is_match
. Caso a validação falhe, retornaremos HttpResponse::BadRequest()
:
#![allow(unused)] fn main() { pub async fn signup_user(state: web::Data<Clients>, info: web::Json<SignUp>) -> impl Responder { use regex::Regex; let email_regex = Regex::new("\\w{1,}@\\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?$").unwrap(); let signup = info.into_inner(); if !email_regex.is_match(&signup.email) { return HttpResponse::BadRequest(); } // ... } }
Agora usaremos uma regex que garante que a senha possua pelo menos uma letra maiúscula, pelo menos uma letra minúscula, pelo menos um número e pelo menos algum dos caracteres @!=_#&~[]{}?/
com uma tamanho entre 32 e 64 caracteres. Essa regex será [[a-z]+[A-Z]+[0-9]+(\s@!=_#&~\[\]\{\}\?\/)]{32,64}
. Cuidado que nosso teste deve falhar a partir de agora, para isso, modifiquei signup.json
para:
{
"email": "my@email.com",
"password": "My cr4azy p@ssw0rd My cr4azy p@ssw0rd"
}
E atualizei db/auth
para validar este novo password:
#![allow(unused)] fn main() { #[cfg(feature = "dynamo")] pub fn insert_new_user(user: User, _: &PgConnection) -> Result<(),DbError>{ use crate::schema::auth_user::dsl::*; use diesel::debug_query; use diesel::pg::Pg; let user = User::from(String::from("my@email.com"), String::from("My cr4azy p@ssw0rd My cr4azy p@ssw0rd")); let query = diesel::insert_into(auth_user).values(&user); let sql = "INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\") VALUES ($1, $2, $3, $4) \ -- binds: [\"my@email.com\", "; assert!(debug_query::<Pg, _>(&query).to_string().contains(sql)); assert!(debug_query::<Pg, _>(&query).to_string().contains("My cr4azy p@ssw0rd My cr4azy p@ssw0rd")); Ok(()) } }
Assim, podemos implementar a mudança no controller com:
#![allow(unused)] fn main() { pub async fn signup_user(state: web::Data<Clients>, info: web::Json<SignUp>) -> impl Responder { use regex::Regex; let email_regex = Regex::new("\\w{1,}@\\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?$").unwrap(); let pswd_regex = Regex::new("[[a-z]+[A-Z]+[0-9]+(\\s@!=_#&~\\[\\]\\{\\}\\?\\/)]{32,64}").unwrap(); let signup = info.into_inner(); if !(email_regex.is_match(&signup.email) && pswd_regex.is_match(&signup.password)) { return HttpResponse::BadRequest(); } // ... } }
Um bom exercício aqui seria criar alguns testes para o controller validar as novas regras do email
e do password
, lembrando que o teste de cenário válido já acontece a nível de integração. Alguns possíveis emails
de teste são "my_email.com.br"
ou "my@email.com.br.us"
, além disso alguns casos interessantes de teste para passwords
são "My Cr4zy p@ssw0rd"
, "my cr4zy p@ssw0rd my cr4zy p@ssw0rd"
e "My Crazy password My Crazy password"
.
Implementando login
O objetivo de nosso endpoint de login será retornar um token jwt
com informações garantindo a validade do sistema. Assim, nosso endpoint receberá um email
e um password
, validará se o password é válido e retornará um token jwt
, que passará a ser validado nos outros endpoints.
Podemos agora mudar a feature dynamo
que atua sobre o Postgres e o DynamoDB para db-test
. Para isso, devemos adicionar a feature a nosso Cargo.toml:
[features]
db-test = []
E a nosso Makefile:
test: db
sleep 2
diesel setup
diesel migration run
cargo test --features "dbtest"
diesel migration redo
run-local:
cargo run --features "db-test"
# ...
Por último, devemos modificar o arquivo src/todo_api/db/auth.rs
para utilizar a nova feature:
#![allow(unused)] fn main() { use diesel::{PgConnection, prelude::*}; use crate::todo_api::model::auth::User; use crate::todo_api::db::error::DbError; #[cfg(not(feature = "db-test"))] pub fn insert_new_user(user: User, conn: &PgConnection) -> Result<(),DbError>{ // ... } #[cfg(feature = "db-test")] pub fn insert_new_user(user: User, _: &PgConnection) -> Result<(),DbError>{ // ... Ok(()) } // ... }
Repita isso para os outros cenários.
Criando o endpoint de login
A partir deste momento vou mudar a forma como apresento os testes, pois creio que já temos uma boa ideia de como eles funcionam. Assim, vou apresentar o teste que escrevi para cada endpoint, mas não resolverei eles mais de forma a relacionar o código sendo escrito ao teste que queremos resolver. Isso se deve ao fato de que eles são praticamente iguais. Assim, o teste deste endpoint seria apenas validar que o status é 200
, mas vamos mudar um pouco e esperar que a resposta venha com uma chave Json token
:
#![allow(unused)] fn main() { mod auth { use actix_web::{ test, App, http::StatusCode, }; use todo_server::todo_api_web::{ routes::app_routes }; use crate::helpers::{read_json}; use dotenv::dotenv; // ... #[actix_rt::test] async fn login_returns_token() { let mut app = test::init_service( App::new() .data(Clients::new()) .configure(app_routes) ).await; let login_req = test::TestRequest::post() .uri("/auth/login") .insert_header((CONTENT_TYPE, ContentType::json())) .set_payload(read_json("signup.json").as_bytes().to_owned()) .to_request(); let resp_body = test::read_response(&mut app, login_req).await; let jwt: String = String::from_utf8(resp_body.to_vec()).unwrap(); assert!(jwt.contains("token")); } } }
Nosso próximo passo será definir o endpoint /auth/login
que receberá um POST
com um Json representado pela struct Login
, que contém os mesmos campos de SignUp
. Faremos uma nova struct para podermos tirar mais proveito do sistema de actors do Actix.
#![allow(unused)] fn main() { use crate::todo_api_web::controller::{ // ... auth::{signup_user, login} }; pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("") .service(ping) .service(readiness) .service(create_todo) .service(show_all_todo) .service( web::scope("/auth") .service(signup_user) .service(login) ) .default_service(web::to(|| HttpResponse::NotFound())), ); } }
Aqui adicionamo uma rota login
que envia o request para o controller login
. Agora vamos ao controller login
:
#![allow(unused)] fn main() { pub async fn login(state: web::Data<Clients>, info: web::Json<Login>) -> impl Responder { use regex::Regex; let email_regex = Regex::new("\\w{1,}@\\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?$").unwrap(); let pswd_regex = Regex::new("[[a-z]+[A-Z]+[0-9]+(\\s@!=_#&~\\[\\]\\{\\}\\?)]{32,64}").unwrap(); let login_user = info.clone(); if !(email_regex.is_match(&login_user.email) && pswd_regex.is_match(&login_user.password)) { return HttpResponse::BadRequest().finish(); } let resp = state.postgres .send(login_user) .await; match resp { Err(e) => { error!("{:?}",e); HttpResponse::NoContent().finish() }, Ok(r_users) => { match r_users { Err(e) => { error!("{:?}",e); HttpResponse::NoContent().finish() }, Ok(users) => { let user = users.first().unwrap(); match user.verify(info.clone().password) { Ok(true) => generate_jwt(user, state).await, Ok(false) => HttpResponse::NoContent().finish(), Err(_) => HttpResponse::NoContent().finish() } } } }, } } }
A primeira coisa que podemos notar no controller de login
é o is_match
das regex, lembrando que usar regex pode sempre ser algo perigoso e devemos ter muito cuidado. Isso é algo que claramente podemos extrair. Em seguida repetimos o processo de outros outros controllers e enviamos uma mensagem com Login
em state.postgres.send(login_user).await
, nesta chamada recebemos um vetor de User
que passam nosso filtro, porém como estamos filtrando pela chave primária email
não pode haver conflitos. creio que a estração das verificações de email e de senha por regex fica com a seguinte cara:
#![allow(unused)] fn main() { pub async fn signup_user(state: web::Data<Clients>, info: web::Json<SignUp>) -> impl Responder { let signup = info.into_inner(); if !is_email_pswd_valids(&signup.email, &signup.password) { return HttpResponse::BadRequest(); } // ... } pub async fn login(state: web::Data<Clients>, info: web::Json<Login>) -> impl Responder { let login_user = info.clone(); if !is_email_pswd_valids(&login_user.email, &login_user.password) { return HttpResponse::BadRequest().finish(); } // ... } pub fn is_email_pswd_valids(email: &str, pswd: &str) -> bool { use regex::Regex; let email_regex = Regex::new("\\w{1,}@\\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?$").unwrap(); let pswd_regex = Regex::new("[[a-z]+[A-Z]+[0-9]+(\\s@!=_#&~\\[\\]\\{\\}\\?)]{32,64}").unwrap(); email_regex.is_match(email) && pswd_regex.is_match(pswd) } }
A vantagem deste formato, é que executar os testes fica ainda mais fácil, pois passam a ser validações unitárias, e o motivo pelo qual deixei anteriormente como exercícios. Assim, os testes podem ser como a seguir:
#![allow(unused)] fn main() { #[cfg(test)] mod valid_email_pswd { use super::is_email_pswd_valids; #[test] fn valid_email_and_pswd() { assert!(is_email_pswd_valids("my@email.com", "My cr4zy P@ssw0rd My cr4zy P@ssw0rd")); } #[test] fn invalid_emails() { assert!(!is_email_pswd_valids("my_email.com", "My cr4zy P@ssw0rd My cr4zy P@ssw0rd")); assert!(!is_email_pswd_valids("my@email.com.br.us", "My cr4zy P@ssw0rd My cr4zy P@ssw0rd")); } #[test] fn invalid_passwords() { assert!(!is_email_pswd_valids("my@email.com.br", "My cr4zy P@ssw0rd")); assert!(is_email_pswd_valids("my@email.com", "my cr4zy p@ssw0rd my cr4zy p@ssw0rd")); assert!(is_email_pswd_valids("my@email.com", "My crazy P@ssword My crazy P@ssword")); assert!(is_email_pswd_valids("my@email.com", "My cr4zy Passw0rd My cr4zy Passw0rd")); } } }
Também podemos observar que no controller login
há uma série de HttpResponse::NoContent().finish()
para qualquer caso de erro. Duas coisas para observarmos aqui, a primeira é a presença de finish
que se deve ao método generate_jwt
que retorna um HttpResponse
, a segunda é que presumo que quando alguém tenta logar em um serviço e ocorro qualquer problema, o serviço deve responder um 2XX
sem nenhuma informação, por isso do NoContent
.
Agora podemos seguir para o caso que todas as extrações de resp
via pattern matching e chegar em user.verify(info.clone().password)
. O objetivo de função é validar que o password
de info: web:Json<Login>
é um password possível para a hash de user.password
. Como está função é somente uma camada em volta da função original, já testada, não é imprescindível implementar testes:
#![allow(unused)] fn main() { // src/todo_api/model/auth.rs impl User { use bcrypt::{verify, BcryptResult}; // ... pub fn verify(&self, pswd: String) -> BcryptResult<bool> { verify(pswd,&self.password) } } }
Note que a resposta de verify
é do tipo BcryptResult
, ou seja, temos 3 cenários:
Ok(true)
-> Caso na qual opassword
enviado é uma hash válida.Ok(false)
-> Caso na qual opassword
não é válido.Err
-> Ocorreu algum erro de validação.
O único dos casos que é importante para nós é o caso 1
, por isso é o caso que aplicamos a função generate_jwt
, cujo objetivo será gerar um token jwt
. Além disso, está função não funcionará para o teste que criamos pois não estamos utilizando uma hash real, assim uma solução para isso é simplesmente responder um tipo BcryptResult<bool>
com conteúdo true
:
#![allow(unused)] fn main() { impl User { // ... #[cfg(not(feature = "dbtest"))] pub fn verify(&self, pswd: String) -> BcryptResult<bool> { verify(pswd,&self.password) } #[cfg(feature = "dbtest")] pub fn verify(&self, pswd: String) -> BcryptResult<bool> { BcryptResult::Ok(true) } // ... } }
Antes de continuar com generate_jwt
precisamos explorar a implementação de Login
, pois é o Login
que é afetado pela função state.postgres.send(login_user).await
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Login { pub email: String, pub password: String, } impl Message for Login { type Result = Result<Vec<User>, DbError>; } impl Handler<Login> for DbExecutor { type Result = Result<Vec<User>, DbError>; fn handle(&mut self, msg: Login, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; scan_user(msg.email, &mut self.0.get().expect("Failed to open connection")) } } }
Login recebe um email
e um password
para depois procurar no banco de dados com scan_user
:
#![allow(unused)] fn main() { pub fn scan_user(user_email: String, conn: &mut PgConnection) -> Result<Vec<User>,DbError>{ use crate::schema::auth_user::dsl::*; let items = auth_user .filter(email.eq(&user_email)) .load::<User>(conn); match items { Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit), Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser), Ok(users) => Ok(users), Err(_) => Err(DbError::CannotFindUser) } } }
É bastante simples o que acontece aqui, filtramos na tabela auth_user
por um user_email
que seja igual ao que enviamos. Caso essa lista seja maior que 1, houve um problema no banco de dados, pois como email
é uma chave primária não podem haver 2, ou mais, repetidos. Qualquer outro Err
é um DbError
de não encontrar o usuário ou problemas de conexão. Temos um Ok
extra que valida se a lista é zero, e retorna o erro CannotFindUser
como a cláusula Err
. E o Ok
restante é o caso que procuramos. Note que ainda temos um refactor a fazer aqui, este refactor é modificar o tipo de retorno Result<Vec<User>,DbError>
para Result<User,DbError>
utilizando um .first().unwrap()
, já que temos certeza que esse first
existe. Além disso, precisamos adaptar este código para o teste, já que a ação user.filter(email.eq(&user_email)).load::<User>(conn)
não deve existir. Fazemos essa adaptação retornando um Ok
com um User
contendo o email que enviamos. Na função scan_user
com a feature dbtest
ainda fazemos um assert na query que será gerada por auth_user.filter(email.eq(&user_email))
e validamos com o debug_query
. Caso você prefira substituir o password
por uma hash válida para a senha sendo enviada no teste, não seria mais necessário utilizar a cfg feature
para verify
:
#![allow(unused)] fn main() { #[cfg(not(feature = "dbtest"))] pub fn scan_user(user_email: String, conn: &PgConnection) -> Result<User, DbError>{ use crate::schema::auth_user::dsl::*; let items = auth_user .filter(email.eq(&user_email)) .load::<User>(conn); match items { Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit), Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser), Ok(users) => Ok(users.first().unwrap().clone().to_owned()), Err(_) => Err(DbError::CannotFindUser) } } #[cfg(feature = "dbtest")] pub fn scan_user(user_email: String, _conn: &PgConnection) -> Result<User, DbError>{ use crate::schema::auth_user::dsl::*; use diesel::debug_query; use diesel::pg::Pg; let query = auth_user.filter(email.eq(&user_email)); let expected = "SELECT \"auth_user\".\"email\", \"auth_user\".\"id\", \"auth_user\".\"password\", \"auth_user\".\"expires_at\", \"auth_user\".\"is_active\" FROM \"auth_user\" WHERE \"auth_user\".\"email\" = $1 -- binds: [\"my@email.com\"]".to_string(); assert_eq!(debug_query::<Pg, _>(&query).to_string(), expected); Ok(User::from(user_email, "this is a hash".to_string())) } }
Agora nossa resp
de state.postgres.send(login_user).await
pode ser resolvida em uma match
, na qual a cláusula de Err
vai simplesmente retornar um NoContent
a cláusula Ok
vai aplicar um novo match
em verify
. De acordo com a resposta de verify, criamos o token. O caso Err
é simplesmente um NoContent
porque houve um problema na criação da hash, já o caso Ok(false)
corresponde a senha incorreta. No caso Ok(true)
, criamos o token em generate_jwt
:
#![allow(unused)] fn main() { // src/todo_api_web/controller/auth.rs pub async fn login(state: web::Data<Clients>, info: web::Json<Login>) -> impl Responder { // ... match resp { Err(e) => { error!("{:?}",e); HttpResponse::NoContent().finish() }, Ok(user) => { let usr = user.unwrap(); match usr.verify(info.clone().password) { Ok(true) => generate_jwt(usr, state).await, Ok(false) => HttpResponse::NoContent().finish(), Err(_) => HttpResponse::NoContent().finish() } } } } // src/todo_api_web/model/auth.rs impl Message for Login { type Result = Result<User, DbError>; } impl Handler<Login> for DbExecutor { type Result = Result<User, DbError>; fn handle(&mut self, msg: Login, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; scan_user(msg.email, &mut self.0.get().expect("Failed to open connection")) } } }
Agora falta implementarmos o generate_jwt
para completarmos esse fluxo.
Gerando um token JWT
O objetivo de criarmos um token JWT
é permitir que o usuário faça requisições para páginas que exigem autentição, e até autorização (é possível passar tokens de autorização), com um token de autenticação no header do request. Essa autenticação vai conter algumas informações cruciais que vão nos permitir validar este token no nosso banco de dados. As informação que vamos adicionar ao token neste momento são as contidas na struct User
exceto password
.
- É importante lembrar que o tópico de segurança é bastante complicado e não é o foco do livro, assim, a solução que vamos apresentar é útil, mas longe de ser uma solução aplicável em produção.
Infelizmente, a função generate_jwt
é cheio de efeitos colaterais e muito dificil de testar unitariamente, assim, vamos pular os testes dele por hora. Vamos manter essa função em um módulo core
, a ideia desse módulo é conter a lógica associada à src/todo_api
, mesmo que a função generate_jwt
possua muitos efeitos colaterais e estará localizada em src/todo_api/core/mod.rs
. O primeiro Efeito colaterial dele é criar uma nova data de expiração para daqui um dia com crate::todo_api::db::helpers::one_day_from_now().naive_utc()
. Essa data será usada para criar uma struct que fará a atualização da data em User
. Essa struct é chamada UpdateDate
e contém dois campos email
e expires_at
.
#![allow(unused)] fn main() { pub async fn generate_jwt(user: User, state: web::Data<Clients>) -> HttpResponse { let utc = crate::todo_api::db::helpers::one_day_from_now().naive_utc(); let update_date = UpdateDate { email: user.email.clone(), expires_at: utc, }; // ... } }
JWT
JWT, ou Json Web Token, é um padrão aberto baseado na RFC 7519 que define uma forma compacta e auto contida de transmitir de forma segura entre duas partes em um formato Json. Este token pode ser assinado com uma chave secreta via
HMAC
ou chaves publicas/privadas viaRSA
ouECDSA.
Estes tokens podem ser encriptados ou não e os dois principais casos de uso são autorização e troca de informações. A estrutura de um JWT éheader
,payload
eassinatura
, assim o formato acaba sendo algo comohhhhh.pppppp.aaaaa
. Usualmente oheader
possui duas partes o tipo, usualmente"typ": "jwt"
e o algoritmo que pode serHMAC SHA256 ou RSA
, algo como"alg": "HS256"
.payload
é onde as informações que queremos trocar estão armazenadas. E assinatura, ousignature
, é uma informação de como entender esses dados. Com o algoritmoHMAC SHA256
a criação de um JWT o seguinte formatoHMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
, note quepayload
eheader
estão em um formatobase64
.
Agora precisamos implementar a struct UpdateDate
. Essa struct está estritamente associada a ao módulo core
atuando somente como um complemento a lógica, por isso adicionel ela em src/todo_api/core/model.rs
, mas se você achar mais adequado é correto também deixar generate_jwt
em src/todo_api/controller/core.rs
e UpdateDate
em src/todo_api/model/core.rs
. Agora, nossa struct também precisa poder se comunicar por mensagem com nosso postgres e para isso vamos implementar as traits Message
e Handle
:
#![allow(unused)] fn main() { use actix::prelude::*; use crate::todo_api::{ db::{ error::DbError, helpers::DbExecutor, }, }; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct UpdateDate { pub email: String, pub expires_at: chrono::NaiveDateTime, } impl Message for UpdateDate { type Result = Result<(), DbError>; } impl Handler<UpdateDate> for DbExecutor { type Result = Result<(), DbError>; fn handle(&mut self, msg: UpdateDate, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::update_user_jwt_date; update_user_jwt_date(msg, &mut self.0.get().expect("Failed to open connection")) } } }
Note que o tipo Result
da nossa Message
é apenas um Result
com um Ok
vazio e um erro do tipo DbError
, Result<(), DbError>
. Neste caso precisamos somente saber se o update da data foi bem sucedido ou falho, com qual erro. Assim, a função handle
simplesmente atualiza a expires_at
no banco conforme a chave email
. É importante também garantir que expires_at
seja do tipo chrono::NaiveDateTime
para não termos problemas com o tipo da tabela auth_user
. Vamos agora olhar a função update_user_jwt_date
.
#![allow(unused)] fn main() { pub fn update_user_jwt_date(update_date: UpdateDate, conn: &PgConnection) -> Result<(), DbError>{ use crate::schema::auth_user::dsl::*; let target = auth_user.filter(email.eq(update_date.email)); match diesel::update(target).set(expires_at.eq(update_date.expires_at)).execute(conn) { Ok(_) => Ok(()), Err(_) => Err(DbError::TryAgain) } } }
Para update_user_jwt_date
precisamos disponibilizar a dsl
de auth_user
para fazermos operações na tabela e fazemos isso com use crate::schema::auth_user::dsl::*;
. A primeira linha de código é encontrar o User
alvo através de um filter
que procura a igualdade entre os campos email
com auth_user.filter(email.eq(update_date.email))
sendo definido em um let target
. Depois disso fazemos um update
nesse target
com diesel::update(target)
e com isso podemos fazer um set
do campo expires_at
com o valor de expires_at
de update_date
com set(expires_at.eq(update_date.expires_at))
. O resultado disso será um tipo Result<(), DbError>
, que podemos utilizar em um match
para fazer pattern matching e retornar se o update foi bem sucedido. Para realizar o teste pulamos a parte do target
e do match
, retornando apenas um Ok(())
:
#![allow(unused)] fn main() { #[cfg(not(feature = "dbtest"))] pub fn update_user_jwt_date(update_date: UpdateDate, conn: &PgConnection) -> Result<(), DbError>{ use crate::schema::auth_user::dsl::*; let target = auth_user.filter(email.eq(update_date.email)); match diesel::update(target) .set((expires_at.eq(update_date.expires_at), is_active.eq(update_date.is_active))) .execute(conn) { Ok(_) => Ok(()), Err(_) => Err(DbError::TryAgain) } } #[cfg(feature = "dbtest")] pub fn update_user_jwt_date(_update_date: UpdateDate, _conn: &PgConnection) -> Result<(), DbError>{ Ok(()) } }
De volta a generate_jwt
fazemos este update de forma a utilizar os recursos de Actor
enviando uma mensagem para UpdateDate
com let resp = state.postgres.send(update_date);
. Note que esta função é async
e não estamos esperando ela com o await
, isso se deve ao fato de que as duas próximas tarefas não precisam que resp
esteja concluída. Enquanto esperamos o momento oportuno para concretizar resp
com await
iniciamos a criação efeitva do token e sua preparação para o tipo de resposta Jwt
:
#![allow(unused)] fn main() { pub async fn generate_jwt(user: User, state: web::Data<Clients>) -> HttpResponse { let utc = crate::todo_api::db::helpers::one_day_from_now().naive_utc(); let update_date = UpdateDate { email: user.email.clone(), expires_at: utc, }; let resp = state.postgres .send(update_date.clone()); let token_jwt = create_token(user, update_date); let jwt = crate::todo_api::core::model::Jwt::new(token_jwt); match resp.await { // ... } } }
E o tipo Jwt
localizado em src/todo_api/core/model.rs
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug)] pub struct Jwt{ token: String } impl Jwt { pub fn new(jwt: String) -> Self { Self { token: jwt } } } }
Antes de concretizarmos resp
com um await
criamos o token com create_token
e passamos este valor para a struct Jwt
com let jwt = crate::todo_api::core::model::Jwt::new(token_jwt);
. create_token
é a função responsável por montar o token com os campos necessários.
#![allow(unused)] fn main() { pub fn create_token(user: User, update_date: UpdateDate) -> String { use serde_json::json; use jsonwebtokens::{Algorithm, AlgorithmID, encode}; use chrono::Utc; let alg = Algorithm::new_hmac(AlgorithmID::HS256, "secret").unwrap(); let header = json!({ "alg": alg.name(), "typ": "jwt", "date": Utc::now().to_string()}); let payload = json!({ "id": user.clone().get_id(), "email": user.email, "expires_at": update_date.expires_at }); encode(&header, &payload, &alg).unwrap() } }
A primeira coisa que fazemos em create_token
é gerar o algoritmo com a struct Algorithm
. Como vamos utilizar um algoritmo HMAC SHA256
chamamos a função new_hmac
e passamos como argumento o id que tipo que vamos utilizar com AlgorithmID::HS256
e o segredo que vai ser passado. Uma boa alternativa para não ter o segredo exposto assim é ler ele de uma variável de ambiente. Depois disso, definimos o header
com o algoritmo, o tipo e a data de criação em json!({ "alg": alg.name(), "typ": "jwt", "date": Utc::now().to_string()});
, note o uso da macro json!
vinda de serde_json
. Da mesma forma que com header, criamos o payload
com os campos que nos interessam, id, email, expires_at
. Por último geramos o token passando todas estas informações como argumento para função encode
em encode(&header, &payload, &alg).unwrap()
.
Para finalizar precisamos que generate_jwt
responda um status com o conteúdo do token. Para isso fazemos um match em resp
e retornamos HttpResponse::InternalServerError().finish()
para o caso de Err
e para o caso de Ok
retornamos um HttpResponse::Ok()
com um Json contendo a struct Jst
serializada:
#![allow(unused)] fn main() { pub async fn generate_jwt(user: User, state: web::Data<Clients>) -> HttpResponse { // ... let resp = state.postgres.send(update_date.clone()); let token_jwt = create_token(user, update_date); let jwt = crate::todo_api::model::core::Jwt::new(token_jwt); match resp.await { Ok(_) => { HttpResponse::Ok() .content_type("application/json") .json(jwt) } Err(e) => { error!("{:?}", e); HttpResponse::InternalServerError().finish() } } } }
Login pronto. Agora precisamos implementar o logout.
Implementando o logout
Um login é útil, mas pode ser necessário apagarmos a sessão que temos com o serviço e para fazer isso é necessário realizar um logout
, que atende pelo método DELETE
. Nosso logout vai modificar nosso user de modo que tenhamos um campo booleano is_active
. Este campo tem como responsabilidade dizer se o user
enviado no token ainda está autenticado. Assim, vamos adicionar o campo is_active
ao struct User
:
#![allow(unused)] fn main() { // src/todo_api/model/auth.rs // ... #[derive(Debug, Serialize, Deserialize, Clone, Queryable, Insertable)] #[table_name = "auth_user"] pub struct User { pub email: String, pub id: uuid::Uuid, #[cfg(test)] pub password: String, #[cfg(not(test))] password: String, #[cfg(test)] pub expires_at: chrono::NaiveDateTime, #[cfg(not(test))] expires_at: chrono::NaiveDateTime, pub is_active: bool } impl User { pub fn from(email: String, password: String) -> Self { let utc = crate::todo_api::db::helpers::one_day_from_now(); Self { email: email, id: uuid::Uuid::new_v4(), password: password, expires_at: utc.naive_utc(), is_active: false } } // ... } }
Se observarmos o rls
do editor vamos perceber o aviso de que Insertable
está com problemas, este problema é que is_active
não está mapeado. Para isso devemos criar uma migração com este campo, chamaremos ela de valid_auth
e executaremos diesel migration generate valid_auth
que criará uma nova pasta dentro de migrations, algo como 2020-02-22-011512_valid_auth
. Depois disso adicionaremos um up.sql
e um down.sql
:
<-- UP.sql -->
ALTER TABLE auth_user
ADD is_active BOOLEAN NOT NULL DEFAULT 'f';
<-- DOWN.sql -->
ALTER TABLE auth_user
DROP is_active;
Esse script consiste em alterar a tabela auth_user
para conter ou não o campo is_active
. Com isso pronto executaremos make db
e em seguida diesel setup
para modificar o schema.rs
que ficará assim:
#![allow(unused)] fn main() { table! { auth_user (email) { email -> Varchar, id -> Uuid, password -> Varchar, expires_at -> Timestamp, is_active -> Bool, } } }
Lembre de adicionar embed_migrations!();
depois de table!(...)
. Antes de continuarmos com logout
precisamos que o login
ative a o campo is_active
e para isso a struct UpdateDate
precisa receber um novo campo booleano is_active
:
#![allow(unused)] fn main() { // src/todo_api/core/model.rs // ... #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct UpdateDate { pub email: String, pub expires_at: chrono::NaiveDateTime, pub is_active: bool } // ... }
Se rodarmos os testes agora veremos que o teste insert_user_matches_url
de src/todo_api/db/auth
falha pois não espera o campo is_active
:
esperado: "INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\") VALUES ($1, $2, $3, $4) -- binds: [\"email@my.com\", 0f3d625b-c85c-490c-b979-f20cbbd6a71d, \"pswd\", 2020-02-23T19:31:34.896595]"`,
encontrado: "INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\", \"is_active\") VALUES ($1, $2, $3, $4, $5) -- binds: [\"email@my.com\", 0f3d625b-c85c-490c-b979-f20cbbd6a71d, \"pswd\", 2020-02-23T19:31:34.896595, false]"`
Assim, devemos editar o teste para conter o campo is_active
com valor default false
:
#![allow(unused)] fn main() { fn insert_user_matches_url() { use crate::todo_api::model::auth::User; let user = User::from(String::from("email@my.com"), String::from("pswd")); let query = diesel::insert_into(auth_user).values(&user); let sql = String::from("INSERT INTO \"auth_user\" (\"email\", \"id\", \"password\", \"expires_at\", \"is_active\") VALUES ($1, $2, $3, $4, $5) \ -- binds: [\"email@my.com\", ") + &user.id.to_string() + ", \"pswd\", " + &format!("{:?}", user.expires_at) +", false]"; assert_eq!(&sql, &debug_query::<Pg, _>(&query).to_string()); } }
Agora precisamos modificar também core/mod.rs
para setar o campo is_active
como true na função generate_jwt
:
#![allow(unused)] fn main() { pub async fn generate_jwt(user: User, state: web::Data<Clients>) -> HttpResponse { let utc = crate::todo_api::db::helpers::one_day_from_now().naive_utc(); let update_date = UpdateDate { email: user.email.clone(), expires_at: utc, is_active: true, }; // ... } }
Assim como a função de db/auth
update_user_jwt_date
, que agora precisa setar o campo is_active
como true
:
#![allow(unused)] fn main() { pub fn update_user_jwt_date(update_date: UpdateDate, conn: &PgConnection) -> Result<(), DbError>{ use crate::schema::auth_user::dsl::*; let target = auth_user.filter(email.eq(update_date.email)); match diesel::update(target) .set((expires_at.eq(update_date.expires_at), is_active.eq(update_date.is_active))) .execute(conn) { Ok(_) => Ok(()), Err(_) => Err(DbError::TryAgain) } } }
Note que agora diesel::update(target)
precisa atualizar 2 campos, e para isso é precisa enviar como parâmetro uma tupla contendo os dois campos a serem atualizados (expires_at.eq(update_date.expires_at), is_active.eq(update_date.is_active))
. Com isso, podemos agora continuar com o logout
.
Para nosso logout
precisamos começar criando o endpoint /auth/logout
com o método delete:
#![allow(unused)] fn main() { use crate::todo_api_web::controller::{ // ... auth::{signup_user, login, logout} }; use actix_web::{web, HttpResponse}; pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("") .service(ping) .service(readiness) .service(create_todo) .service(show_all_todo) .service( web::scope("/auth") .service(signup_user) .service(login) .service(logout) ) .default_service(web::to(|| HttpResponse::NotFound())), ); } }
O teste para este cenário será:
#![allow(unused)] fn main() { #[actix_rt::test] async fn logout_accepted() { dotenv().ok(); let mut app = test::init_service( App::new() .data(Clients::new()) .configure(app_routes) ).await; let logout_req = test::TestRequest::delete() .uri("/auth/logout") .header("Content-Type", "application/json") .header("x-auth", "token") .set_payload(read_json("logout.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app,logout_req).await; assert_eq!(resp.status(), StatusCode::ACCEPTED); } }
E logout.json
será:
{
"email": "my@email.com"
}
Agora com o teste pronto podemos passar para entender o controller de logout
. Em logout
vamos receber o email como parâmetro e um token válido, conforme o teste. Com o email vamos buscar a entidade a ser atualizada e no token vamos verificar a validade do token e se pertence ao usuário correto. Uma vez que as validações estiverem corretas, inativamos seu token com is_active: false
. Não é tão crítico garantir o logout
por não se tratar de um código em produção e por ser pouco sensível invalidar um token, caso você queira levar este código a produção, garanta a melhor estratégia com sua equipe de segurança. No nosso controller a primeira coisa que precisamos fazer é verificar se o conteúdo de Logout
é um email de verdade, para evitar superficialmente SQL Injection
. Fazemos isso com:
#![allow(unused)] fn main() { pub async fn logout(state: web::Data<Clients>, info: web::Json<Logout>) -> impl Responder { use regex::Regex; let logout_user = info.clone(); let email_regex = Regex::new("\\w{1,}@\\w{2,}.[a-z]{2,3}(.[a-z]{2,3})?$").unwrap(); if !email_regex.is_match(&logout_user.email) { return HttpResponse::BadRequest().finish(); } // ... } }
Perceba que caso o campo email não coincida com a regex, nos retornamos BadRequest
. Além disso, ainda falta implementarmos a struct Logout
que nos permitirá trocar mensagens com o actor de DbExecutor
:
#![allow(unused)] fn main() { // src/todo_web_api/model/auth.rs // ... #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Logout { pub email: String, } impl Message for Logout { type Result = Result<User, DbError>; } impl Handler<Logout> for DbExecutor { type Result = Result<User, DbError>; fn handle(&mut self, msg: Logout, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; scan_user(msg.email, &mut self.0.get().expect("Failed to open connection")) } } }
Vale salientar que a função scan_user
já possui implementação para a feature db-test
. Além disso, é importante ressaltar que a struct Logout
faz exatamente a mesma coisa que a struct Login
, exceto pelo fato de que Login
possui o campo password
, por isso podemos simplificar a nosso model
contendo apenas um tipo de Login/Logout
com o campo password
opcional. Assim Login
pode se transformar em Auth
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Auth { pub email: String, pub password: Option<String>, } impl Message for Auth { type Result = Result<User, DbError>; } impl Handler<Auth> for DbExecutor { type Result = Result<User, DbError>; fn handle(&mut self, msg: Auth, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; scan_user(msg.email, &mut self.0.get().expect("Failed to open connection")) } } }
Assim, precisamos atualizar nosso controller/auth
para utilizar Auth
em vez de Login
e Logout
:
#![allow(unused)] fn main() { pub async fn login(state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder { let login_user = info.clone(); if !is_email_pswd_valids(&login_user.email, &login_user.password.clone().unwrap()) { return HttpResponse::BadRequest().finish(); } let resp = state.postgres .send(login_user) .await; match resp { Err(e) => { error!("{:?}",e); HttpResponse::NoContent().finish() }, Ok(user) => { let usr = user.unwrap(); match usr.verify(info.clone().password.unwrap()) { Ok(true) => generate_jwt(usr, state).await, Ok(false) => HttpResponse::NoContent().finish(), Err(_) => HttpResponse::NoContent().finish() } } } } pub async fn logout(state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder { // ... } }
Para continuarmos com logout
precisamos receber o conteúdo do header em um request, fazemos isso adicionando o request aos argumentos de logout
:
#![allow(unused)] fn main() { pub async fn logout(req: HttpRequest, state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder {...} }
Para acessarmos o conteúdo que enviamos agora vamos utilizar de uma função chamada headers
, que retorna um mapa com todos os headers disponíveis. Nosso header de autorizaçnao terá uma cara um pouco diferente, pois se chamará x-auth
e para obtermos ele basta chamarmos a função get
que nos retornará um Option
de HeaderValue
:
#![allow(unused)] fn main() { pub async fn logout(req: HttpRequest, state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder { use regex::Regex; let jwt = req.headers().get("x-auth"); // ... } }
Agora vamos fazer uma pequena mudança para tornar mais claro e organizado o controller. A função is_email_pswd_valids
não pertence a este domínio, assim moveremos ela, e seus testes, para o módulo de core em src/todo_api/core/mod.rs
, lembre-se de utilizar o use crate::todo_api::core
no controller.
Em logout
paramos no match do email
, mas agora com a informação de email queremos receber informações de User
para podermos fazer validações para o logout. Fazemos isso utilizando let resp = state.postgres.send(logout_user.clone())
que se comporta de forma identica ao caso de login
, e como não temos necessidade desta informação agora, podemos não utilizar o await
imediatamente. O próximo passo é entender o estado associado ao valor jwt
, fazemos isso em um match
, na qual a cláusula None
é uma resposta de BadRequest
e a resposta Some
vai agir sobre jwt
:
#![allow(unused)] fn main() { pub async fn logout(req: HttpRequest, state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder { // ... let resp = state.postgres .send(logout_user.clone()); match jwt { None => return HttpResponse::BadRequest().finish(), Some(jwt) => { let jwt_value : JwtValue = serde_json::from_value(decode_jwt(jwt.to_str().unwrap())).expect("failed to parse JWT Value"); match validate_jwt_date(jwt_value.expires_at) { false => HttpResponse::Unauthorized().finish(), true => { validate_jwt_info(jwt_value.email, logout_user.email, resp.await.expect("Failed to read contact info")) } } } } } }
A primeira coisa que devemos fazer em Some
é decodificar o jwt
com a função decode_jwt
, que recebe como argumento um tipo &str
(jwt.to_str().unwrap(
). Nosso uso de decode_jwt
é bem simples, pois queremos apenas saber se o token ainda é válido, fazemos isso da seguinte forma:
#![allow(unused)] fn main() { // src/todo_api/core/mod.rs pub fn decode_jwt(jwt: &str) -> Value { use jsonwebtokens::raw::{TokenSlices, split_token, decode_json_token_slice}; let TokenSlices {claims, .. } = split_token(jwt).unwrap(); let claims = decode_json_token_slice(claims).expect("Failed to decode token"); claims } }
A função decode_jwt
consiste em separar os tokents do argumento jwt
em partes como claims
e headers
e depois aplicar a função decode_json_token_slice
para extrair o tipo serde_json::value::Value
de claims
e retornar Value
. Essa implementação falharia nosso teste, assim precisamos retornar algum valor aleatório de Value
, fazemos isso utilizando serde_json::from_str
:
#![allow(unused)] fn main() { #[cfg(feature = "db-test")] pub fn decode_jwt(jwt: &str) -> Value { serde_json::from_str("{\"expires_at\": \"2020-11-02T00:00:00\", \"id\": \"bc45a88e-8bb9-4308-a206-6cc6eec9e6a1\", \"email\": \"my@email.com\"}").unwrap() } }
Essa função não necessita grandes testes, já que ela não vai ser alterada com o tempo, mas é sempre bom testar que os valores batem. Assim, o módulo a seguire testa um token Jwt criado no site jwt.io e os valores de seu claim
sendo transformado em Json pela macro json!
. Depois disso, testamos a igualdade das partes. Com o teste a seguir vamos quebrar nossa pipeline de testes, pois este teste não utilzia a feature dbtest
e é executado junto com todos os oturos testes. A solução mais simples para isso é separar testes unitários de testes de integração. Assim, criaremos um target unit
no Makefile que executará cargo test --lib
, e os testes de integração serão executados com cargo test --test lib --features "dbtest"
que executará toda lib
de tests/lib
. Note os argumentos --locked
, --no-fail-fast
e -- --test-threads 3
, que representam validar o Cargo.lock
, não terminar o processo quando algum testes falha e executar os testes em 3 threads, repectivamente. Além disso, os testes all_args_are_equal_is_accepted
e all_args_are_not_equal_is_unauth
deverão ser movidos para a pasta de testes de integração tests
, pois necessitam da feature db-test
, coloquei eles em um módulo todo_api_web/validation
.
#![allow(unused)] fn main() { #[cfg(test)] mod decode_jwt { use super::decode_jwt; use serde_json::json; #[test] fn decodes_random_jwt() { let jwt = decode_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjE1MTYyMzkwMjJ9.tRF6jrkFnCfv6ksyU-JwVq0xsW3SR3y5cNueSTdHdAg"); let expected = json!({"sub": "1234567890", "name": "test", "iat": 1516239022 }); assert_eq!(jwt, expected); } } }
int: db
sleep 2
diesel setup
diesel migration run
cargo test --test lib --no-fail-fast --features "dbtest" -- --test-threads 3
diesel migration redo
unit:
cargo test --locked --no-fail-fast --lib -- --test-threads 3
test: unit int
#![allow(unused)] fn main() { // todo_api_web/validation.rs use todo_server::todo_api::core::{validate_jwt_info}; use todo_server::todo_api::model::auth::User; use todo_server::todo_api_web::model::http::Clients; use actix_web::http::StatusCode; #[actix_rt::test] async fn all_args_are_equal_is_accepted() { use dotenv::dotenv; dotenv().ok(); let exec = Clients::new(); let state = actix_web::web::Data::new(exec); let user = User::from("my@email.com".to_string(), "pass".to_string()); let email = "my@email.com".to_string(); let resp = validate_jwt_info(email.clone(), email, Ok(user), state).await; assert_eq!(resp.status(), StatusCode::ACCEPTED); } #[actix_rt::test] async fn all_args_are_not_equal_is_unauth() { use dotenv::dotenv; dotenv().ok(); let exec = Clients::new(); let state = actix_web::web::Data::new(exec); let user = User::from("not_my@email.com".to_string(), "pass".to_string()); let email = "my@email.com".to_string(); let resp = validate_jwt_info(email.clone(), email, Ok(user), state).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } }
Depois de aplicarmos decode_jwt
ao valor jwt
transformamos este dado em algo manipulável coma struct que representa seu formato JwtValue
, let jwt_value :JwtValue = serde_json::from_value(decode_jwt(jwt.to_str().unwrap()))
. Com jwt_value
em mãos podemos checar se a data está correta coma função validate_jwt_date
, que verificar se a data do momento é inferior ou igual a expires_at
:
#![allow(unused)] fn main() { pub fn validate_jwt_date(jwt_expires: chrono::NaiveDateTime) -> bool { chrono::Utc::now().naive_utc() <= jwt_expires } }
Com este teste implementado a função #[cfg(feature = "db-test")] decode_jwt
deve começar a falhar a partir do dia 2 de Novembro, assim, precisamos modificar ela para algo mais próximo de infinito, como mil anos deste momento:
#![allow(unused)] fn main() { #[cfg(feature = "db-test")] pub fn decode_jwt(jwt: &str) -> Value { serde_json::from_str("{\"expires_at\": \"3020-11-02T00:00:00\", \"id\": \"bc45a88e-8bb9-4308-a206-6cc6eec9e6a1\", \"email\": \"my@email.com\"}").unwrap() } }
Voltando a validate_jwt_date
, seu tipo de retorno é um booleano, que podemos fazer match
para validar as respostas. Caso a resposta seja false, respondemos com HttpResponse::Unauthorized().finish()
e caso seja verdadeiro chamamos um outra função que validará as informações internas, validate_jwt_info(jwt_value.email, logout_user.email, resp.await.expect("Failed to read contact info"))
. Essa validação consiste em validar a coerência entre todos os email
s, do Jwt, do Json enviado pelo DELETE
e o salvo no banco. Note que o email
salvo no banco é chamado através da concretização da future resp
com await
:
#![allow(unused)] fn main() { pub fn validate_jwt_info(jwt_email: String, req_email: String, user: Result<User, DbError>) -> HttpResponse { match user { Err(_) => HttpResponse::Unauthorized().finish(), Ok(u) => { if u.email == jwt_email && jwt_email == req_email { HttpResponse::Accepted().finish() } else { HttpResponse::Unauthorized().finish() } } } } }
A primeira coisa que fazemos em validate_jwt_info
é um match
de seu Result
. Se ocorrer algum erro, o mais fácil é simplesmente responder que a pessoa não tem autorização. Caso não ocorram erros, velrificamos a igualdade entre os emails com if u.email == jwt_email && jwt_email == req_email
, retornando HttpResponse::Accepted().finish()
em caso de sucesso e HttpResponse::Unauthorized().finish()
em caso de falha. Outro ponto importante aqui é que is_active
deve se tornar falso. E para isso precisamos criar uma nova struct Inactivate
que comunicará com DbExecutor
para inativar o email associado:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Inactivate { pub email: String, pub is_active: bool } }
Que pode ter uma função new
que recebe o email
é já cria a struct com is_active = false
:
#![allow(unused)] fn main() { impl Inactivate { pub fn new(email: String) -> Self { Self { email: email, is_active: false } } } }
Depois disso precisamos implementar as traits Message
e Handle
:
#![allow(unused)] fn main() { impl Message for Inactivate { type Result = Result<(), DbError>; } impl Handler<Inactivate> for DbExecutor { type Result = Result<(), DbError>; fn handle(&mut self, msg: Inactivate, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::inactivate_user; inactivate_user(msg, &mut self.0.get().expect("Failed to open connection")) } } }
Quando a inactivate_user
, seu corpo é muito parecido com update_user_jwt_date
, pois encontramos o target
da mesma forma, mas fazemos update apenas no campo is_active
:
#![allow(unused)] fn main() { pub fn inactivate_user(msg: Inactivate, conn: &PgConnection) -> Result<(), DbError> { use crate::schema::auth_user::dsl::*; let target = auth_user.filter(email.eq(msg.email)); match diesel::update(target) .set(is_active.eq(msg.is_active)) .execute(conn) { Ok(_) => Ok(()), Err(_) => Err(DbError::TryAgain) } } }
Além disso, a função de teste é exatamente igual a update_user_jwt_date
, pois retorna somente um Ok(())
:
#![allow(unused)] fn main() { #[cfg(not(feature = "dbtest"))] pub fn inactivate_user(msg: Inactivate, conn: &PgConnection) -> Result<(), DbError> { use crate::schema::auth_user::dsl::*; let target = auth_user.filter(email.eq(msg.email)); match diesel::update(target) .set(is_active.eq(msg.is_active)) .execute(conn) { Ok(_) => Ok(()), Err(_) => Err(DbError::TryAgain) } } #[cfg(feature = "dbtest")] pub fn inactivate_user(_msg: Inactivate, _conn: &PgConnection) -> Result<(), DbError> { Ok(()) } }
Para finalizar a atualização devemos enviar a struct como mensagem com state.postgres.send
:
#![allow(unused)] fn main() { pub async fn validate_jwt_info(jwt_email: String, req_email: String, user: Result<User, DbError>, state: web::Data<Clients>) -> HttpResponse { match user { Err(_) => HttpResponse::Unauthorized().finish(), Ok(u) => { if u.email == jwt_email && jwt_email == req_email { let inactivate = Inactivate::new(req_email); let is_inactive = state.postgres.send(inactivate).await; match is_inactive { Ok(_) => HttpResponse::Accepted().finish(), Err(_) => HttpResponse::Unauthorized().finish() } } else { HttpResponse::Unauthorized().finish() } } } } }
Algumas coisas mudaram. Agora precisamos passar state
como argumento state: web::Data<Clients>
e ao utilizarmos state.postgres.send(inactivate)
, precisamos de um await
, que exige que nossa função passe a ser async
com pub async fn validate_jwt_info
. Além disso, chamamos a função new
da struct Inactivate
com algum dos emails que temos e depois enviamos ela para DbExecutor
com let is_inactive = state.postgres.send(inactivate).await;
. Um pattern matching simples em is_inactive
nos permite responder Accepted
para o único caso que ocorreu tudo bem. Lembre de incorporar crate::todo_api::core::model::Inactivate
em seu escopo e de modificar o controller de logout
para enviar o state
e utilizar await
em validate_jwt_info
:
#![allow(unused)] fn main() { pub async fn logout(req: HttpRequest, state: web::Data<Clients>, info: web::Json<Auth>) -> impl Responder { // ... match jwt { None => return HttpResponse::BadRequest().finish(), Some(jwt) => { let jwt_value : JwtValue = serde_json::from_value(decode_jwt(jwt.to_str().unwrap())).expect("failed to parse JWT Value"); match validate_jwt_date(jwt_value.expires_at) { false => HttpResponse::Unauthorized().finish(), true => { validate_jwt_info(jwt_value.email, logout_user.email, resp.await.expect("Failed to read contact info"), state).await } } } } } }
Agora, os testes all_args_are_equal_is_accepted
e all_args_are_not_equal_is_unauth
em core/mod.rs
passam a falhar por não receberem o state correto. Como a função validate_jwt_info
passou a ser async
sua testabilidade diminuiu, junto com isso vamos utilizar CLients::new
que depende de dotenv
estar executando. Para isso, devemos criar um web::Data<CLients>
que será passado como argumento e disponibilizar um runtime para async
com #[actix_rt::test]
:
#![allow(unused)] fn main() { #[actix_rt::test] async fn all_args_are_equal_is_accepted() { use dotenv::dotenv; dotenv().ok(); let exec = Clients::new(); let state = actix_web::web::Data::new(exec); let user = User::from("my@email.com".to_string(), "pass".to_string()); let email = "my@email.com".to_string(); let resp = validate_jwt_info(email.clone(), email, Ok(user), state).await; assert_eq!(resp.status(), StatusCode::ACCEPTED); } #[actix_rt::test] async fn all_args_are_not_equal_is_unauth() { use dotenv::dotenv; dotenv().ok(); let exec = Clients::new(); let state = actix_web::web::Data::new(exec); let user = User::from("not_my@email.com".to_string(), "pass".to_string()); let email = "my@email.com".to_string(); let resp = validate_jwt_info(email.clone(), email, Ok(user), state).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } }
Por último, precisamos dar uma organizada no nosso código.
Refatorando
Existem três coisas que eu gostaria de refatorar no momento. A primeira é o módulo de erros db/error.rs
, que está totalmente deslocado. A segunda é mover o core/model.rs
para model/core.rs
, pois creio que agora já cresceu bastante. E a terceira é encontrar um nome melhor para UpdateDate
, como UpdateUserStatus
. Começando pela terceira, selecionei para que meu editor de texto encontrasse todos os casos de UpdateDate
e substituisse eles por UpdateUserStatus
sem grandes conflitos. Depois disso, vamos mover o módulo de erros. Para iniciarmos o processo, precisamos mover a definição do módulo de db/mod.rs
para model/mod.rs
:
#![allow(unused)] fn main() { //db/mod.rs pub mod helpers; pub mod todo; pub mod auth; // model/mod.rs use aws_sdk_dynamodb::model::AttributeValue; use std::collections::HashMap; use uuid::Uuid; pub mod auth; pub mod error; // ... }
Movemos todo o arquivo e precisamores modificar o caminho do use
deste arquivo nos seguintes arquivos:
src/todo_api/core
emmod.rs
emodel.rs
.src/todo_api/db/auth.rs
src/todo_api_web/model/auth.rs
São mudanças bastante simples, basta substituir o db
pelo model
nos caminhos dos use
. E para a segunda mudança, vamos criar o módulo core
em model/mod.rs
com pub mod core
e mover o arquivo core/model.rs
para model/core.rs
. Vamos modificar os mesmos arquivos que modificamos em db/error
, a única diferença é que a função generate_jwt
incorporava Jwt
em seu escopo de forma individual. Executando nossos testes com make test
está tudo ok e podemos continuar para implementar o requerimento de jwt nas chamadas dos endpoints que já temos.
Exigindo Autenticação
Agora que implementamos a lógica de login, precisamos aplicar ela ao nosso serviço. Atualmente nosso serviço possui 4 conjuntos de rotas, /auth/
, /api/
, /ping
, /~/ready
. Dessas rotas somente uma precisa de autenticação (api
) enquanto as outras servem para fazer a autenticação (auth
), ver a saúde do serviço (ping
) e ver a disponibildiade de receber chamadas do serviço (ready
). Assim, precisamos implementar "algo" que vai aplicar o sistema de login somente a rota /api
. Esse algo será um middleware que nós vamos construir, ao contrário dos outros que já utilizamos, e lidará com a lógica de autenticação.
Middleware
Já utilizamos Middlewares anterioemente, mas como foram utilizações superficiais não foi preciso entender mais a fundo o que eram. Agora creio que seja um momento interessante de defini-los. Middlewares não são um conceito exclusivo de aplicações web, podendo ser utilizados tanto em sistemas operacionais como em programas do dia a dia. Os middlewares proveem um conjunto de serviços e capacidades comuns a uma aplicação que sua base não prove. Esses serviços e capacidades podem ser gerenciamento de dados, tratamento de mensagens, autenticação e logs. Assim, middlewares atual como um tecido conectivo de vários serviços da aplicação.
No caso de middlewares de aplicações web, geralmente sua funcionalidade é adicionar comportamentos aos processamento de request e de response. Eles consegue se conectar a um request, ou a um response, que chegou ao servidor e alterar este request, inclusive respondendo antes do esperado, ou alterando o response. Utilizamos o middleware de
Logger
para incluir logs ao processamento do nosso request e o middleware deDefaultHeaders
para incluir um header em nosso response.
As alterações a seguir nos exigiram modificar o Cargo.toml para conter a crate futures
e mover a crate actix-server
para dependencies
:
[dependencies]
actix = "0.9.0"
#...
jsonwebtokens = "1.2.0"
actix-service = "2.0.2"
futures = "0.3"
[dev-dependencies]
bytes = "0.5.3"
Estrutura de um middleware com Actix
Um middleware pode ser registrado em cada App
, scope
ou Resource
do servidor e é executado em ordem oposta a seu registro. De modo geral, middlewares em Actix são um tipo, preferenciamente uma struct, que implementa as traits Service
e Transform
, da crate actix_service
. Assim, cada um dos métodos da trait tem a capacidade de responder algo imediatamente ou através de uma future. O exemplo mais básico de Middleware seria um hello world
no request (Hello from Request
) e outro na response (Hello from Response
), onde usamos wrap_fn
para criar o middleware, conforme o código a seguir:
use actix_web::{dev::Service as _, web, App}; use futures_util::future::FutureExt; #[actix_web::main] async fn main() { let app = App::new() .wrap_fn(|req, srv| { println!("Hi from start. You requested: {}", req.path()); srv.call(req).map(|res| { println!("Hi from response"); res }) }) .route( "/index.html", web::get().to(|| async { "Hello, middleware!" }), ); }
Definindo o Middleware de autenticação
A primeira coisa que devemos fazer aqui é criar o módulo middleware em nosso código. Esse módulo estará contido em src/todo_api_web/middleware/mod.rs
. A função authentication_middleware
será passada como argumento para o wrap
de App
, App:new().wrap(from_fn(authentication_middleware))
. Este bloco de código fica assim:
#![allow(unused)] fn main() { use crate::todo_api::{core::decode_jwt, model::core::JwtValue}; use actix_web_lab::middleware::Next; use actix_web::Error; use actix_web::{ body::MessageBody, dev::{ServiceRequest, ServiceResponse}, web::Data, }; use super::model::http::Clients; pub async fn authentication_middleware( mut req: ServiceRequest, next: Next<impl MessageBody>, ) -> Result<ServiceResponse<impl MessageBody>, Error> { let data = req.extract::<Data<Clients>>().await.unwrap(); let jwt = req.headers().get("x-auth"); match jwt { None => Err(actix_web::error::ErrorInternalServerError( "Error in authentication middleware", )), Some(token) => { let decoded_jwt: JwtValue = serde_json::from_value(decode_jwt(token.to_str().unwrap())) .expect("Failed to parse Jwt"); let valid_jwt = data.postgres.send(decoded_jwt); let fut = next.call(req).await?; match valid_jwt.await { Ok(true) => { let (req, res) = fut.into_parts(); let res = ServiceResponse::new(req, res); Ok(res) } _ => Err(actix_web::error::ErrorInternalServerError( "Error in authentication middleware", )), } } } } }
A função call
é o centro de nossa atenção, sendo ela responsável pela manipulação de dados que queremos fazer. O primeiro caso que vamos ver é o fato de querermos que este middleware atue somente nas rotas /api/
, assim temos duas soluções para isso. A primeira seria adicionar este middleware diretamente em web::scope("api/")
do arquivo de rotas:
#![allow(unused)] fn main() { pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("") .service( web::scope("/api") .service(create_todo) .service(show_all_todo) .wrap(from_fn(authentication_middleware)), ) .service( web::scope("/auth") .service(signup_user) .service(login) .service(logout), ) .service(ping) .service(readiness) .default_service(web::to(|| HttpResponse::NotFound())), ); } }
Não gosto muito desta alternativa pois ela impacta a testabilidade. Portanto, prefiro a segunda alternativa que é criar uma condicional que verifica se a rota do request começa com os scope
que queremos. Caso a condicional for verdadeira, aplicamos nossa lógica, senão, simplesmente damos sequência ao request:
#![allow(unused)] fn main() { pub async fn authentication_middleware( mut req: ServiceRequest, next: Next<impl MessageBody>, ) -> Result<ServiceResponse<impl MessageBody>, Error> { if req.path().starts_with("/api/") { // ... } else { let fut = next.call(req).await?; let (req, res) = fut.into_parts(); let res = ServiceResponse::new(req, res); Ok(res) } } }
O if
que definimos extrai o path
, rota, de ServiceRequest
, e aplica a função starts_with
com o início da rota que queremos, /api/
. Com isso, toda as rotas do serviço que começarem com /api/
serão alteradas por este middleware. O próximo passo é extrairmos o header x-auth
dos headers do request:
#![allow(unused)] fn main() { pub async fn authentication_middleware( mut req: ServiceRequest, next: Next<impl MessageBody>, ) -> Result<ServiceResponse<impl MessageBody>, Error> { if req.path().starts_with("/api/") { let data = req.extract::<Data<Clients>>().await.unwrap(); let jwt = req.headers().get("x-auth"); // ... } } }
Exatraimos o header x-auth
aplicando a função headers
a request, que obtém todos os headers, e posteriormente escolhendo um header específico com get
. O retorno desta função é um Option<String>
contendo a String de Jwt. Como este campo é obrigatório, fazemos um match
em jwt
e no caso None
retornamos um BadRequest
com a informação que x-auth
é requerido, x-auth is required
. Depois disso precisamos de duas coisas, decodificar o token Jwt e enviar a resposta decodificada para validar ela no banco de dados, assim precisaremos da função decode_jwt
para decodificar o jwt
e de Clients
armazenado em data
para comunicar com o banco de dados:
#![allow(unused)] fn main() { pub async fn authentication_middleware( mut req: ServiceRequest, next: Next<impl MessageBody>, ) -> Result<ServiceResponse<impl MessageBody>, Error> { if req.path().starts_with("/api/") { let data = req.extract::<Data<Clients>>().await.unwrap(); let jwt = req.headers().get("x-auth"); match jwt { None => Err(actix_web::error::ErrorInternalServerError( "Error in authentication middleware", )), Some(token) => { let decoded_jwt: JwtValue = serde_json::from_value(decode_jwt(token.to_str().unwrap())) .expect("Failed to parse Jwt"); let valid_jwt = data.postgres.send(decoded_jwt); let fut = next.call(req).await?; match valid_jwt.await { Ok(true) => { let (req, res) = fut.into_parts(); let res = ServiceResponse::new(req, res); Ok(res) } _ => Err(actix_web::error::ErrorInternalServerError( "Error in authentication middleware", )), } } } } else { // ... } }
Para recordar
decode_jwt
#![allow(unused)] fn main() { pub fn decode_jwt(jwt: &str) -> Value { use jsonwebtokens::raw::{decode_json_token_slice, split_token, TokenSlices}; let TokenSlices { claims, .. } = split_token(jwt).unwrap(); let claims = decode_json_token_slice(claims).expect("Failed to decode token"); claims } }
Nossa função call
transforma o token em uma struct JwtValue
, que corresponde aos campos presentes no token, através da função serde_json::from_value
. O valor de JwtValue
é enviado para o banco de dados através de data.postgres.send(decoded_jwt)
. Com o resultado da troca de mensagens com DbExecutor
via data.postgres.send
recebemos um tipo Result<bool, MailBoxError>
e fazemos match
. O único caso que nos interessa é o Ok(true)
, para todos os outros lançamos uma erro. Este erro retornará InternalServerError
, pois não podemos reutilizar o conteúdo de req
já que foi utilizado em let fut = self.service.call(req);
para conretizar o request. Caso o resultado do match
seja Ok(true)
, deixamos o request prosseguir. Agora, vamos a implementação de JwtValue
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug)] pub struct JwtValue { pub id: String, pub email: String, pub expires_at: chrono::NaiveDateTime, } impl Message for JwtValue { type Result = bool; } impl Handler<JwtValue> for DbExecutor { type Result = bool; fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::token_is_valid; let user = token_is_valid(&msg, &self.0.get().expect("Failed to open connection")); match user { Err(_) => false, Ok(user) => { match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) { (true, true, true) => true, _ => false } } } } } }
Nosso token Jwt possui 3 campos em seu claim
id, email, expires_at
, assim a implementação de JwtValue
possui estes 3 campos, definidos como String, String, chrono::NaiveDateTime
, respectivamente. Depois definimos a trait Message
, com o tipo type Result = bool;
. Para Handler
, procuramos o User
com token_is_valid(&msg, &self.0.get().expect("Failed to open connection"))
e depois aplicamos match
a sua resposta. Em caso de Err
, retornamos false
, e em caso de Ok
, verificamos todas as condições que queremos em outro match
(poderia ser um if/else
, mas creio que o match ficou mais elegante devido ao uso da tupla). Caso todos os itens da tupla, (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id)
sejam verdadeiros, retornamos true
, senão false
. Os itens da tupla são:
- Usuário está ativo com
user.is_active
. - Data atual é inferior a data
expires_at
do token comvalidate_jwt_date(user.expires_at)
. - O id do token é o mesmo do usuário cadastrado com
user.id.to_string() == msg.id
.
Quanto a nossa função token_is_valid
:
#![allow(unused)] fn main() { pub fn token_is_valid(token: &JwtValue, conn: &PgConnection) -> Result<User, DbError> { use crate::schema::auth_user::dsl::*; let items = auth_user.filter(email.eq(&token.email)).load::<User>(conn); match items { Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit), Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser), Ok(users) => Ok(users.first().unwrap().clone().to_owned()), Err(_) => Err(DbError::CannotFindUser), } } }
Note que ela é praticamente igual a função scan_user
. Assim, podemos substituir ela por scan user, obtendo o campo email
de JwtValue
:
#![allow(unused)] fn main() { impl Handler<JwtValue> for DbExecutor { type Result = bool; fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; let user = scan_user(String::from(&msg.email), &self.0.get().expect("Failed to open connection")); match user { Err(_) => false, Ok(user) => { match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) { (true, true, true) => true, _ => false } } } } } }
Com tudo isso pronto, basta adicionar o middleware em App::new()
:
#[actix_web::main] async fn main() -> Result<(), std::io::Error> { std::env::set_var("RUST_LOG", "actix_web=debug"); env_logger::init(); let client = Clients::new().await; create_table(&client.clone()).await; HttpServer::new(move|| { App::new() .app_data(Data::new(client.clone())) .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o")) .wrap(from_fn(authentication_middleware)) .configure(app_routes) }) .workers(num_cpus::get() - 2) .max_connections(30000) .bind(("0.0.0.0", 4000)) .unwrap() .run() .await }
Testando o middleware
Nosso middleware funciona bem, e basta executar o comando make run
para se divertir com ele, porém não temos nenhum teste que garante o comportamento do middleware. Assim, podemos criar pelo menos dois testes. O primeiro teste é não enviar um header x-auth
para uma rota /api/
e o segundo teste é enviar um token aleatório. Como decode_token
possui uma versão para feature db-test
, o resultado será sempre um user válido. Assim, vamos ao primeiro teste:
#![allow(unused)] fn main() { #[cfg(test)] mod middleware { use actix_web::{test, App, http::StatusCode}; use dotenv::dotenv; use todo_server::todo_api_web::model::http::Clients; use todo_server::todo_api_web::routes::app_routes; use crate::helpers::read_json; #[actix_rt::test] async fn bad_request_todo_post() { dotenv().ok(); let mut app = test::init_service( App::new() .data(Clients::new()) .wrap(todo_server::todo_api_web::middleware::Authentication) .configure(app_routes) ).await; let req = test::TestRequest::post() .uri("/api/create") .header("Content-Type", "application/json") .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } } }
Este teste é práticamente igual ao teste que criamos uma todo_card
, porém possui a função .wrap(todo_server::todo_api_web::middleware::Authentication)
associada a App
e em vez de validar a resposta valida o status como BAD_REQUEST
. Depois disso, podemos criar um teste que adiciona um header x-auth
com um Jwt contendo valores aleatórios para nossos campos:
{
"id": "7562bf53-6156-433b-a201-90bbc74b0127",
"email": "my@email.com",
"expires_at": "2014-11-28T12:00:09"
}
Algoritmo HS256 com chave `your-256-bit-==secret`
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijc1NjJiZjUzLTYxNTYtNDMzYi1hMjAxLTkwYmJjNzRiMDEyNyIsImVtYWlsIjoibXlAZW1haWwuY29tIiwiZXhwaXJlc19hdCI6IjMwMjAtMTEtMjhUMTI6MDA6MDkifQ.hom6KvmmLIuu3dLCSUrOK9KBWyUb0fvdX4hIay52UIY
O teste para estes valores é:
#![allow(unused)] fn main() { #[actix_rt::test] async fn good_token_todo_post() { dotenv().ok(); let mut app = test::init_service( App::new() .data(Clients::new()) .wrap(todo_server::todo_api_web::middleware::Authentication) .configure(app_routes) ).await; let req = test::TestRequest::post() .uri("/api/create") .header("Content-Type", "application/json") .header("x-auth", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijc1NjJiZjUzLTYxNTYtNDMzYi1hMjAxLTkwYmJjNzRiMDEyNyIsImVtYWlsIjoibXlAZW1haWwuY29tIiwiZXhwaXJlc19hdCI6IjMwMjAtMTEtMjhUMTI6MDA6MDkifQ.hom6KvmmLIuu3dLCSUrOK9KBWyUb0fvdX4hIay52UIY") .set_payload(read_json("post_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; println!("{:?}", resp); assert_eq!(resp.status(), StatusCode::CREATED); } }
Se executarmos este teste vamos receber como resposta um panic!
, pois o middleware vai conter um is_active
false que vai se encadear para um Err
. Assim, precisaremos fazer algumas modificações em scan_user
, User.from
e handle
de JwtValue. Com isso, as modificações serão em ordem de encadeamento:
#![allow(unused)] fn main() { // src/todo_api/model/core.rs impl Handler<JwtValue> for DbExecutor { type Result = bool; #[cfg(not(feature = "dbtest"))] fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::scan_user; let user = scan_user(String::from(&msg.email), &self.0.get().expect("Failed to open connection")); match user { Err(_) => false, Ok(user) => { match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) { (true, true, true) => true, _ => false } } } } #[cfg(feature = "dbtest")] fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result { use crate::todo_api::db::auth::test_scan_user; let user = test_scan_user(String::from(&msg.email), String::from(&msg.id), &self.0.get().expect("Failed to open connection")); match user { Err(_) => false, Ok(user) => { match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) { (true, true, true) => true, _ => false } } } } } // src/todo_api/db/auth.rs #[cfg(not(feature = "dbtest"))] pub fn scan_user(user_email: String, conn: &PgConnection) -> Result<User, DbError> { use crate::schema::auth_user::dsl::*; let items = auth_user.filter(email.eq(&user_email)).load::<User>(conn); match items { Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit), Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser), Ok(users) => Ok(users.first().unwrap().clone().to_owned()), Err(_) => Err(DbError::CannotFindUser), } } #[cfg(feature = "dbtest")] pub fn scan_user(user_email: String, _conn: &PgConnection) -> Result<User, DbError> { use crate::schema::auth_user::dsl::*; use diesel::debug_query; use diesel::pg::Pg; let query = auth_user.filter(email.eq(&user_email)); let expected = "SELECT \"auth_user\".\"email\", \"auth_user\".\"id\", \"auth_user\".\"password\", \"auth_user\".\"expires_at\", \"auth_user\".\"is_active\" FROM \"auth_user\" WHERE \"auth_user\".\"email\" = $1 -- binds: [\"my@email.com\"]".to_string(); assert_eq!(debug_query::<Pg, _>(&query).to_string(), expected); Ok(User::from(user_email, "this is a hash".to_string())) } #[cfg(feature = "dbtest")] pub fn test_scan_user(user_email: String, auth_id: String, _conn: &PgConnection) -> Result<User, DbError> { Ok(User::test_from(user_email, "this is a hash".to_string(), auth_id)) } // src/todo_api/model/auth.rs impl User { pub fn from(email: String, password: String) -> Self { let utc = crate::todo_api::db::helpers::one_day_from_now(); Self { email: email, id: uuid::Uuid::new_v4(), password: password, expires_at: utc.naive_utc(), is_active: false, } } #[cfg(feature = "dbtest")] pub fn test_from(email: String, password: String, id: String) -> Self { let utc = crate::todo_api::db::helpers::one_day_from_now(); Self { email: email, id: uuid::Uuid::parse_str(&id).unwrap(), password: password, expires_at: utc.naive_utc(), is_active: true, } } // ... } }
Precisamos modificar handle
pois precisamos enviar id
como argumento para validar seu valor posteriormente. Como enviamos id, precisamos modificar scan_user
para criar um test_scan_user
que receba o id
como argumento e passe para um User::from
que também suporte configurar id
e definir is_active
como true
. Com isso, todos nossos testes passam e podemos prosseguir para os últimos passos, criar um CI, obter um todo
pelo seu id
e fazer update de um todo
.
Configurando um CI
Como já temos a estrutura central de nosso projeto pronta (autenticação com postgres, gerenciamento de dados com DynamoDB e middlewares) podemos começar a pensar em um CI. Para isso, devemos considerar as restrições que temos no nosso make test
, que executamos contra um container docker do psotgres:
db:
docker run -i --rm --name auth-db -p 5432:5432 -e POSTGRES_USER=auth -e POSTGRES_PASSWORD=secret -d postgres
int: db
sleep 2
diesel setup
diesel migration run
cargo test --test lib --no-fail-fast --features "dbtest" -- --test-threads 3
diesel migration redo
unit:
cargo test --locked --no-fail-fast --lib -- --test-threads 3
test: unit int
Nosso Makefile para testes consistem em 3 alvos int
para testes de integração, unit
para testes unitários e test
para executar os dois. Algo que podemos ficar em dúvida é o porquê deles estarem separados em int
e unit
, o motivo é simplesmente que é mais fácil executar os testes unitários e eles são executados mais rapidamente que os de integração. Bom, vamos aos nossos alvos de teste. o alvo unit
possui as flags --lib
que executa somente os testes encontrados nos módulos de lib.rs
e a flag --no-fail-fast
que executa todos os testes mesmo que algum deles falhe. Para um CI é bastante útil esta flag de --no-fail-fast
, pois queremos saber tudo que precisamos corrigir e, assim, não precisarmos ficar corrigindo a cada commit para a branch. Já o teste de integração possui diversos passos:
- executa o target
db
, que sobe um container docker com postgres. sleep 2
pausa o processo por 2 segundos até que o container se estabilize.diesel setup
, falamos anteriormente, mas ajuda o diesel a configurar o banco recém gerado (pode não ser necessário).disel migration run
, executa as migrações para o banco.cargo test
, os testes em si.diesel migration redo
, não precisa estar presente no CI, pois o contaienr com postgres será destruido depois da execução, não necessitando fazerrollback
das migrações.
Agora vamos entender o item 5
. Executamos o cargo test
com a flag --test
que indica que vamos executar os testes de integração apenas do arquivo lib.rs
, que contém nossos módulos. Depois ativamos a feature dbtest
com --features "dbtest"
. Agora que sabemos os passos que precisamos executar no CI podemos começar a pensar em sua criação.
Travis-CI
O modelo de CI que escolhi para este projeto é o Travis-CI, pois acredito que seja bem simples e executa tudo que precisamos fazer, build e testes. Para executar o travis-ci é necessário entrar no site deles, https://travis-ci.org/, se registrar e dar permissão para o seu github, existe um botão que já faz a autorização e o registro Sign in with github
. Caso seus repositórios não apareçam, sugiro clicar em profile
, imagem no canto superior direito, e na página de profile
clicar em sync account
. Agora basta você dar permissão para os repositórios específicos clicando nas chaves ao lado do nome dos repositórios.
Uma vez que esse processo de registro estiver pronto, você vai precisar adicionar um arquivo .travis.yml
ao seu repositório. Este arquivo é uma sequencia de comandos que o CI precisará executar. No nosso caso, este arquivo terá o seguinte formato:
language: rust
rust:
- nightly
- 1.40.0
- stable
cache: cargo
services:
- postgresql
before_script:
- psql -c 'create database auth_db;' -U postgres
- echo "DATABASE_URL=postgres://postgres@localhost/auth_db" > .env
- cargo install diesel_cli --no-default-features --features=postgres
- diesel migration run
script:
- cargo build --verbose --all
- cargo test --locked --no-fail-fast --lib
- cargo test --test lib --no-fail-fast --features "dbtest"
O primeiro passo é declarar qual linguagem que estamos utilizando, no nosso caso rust
, language: rust
. Depois disso, definimos quais targets de rust vamos executar nosso código. Como a versão de rust que eu estou suando é a 1.40
, espero que o código tenha que passar pelo menos nesta versão, além disso, gostaria de testar em nightly
e na última stable
sob a chave rust:
. Precisamos do cargo
também, para isso salvamos seu conteúdo em cache com cache: cargo
. O serviço que vamos utilizar como banco de dados é o postgres, assim é necessário declará-lo em services: - postgresql
. O próximo passo corresponde aos scripts q devemos executar antes dos nossos cenários de teste, para isso utilizamos a chave before_script
com os seguintes ítens:
psql -c 'create database auth_db;' -U postgres
, corresponde aodiesel setup
e nos permite criar a base de dadosauth_db
no postgres.echo "DATABASE_URL=postgres://postgres@localhost/auth_db" > .env
é preciso ter o campoDATABASE_URL
configurado em seu.env
para executar oDbExecutor
, assim utilizamos oecho <campo exportado> > .env
para enviar o campo exportado para.env
.cargo install diesel_cli --no-default-features --features=postgres
instalamos odiesel_cli
para poder executar as migrações.diesel migration run
, executamos as migrações.
Por último, definimos os scripts que vamos executar com a chave script:
:
cargo build --verbose --all
, testar se o build funciona.cargo test --locked --no-fail-fast --lib
executar os testes unitários.cargo test --test lib --no-fail-fast --features "dbtest"
, executar os testes de integração.
Quando comitarmos isso e habilitarmos o travis ler este repositório, teremos um resultado como este:
Agora podemos terminar nosso todo sever implementando um get by id
.
Concluindo o serviço
Falta pouco para termos nosso serviço pronto, pois precisamos implementar um get
por id e um update
. O get
por id não é muito diferente da rota index
, a única diferença é que vamos passar um parâmetro id
e chamaremos a rota de show
e será um método GET
também. Já o update
é um pouco diferente pois vamos enviar um corpo Json com as informações para atualizar em uma rota update
com o método PUT
. Assim, os endpoints que vamos implementar são:
- HTTP autenticado em
show/{id}
com o métodoGET
. - HTTP autenticado em
update/{id}
com o métodoPUT
e um body do tipo Json.
Show por ID
Como já falamos anteriormente, nosso objetivo agora é recuperar um TodoCard
com base em seu id
de inserção no banco de dados. Faremos isso utilizando a mesma função que utilizamos na rota index
, scan
. Para isso, sabemos que vamos precisar da rota show/{id}
, como já mencionamos, e vamos precisar retornar um TodoCard
. Assim, imagino que um bom teste para este cenário seria o seguinte:
#![allow(unused)] fn main() { #[cfg(test)] mod show_by_id { use actix_web::{test, App}; use dotenv::dotenv; use todo_server::todo_api_web::model::{ http::Clients, todo::TodoCard, }; use todo_server::todo_api_web::routes::app_routes; use serde_json::from_str; use crate::helpers::{mock_get_todos}; #[actix_rt::test] async fn test_todo_card_by_id() { dotenv().ok(); let mut app = test::init_service(App::new().data(Clients::new()).configure(app_routes)).await; let req = test::TestRequest::with_uri("/api/show/544e3675-19f5-4455-9ed9-9ccc577f70fe").to_request(); let resp = test::read_response(&mut app, req).await; let todo_card: TodoCard = from_str(&String::from_utf8(resp.to_vec()).unwrap()).unwrap(); assert_eq!(&todo_card, mock_get_todos().get(0usize).unwrap()); } } }
Este teste consiste em definir um request com um uuid, neste caso aleatório, para a rota show
com test::TestRequest::with_uri("/api/show/544e3675-19f5-4455-9ed9-9ccc577f70fe").to_request()
. Com o request em mão, chamamos o serviço para obter uma respose com test::read_response(&mut app, req).await
e convertemos esta response em um TodoCard
, let todo_card: TodoCard = from_str(&String::from_utf8(resp.to_vec()).unwrap()).unwrap()
. Como vamos mockar a resposta de TodoCard
com o primeiro valor de mock_get_todos
, basta comparar os dois com assert_eq!(&todo_card, mock_get_todos().get(0usize).unwrap())
.
O primeiro passo para resolver este teste é adicionar a rota a função app_routes
:
#![allow(unused)] fn main() { // src/todo_api_web/routes.rs // ... pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("/") .service( web::scope("api/") .route("create", web::post().to(create_todo)) .route("index", web::get().to(show_all_todo)) .route("show/{id}", web::get().to(show_by_id)), ) .service( web::scope("auth/") .route("signup", web::post().to(signup_user)) .route("login", web::post().to(login)) .route("logout", web::delete().to(logout)), ) .route("ping", web::get().to(pong)) .route("~/ready", web::get().to(readiness)) .route("", web::get().to(|| HttpResponse::NotFound())), ); } }
Para recebermos o ID como argumento de rota precisamos definir-lo como {id}
, depois disso fazemos um GET
redirecionando o request para o controller show_by_id
:
#![allow(unused)] fn main() { // src/todo_api_web/controller/todo.rs // ... pub async fn show_by_id(id: web::Path<String>, state: web::Data<Clients>) -> impl Responder { let uuid = id.to_string(); match get_todo_by_id(uuid, state.dynamo.clone()) { None => { error!("Failed to read todo cards"); HttpResponse::NotFound().finish() } Some(todo_id) => HttpResponse::Ok().content_type("application/json") .json(todo_id) } } }
Na função show_by_id
vemos um ítem novo logo de cara, web::Path<String>
, a função deste ítem é extrair o conteúdo dos argumentos presentes na url do request, ou seja, todas as chaves encontradas entres os símbolos {
e }
, no nosso caso {id}
. Para o caso de um único argumento a estrutura de web::Path
é como estamos utilizando, mas para o caso de mais argumentos se utiliza tuplas para definir a sequencia de argumentos, por exemplo /api/show/{id}/task/{title}
, uma rota para obter o status de uma task
de um TodoCard
de id
específico, obteriamos os valores com web::Path<(String,String)>
. Valores diferentes de string podem ser passados desde que sejam serializáveis pelo serviço, por exemplo o código que escrevemos poderia substituir String
por Uuid
, caso fossemos utiliza-la:
#![allow(unused)] fn main() { pub async fn show_by_id(id: web::Path<uuid::Uuid>, state: web::Data<Clients>) -> impl Responder { let uuid = id.into_inner().to_string(); // ... } }
Não vamos utilizar o web::Path
com Uuid
pois, no futuro, vamos querer enviar um response BadRequest
caso o campo id
não seja um Uuid
. Se deixassemos assim o response seria InternalServerError
, que não é um status muito indicativo. Mantendo o web::Path
como String
passamos ao próximo ítem, uma funcnao de todo_api/db/todo.rs
que recupera um TodoCard
com base em seu id
, get_todo_by_id
. Os argumentos passados a get_todo_by_id
são uma String
contendo o id
e o cliente para dynamo
. Essa função retorna o tipo Option<TodoCard>
, que para o padrão None
vai retornar um status NotFound
, indicando que este elemento não foi encontrado e para o caso Some
vai retornar um Ok
com um corpo contendo um Json com o valor do TodoCard
encontrado.
A função get_todo_by_id
é semelhante a função get_todos
, mas com uma pequerna diferença, a struct ScanInput
utilizanda para fazer a busca no banco possui dois campos extras filter_expression
e expression_attribute_values
. filter_expression
é responsável por definir qual vai ser o filtro aplicado a este scan
, por exemplo =, >=, <
. No nosso caso, nossa filter_expression
será Some("id = :id".into())
, ou seja, vamos procurar um id
que seja igual ao argumento :id
. Poderiamos ter mais filtros em filter_expression
, mas usaremos somente esse. Agora precisamos definir o argumento :id
para aplicar em filter_expression
. Este argumento é adicionado a query através de expression_attribute_values
, que recebe um HashMap
contendo o nome das chaves, :id
no nosso caso, e um AttributeValue
com a informação de id
:
#![allow(unused)] fn main() { use std::collections::HashMap; use rusoto_dynamodb::{AttributeValue, DynamoDb}; let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from(":id"), attr); let scan_item = ScanInput { // ... filter_expression: Some("id = :id".into()), expression_attribute_values: Some(_map), ..ScanInput::default() } }
Filter Expression
A lista de possíveis operadores para
filter_expression
é a seguinte:
- Funções:
attribute_exists | attribute_not_exists | attribute_type | contains | begins_with | size
, todas sensitivas a letras maísculas.- Operadores de comparação:
= | <> | < | > | <= | >= | BETWEEN | IN
- Operadores lógicos:
AND | OR | NOT
Com a Struct ScanInput
definida podemos executar a query em si com client.scan(scan_item).sync()
e aplicar um match
a resposta de scan
. Existem dois padrões possíveis Ok
e Err
, como nosso controller espera um Option<TodoCard>
retornamos um None
no caso de Err
. E no caso de Ok
ainda temos que cuidar o caso de a resposta de Ok
vir vazia:
#![allow(unused)] fn main() { match client.scan(scan_item).sync() { Ok(resp) => { let todo_id = adapter::scanoutput_to_todocards(resp); if todo_id.first().is_some() { debug!("Scanned {:?} todo cards", todo_id); Some(todo_id.first().unwrap().to_owned()) } else { error!("Could find todocard with ID."); None } } Err(e) => { error!("Could not scan todocard due to error {:?}", e); None } } }
Como a estrutura de resp
é um ScanOutput
, como em get_todos
, podemos aplicar o mesmo adapter adapter::scanoutput_to_todocards
a resp
, porém a resposta deste adapter será um vetor de TodoCard
. Como queremos somente um único elemento na resposta dessa query, aplicamos a função first
e validamos o caso de ela não retornar Some
, indicando com uma respostas None
. Para o caso de retornar sim, retornamos um Option
com o primeiro TodoCard
com Some(todo_id.first().unwrap().to_owned())
. A função completa ficou como a seguir, funcnao de teste esta logo depois retornando apenas Some(TodoCard{...})
:
#![allow(unused)] fn main() { #[cfg(not(feature = "dbtest"))] pub fn get_todo_by_id(id: String, client: DynamoDbClient) -> Option<TodoCard> { use rusoto_dynamodb::{AttributeValue, DynamoDb}; use std::collections::HashMap; let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from(":id"), attr); let scan_item = ScanInput { limit: Some(100i64), table_name: TODO_CARD_TABLE.to_string(), filter_expression: Some("id = :id".into()), expression_attribute_values: Some(_map), ..ScanInput::default() }; match client.scan(scan_item).sync() { Ok(resp) => { let todo_id = adapter::scanoutput_to_todocards(resp); if todo_id.first().is_some() { debug!("Scanned {:?} todo cards", todo_id); Some(todo_id.first().unwrap().to_owned()) } else { error!("Could find todocard with ID."); None } } Err(e) => { error!("Could not scan todocard due to error {:?}", e); None } } } #[cfg(feature = "dbtest")] pub fn get_todo_by_id(id: String, client: DynamoDbClient) -> Option<TodoCard> { use rusoto_dynamodb::{AttributeValue, DynamoDb}; use std::collections::HashMap; use crate::todo_api_web::model::todo::{State, Task}; let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from(":id"), attr); let scan_item = ScanInput { limit: Some(100i64), table_name: TODO_CARD_TABLE.to_string(), filter_expression: Some("id = :id".into()), expression_attribute_values: Some(_map), ..ScanInput::default() }; Some( TodoCard { id: Some(uuid::Uuid::parse_str("be75c4d8-5241-4f1c-8e85-ff380c041664").unwrap()), title: String::from("This is a card"), description: String::from("This is the description of the card"), owner: uuid::Uuid::parse_str("ae75c4d8-5241-4f1c-8e85-ff380c041442").unwrap(), tasks: vec![ Task { title: String::from("title 1"), is_done: true, }, Task { title: String::from("title 2"), is_done: true, }, Task { title: String::from("title 3"), is_done: false, }, ], state: State::Doing, } ) } }
Validando o Uuid
Nosso próximo passo é validar que o formato enviado é um Uuid
. Para isso criaremos um teste que faz um request com um formato aleatório de dado e retorna BadRequest
com a mesagem que "id deve ser um Uuid".
#![allow(unused)] fn main() { #[actix_rt::test] async fn test_todo_card_without_uuid() { dotenv().ok(); let mut app = test::init_service(App::new().data(Clients::new()).configure(app_routes)).await; let req = test::TestRequest::with_uri("/api/show/fake-uuid").to_request(); let resp = test::read_response(&mut app, req).await; let message = String::from_utf8(resp.to_vec()).unwrap(); assert_eq!(&message, "Id must be a Uuid::V4"); } }
Para resolver este teste a implementação de código é bastante simples, basta adicioanrmos um if
que verifica se o parse_str
é do tipo Err
e em caso de true
retornar HttpResponse::BadRequest().body("Id must be a Uuid::V4")
. Assim, nossa função ficou da seguinte forma:
#![allow(unused)] fn main() { pub async fn show_by_id(id: web::Path<String>, state: web::Data<Clients>) -> impl Responder { let uuid = id.to_string(); if uuid::Uuid::parse_str(&uuid).is_err() { return HttpResponse::BadRequest().body("Id must be a Uuid::V4"); } match get_todo_by_id(uuid, state.dynamo.clone()) { None => { error!("Failed to read todo cards"); HttpResponse::NotFound().finish() } Some(todo_id) => HttpResponse::Ok().content_type("application/json") .json(todo_id) } } }
Atualizando TodoCards
Agora vamos aprender como atualizar as informações de uma TodoCard
no DynamoDB. Vamos focar em atualizar somente dois atributos description
e state
, depois discutiremos estratégias para implementar updates em tasks
, pois os outros argumentos são essencialmente iguais a description
e state
. Agora precisamos definir como será nosso endpoint de atualização, para isso podemos definir sua rota como /api/update/{id}
e responderá via método PUT
. Assim, nosso body conterá os campos state
e/ou description
, como no exemplo de put_todo.json
:
{
"state": "Doing",
"description": "dfwgferf"
}
Um teste para esse cenário seria o seguinte:
#![allow(unused)] fn main() { // tests/test_api_web/controller.rs // ... #[cfg(test)] mod update { use actix_web::{test, App, http::StatusCode}; use dotenv::dotenv; use todo_server::todo_api_web::model::{ http::Clients, }; use todo_server::todo_api_web::routes::app_routes; use crate::helpers::{read_json}; #[actix_rt::test] async fn test_todo_card_by_id() { dotenv().ok(); let mut app = test::init_service(App::new().data(Clients::new()).configure(app_routes)).await; let req = test::TestRequest::put() .uri("/api/update/544e3675-19f5-4455-9ed9-9ccc577f70fe") .header("Content-Type", "application/json") .set_payload(read_json("put_todo.json").as_bytes().to_owned()) .to_request(); let resp = test::call_service(&mut app, req).await; assert_eq!(resp.status(), StatusCode::OK); } } }
Criando a Rota
Temos nosso teste, mas agora precisamos criar a rota em src/todo_api_web/routes.rs
seguindo o padrão PUT
na rota /api/update/{id}
:
#![allow(unused)] fn main() { use crate::todo_api_web::controller::{ // ... todo::{create_todo, show_all_todo, show_by_id, update_todo}, }; pub fn app_routes(config: &mut web::ServiceConfig) { config.service( web::scope("/") .service( web::scope("api/") .route("create", web::post().to(create_todo)) .route("index", web::get().to(show_all_todo)) .route("show/{id}", web::get().to(show_by_id)) .route("update/{id}", web::put().to(update_todo)), ) // ... ); } }
Agora, precisamos implementar o controller update_todo
em src/todo_api_web/controller/todo.rs
:
#![allow(unused)] fn main() { pub async fn update_todo( id: web::Path<String>, info: web::Json<TodoCardUpdate>, state: web::Data<Clients>) -> impl Responder { let uuid = id.to_string(); if uuid::Uuid::parse_str(&uuid).is_err() { return HttpResponse::BadRequest().body("Id must be a Uuid::V4"); } match update_todo_info(uuid, info.into_inner(), state.dynamo.clone()) { true => HttpResponse::Ok().finish(), false => HttpResponse::NotFound().finish() } } }
Os argumentos para a função update_todo
são id
que vem da rota da url {id}
com web::Path<String>
, info
que corresponde ao corpo do PUT
do tipo web::Json<TodoCardUpdate>
e o state
que vem do estao da aplicação com web::Data<Clients>
. Primeiro passo é converter o campo id
em String
com to_string
para validar se essa string é um Uuid
com uuid::Uuid::parse_str(&uuid)
e retornar um HttpResponse::BadRequest().body("Id must be a Uuid::V4")
caso o resultado de parse_str
seja do tipo Err
:
#![allow(unused)] fn main() { let uuid = id.to_string(); if uuid::Uuid::parse_str(&uuid).is_err() { return HttpResponse::BadRequest().body("Id must be a Uuid::V4"); } }
Depois disso, chamamos a função update_todo_info
que retorna um booleano para aplicarmos pattern matching em true
, retornando HttpResponse::Ok().finish()
, ou em false
, retornando HttpResponse::NotFound().finish()
. A função update_todo_info
está localizada em src/todo_api/db/todo.rs
e é bastante extensa:
#![allow(unused)] fn main() { #[cfg(not(feature = "dbtest"))] pub fn update_todo_info(id: String, info: TodoCardUpdate, client: DynamoDbClient) -> bool { use rusoto_dynamodb::{AttributeValue, DynamoDb}; use std::collections::HashMap; let expression = adapter::update_expression(&info); let attribute_values = adapter::expression_attribute_values(&info); let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from("id"), attr); let update = UpdateItemInput { table_name: TODO_CARD_TABLE.to_string(), key: _map, update_expression: expression, expression_attribute_values: attribute_values, ..UpdateItemInput::default() }; match client.update_item(update).sync() { Ok(_) => true, Err(e) => { error!("failed due to {:?}", e); false } } } }
A primeira coisa que precisamos ressaltar neste código é o UpdateItemInput
, que é a struct responsável por executar a atualização da todo
com o id
enviado na rota. Os campos necessários são table_name
, que é o nome da tabela, key
que é um AttributeValue
com todos os valores de key
, no nosso caso é somente id
, update_expression
que define quais argumentos serão atualizados através do adapter adapter::update_expression
, expression_attribute_values
que contém os argumentos para atualizar as informações através do adapter::expression_attribute_values
que transforma os valores de TodoCardUpdate
em um HashMap<String, AttributeValue>
. Assim, para transformar o id
em um HashMap<String, AttributeValue>
podemos utilizar a seguinte lógica:
#![allow(unused)] fn main() { let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from("id"), attr); }
A função para executar a atualização no Dynamo é update_item
, lembre-se que após o sync
o resultado é do tipo Result
, por isso do match
. Já os adapter são os seguintes:
#![allow(unused)] fn main() { // src/todo_api/adapter/mod.rs // ... pub fn update_expression(info: &TodoCardUpdate) -> Option<String> { let data = info.clone(); match (data.description, data.state) { (Some(_), Some(_)) => Some(String::from("SET description = :d, state_db = :s")), (_, Some(_)) => Some(String::from("SET state_db = :s")), (Some(_), _) => Some(String::from("SET description = :d")), _ => None } } pub fn expression_attribute_values(info: &TodoCardUpdate) -> Option<HashMap<String, AttributeValue>> { let data = info.clone(); match (data.description, data.state) { (Some(desc), Some(state)) => { let mut _map = HashMap::new(); let mut attr_d = AttributeValue::default(); attr_d.s = Some(String::from(desc)); let mut attr_s = AttributeValue::default(); attr_s.s = Some(String::from(state.to_string())); _map.insert(String::from(":d"), attr_d); _map.insert(String::from(":s"), attr_s); Some(_map) }, (_, Some(state)) => { let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(String::from(state.to_string())); _map.insert(String::from(":s"), attr); Some(_map) }, (Some(desc), _) => { let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(String::from(desc)); _map.insert(String::from(":d"), attr); Some(_map) }, _ => None } } }
update_expression
é responsável pro criar a expressão que vai determinar o que será atualizado. Como recebemos 2 campos Optional
, description
e state
, temos 4 possibilidades:
- Ambos existem retorna
"SET description = :d, state_db = :s")
. - Somente
state
existe retorna"SET state_db = :s"
. - Somente
description
existe retorna"SET description = :d"
. - Nenhum retorna um
None
.
Os testes para update_expression
são os seguintes:
#![allow(unused)] fn main() { #[cfg(test)] mod update_expression_test { use super::update_expression; use crate::todo_api_web::model::todo::{State, TodoCardUpdate}; #[test] fn description_and_state() { let todo_update = TodoCardUpdate {description: Some("haiushdusd".to_string()), state: Some(State::Doing)}; let expected = Some(String::from("SET description = :d, state_db = :s")); assert_eq!(expected, update_expression(&todo_update)); } #[test] fn description() { let todo_update = TodoCardUpdate {description: Some("haiushdusd".to_string()), state: None}; let expected = Some(String::from("SET description = :d")); assert_eq!(expected, update_expression(&todo_update)); } #[test] fn state() { let todo_update = TodoCardUpdate {description: None, state: Some(State::Doing)}; let expected = Some(String::from("SET state_db = :s")); assert_eq!(expected, update_expression(&todo_update)); } #[test] fn none() { let todo_update = TodoCardUpdate {description: None, state: None}; let expected = None; assert_eq!(expected, update_expression(&todo_update)); } } }
Já expression_attribute_values
é um pouco mais complicada pois deve retornar um Option<HashMap<String, AttributeValue>>
, mas as regras de pattern matching são as mesmas. Assim vamos entender o caso que existe tanto description
quanto state
. Para update_expression
não nos interessava o conteúdo da expression, assim utilizavamos Some(_)
para fazer pattern matching, porém em expression_attribute_values
eles interessam já que será inseridos dentro do HashMap
. A primeira cosia que devemos fazer é criar um HashMap
com let mut _map = HashMap::new();
e determinar os AttributeValue
para state
e para description
, let mut attr_s = AttributeValue::default();
e let mut attr_d = AttributeValue::default();
respectivamente. Depois disso, inserimos o conteúdo de state
e de description
no campo s
, de String, através de attr_d.s
, attr_s.s = Some(String::from(state.to_string()));
e attr_d.s = Some(String::from(desc));
. Inserimos estes valores no mapa com _map.insert(String::from(":d"), attr_d); _map.insert(String::from(":s"), attr_s);
e retornamos seu valor em Some(_map)
. A função para teste é a seguinte:
#![allow(unused)] fn main() { #[cfg(feature = "dbtest")] pub fn update_todo_info(id: String, info: TodoCardUpdate, client: DynamoDbClient) -> bool { use rusoto_dynamodb::{AttributeValue, DynamoDb}; use std::collections::HashMap; let expression = adapter::update_expression(&info); let attribute_values = adapter::expression_attribute_values(&info); let mut _map = HashMap::new(); let mut attr = AttributeValue::default(); attr.s = Some(id); _map.insert(String::from("id"), attr); let update = UpdateItemInput { table_name: TODO_CARD_TABLE.to_string(), key: _map, update_expression: expression, expression_attribute_values: attribute_values, ..UpdateItemInput::default() }; true } }
Agora vamos entender como nosso código mudaria para incluir os outros campos de atualização.
Atualizando outros campos
Considerando que a struct que temos no banco de dados é a seguinte e que o campo id
não será atualizado, podemos discutir como adicionar title
, owner
e tasks
:
#![allow(unused)] fn main() { pub struct TodoCard { pub id: Option<Uuid>, pub title: String, pub description: String, pub owner: Uuid, pub tasks: Vec<Task>, pub state: State, } }
Bom, title
e owner
são bastante triviais, pois bastaria expandir nossos adapters para lidarem com mais duas strings, modificando nossa struct TodoCardUpdate
para:
#![allow(unused)] fn main() { pub struct TodoCardUpdate { pub description: Option<String>, pub state: Option<State>, pub title: Option<String>, pub owner: Option<Uuid> } }
Já o adapter update_expression
ficaria semelhante ao seguinte:
#![allow(unused)] fn main() { pub fn update_expression(info: &TodoCardUpdate) -> Option<String> { let data = info.clone(); match (data.description, data.state, data.title, data.owner) { (Some(_), Some(_), Some(_), Some(_)) => Some(String::from("SET description = :d, state_db = :s, title = :t, owner = :o")), ... (Some(_), Some(_), _, _) => Some(String::from("SET description = :d, state_db = :s")), (_, Some(_), Some(_), _) => Some(String::from("SET title = :t, state_db = :s")), (_, _, Some(_), Some(_)) => Some(String::from("SET title = :t, owner = :o")), (Some(_), _, _, Some(_)) => Some(String::from("SET description = :d, owner = :o")), ... (_, Some(_), _, _) => Some(String::from("SET state_db = :s")), (Some(_), _, _, _) => Some(String::from("SET description = :d")), (_, _, Some(_), _) => Some(String::from("SET title = :t")), (_, _, _, Some(_)) => Some(String::from("SET owner = :o")), _ => None } } }
Acredito que esta solução pode ficar um pouco verbosa, assim, uma ideia seria transformar esses 4 campos em um vetor e iterar nele de forma posicional, o que não geraria uma solução muito elegante também, mas seria muito útil para o caso de expression_attribute_values
, como o pseudo código a seguir:
#![allow(unused)] fn main() { // pseudo código pub fn expression_attribute_values(info: &TodoCardUpdate) -> Option<HashMap<String, AttributeValue>> { let data = info.clone(); let mut _map = HashMap::new(); let data_vec = vec![data.description, data.state, data.title, data.owner]; data_vec.iter() .map(|i| if i.is_some() { let mut attr = AttributeValue::default(); attr.s = Some(String::from(i)); attr } else { None }) .enumerate(|(idx, item)| match idx { 0 => (":d".to_string(), item), 1 => (":s".to_string(), item), 2 => (":t".to_string(), item), 3 => (":o".to_string(), item), _ => ("".to_string(), None) }) .fold(_map,|acc, i| if i.is_some() { acc.insert(i.0, i.1) }; acc); Some(_map) } }
Tasks
Agora precisamos discutir tasks
, elas são mais complicadas pois não criamos o conceito de id
nelas, assim a solução que eu creio ser mais simples para lidar com elas é criar uma struct que contém três argumentos is_bool
, previous_text
, new_text
. O campo is_bool
é equivalente ao da struct Task
, já o argumento previous_text
é o argumento que identifica qual o texto existente de Task
no banco, e new_text
é o texto que queremos atualizar. Para entender como ficaria a adição, a atualização e o remoção teremos o seguinte:
- Adicão:
previous_text = None
,new_text = Some
. - Atualização:
previous_text = Some
,new_text = Some
. - Remoção:
previous_text = Some
,new_text = None
.
#![allow(unused)] fn main() { pub struct TaskUpdate { pub is_bool: bool, pub previous_text: Option<String>, pub new_text: Option<String>, } }
Portanto, quando identificarmos que previous_text
não existe, criamos uma nova task
, e quando identificarmos que new_text
não existe, deletamos a task
com o texto da previous_text
. Já a atualização filtramos todas as tasks que contém o previous_text
com new_text
, assim se ambos são iguais atualizamos somente is_bool
e em caso de não existir uma task com previous_text
, simplesmente criamos uma nova new_text
. Isso poderia ser feito em endpoint que responde a um POST
em /api/update/{id}/tasks
.
Fica como um bom desafio fazer estas mudanças que discutimos aqui antes de seguir para a próxima parte, assim como criar um endpoint de DELETE
. Nesta parte aprendemos a criar um serviço REST
com actix que cria e gerencia tarefas via create
, update
, show
e index
, salvando estas informações em um DynamoDB. Além disso, criamos um middleware de autenticação e endpoints de autenticação, via diesel. Outros middlewares que utilizamos foi o Logger
, que infelizmente não funciona com dotenv
, necessária para o Logger
, e um middleware que cria o header x-request-id
. Aprendemos a gerenciar o estado da aplicação com .data()
e a configurar rotas com .configure()
. Por último, aprendemos a tornar nosso sistema tolerante a falhas e a configurar o docker com todas as dependências.
Agora vamos aprender a utilizar graphql com Actix para fazer um sistema de busca de rotas de voos.
GraphQL com Actix
Configurando o GraphQL
Agora que temos contexto de como funciona o Actix, será muito mais simples criar um novo serviço, assim o objetivo neste capítulo será focar na parte GraphQL deste novo serviço. Antes vamos entender um pouco o que é GraphQL e porque vamos utlizar essa tecnologia.
GraphQL
GraphQL é uma tecnologia desenvolvida pelo Facebook que consiste em uma linguagem de queries para APIs e um runtime para executar estas queries. Além disso, GraphQL provê ferramentas para entender e descrever os dados das APIs, da ao cliente o poder de decidir quais dados quer consumir e facilita e evolução de APIs. De forma resumida, são quatro etapas que envolvem o GraphQL:
- Descrever seus dados via tipos:
type Project {
name: String
tagline: String
contributors: [User]
}
- Receber um request com os dados a serem consumidos:
{
project(name: "GraphQL") {
tagline
}
}
- Realizar as consultas a todos os serviços/APIs necessários
- Responder exatamente o que o cliente pediu.
{
"data": {
"project": {
"tagline": "A query language for APIs"
}
}
}
Assim, o motivo de escolhermos GraphQL como tecnologia para este serviço é a necessidade de consultar diversas fontes para um mesmo request, como mais de uma API e caching.
Queries Básicas
Vamos começar com o básico, fazer o sistema responder 404 NOT_FOUND
para rotas diversas e depois iniciar com uma query que responderá um simples pong
quando chamarmos a query ping
. Para isso, nosso primeiro passo é criar o serviço com cargo new recommendations-gql --bin
, e adicionar as dependências básicas ao nosso Cargo.toml:
[dependencies]
actix-web = "2.0.0"
actix-rt = "1.0.0"
juniper = "0.14.2"
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
serde_derive = "1.0.104"
Agora precisamos adicionar o caso de status code 404
. Para esse caso vamos utilizar outro recurso que não utilizamos antes que é o default_service
. Ele nos permite responder um valor default para qualquer rota não encontrada, neste caso escrevemos 404
:
// main.rs
use actix_web::{web, App, HttpServer};
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(move || {
App::new()
.default_service(web::to(|| async { "404" }))
})
.bind("127.0.0.1:4000")?
.run()
.await
}
Pronto! ao acessar localhost:4000
recebemos um 404
. Próximo passo é adicionarmos a query de ping
.
Ping em GraphQL
Primeiro passo para o ping
seria pensarmos o schema correspondente na estrutura GraphQL. Assim, nosso objetivo é realizar uma query nomeada ping
que retorne uma string contento "pong"
. Para isso sabemos que precisaremos que uma função ping
que retornar um Result<String, Error>
que contém pong
, algo como:
#![allow(unused)] fn main() { fn ping() -> Result<String, Error> { Ok(String::from("pong")) } }
Mas esta função precisa estar dentro de um contexto Query
do graphql e para isso criamos uma struct chamada de QueryRoot
, que corresponde a raiz das queries, e aplicamos a macro #[juniper::object]
que transforma essa implementação de queries, impl QueryRoot
, em um objeto graphql do tipo Query
. Note que o tipo de retorno é um FieldResult
, que corresponde a um tipo Result
que já abstraiu o tipo do Error
para um erro que o GraphQL possa entender.
#![allow(unused)] fn main() { use juniper::FieldResult; pub struct QueryRoot; #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } } }
Segundo passo seria declarar um tipo Schema
que poderá ser utilizado pelos handlers do Graphql para validar as queries, as mutations e os tipos. Esse Schema
é composto de duas partes, a QueryRoot
e a MutationRoot
, que são designadas a um nó contendo os schemas chamado RootNode
, pub type Schema = RootNode<'static, QueryRoot, MutationRoot>;
. Como ainda não temos nenhuma mutation, nosso MutationRoot
é bastante simples:
#![allow(unused)] fn main() { pub struct MutationRoot; #[juniper::object] impl MutationRoot {} }
Queries e Mutations
O objetivo de uma query é "perguntar" para o sistema algum conjunto de infotmações, enquanto o objetivo da mutation é "mutar" alguma informação que o sistema possui, mas suas declarações são bastante parecidas com as das queries.
Por último precisamos de uma função que retorne o schema que criamos, que chamaremos de create_schema
:
#![allow(unused)] fn main() { use juniper::FieldResult; use juniper::RootNode; pub struct QueryRoot; #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } } pub struct MutationRoot; #[juniper::object] impl MutationRoot {} pub type Schema = RootNode<'static, QueryRoot, MutationRoot>; pub fn create_schema() -> Schema { Schema::new(QueryRoot {}, MutationRoot {}) } }
Com isso podemos criar o módulo schema
em schema.rs
. Próximo passo é disponibilizar este Schema
para a aplicação, podemos fazer isso utilizando a função data
de actix_web::App
:
#[macro_use] extern crate juniper; extern crate serde_json; use actix_web::{web, App, HttpServer}; mod schemas; use crate::schemas::{create_schema, Schema}; #[actix_rt::main] async fn main() -> std::io::Result<()> { let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema()); HttpServer::new(move || { App::new() .data(schema.clone()) .default_service(web::to(|| async { "404" })) }) .bind("127.0.0.1:4000")? .run() .await }
Estamos utilizando a definição let schema: std::sync::Arc<Schema> =
para fazer um vínculo da variável schema
ao contexto de main
, note, também, que seu tipo precis ser std::sync::Arc
, pois todas as threads do graphql estarão acessado esse Schema
. O próximo passo é definirmos as rotas dos handlers
para o GraphQL, fazemos isso no módulo handlers
e exportamos estas infos pela função routes
:
#[macro_use] extern crate juniper; extern crate serde_json; use actix_web::{web, App, HttpServer}; mod handlers; mod schemas; use crate::handlers::routes; use crate::schemas::{create_schema, Schema}; #[actix_rt::main] async fn main() -> std::io::Result<()> { let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema()); HttpServer::new(move || { App::new() .data(schema.clone()) .configure(routes) .default_service(web::to(|| async { "404" })) }) .bind("127.0.0.1:4000")? .run() .await }
Assim, a função routes
do módulo handlers
possuirá a seguinte estrutura:
#![allow(unused)] fn main() { pub fn routes(config: &mut web::ServiceConfig) { config .route("/graphql", web::post().to(graphql)) .route("/graphiql", web::get().to(graphiql)); } }
Nossa configuração possuirá duas rotas /graphql
, que é a rota na qual fazemos um post com nossa query, e /graphiql
, que será uma rota que nos exibirá uma página web interativa da nossa aplicação:
Note que a direita na rota graphiql
existe uma aba chamada de Documentation Explorer
, ela nos permite saber as queries e as mutations disponíveis, assim como seus tipos de entrada e tipos de retorno.
Agora, o handler graphiql
é bastante simples, sua única função é encondar em HTML
o handler graphql
:
#![allow(unused)] fn main() { pub async fn graphiql() -> HttpResponse { HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(graphiql_source("/graphql")) } }
Com isso, podemos finalmente entender o que o handler graphql
faz:
#![allow(unused)] fn main() { use std::sync::Arc; use actix_web::{web, Error, HttpResponse}; use juniper::http::graphiql::graphiql_source; use juniper::http::GraphQLRequest; use crate::schemas::{Schema}; pub async fn graphql( schema: web::Data<Arc<Schema>>, data: web::Json<GraphQLRequest>, ) -> Result<HttpResponse, Error> { let res = web::block(move || { let res = data.execute(&schema, &()); Ok::<_, serde_json::error::Error>(serde_json::to_string(&res)?) }) .await .map_err(Error::from)?; Ok(HttpResponse::Ok() .content_type("application/json") .body(res)) } }
O handler graphql
recebe como argumento dois campos 1. schema
através do tipo actix web::Data<Arc<Schema>>
e o request data
do tipo web::Json<GraphQLRequest>
. A magia acontece na linha data.execute(&schema, &())
, na qual o GraphQL executa nosso request, data
, com base no schema
. Depois disso, encodamos o resultado para Json e respondemos como um Result<HttpResponse, Error>
oriundo de Ok(HttpResponse::Ok().content_type("application/json").body(res))
. Se você executar este código será possível fazer a query ping
em localhost:4000/graphql:
Testando o endpoint
Como na parte anterior do livro falamos de como criar testes de integração no diretório tests/
, agora vamos partir para outra estratégia, que é criar testes de integração dentro do src/
, pois isto nos permite tirar proveito da flag #[cfg(test)]
. Para fazermos isso, precisamos criar um módulo test
, anotoado com a flag #[cfg(test)]
em main.rs
:
#![allow(unused)] fn main() { ... mod handlers; mod schemas; #[cfg(test)] mod test; ... }
Depois disso é preciso criar o arquivo src/test/mod.rs
, que declarará o nome dos submodulos de teste, neste caso um simples pub mod ping;
. Para testarmos o ping
precisamos criar o módulo que declaramos em src/test/ping.rs
:
#![allow(unused)] fn main() { #[cfg(test)] mod ping_readiness { use crate::handlers::routes; use crate::schemas::{create_schema, Schema}; use actix_web::{test, App}; use bytes::Bytes; #[actix_rt::test] async fn test_ping_pong() { let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema()); let mut app = test::init_service(App::new().data(schema.clone()).configure(routes)).await; let req = test::TestRequest::post() .uri("/graphql") .header("Content-Type", "application/json") .set_payload("{\"query\": \"query ping { ping }\"}") .to_request(); let resp = test::read_response(&mut app, req).await; assert_eq!(resp, Bytes::from_static(b"{\"data\":{\"ping\":\"pong\"}}")); } } }
A estrutura do teste é praticamente igual a estrutura que estavamos utilizando anteriormente, as únicas diferenças são let schema: std::sync::Arc<Schema> = std::sync::Arc::new(create_schema());
, que a rota agora é /graphql
e que o payload é um json contendo um campo query
seguido de sua query "{\"query\": \"query ping { ping }\"}"
.
Agora vamos a implementação da primeira query de consulta, que chamaremos de bestPrices
.
Best Prices
Nesta query, bestPrices
, vamos fazer uma consulta a uma API externa que retorna os melhores preços para uma rota (data, origem e destino). Consultaremos a URL de bestPrices
da Latam https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/bestprices/oneway?departure=<YYYY-mm-dd>&origin=<IATA>&destination=<IATA>&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode=
, na qual departure
é a data de partida no formato ano-mes-dia
, origin
é o código IATA da cidade ou do aeroporto de origem, destination
é o código IATA da cidade ou do aeroporto de destino. Assim, nossa query deve receber 3 argumentos departure
, origin
e destination
e retornar um conjunto de melhores preços, além de lançar erros. Caso estes argumentos não estejam dentro do padrão esperado. Com isso, nosso primeiro passo será implementar a função bestPrices
que recebe os 3 argumentos e por enquanto retornará uma String
.
Implementando a função básica de bestPrices
Nosso objetivo agora é fazer nosso GraphQL responder da seguinte forma:
A query que usamos é:
query {
bestPrices(
departure: "sdf",
origin: "sdf",
destinantion: "sdfg")
ping
}
E o valor de retorno é:
{
"data": {
"bestPrices": "test",
"ping": "pong"
}
}
O resultado de uma query GraphQL como a que mostamos retorna o campo data
que é um mapa contendo os resultados das queries bestPrices
e ping
. Para resolvermos isso, podemos escrever a seguinte função:
#![allow(unused)] fn main() { // schema/mod.rs // ... #[juniper::object] impl QueryRoot { //... fn bestPrices(departure: String, origin: String, destinantion: String) -> FieldResult<String> { Ok(String::from("test")) } } // ... }
Agora podemos começar a pensar um pouco melhor na organização do nosso código. Na seção de apresentação do livro desenhamos o seguinte diagrama:
api
main
|-> boundaries
|-> web
|-> db
|-> message
|-> controllers/resolvers
|-> adapters
|-> core
|-> business
|-> compute
|-> models/schemas
Com este esquema em mente, vamos ordenar como nossos arquivos ficarão organizados para um projeto GraphQL e exemplificar para onde cada conjunto já existente será movido:
api
main
|-> boundaries
|-> web
|-> db
|-> resolvers
|-> graphql
|-> queries
|-> mutations
|-> internal
|-> adapters
|-> core
|-> business
|-> compute
|-> schemas
|-> graphql
|-> model
|-> db
|-> web
|-> errors
Com esta definição em mente vamos alocar o projeto que contém as rotas e os handlers GraphQL em boundaries/web/handlers.rs
, pois este aquivo é responsável pela interface web do projeto. Qualquer módulo de comunicaçnao com banco ficaria em boudnaries/db/
, assim como de Kafka ficaria em boudnaries/kafka
ou boudnaries/messages
. Nosso arquivo schema/mod.rs
possui as configurações de resolvers, assim não faz sentido que esteja em schema/
, e moveremos ele para resolvers/graphql/
, poderiamos separar em queries
e mutations
, mas como nosso projeto somente conterá 2 queries, não precisamos nos preocupar em extrair para pastas diferentes. Além disso, chamei o que tipicamente é considerado um controller
de resolver/internal
, por simplicidade, caso prefira chamar de controller esta adequado também. Na pasta schemas
vamos adicionar todos os schemas de referência ao GraphQL em schemas/graphql
, assim como os de comunicação com o banco em schemas/model/db
e de interface web em schemas/model/web
. Já os erros de que usaremos para comunicar problemas estarão em schemas/errors
. Caso você fique com dúvidas de como ficou a organizacão do código, ela está disponível no commit https://github.com/web-dev-rust/airline-tickets/commit/c33a78cffbd74be49727c744623dcd1e10902cd4.
Validando argumentos
Com a função que implementamos para bestPrices
precisamos agora implementar os erros correspondentes, para isso criaremos o módulo schemas/errors.rs
e lá implementaremos os erros Graphql. O primeiro erro que vamos implementar é o erro do formato de origin
e destination
, pois IATAs devem ser 3 letras. Chamaremos esse conjunto de erros de InputError
e o erro correspondente ao IATA de IataFormatError
:
#![allow(unused)] fn main() { use juniper::{FieldError, IntoFieldError}; pub enum InputError { IataFormatError, } impl IntoFieldError for InputError { fn into_field_error(self) -> FieldError { match self { InputError::IataFormatError => FieldError::new( "The IATA format for origin and destinantion consists of 3 letter", graphql_value!({ "type": "IATA FORMAT ERROR" }), ), } } } }
Agora para usarmos esse erro precisamos modificar a função bestPrices
em resolvers/graphql.rs
para usar o tipo InputError
:
#![allow(unused)] fn main() { use crate::schema::errors::InputError; // ... #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } fn bestPrices( departure: String, origin: String, destinantion: String, ) -> Result<String, InputError> { if origin.len() != 3 || !origin.chars().all(char::is_alphabetic) { return Err(InputError::IataFormatError); } Ok(String::from("test")) } } //... }
Se formos em localhost:4000/graphql
e enviarmos {bestPrices(departure: "IAT", origin: "IATA", destinantion: "sdfg")}
(origin
com 4 letras) receberemos o campo error
com o campo InputError::IataFormatError
:
{
"data": null,
"errors": [
{
"message": "The IATA format for origin and destinantion consists of 3 letter",
"locations": [
{
"line": 1,
"column": 2
}
],
"path": [
"bestPrices"
],
"extensions": {
"type": "IATA FORMAT ERROR"
}
}
]
}
O campo destination
também é um IATA e precisamos aplicar a lógica iata.len() != 3 || !iata.chars().all(char::is_alphabetic)
a ambos os campos, assim vamos criar um módulo de lógica que controlar quando esse erro deve ser lançado. O módulo será core/error.rs
:
use crate::schema::errors::InputError;
pub fn iata_format(origin: &str, destination: &str) -> Result<(), InputError> {
if origin.len() != 3
|| !origin.chars().all(char::is_alphabetic)
|| destination.len() != 3
|| !destination.chars().all(char::is_alphabetic)
{
Err(InputError::IataFormatError)
} else {
Ok(())
}
}
Os testes para esta função são:
#![allow(unused)] fn main() { #[cfg(test)] mod iata { use super::iata_format; use crate::schema::errors::InputError; #[test] fn len_should_be_3() { assert_eq!( iata_format("IATA", "IAT").err().unwrap(), InputError::IataFormatError ); assert_eq!( iata_format("IAT", "IATA").err().unwrap(), InputError::IataFormatError ); } #[test] fn only_letters() { assert_eq!( iata_format("IAT", "I4T").err().unwrap(), InputError::IataFormatError ); assert_eq!( iata_format("I&T", "IAT").err().unwrap(), InputError::IataFormatError ); } } }
Nesta função validamos que o formato IATA é respeitado tanto para origin
quanto para destination
, somente 3 letras. Caso alguma das verificaçnoes falhe, lançamos o erro InputError::IataFormatError
. Depois disso, aplicamos a função iata_format
em nosso resolver através de um match, que retorna o erro ou executa alguma função interna:
#![allow(unused)] fn main() { use crate::core::error; // ... #[juniper::object] impl QueryRoot { fn bestPrices( departure: String, origin: String, destination: String, ) -> Result<String, InputError> { match error::iata_format(&origin, &destination) { Err(e) => Err(e), Ok(_) => Ok(String::from("test")), } } } }
Próximo passo é determinar se departure
é uma data e seu valor é superior ao dia de hoje.
Validando datas
Para trabalharmos com datas precisamos incluir a crate chrono = "0.4.11"
no campo [dependencies]
do Cargo.toml. A primeira coisa que vamos verificar é se o formato da data está correto. Podemos fazer isso com a seguinte função:
#![allow(unused)] fn main() { use chrono::naive::NaiveDate; // ... pub fn departure_date_format(date: &str) -> Result<(), InputError> { let departure = NaiveDate::parse_from_str(date, "%Y-%m-%d"); match departure { Err(_) => Err(InputError::DateFormatError), Ok(d) => Ok(()), } } }
Com parse_from_str
verificamos se o formato da string departure
esta correto de acordo com o formatador que passamos "%Y-%m-%d"
. parse_from_str
nos retorna um Result
que podemos utilizar para compor o erro. Precisamos incluir um novo caso de erro, DateFormatError
em InputError
e adicionar sua cláusula no macth
. Assim, validamos isso com os testes a seguir:
#![allow(unused)] fn main() { #[cfg(test)] mod date { use super::departure_date_format; use crate::schema::errors::InputError; #[test] fn date_is_correct() { assert!(departure_date_format("3020-01-20").is_ok()); } #[test] fn date_should_be_yyyy_mm_dd() { assert_eq!( departure_date_format("2020/01/20").err().unwrap(), InputError::DateFormatError ); } } }
Próximo passo é verificar se a data de departure data é maior que a data de hoje, para isso podemos utilizar a função signed_duration_since
que nos retorna uma Duration
desde a data passada como argumento (today
). Podemos comparar essa data extraindo o número de dias com num_days
e verificar se é maior que 0
. Novamente precisamos adicionar um nove erro InvalidDateError
em InputError
.
#![allow(unused)] fn main() { use chrono::{naive::NaiveDate, offset::Utc}; // ... pub fn departure_date_format(date: &str) -> Result<(), InputError> { let departure = NaiveDate::parse_from_str(date, "%Y-%m-%d"); match departure { Err(_) => Err(InputError::DateFormatError), Ok(d) => { let today = Utc::today(); if d.signed_duration_since(today.naive_utc()).num_days() > 0 { Ok(()) } else { Err(InputError::InvalidDateError) } } } } }
E o teste para esse novo caso pode ser:
#![allow(unused)] fn main() { #[test] fn date_should_be_greater_than_today() { assert_eq!( departure_date_format("2019-01-20").err().unwrap(), InputError::InvalidDateError ); } }
O módulo schema/error.rs
fica da seguinte forma:
#![allow(unused)] fn main() { use juniper::{FieldError, IntoFieldError}; #[derive(Debug, Clone, PartialEq)] pub enum InputError { IataFormatError, DateFormatError, InvalidDateError, } impl IntoFieldError for InputError { fn into_field_error(self) -> FieldError { match self { InputError::IataFormatError => FieldError::new( "The IATA format for origin and destinantion consists of 3 letter", graphql_value!({ "type": "IATA FORMAT ERROR" }), ), InputError::DateFormatError => FieldError::new( "departure date should be formated yyyy-mm-dd", graphql_value!({ "type": "DATE FORMAT ERROR" }), ), InputError::InvalidDateError => FieldError::new( "Date should be greater than today", graphql_value!({ "type": "INVALID DATE ERROR" }), ), } } } }
Agora podemos adicionar este novo grupo de erros ao nosso resolver com:
fn bestPrices(
departure: String,
origin: String,
destination: String,
) -> Result<String, InputError> {
match (
error::iata_format(&origin, &destination),
error::departure_date_format(&departure),
) {
(Err(e), Err(e2)) => Err(e),
(Err(e), _) => Err(e),
(_, Err(e)) => Err(e),
_ => Ok(String::from("test")),
}
}
Próximo passo é responder as informações de bestPrices
em vez de Ok(String::from("test"))
.
Respondendo informacões de bestPrices
Para este caso devemos utilizar um cliente HTTP, que usualmente são assíncronos em Rust, porém a crate que estamos utilizando para GraphQL ainda não tem um suporte muito sólido para async/await
, e por isso preferi utilizar a crate de cliente HTTP reqwest
com o módulo reqwest::blocking
, mesmo que actix possua seu próprio módulo de cliente actix_web::client
.
Exemplo de client com
actix_web::client
use actix_web::client::Client; #[actix_rt::main] async fn main() { let mut client = Client::default(); // Cria `request builder` e envia com `send` let response = client.get("http://www.rust-lang.org") .header("User-Agent", "Actix-web") .send().await; // <-Envia o request println!("Response: {:?}", response); }
Conhecendo o endpoint
Consultando o endpoint de best_prices
para data "2020-07-21"
, para origem POA
e para destino GRU
https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/bestprices/oneway?departure={data}&origin={iata}&destination={iata}&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode= recebemos o seguinte campos relevantes no Json:
{
"itinerary":{
"date":"2020-07-21",
"originDestinations":[
{
"duration":95,
"departure":{
"airport":"POA",
"city":"POA",
"country":"BR",
"timestamp":"2020-07-21T11:10-03:00"
},
"arrival":{
"airport":"GRU",
"city":"SAO",
"country":"BR",
"timestamp":"2020-07-21T12:45-03:00"
}
}
]
},
"bestPrices":[
{
"date":"2020-07-18",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-19",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-20",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-21",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-22",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-23",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
},
{
"date":"2020-07-24",
"available":true,
"price":{
"amount":117.03, "currency":"BRL"
}
}
]
}
Com isso, precisamos modelar a resposta de cada campo para uma estrutura de dados correspondete localizadas em schema/model/web.rs
, chamaremos esta estrutura de BestPrices
:
#![allow(unused)] fn main() { use juniper::GraphQLObject; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct BestPrices { itinerary: Itinerary, best_prices: Vec<BestPrice>, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Itinerary { date: String, origin_destinations: Vec<OriginDestination>, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct OriginDestination { duration: i32, departure: AirportInfo, arrival: AirportInfo, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct AirportInfo { airport: String, city: String, country: String, timestamp: String, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct BestPrice { date: String, available: bool, price: Option<Price>, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Price { amount: f64, currency: String, } } }
Para podermos converter o json em uma estrutura de dados Rust vamos precisar utilizar a crate serde
e adicionar #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
em todas as struct anteriores. Além disso, utilizaremos #[serde(rename_all = "camelCase")]
para transformar campos snake_case
em camelCase
, como origin_destinations
, e a macro GraphQLObject
para indicar que estas structs correspondem a um objeto GraphQL. Agora podemos fazer um request para este endpoint, para isso vamos criar o módulo boundaries/http_out
e utilizar o reqwest
para fazer um GET
no endpoint:
#![allow(unused)] fn main() { use reqwest::{blocking::Response, Result}; pub fn best_prices(departure: String, origin: String, destination: String) -> Result<Response> { let url = format!("https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/bestprices/oneway?departure={}&origin={}&destination={}&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode=", departure, origin, destination); reqwest::blocking::get(&url) } }
A função best_prices
formata a url
do request adicionando os parâmetros departure, origin, destination
para utilizar a função bloqueante get
de reqwest
, reqwest::blocking::get(&url)
. O tipo de retorno é um Result<Response>
da própria crate reqwest
.
Resolvendo BestPrices
Com a função boundaries::http_out::best_prices
fazendo o request, precisamos transformar o resultado desse request em uma estrutura de dados do tipo BestPrices
que serializa e implementa GraphQLObject
. Para coordenarmos isso, criamos um módulo resolvers/internal
que vai implementar a função best_prices_info
:
#![allow(unused)] fn main() { use crate::boundaries::http_out::best_prices; use crate::schema::{errors::InputError, model::web::BestPrices}; pub fn best_prices_info( departure: String, origin: String, destination: String, ) -> Result<BestPrices, InputError> { let best_prices_text = best_prices(departure, origin, destination) .unwrap() .text() .unwrap(); let best_prices: BestPrices = serde_json::from_str(&best_prices_text).unwrap(); Ok(best_prices) } }
Note que o resultado da função boundaries::http_out::best_prices
é um reqwest::Result<reqwest::blocking::response>
, e que para utilizarmos seus dados precisamos tratar como um Result
usual, por isso aplicamos unwrap
. Além disso, queremos a informação presente no body
da resposta, que obtemos como texto utilizando a função text
, que retorna um Result
, definimos o resultado deste processo como best_prices_text
. Com best_prices_text
podemos transformar esse texto em uma estrutura BestPrices
utilizando a função serde_json::from_str
, como em let best_prices: BestPrices = serde_json::from_str(&best_prices_text).unwrap();
e retornar essa infomacão em um Ok
. O código ainda possui alguns defeitos como a grande quantidade de unwraps
e um InputError
totalmente deslocado, logo veremos como melhorar o código neste sentido. best_prices_info
ainda não está conectado a nenhuma parte do código GraphQL, assim, precisamos chamar esta função no resolver GraphQL best_prices
e mudar seu tipo de resposta para utilizar schema::model::web::BestPrices
, Result<BestPrices, InputError
.
#![allow(unused)] fn main() { use crate::core::error; use crate::resolvers::internal::best_prices_info; use crate::schema::{errors::InputError, model::web::BestPrices}; use juniper::FieldResult; use juniper::RootNode; pub struct QueryRoot; #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } fn bestPrices( departure: String, origin: String, destination: String, ) -> Result<BestPrices, InputError> { match ( error::iata_format(&origin, &destination), error::departure_date_format(&departure), ) { (Err(e), Err(e2)) => Err(e), (Err(e), _) => Err(e), (_, Err(e)) => Err(e), _ => best_prices_info(departure, origin, destination), } } } // ... }
Melhorando as mensagens de erro.
O conceito de Input Error
é particularmente estranho para erros de conversão de Json com serde
ou de request com reqwest
, assim, uma possível solução é fazer um "super grupo" de erros, que vou chamar de GenericError
, e esse vai possuír um enum
chamado InternalError
:
#[derive(Debug, Clone, PartialEq)]
pub enum GenericError {
InputError(InputError),
InternalError(InternalError),
}
#[derive(Debug, Clone, PartialEq)]
pub enum InputError {
IataFormatError,
DateFormatError,
InvalidDateError,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InternalError {
RequestFailedError,
ResponseParseError,
}
A próxima mudança que podemos fazer é alterar todos os Result<BestPrices, InputError>
para Result<BestPrices, GenericError>
, o que causa uma grande quantidade de alarmes em nosso código, mas em vez de arrumarmos cada um dos alarmes e termos mais dor de cabeça, vamos implementar a trait From
para dois erros do tipo GenericError::InternalError
, reqwest::Error
e serde_json::Error
, pois estes são os erros que queremos lançar na função esolvers::internal::best_prices_info;
. O primeiro erro, reqwest::Error
, tem como objetivo retirar o unwrap
e o expect
da chamada best_prices(departure, origin, destination).unwrap().text().expect(...);
, obtendo como resultado best_prices(departure, origin, destination)?.text()?;
, que nos ajuda a aproveitar o tipo de retorno GenericError
. O mesmo faremos para transformar a chamada de serde_json::from_str(&best_prices_text).unwrap();
em serde_json::from_str(&best_prices_text)?;
aplicando a trait From
no tipo de erro serde_json::Error
:
#![allow(unused)] fn main() { impl From<reqwest::Error> for GenericError { fn from(e: reqwest::Error) -> Self { GenericError::InternalError(InternalError::RequestFailedError) } } impl From<serde_json::Error> for GenericError { fn from(e: serde_json::Error) -> Self { GenericError::InternalError(InternalError::ResponseParseError) } } }
O efeito disso é que o arquivo resolver/internal.rs
se torna muito mais simples:
#![allow(unused)] fn main() { use crate::boundaries::http_out::best_prices; use crate::schema::{errors::GenericError, model::web::BestPrices}; pub fn best_prices_info( departure: String, origin: String, destination: String, ) -> Result<BestPrices, GenericError> { let best_prices_text = best_prices(departure, origin, destination)?.text()?; let best_prices: BestPrices = serde_json::from_str(&best_prices_text)?; Ok(best_prices) } }
Com isso, podemos notar que a vida da query bestPrices
ficou muito mais simples, pois o match
se torna desnecessário, já que as funções error::iata_format
e error::departure_date_format
retornam um tipo Result<(),InputError>
, que é facilmente convertido para um Result<(),GenericError>
, nos permitindo utilizar a sintaxe try
para elas na query bestPrices
, error::iata_format(&origin, &destination)?;
e error::departure_date_format(&departure)?;
. O arquivo core/error.rs
passa a ter a seguinte aparência:
#![allow(unused)] fn main() { use crate::schema::errors::{GenericError, InputError}; use chrono::{naive::NaiveDate, offset::Utc}; pub fn iata_format(origin: &str, destination: &str) -> Result<(), GenericError> { if origin.len() != 3 || !origin.chars().all(char::is_alphabetic) || destination.len() != 3 || !destination.chars().all(char::is_alphabetic) { Err(GenericError::InputError(InputError::IataFormatError)) } else { Ok(()) } } pub fn departure_date_format(date: &str) -> Result<(), GenericError> { let departure = NaiveDate::parse_from_str(date, "%Y-%m-%d"); match departure { Err(_) => Err(GenericError::InputError(InputError::DateFormatError)), Ok(d) => { let today = Utc::today(); if d.signed_duration_since(today.naive_utc()).num_days() > 0 { Ok(()) } else { Err(GenericError::InputError(InputError::InvalidDateError)) } } } } #[cfg(test)] mod date { use super::departure_date_format; use crate::schema::errors::{InputError, GenericError}; #[test] fn date_is_correct() { assert!(departure_date_format("3020-01-20").is_ok()); } #[test] fn date_should_be_yyyy_mm_dd() { assert_eq!( departure_date_format("2020/01/20").err().unwrap(), GenericError::InputError(InputError::DateFormatError) ); } #[test] fn date_should_be_greater_than_today() { assert_eq!( departure_date_format("2019-01-20").err().unwrap(), GenericError::InputError(InputError::InvalidDateError) ); } } #[cfg(test)] mod iata { use super::iata_format; use crate::schema::errors::{InputError, GenericError}; #[test] fn len_should_be_3() { assert_eq!( iata_format("IATA", "IAT").err().unwrap(), GenericError::InputError(InputError::IataFormatError) ); assert_eq!( iata_format("IAT", "IATA").err().unwrap(), GenericError::InputError(InputError::IataFormatError) ); } #[test] fn only_letters() { assert_eq!( iata_format("IAT", "I4T").err().unwrap(), GenericError::InputError(InputError::IataFormatError) ); assert_eq!( iata_format("I&T", "IAT").err().unwrap(), GenericError::InputError(InputError::IataFormatError) ); } } }
Todas essas mudanças nos permitem ainda simplificar a query bestPrices
para utilizar os operadores try
:
#![allow(unused)] fn main() { #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } fn bestPrices( departure: String, origin: String, destination: String, ) -> Result<BestPrices, GenericError> { error::iata_format(&origin, &destination)?; error::departure_date_format(&departure)?; let best_price = best_prices_info(departure, origin, destination)?; Ok(best_price) } } }
Note que ainda há um problema envolvendo a trait IntoFieldError
que não está implementada para o enum GenericError
. Fazemos isso refatorando a implementação da trait para o enum InputError
, reaproveitando todos seus campos:
#![allow(unused)] fn main() { impl IntoFieldError for GenericError { fn into_field_error(self) -> FieldError { match self { GenericError::InputError(InputError::IataFormatError) => FieldError::new( "The IATA format for origin and destinantion consists of 3 letter", graphql_value!({ "type": "IATA FORMAT ERROR" }), ), GenericError::InputError(InputError::DateFormatError) => FieldError::new( "departure date should be formated yyyy-mm-dd", graphql_value!({ "type": "DATE FORMAT ERROR" }), ), GenericError::InputError(InputError::InvalidDateError) => FieldError::new( "Date should be greater than today", graphql_value!({ "type": "INVALID DATE ERROR" }), ), GenericError::InternalError(InternalError::RequestFailedError) => FieldError::new( "Could not complete properly request to the backend", graphql_value!({ "type": "REQUEST FAILED ERROR" }), ), GenericError::InternalError(InternalError::ResponseParseError) => FieldError::new( "Could not parse response from backend", graphql_value!({ "type": "RESPONSE PARSE ERROR" }), ), } } } }
Agora que evoluímos os erros da nossa API, podemos fazer a query de recommendations.
Recommendations
Nesta query, recommendations
, vamos fazer uma consulta a mesma API anterior, mas em um endpoint diferente. Esse endpoint retorna todas as opções de voo para uma rota (data, origem e destino). Consultaremos a URL de recommendations
da Latam https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/recommendations/oneway?departure=<YYYY-mm-dd>&origin=<IATA>&destination=<IATA>&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode=
, na qual departure
é a data de partida no formato ano-mes-dia
, origin
é o código IATA da cidade ou do aeroporto de origem, destination
é o código IATA da cidade ou do aeroporto de destino. Assim, nossa query deve receber 3 argumentos departure
, origin
e destination
e retornar uma lista de todas as opções de vôo, além de lançar erros. Assim como a query bestPrices
, nossa query de recommendations
vai receber os mesmos parâmetros:
BestPrices:
#![allow(unused)] fn main() { bestPrices( departure: String, origin: String, destination: String, ) }
Recommendations:
#![allow(unused)] fn main() { recommendations( departure: String, origin: String, destination: String, ) }
O tipo de retorno de recommendations
será semelhante ao tipo de bestPrices
, Result<Recommendations, GenericError>
. As validações dos tipos de entrada podem continuar iguais:
#![allow(unused)] fn main() { #[juniper::object] impl QueryRoot { fn ping() -> FieldResult<String> { Ok(String::from("pong")) } fn bestPrices( departure: String, origin: String, destination: String, ) -> Result<BestPrices, GenericError> { error::iata_format(&origin, &destination)?; error::departure_date_format(&departure)?; let best_price = best_prices_info(departure, origin, destination)?; Ok(best_price) } fn recommendations( departure: String, origin: String, destination: String, ) -> Result<Recommendations, GenericError> { error::iata_format(&origin, &destination)?; error::departure_date_format(&departure)?; // ... } } }
Agora podemos começar a implementar o resolver recommendations_info
, começando por conehcer o endpoint.
Conhecendo o endpoint de recommendations
Consultando o endpoint de recommendations
para data "2020-07-21"
, para origem POA
e para destino GRU
https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/recommendations/oneway?departure={data}&origin={iata}&destination={iata}&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode= recebemos o seguinte campos relevantes no Json (muitos campos foram ocultos pois não vamos utilizar):
{
"data":[
{
"flights":[
{
"flightCode":"LA4596",
"arrival":{
"airportCode":"GRU",
"airportName":"Guarulhos Intl.",
"cityCode":"SAO",
"cityName":"São Paulo",
"countryCode":"BR",
"date":"2020-07-21",
"dateTime":"2020-07-21T10:50-03:00",
"overnights":0,
"time":{ "stamp":"10:50", "hours":"10", "minutes":"50" }
},
"departure":{
"airportCode":"POA",
"airportName":"Salgado Filho",
"cityCode":"POA",
"cityName":"Porto Alegre",
"countryCode":"BR",
"date":"2020-07-21",
"dateTime":"2020-07-21T09:15-03:00",
"overnights":null,
"time":{ "stamp":"09:15", "hours":"09", "minutes":"15" }
},
"stops":0,
"segments":[
{
"flightCode":"LA4596",
"flightNumber":"4596",
"waitTime":null,
"equipment":{ "name":"Airbus 321", "code":"321" },
"legs":[
],
"airline":{
"marketing":{ "code":"LA", "name":"LATAM Airlines" },
"operating":{
"code":"JJ",
"name":"LATAM Airlines Brasil"
},
"code":"LA"
},
"duration":"PT1H35M",
"departure":{
"airportCode":"POA",
"airportName":"Salgado Filho",
"cityCode":"POA",
"cityName":"Porto Alegre",
"countryCode":"BR",
"date":"2020-07-21",
"dateTime":"2020-07-21T09:15-03:00",
"overnights":null,
"time":{ "stamp":"09:15", "hours":"09", "minutes":"15" }
},
"arrival":{
"airportCode":"GRU",
"airportName":"Guarulhos Intl.",
"cityCode":"SAO",
"cityName":"São Paulo",
"countryCode":"BR",
"date":"2020-07-21",
"dateTime":"2020-07-21T10:50-03:00",
"overnights":0,
"time":{ "stamp":"10:50", "hours":"10", "minutes":"50"
}
},
"familiesMap":{
"Y-LIGHT":{
"segmentClass":"G",
"farebasis":"GLYX0N1"
},
"Y-PLUS":{
"segmentClass":"G",
"farebasis":"GLYX0N8"
},
"Y-TOP":{
"segmentClass":"G",
"farebasis":"GLYX0N9"
},
"W-PLUS":{
"segmentClass":"P",
"farebasis":"GDKX0N2"
},
"W-TOP":{
"segmentClass":"W",
"farebasis":"XJ7X0NA"
}
}
}
],
"flightDuration":"PT1H35M",
"cabins":[
{
"code":"Y",
"displayPrice":101.03,
"availabilityCount":18,
"displayPrices":{
"slice":101.03,
"wholeTrip":101.03,
"sliceDiscountCode":"",
"wholeTripDiscountCode":""
},
"fares":[
{
"code":"SL",
"category":"LIGHT",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN002",
"price":{
"adult":{
"amountWithoutTax":68.9,
"taxAndFees":32.13,
"total":101.03
}
},
"availabilityCount":18,
},
{
"code":"SE",
"category":"PLUS",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN009",
"price":{
"adult":{
"amountWithoutTax":123.9,
"taxAndFees":32.13,
"total":156.03
}
},
"availabilityCount":18
},
{
"code":"SF",
"category":"TOP",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN00C",
"price":{
"adult":{
"amountWithoutTax":193.9,
"taxAndFees":32.13,
"total":226.03
}
},
"availabilityCount":18
}
]
},
{
"code":"W",
"displayPrice":247.03,
"availabilityCount":9,
"displayPrices":{
"slice":247.03,
"wholeTrip":247.03,
"sliceDiscountCode":"",
"wholeTripDiscountCode":""
},
"fares":[
{
"code":"RA",
"category":"PLUS",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN00F",
"price":{
"adult":{
"amountWithoutTax":214.9,
"taxAndFees":32.13,
"total":247.03
}
},
"availabilityCount":9,
},
{
"code":"RY",
"category":"TOP",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN00K",
"price":{
"adult":{
"amountWithoutTax":673.9,
"taxAndFees":32.13,
"total":706.03
}
},
"availabilityCount":12,
}
]
}
]
},
...Mais recomendações aqui
],
"currency":"BRL",
"recommendedFlightCode":"LA4596"
}
],
"status":{
"code":200,
"message":""
}
}
Com esse Json podemos começar a modelar a resposta de recommendations, conforme fizemos com BestPrices
.
Modelando Recommendations
Para modelar recomendarions precisamos fazer uma pequena refatoração em BestPrices
, para evitarmos a que Recommendations
e BestPrices
fiquem misturados no mesmo módulo. Podemos criar um novo arquivo para cada um dos módulos, como temos feito, ou criar módulos internos dentro de schema/model/web.rs
:
#![allow(unused)] fn main() { pub mod best_prices { use juniper::GraphQLObject; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct BestPrices { itinerary: Itinerary, best_prices: Vec<BestPrice>, } // ... } }
Agora podemos criar o módulo recommendations
com:
#![allow(unused)] fn main() { pub mod recommendations { use juniper::GraphQLObject; use serde::{Deserialize, Serialize}; // ... } }
Nossa estrutura de Recommendations
contém 2 campos principais data
e status
. status
é uma struct com os campos code
do tipo i32
e message
do tipo String
. Já data
é um vetor de uma struct que contrém os campos flight
, currency
, recommendedFlightCode
, resultando em algo assim:
#![allow(unused)] fn main() { pub mod recommendations { use juniper::GraphQLObject; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Recommendations { data: Vec<Data>, status: Status, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Status { code: i32, message: String, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Data { flights: Vec<Flight>, recommended_flight_code: String, currency: String, } } }
Agora precisamos implementar as struct Flight
derivada do seguinte Json:
{
"flightCode":"LA4596",
"arrival":{ ... },
"departure":{ ... },
"stops":0,
"segments":[ ... ],
"flightDuration":"PT1H35M",
"cabins":[ ... ]
}
Assim, os campos são as strings flightDuration
e flightCode
, o campo stops
do tipo i32
, as duas structs de arrival
e departure
, os vetores de segments
e de cabins
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Flight { flight_code: String, arrival: Location, departure: Location, stops: i32, segments: Vec<Segment>, flight_duration: String, cabins: Vec<Cabin> } }
Note que arrival
e departure
estão com o mesmo tipo, Location
, pois possuem a mesma estrutura:
{
"airportCode":"POA",
"airportName":"Salgado Filho",
"cityCode":"POA",
"cityName":"Porto Alegre",
"countryCode":"BR",
"date":"2020-07-21",
"dateTime":"2020-07-21T09:15-03:00",
"time":{ "stamp":"09:15", "hours":"09", "minutes":"15" }
}
Que se torna a struct Location
, na qual quase todos os campos são Strings exceto por time
que será a struct Time
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Location { airport_code: String, airport_name: String, city_code: String, city_name:String, country_code: String, date: String, date_time: String, time: Time, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Time { stamp: String, hours: String, minutes: String, } }
Para implementarmos Segment
precisamos nos basear no seguinte json:
{
"flightCode":"LA4596",
"flightNumber":"4596",
"equipment":{ "name":"Airbus 321", "code":"321" },
"airline":{
"marketing":{ "code":"LA", "name":"LATAM Airlines" },
},
"duration":"PT1H35M",
"departure": Location,
"arrival": Location,
}
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Segment { flight_code: String, flight_number: String, equipment: Info, airline: Airline, duration: String, departure: Location, arrival: Location, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Info { name: String, code: String, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Airline { marketing: Info, } }
Por último, podemos implementarmos Cabin
e vamos nos basear no json:
{
"code":"Y",
"displayPrice":101.03,
"availabilityCount":18,
"displayPrices":{
"slice":101.03,
"wholeTrip":101.03,
},
"fares":[...]
}
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Cabin { code: String, display_price: f64, availability_count: i32, display_prices: DisplayPrice, fares: Vec<Fare> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct DisplayPrice { slice: f64, whole_trip: f64, } }
Por último precisamos modelar Fare
cujo Json é:
{
"code":"SL",
"category":"LIGHT",
"fareId":"250/BdC0XLNrp3H333zqIqmJJpNOO/05D8NwB5zcjHVNnkyl4GjqR/YOQrcDNWLPERAtEBLYqieN002",
"availabilityCount":18,
"price":{
"adult":{
"amountWithoutTax":68.9,
"taxAndFees":32.13,
"total":101.03
}
}
},
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct Fare { code: String, category: String, fare_id: String, availability_count: i32, price: Price } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] pub struct Price { adult: PriceInfo } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, GraphQLObject)] #[serde(rename_all = "camelCase")] pub struct PriceInfo { amount_without_tax: f64, tax_and_fees: f64, total: f64, } }
Com a modelagem pronta, podemos finalizar a implementação da query recommendations
.
Implementando a Query recommendations
Como a estrutura de recommendations
será praticamente igual a de best_prices
, podemos nos antecipar e adicionar o resolver de recommendations
na implementação de QueryRoot
:
#![allow(unused)] fn main() { use crate::resolvers::internal::{best_prices_info, recommendations_info}; use crate::schema::{errors::{GenericError}, model::web::{best_prices::BestPrices, recommendations::Recommendations}}; // ... #[juniper::object] impl QueryRoot { // ... fn recommendations( departure: String, origin: String, destination: String, ) -> Result<Recommendations, GenericError> { error::iata_format(&origin, &destination)?; error::departure_date_format(&departure)?; let recommendations = recommendations_info(departure, origin, destination)?; Ok(recommendations) } } }
Vamos receber um erro indicando que recommendations_info
não foi implementada ainda, e, assim, podemos partir para sua implementação, que seráexatamente igual a best_prices_info
, apenas renomeando os campos de best_prices
para recommendations
:
#![allow(unused)] fn main() { use crate::boundaries::http_out::{best_prices, recommendations}; use crate::schema::{errors::GenericError, model::web::{best_prices::BestPrices, recommendations::Recommendations}}; /// ... pub fn recommendations_info( departure: String, origin: String, destination: String, ) -> Result<Recommendations, GenericError> { let recommendations_text = recommendations(departure, origin, destination)?.text()?; let recommendations: Recommendations = serde_json::from_str(&recommendations_text)?; Ok(recommendations) } }
Agora precisamos implementar a função http_out::recommendations
que também é parecida com a função http_out::best_prices
pois possui apenas a url
de request diferente:
#![allow(unused)] fn main() { // boundaries/http_out.rs use reqwest::{blocking::Response, Result}; // ... pub fn recommendations(departure: String, origin: String, destination: String) -> Result<Response> { let url = format!("https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/recommendations/oneway?departure={}&origin={}&destination={}&cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode=", departure, origin, destination); reqwest::blocking::get(&url) } }
Agora podemos executar cargo run
e brincar com a interface gráfica de nosso serviço em localhost:4000/graphiql
. Um exemplo de request que podemos utilizar é:
{
bestPrices(departure: "2020-07-21",
origin: "POA",
destination: "GRU") {
bestPrices {
date
available
price {amount}
}
}
recommendations(departure: "2020-07-21",
origin: "POA",
destination: "GRU") {
data {
recommendedFlightCode
flights {
flightCode
flightDuration
arrival {
airportCode
airportName
dateTime
}
departure {
airportCode
airportName
dateTime
}
}
}
}
}
- Lembre de atualizar as datas, quando este livro foi escrito elas estavam distantes.
A resposta parcial desta query é:
{
"data": {
"bestPrices": {
"bestPrices": [...]},
"recommendations": {
"data": [
{
"recommendedFlightCode": "LA4596",
"flights": [
{
"flightCode": "LA4596",
"flightDuration": "PT1H35M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T10:50-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T09:15-03:00"
}
},
{
"flightCode": "LA4629",
"flightDuration": "PT1H35M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T22:20-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T20:45-03:00"
}
},
{
"flightCode": "LA3287",
"flightDuration": "PT1H40M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T16:55-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T15:15-03:00"
}
},
{
"flightCode": "LA3152LA3633",
"flightDuration": "PT4H55M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T13:40-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T08:45-03:00"
}
},
{
"flightCode": "LA3152LA3613",
"flightDuration": "PT8H30M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T17:15-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T08:45-03:00"
}
},
{
"flightCode": "LA3090LA3029LA3296",
"flightDuration": "PT12H45M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T22:10-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T09:25-03:00"
}
},
{
"flightCode": "LA3090LA3151LA3687",
"flightDuration": "PT12H50M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T22:15-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T09:25-03:00"
}
},
{
"flightCode": "LA3152LA3175LA3687",
"flightDuration": "PT13H30M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T22:15-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T08:45-03:00"
}
},
{
"flightCode": "LA3152LA3028LA3381",
"flightDuration": "PT13H35M",
"arrival": {
"airportCode": "GRU",
"airportName": "Guarulhos Intl.",
"dateTime": "2020-07-21T22:20-03:00"
},
"departure": {
"airportCode": "POA",
"airportName": "Salgado Filho",
"dateTime": "2020-07-21T08:45-03:00"
}
}
]
}
]
}
}
}
Um exemplo utilizando variables
pelo Postman
pode ser a foto a seguir. A mesma estratégia poderá ser utilizada via curl
.
Nosso próximo passo é implementar um sistema de caching com redis, para evitar múltiplos requests para a API da Latam.
Adicionando Caching com Redis
Vamos utilizar como plataforma de caching o banco de dados Redis e para isso precisamos disponibilizar um container de redis. Podemos fazer isso adicionando o alvo redis
em um Makefile. Esse Makefile vai conter um comando para executar o docker com docker run -p 6379:6379 --name some-redis -d redis
. Inclusive podemos incluir um alvo para executar cargo run
:
redis:
docker run -p 6379:6379 --name some-redis -d redis
run:
cargo run
Se executarmos make redis
vamos obter o seguinte output no terminal:
docker run -p 6379:6379 --name some-redis -d redis
Unable to find image 'redis:latest' locally
latest: Pulling from library/redis
8559a31e96f4: Pull complete
85a6a5c53ff0: Pull complete
b69876b7abed: Pull complete
a72d84b9df6a: Pull complete
5ce7b314b19c: Pull complete
04c4bfb0b023: Pull complete
Digest: sha256:800f2587bf3376cb01e6307afe599ddce9439deafbd4fb8562829da96085c9c5
Status: Downloaded newer image for redis:latest
0190e745a049650ba4a594b2f379483729fb9b5f01b4b1f7467ef4641772e042
Disponibilizando o Redis Client para as Queries
A primeira coisa que precisamos fazer é adicionar a crate ao seu Cargo.toml
:
[dependencies]
actix-web = "2.0.0"
actix-rt = "1.0.0"
juniper = "0.14.2"
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
serde_derive = "1.0.104"
chrono = "0.4.11"
reqwest = { version = "0.10.4", features = ["blocking", "json"] }
redis = "0.16.0"
Com isso podemos começar disponibilizando uma função que retorna o redis::Client
. Na documentação vemos que basta executarmos redis::Client::open("redis://127.0.0.1/")?
para obtermos o Client
, mas por moticos de consistência de código criremos um módulo boundaries/redis.rs
que possuirá a função redis_client
cujo tipo de retorno será um RedisResult<Client>
:
#![allow(unused)] fn main() { use redis::{Client,RedisResult}; pub fn redis_client() -> RedisResult<Client> { Ok(redis::Client::open("redis://127.0.0.1/")?) } }
Com a função redis_client
implementada podemos pensar em como vamos incluir o caching no nosso sistema. O local que eu acredito ser mais apropriado é em resolvers/internal.rs
, pois é o estágio anterior a fazermos o request e funciona bem como um controller também. Assim, na função recommendations_info
podemos criar a conexão do Redis com let mut con = redis_client()?.get_connection()?
. con
é um tipo mutável pois as operações de set
e get
, adicionar e ler respectivamente, exigem mutabilidade. Além disso, para podermos utilizar get
e set
precisamos utilizar a diretiva use redis::Commands;
.
Outro fato importante é definirmos como vamos querer criar essa estratégia de caching. Sabemos que a função recommendations_info
recebe 3 argumentos, departure, origin, destination
, então podemos concluir que estes argumentos são chaves para o caching, porém ainda precisamos definir uma estratégia de tempo. Como sei que as passagens não mudam muito de um dia pro outro, vamos utilizar a atual data como principal chave do caching. Podemos fazer isso utilizando a crate chrono
e sua função Utc::today().to_string()
que retorna uma data como 2020-06-18UTC
, e vamos salvar essa informação no valor today
. Para compormos essas chaves podemos utilizar a seguinte declaração let redis_key = format!("r{}:{}:{}:{}", &today, &departure, &origin, &destination);
, que combinado retorna r2020-06-18UTC:2020-07-21:POA:GRU
, o r
faz referência a recommendations
.
Com con
podemos chamar a função get
com a redis_key
e aplicar um match
em seu resultado. Este get
vai nos retornar um Result que caso seja Ok
vai retornar o retorno o body do request http
e caso seja Err
precisaremos aplicar a função set
com set(&redis_key, &recommendations_text)
. Algo como:
#![allow(unused)] fn main() { match con.get(&redis_key) { Ok(response) => response, Err(_) => { let _recommendations = recommendations(departure, origin, destination)?.text()?; let _: () = con.set(&redis_key, &_recommendations)?; _recommendations } }; }
Assim, o resultado deste match
agora pode ser utilizado em um let recommendations_text = match ...
que será passado para a função let recommendations: Recommendations = serde_json::from_str(&recommendations_text)?
e retornar um Ok(recommendations)
:
#![allow(unused)] fn main() { use crate::boundaries::{ http_out::{best_prices, recommendations}, redis::redis_client}; use crate::schema::{errors::GenericError, model::web::{best_prices::BestPrices, recommendations::Recommendations}}; use redis::Commands; use chrono::Utc; // ... pub fn recommendations_info( departure: String, origin: String, destination: String, ) -> Result<Recommendations, GenericError> { let mut con = redis_client()?.get_connection()?; let today = Utc::today().to_string(); let redis_key = format!("r{}:{}:{}:{}", &today, &departure, &origin, &destination); let recommendations_text = match con.get(&redis_key) { Ok(response) => response, Err(_) => { let _recommendations = recommendations(departure, origin, destination)?.text()?; let _: () = con.set(&redis_key, &_recommendations)?; _recommendations } }; let recommendations: Recommendations = serde_json::from_str(&recommendations_text)?; Ok(recommendations) } }
Aplicando Caching a best_prices
Agora para aplicarmos caching em best_prices_info
podemos copiar a solução de recommendations_info
modificando as funções para best_prices
e definir a redis_key
com a inicial bp
, let redis_key = format!("bp{}:{}:{}:{}", &today, &departure, &origin, &destination);
:
#![allow(unused)] fn main() { use crate::boundaries::{ http_out::{best_prices, recommendations}, redis::redis_client}; use crate::schema::{errors::GenericError, model::web::{best_prices::BestPrices, recommendations::Recommendations}}; use redis::Commands; use chrono::Utc; pub fn best_prices_info( departure: String, origin: String, destination: String, ) -> Result<BestPrices, GenericError> { let mut con = redis_client()?.get_connection()?; let today = Utc::today().to_string(); let redis_key = format!("bp{}:{}:{}:{}", &today, &departure, &origin, &destination); let best_prices_text = match con.get(&redis_key) { Ok(response) => response, Err(_) => { let _best_prices = best_prices(departure, origin, destination)?.text()?; let _: () = con.set(&redis_key, &_best_prices)?; _best_prices } }; let best_prices: BestPrices = serde_json::from_str(&best_prices_text)?; Ok(best_prices) } pub fn recommendations_info( departure: String, origin: String, destination: String, ) -> Result<Recommendations, GenericError> { let mut con = redis_client()?.get_connection()?; let today = Utc::today().to_string(); let redis_key = format!("r{}:{}:{}:{}", &today, &departure, &origin, &destination); let recommendations_text = match con.get(&redis_key) { Ok(response) => response, Err(_) => { let _recommendations = recommendations(departure, origin, destination)?.text()?; let _: () = con.set(&redis_key, &_recommendations)?; _recommendations } }; let recommendations: Recommendations = serde_json::from_str(&recommendations_text)?; Ok(recommendations) } }
Em nossos exemplos fizemos caching de mesma data, mas é possível passar chaves que expiram com as funções set_ex
que recebe a quantidade de segundos até expirar no formato usize
, pub fn set_ex<'a, K: ToRedisArgs, V: ToRedisArgs>(key: K, value: V, seconds: usize) -> Self
, e a função pset_ex
que faz a mesma coisa, mas com milisegundos, pub fn pset_ex<'a, K: ToRedisArgs, V: ToRedisArgs>(key: K, value: V, milliseconds: usize) -> Self
. Outras funcões interessantes de se olhar são mset_nx
, getset
, getrange
, setrange
, persist
, append
, outras funcões podem ser encontradas em https://docs.rs/redis/0.16.0/redis/struct.Cmd.html.
Nesta parte aprendemos a utilizar GraphQL com Actix, fazer requests HTTP síncronos e salvar essas informações como caching em uma banco de dados Redis. Com isso podemos começar um frontend com WebAssemby capaz de processar as informações do GraphQL em uma single page app que nos permitirá interagir com as passagens da Latam.
Frontend com WebAssembly e YewStack
Setup de WebAssembly
Antes de fazermos o setup de WebAssembly para nosso computador, vamos entender o porquê de WebAssembly e o que é YewStack que vamos utilizar.
WebAssembly
O que é WebAssembly
WebAssembly, também conhecido como Wasm
, é um padrão que define um formato binário portável e compacto para executáveis com velocidades quase nativas. Sua correspondente textual da linguagem assembly é o .wat
, que utiliza expressões-s
, que lembra um pouco Clojure e Scheme. Sua principal aplicação é como interface simples entre o programa que você desenvolveu e seu host ambiente, assim seu maior objetivo é possibilitar páginas web de alta performance, mas existem outros exemplos aplicando os conceitos de WemAssemlby para mobile por exemplo.
Por que WebAssembly
Aplicações web JavaScript possui uma certa incerteza quanto a performance que o produto final vai atingir. Seu sistema de tipos dinâmico dificulta muitas otimizações e o Garbage Collector muitas vezes não ajuda por depender de algumas pausas confusas. Além disso, aplicar um superset tipado não garante efetivamente uma melhora nesses quesitos, pois no final das contas, continua tendo que executar JavaScript. Outro grande problema pode ser o fato de pequenas mudanças impactarem profundamente a performance caso você caia em buracos como fadiga do JavaScript
(JavaScript fatigue) e como inferno de dependências
(dependency hell), que podem confundir muito o sistema de JIT. Outro ponto importante é o tamanho do código, especialmente quando falamos de dependency hell, o JavaScript pode causar grande impacto de performance, pois o tamanho do arquivo que vamos comunicar via internet é crucial.
Assim, Rust provê um controler de baixo nível e uma performance confiável, especialmente por não depender de Garbage Collectors não deterministicos como o JavaScript, por possibilitar arquivos de tamanho reduzido, já que você só recebe o que você precisa, e por dar poder as pessoas que vão desenvolver de como o layout de memória será. Outra vantagem é que WebAssembly permite uma portabilidade muito boa, de forma que você pode começar implementando apenas as partes mais críticas de seu JavaScript.
YewStack
Yew é um framework moderno para criar apps frontend multi-threaded com WebAssembly e Rust.
- Possui um framework baseado em componentes que permite criar UIs de forma interativa. Assim, quem já possui experiência de React e Elm devem se sentir bastante confortáveis com o funcionamento do Yew.
- Sua performance se da por minimizar chamadas a API de DOM e por permitir muito processamento nos web workers de background.
- Permite ótima interoperabilidade com JavaScript, o que permite desenvolver aproveitando os pacotes NPM e integrar com aplicações JavaScript já existentes.
Setup
Primeiro devemos começar com o setup do WebAssembly para Rust:
- Começamos com o
wasm-pack
, uma ferramenta para construir, testar e publicar WebAssembly gerado por Rust. Basta executar o comandocurl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
ou acessar o site https://rustwasm.github.io/wasm-pack/installer/ para mais informações. Outra opção é utilizar ocargo install wasm-pack
. - O segundo passo é instalar o
cargo-generate
, que pode ser feito através do comandocargo install cargo-generate
. Sua função é facilitar a subida e execução de novos projetos e seus tempaltes em Rust. - Por fim, instalar o NPM de sua escolha, uma sugestão é fazer o download pelo site https://www.npmjs.com/get-npm e executar
npm install npm@latest -g
.
Setup para o Yew
Para utilizarmos o Yew vamos precisar de 2 novos pacotes, wasm-bindgen
e cargo-web
, e uma dependência NPM chamada rollup
.
wasm-bindgen
pode ser encontrado comodependencies
.- Para instalar o
cargo-web
você precisa executar o comandocargo install cargo-web
. Para obuild
basta utilizarcargo web build
e orun
utilizarcargo web run
. Necessário parastdweb
. - Para o exemplo teste precisamos instalar o
rollup
executenpm install --global rollup
- Para o nosso exemplo de teste vamos utilozar Python, ou Python3 se você preferir, e o módulo
http
isntalado compip install http
. Outros servidores também seriam possíveis como ominiserve
do cargo, que pode ser obtido através do comandocargo +nightly install miniserve
.
Os alvos suportados são:
wasm32-unknown-unknown
wasm32-unknown-emscripten
asmjs-unknown-emscripten
Hello World
Nesse exemplo vamos utilizar o template mínimo da yew-stack
, para isso faça clone do repositório https://github.com/yewstack/yew-wasm-pack-minimal.git. Você verá que o arquivo Cargo.toml
está configurado da seguitne forma:
[package]
authors = [
"Kelly Thomas Kline <kellytk@sw-e.org>",
"Samuel Rounce <me@samuelrounce.co.uk>"
]
categories = ["gui", "wasm", "web-programming"]
description = "yew-wasm-pack-minimal demonstrates the minimum code and tooling necessary for a frontend web app with simple deployable artifacts consisting of one HTML file, one JavaScript file, and one WebAssembly file, using Yew, wasm-bindgen, and wasm-pack."
edition = "2018"
keywords = ["yew", "wasm", "wasm-bindgen", "web"]
license = "MIT/Apache-2.0"
name = "yew-wasm-pack-minimal"
readme = "README.md"
repository = "https://github.com/yewstack/yew-wasm-pack-minimal"
version = "0.1.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "^0.2"
yew = "0.12"
Na tag author
mude para seu nome e seu email, a description
pode ser "hello world in wasm"
, a tag name
pode ser hello-world-wasm
, estaremos utilizando websys
. Caso você deseje utilizar a stdweb
basta modificar a dependência yew
para yew = { version = "0.15", package = "yew-stdweb" }
. Alguns exemplos da diferença entre websys
e stdweb
:
#![allow(unused)] fn main() { // web-sys let window: web_sys::Window = web_sys::window().expect("window not available"); window.alert_with_message("hello from wasm!").expect("alert failed"); // stdweb let window: stdweb::web::Window = stdweb::web::window(); window.alert("hello from wasm!"); // stdweb com a macro js! use stdweb::js; use stdweb::unstable::TryFrom; use stdweb::web::Window; let window_val: stdweb::Value = js!{ return window; }; let window = Window::try_from(window_val).expect("conversion to window failed"); window.alert("hello from wasm!"); }
Dentro da macro js!
é possível utilizar JavaScript, como no exemplo js!{ return window; }
.
Diferenças entre web-sys
e stdweb
| | web-sys | stdweb |
|- |- |- |
| Status do projeto | Ativamente mantido pelo wasm-working-group | Pouca atividade no Github, já passou longos períodos sem commits |
| Cobertura da Web API | APIs Rust são auto geradas pela spec Web IDL e deveriam ter cobertura de ~100%. | APIs do Browser são adicionadas de acordo com as necessidades da comunidade |
| Design da API Rust | Toma medidas conservadoras ao retornar quase sempre um Result
para as chamadas da API | Prefere utilizar panic!
em vez de Result
. |
| Suporte para build tools | * wasm-pack
* wasm-bindgen
| * wasm-pack
* wasm-bindgen
* cargo-web
|
| Alvos suportados | * wasm32-unknown-unknown
| * wasm32-unknown-unknown
* wasm32-unknown-emscripten
* asmjs-unknown-emscripten
|
Executando o projeto
Opção 1
- Para buildar o projeto execute
wasm-pack build --target web
. - Para fazer o bundle utilize
rollup ./main.js --format iife --file ./pkg/bundle.js
. - Para expor seu bundle no browser execute o comando
python -m SimpleHTTPServer
para Python2 epython3 -m http.server
para Python3, depois acesse em seu browserlocalhost:8000
para ver um bome velhoHello world!
.
Opção 2
- Crie a pasta
static
através de ummkdir static
e inclua o seguinte arquivoindex.html
:
<html lang="en">
<head>
<meta charset="utf-8">
<title>Yew Sample App</title>
<script type="module">
import init from "./wasm.js"
init()
</script>
</head>
<body></body>
</html>
- Modifique a
src/lib.rs
para expor a função:
#![allow(unused)] fn main() { mod app; use wasm_bindgen::prelude::*; use yew::App; #[wasm_bindgen(start)] pub fn run_app() { App::<app::App>::new().mount_to_body(); } }
- Execute o comando
wasm-pack build --target web --out-name wasm --out-dir ./static
para buildar seu projeto. - Disponibilize o bundle no browser com
miniserve ./static --index index.html
. E pronto, basta acessarlocalhost:8080
.
Ao longo das próximas etapas do projeto vamos utilizar a opção 2
, mas se você preferir utilizar a opção 1
fique a vontade.
Iniciando o projeto
Para este passo vamos criar um novo projeto chamado wasm-airline
com o comando cargo new --lib wasm-airline
. Em nosso Cargo.toml
vamos adicionar o seguinte código:
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
yew = "0.16"
wasm-bindgen = "0.2"
Depois vamos ao nosso arquivo src/lib.rs
e adicionamos o seguinte código:
#![allow(unused)] fn main() { mod app; use wasm_bindgen::prelude::*; use yew::prelude::App; #[wasm_bindgen(start)] pub fn run_app() { App::<app::Airline>::new().mount_to_body(); } }
E com isso precisamos criar a struct Airline
no módulo app
, cuja estrutura base será igual a do exemplo de hello-world
:
#![allow(unused)] fn main() { // src/app.rs use yew::prelude::*; pub struct Airline {} pub enum Msg {} impl Component for Airline { type Message = Msg; type Properties = (); fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self { Airline {} } fn change(&mut self, _: <Self as yew::html::Component>::Properties) -> bool { false // Essa função deve retornar true somente se as `Properties` mudarem } fn update(&mut self, _msg: Self::Message) -> ShouldRender { true } fn view(&self) -> Html { html! { <p>{ "Hello world!" }</p> } } } }
- esqueça de criar a pasta
static
e incluir o arquivostatic/index.html
:
<html lang="en">
<head>
<meta charset="utf-8">
<title>Yew Sample App</title>
<script type="module">
import init from "./wasm.js"
init()
</script>
</head>
<body></body>
</html>
Para executar o projeto vamos criar um Makefile
que vai executar os comandos que definimos no capítulo anterior para executar o projeto:
build:
wasm-pack build --target web --out-name wasm --out-dir ./static
serve:
miniserve ./static --index index.html
run: build serve
Ao executar make run
podemos acessar localhost:8080
e encontrar o texto Hello world!
. Se fizermos uma alteração dentro da função view
para algo como:
#![allow(unused)] fn main() { fn view(&self) -> Html { html! { <div> <p>{ "Hello world!" }</p> <p>{ "Hello Julia" }</p> </div> } } }
Podemos recompilar apenas executando make build
e recarregar a página. No próximo capítulo vamos iniciar carregando os dados de BestPrices
.
Componente de Best Prices
Vamos começar com o código que corresponde ao componente de Best Prices, aquele que é o carrossel de preços na parte da imagem a seguir.
Fazendo o request para para nosso serviço da parte 2
A primeira coisa que precisamos fazer para podermos utilizar os dois serviços (frontend e backend) em localhost
é implementar um sistema de CORS no serviço GraphQL que fizemos anteriormente. Essa mudanca é bastante simples e impacta muito pouco na estrutura do código. O que vamos fazer é adicionar a crate actix-cors
no Cargo.toml
:
[package]
name = "recommendations-gql"
version = "0.1.0"
authors = ["Julia Naomi <jnboeira@outlook.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "2.0.0"
# ...
redis = "0.16.0"
actix-cors = "0.2.0"
[dev-dependencies]
bytes = "0.5.3"
Agora no arquivo main.rs
precisamos adicionar as configurações de CORS, para isso disponibilizamos Cors
através da diretiva use actix_cors::Cors;
e dentro da função main
criamos o recurso de Cors
envolto em um wrap
com:
#[actix_rt::main] async fn main() -> std::io::Result<()> { let resolvers: std::sync::Arc<Resolver> = std::sync::Arc::new(create_resolver()); HttpServer::new(move || { App::new() .data(resolvers.clone()) .wrap( Cors::new() // ... .finish(), ) .configure(routes) .default_service(web::to(|| async { "404" })) }) .bind("127.0.0.1:4000")? .run() .await }
Agora precisamos configurar quais domínios, métodos e headers serão adicionados. Existem duas maneiras de fazer isso, a primeira e mais simples é atraveés da função supported_credentials
:
#![allow(unused)] fn main() { App::new() .data(resolvers.clone()) .wrap( Cors::new() .supports_credentials() .max_age(3600) .finish(), ) .configure(routes) .default_service(web::to(|| async { "404" })) }
E a segunda é adicionando explicitamente as informações com as funções allowed_*
. Vamos utilizar esta abordagem:
#![allow(unused)] fn main() { App::new() .data(resolvers.clone()) .wrap( Cors::new() .allowed_origin("http://localhost:8080") .allowed_origin("http://127.0.0.1:8080") .allowed_origin("http://0.0.0.0:8080") .allowed_methods(vec!["GET", "POST"]) .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT]) .allowed_header(header::CONTENT_TYPE) .max_age(3600) .finish(), ) .configure(routes) .default_service(web::to(|| async { "404" })) }
Agora basta executar um make redis
e depois um make run
e esse serviço estará executando.
Printando o request
O passo mais básico que podemos fazer com um request é printar sua resposta na tela. Não precisamos de nenhum estilo bonito ou organização neste ponto, serve somente para sabermos que o request foi bem sucedido. Para fazermos isso, podemos começar pensando como será nossa view
. Atualmente nossa view
está com a seguinte aparência:
#![allow(unused)] fn main() { fn view(&self) -> Html { html! { <div> <p>{ "Hello world!" }</p> <p>{ "Hello Julia" }</p> </div> } } }
Mas se vamos fazer um request precisaremos de um booleano que indica o estado de loading
e uma String
que representa a resposta do do backend GraphQL:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { html! { <div class="loading"> {"Loading..."} </div> } } else { html! { <div> <p>{ if let Some(data) = &self.graphql_response { data } else { "Failed to fetch" } }</p> </div> } } } }
Assim, defini as duas propriedades da struct Airline
são fetching
que indica que um request está sendo feito e graphql_response
que corresponde ao corpo da resposta do GraphQL. Nosso view
possui duas modalidades definidas pelo if/else
, caso o self.fetching
seja true
vamos exibir o texto Loading...
no HTML, que podemos construir utilizando a macro html!
e injetando o HTML correspondente os q desejamos executar, no caso html! {<div class="loading">{"Loading..."}</div>}
. Já no nosso else
utilizamos a macro html!
da mesma forma, mas decidimos o que exibir com base no if-let
que extrai o campo Option
de graphql_response
. Com isso, podemos começar a implementar a struct Airline
e implementar a função create
que vai definir os valores iniciais de cada propriedade:
#![allow(unused)] fn main() { use yew::prelude::*; // ... pub struct Airline { // ... fetching: bool, graphql_response: Option<String> } pub enum Msg { // .. Fetching(bool) } impl Component for Airline { type Message = Msg; type Properties = (); fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { Airline { // ... fetching: false, graphql_response: None } } // ... } }
Note que a função create
pertence a trait Component
, que equivale as funções básicas do React. E o enum Msg
do tipo Message
funciona de forma que para cada mensagem enviada uma ação é tomada, o que explica os dois estados do if/else
reagindo diferentemente ao self.fetching
, que é recebido pela opção Msg::Fetching
que recebe um bool
. Esta funcionalidade de atualizar o estado pertence a função update
da trait Component
. Já a função update
tem a seguinte aparência:
#![allow(unused)] fn main() { impl Component for Airline { type Message = Msg; type Properties = (); // ... fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { // ... Msg::Fetching(fetch) => { self.fetching = fetch; } } true } } }
Na função update
para cada opção de Msg
o match toma uma ação. Outra função importante nesse contexto é a função create
que funciona de forma a atualizar a view
em caso de alguma propriedade mudar:
#![allow(unused)] fn main() { impl Component for Airline { type Message = Msg; type Properties = (); // ... fn change(&mut self, _: <Self as yew::html::Component>::Properties) -> bool { false } } }
Na função change
, se alguma propriedade mudar, é preciso retornar true
, e se nada acontecer retornar false
. Com isso, falamos de todas as funções de implementação obrigatória da trait Component
, mas ainda temos uma função extra que podemos utilizar para nossa aplicação, a rendered
, que corresponde ao componentDidMount
do React, e ela é aplicada de forma diferente para o primeiro render
:
#![allow(unused)] fn main() { fn rendered(&mut self, first_render: bool) { if first_render { Msg::Fetching(true); self.fetch_data(); } } }
Para o primeiro render
atualizamos o estado de fetching
para true com Msg::Fetching(true)
para que possamos exibir Loading...
na view
:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { html! { <div class="loading"> {"Loading..."} </div> } } // ... } }
Depois disso, temos a função que executa o fetch
em si, self.fetch_data()
. Para essa função vamos precisar de um novo impl
que nos permita acessar o self
de Airline
:
#![allow(unused)] fn main() { use crate::gql::fetch_gql; impl Airline { pub fn fetch_data(&mut self) { let request = fetch_gql(); let callback = self.link.callback( move |response: Response<Text>| { let (meta, data) = response.into_parts(); if meta.status.is_success() { Msg::FetchGql(Some(data)) } else { Msg::FetchGql(None) } }, ); let request = Request::builder() .method("POST") .header("content-type", "application/json") .uri(self.graphql_url.clone()) .body(Json(&request)) .unwrap(); let task = self.fetch.fetch(request, callback).unwrap(); self.fetch_task = Some(task); Msg::Fetching(false); } } }
Primeiro passo de fetch_data
é função da fetch_gql
do módulo gql
, que retorna o Json para executar a query no serviço, que depende da crate serde_json
:
#![allow(unused)] fn main() { use serde_json::{json, Value}; pub fn fetch_gql() -> Value { json!({ "query": "{ bestPrices(departure: \"2020-07-21\", origin: \"POA\", destination: \"GRU\") { bestPrices { date available price {amount} } } }" }) } }
Em seguida, encontramos self.link
, um novo tipo a ser adicionado a nossa struct Airline
, que é do tipo ComponentLink<Self>
, cuja principal função é fazer callback. No nosso caso, esses callback processam a resposta da requisição, response
, separam a resposta através da funçnao into_parts
em metadados, meta
, e em corpo, data
para executar um pattern matching dos valores. Se a requisição retornou 2xx
, através da função meta.status.is_success()
, enviamos a mensagem Msg::FetchGql(Some(data))
, senão enviamos a mensagem Msg::FetchGql(None)
.
O próximo passo é montar a requisição com yew::services::fetch::Request::builder()
, na qual definimos o método com method("POST")
, os headers
, a url
já salva em self.graphql_url
e o corpo do request em body
, que é o tipo Value
retornado em let request = fetch_gql();
transformado em Json através da função Json()
. Por último definimos o fetch
com seu request
e seu callback
, fetch(request, callback)
e passamos seu resultado para FetchTask
definida em self.fetch_task
, que executará o fetch:
#![allow(unused)] fn main() { let task = self.fetch.fetch(request, callback).unwrap(); self.fetch_task = Some(task); }
Para então definirmos que fetching
é false em Msg::Fetching(false)
. Agora precisamos adicionar os novos tipos de dados presentes em nossa struct Airline
:
#![allow(unused)] fn main() { use yew::prelude::*; use yew::services::{ fetch::{FetchService, FetchTask, Request, Response} }; use yew::format::{Text, Json}; use crate::gql::fetch_gql; pub struct Airline { fetch: FetchService, link: ComponentLink<Self>, fetch_task: Option<FetchTask>, fetching: bool, graphql_url: String, graphql_response: Option<String> } impl Airline { pub fn fetch_data(&mut self) { // ... } } pub enum Msg { FetchGql(Option<Text>), Fetching(bool) } impl Component for Airline { type Message = Msg; type Properties = (); fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { Airline { fetch: FetchService::new(), link: link, fetch_task: None, fetching: true, graphql_url: "http://localhost:4000/graphql".to_string(), graphql_response: None } } // ... } }
Para cada um dos campos:
fetch: FetchService
:FetchService
é a struct com conhecimentos de como realizar um Fetch via WebAssembly, algo como ofetch
em JavaScript. Para inicializar este valor basta executarFetchService::new()
.link: ComponentLink<Self>
: como já falamos é responsável por fazer as conexões doComponent
comcallbacks
. É inicializado com pelo próprioComponent
.fetch_task: Option<FetchTask>
: é basicamente um handler para os request. Se seu estado éNone
nada é executado, se seu estado éSome
com algumaFetchTask
ele a executa. Inicializado comNone
.fetching: true,
: Indica se a aplicação está fazendo um request. Inicializado comtrue
pois é a primeira coisa que o sertviço executa.graphql_url: String,
: Url na qual faremos o request, neste caso o nosso endpoint local/graphql
,"http://localhost:4000/graphql"
.graphql_response: Option<String>
: Por enquanto um tipo String que contém os dados da resposta do Graphql. Logo transformaremos em uma struct com domínio próprio.
Modelando a response de BestPrices
Nosso Json de resposta é o seguinte:
{
"data":{
"bestPrices":{
"bestPrices":[
{
"date":"2020-07-18",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-19",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-20",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-21",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-22",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-23",
"available":true,
"price":{
"amount":110.03
}
},
{
"date":"2020-07-24",
"available":true,
"price":{
"amount":99.03
}
}
]
}
}
}
Assim, resumidamente, nosso tipo bestPrices
é um vetor de date
, available
e price
:
{
"date":"2020-07-24",
"available":true,
"price":{
"amount":99.03
}
}
Para este Json vamos precisar da crate serde
, basta adicionar ela no Cargo.toml
, pois vamos precisar Serializar e Deserializar as informações do response neste momento. Depois, precisamos adicionar as informações básicas da response {"data":{ "bestPrices":{ ... }}}
, faremos isso com as seguintes structs no módulo gql
:
#![allow(unused)] fn main() { use crate::best_prices::BestPrices; // ... #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct GqlResponse { data: GqlFields } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct GqlFields { best_prices: BestPrices } }
Como já expliquei Serde na parte anterior, não vou entrar em detalhes de novo. Agora, precisamos implementar a struct BestPrices
no novo módulo best_prices
, que conterá as seguintes structs:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct BestPrices { best_prices: Vec<BestPrice> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct BestPrice { date: String, available: bool, price: Option<Price> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Price { amount: f64 } }
Por último, precisamos modificar Airline
para converter o tipo graphql_response
em Option<GqlResponse>
e atualizar o update para que ele faça a transformação de String
para GqlResponse
através da função serde_json::from_str(&val).unwrap()
:
#![allow(unused)] fn main() { fn update(&mut self, msg: Self::Message) -> ShouldRender { Msg::FetchGql(data) => { self.graphql_response = match data { Some(Ok(val)) => { self.fetching = false; let resp: GqlResponse = from_str(&val).unwrap(); Some(resp) }, _ => { self.fetching = false; None } } }, // ... } }
Nossa view reclará de tipos incompatíveis agora, para isso, vamos apenas utilizar a função serde_json::to_string
:
#![allow(unused)] fn main() { if let Some(data) = &self.graphql_response { serde_json::to_string(data).unwrap() } else { "Failed to fetch".to_string() } }
Construindo a view
de BestPrices
A primeira mudança que vamos fazer é adicionar uma animação de loading
no lugar do texto, para isso vamos adicionar um css no caminho static/styles.css
e incluir isso no index.html
:
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="./styles.css">
<title>Yew Sample App</title>
<script type="module">
import init from "./wasm.js"
init()
</script>
</head>
<body></body>
</html>
.loading-margin {
margin: 25rem;
}
.loader {
border: 1.25rem solid #f3f3f3; /* Light grey */
border-top: 1.25rem solid #03253b; /* Blue */
border-radius: 50%;
width: 12.5rem;
height: 12.5rem;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
E adicionar os estilos no view
:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { html! { <div class="loading-margin"> <div class="loader"></div> </div> } } // ... } }
Agora podemos implementar a função view
para BestPrices
, que será basicamente um carrousel com várias células centralizadas, conforme a imagem a seguir e seu css correspondente:
/* ... */
.body {
width: 80%;
text-align: center;
}
.carrousel {
transform: translate(10%, 50%);
display: table-row;
}
.cell {
text-align: center;
vertical-align: middle;
font-size: medium;
height: 100%;
width: 15rem;
display: table-cell;
border: 1px solid lightgrey;
}
.empty-cell {
padding: 2rem 1rem;
background-color: #e1e1e1;
}
.full-cell {
padding: 2rem 1rem;
background-color: #f1f0f0;
}
A função view
será uma implementação da struct BestPrices
e fará uma iteração sob cada um dos 7 elementos do vetor. Note que os dias que vierem com available = false
, também virão com price = None
e precisamos tratar este caso também. Começamos com algo bem simples como:
#![allow(unused)] fn main() { impl BestPrices { pub fn view(&self) -> VNode { let carrousel = format!("De frente para este {:?}", "carrosel"); html!{ <div class="carrousel"> {carrousel} </div> } } } }
Neste caso, o que fizemos foi criar uma implementação pública de BestPrices
da função view
que retorna um VNode
, que é basicamente um nodo virtual deste HTML que está sendo gerado. a variável carrousel
está englobada por uma classe css chamada .carrousel
que translada o carrosel para baixo e para o meio e transforma-se em uma linha de tabela, table-row
. Depois disso, podemos chamar esta função no nosso app
com:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { html! { <div class="loading-margin"> <div class="loader"></div> </div> } } else { html! { <div class="body"> { if let Some(data) = &self.graphql_response { data.clone().best_prices().view() } else { html!{ <p class="failed-fetch"> {"Failed to fetch"} </p> } } } </div> } } } }
Antecedendo a chamada da view
criei uma função que encurta o retorno do campo best_prices
, do tipo BestPrices
, e evita que ele seja público. Essa fução pode ser encontrada no módulo gql
da seguinte forma:
#![allow(unused)] fn main() { impl GqlResponse { pub fn best_prices(self) -> BestPrices { self.data.best_prices } } }
O próximo passo é é transforma a variável carrousel
em uma lista de Vec<HTML>
para podermos renderizá-la conforme o exercício. Para isso, vamos pegar o valor de self.best_prices
e iterar sobre ele aplicando um map que transforma cada Best_rice
em um Html
da seguinte forma self.best_prices.into_iter().map(|bp| html!{...}).collect::<Html>()
. Quanto ao map
precisamos definir qual tipo de célula utilizar, especialmente por conta do campo price
que é Option
, faremos isso com a propriedade bp.available
. Se bp.available
for true
, criamos uma célula cheia com as propriedades de data e preço, se for false
criamos uma célula vazia com a propriedade de data e uma indição de preço indisponível como N/A
:
#![allow(unused)] fn main() { .map(|bp| html!{ <div class="cell"> { if bp.available { html!{ <div class="full-cell"> { { let date = Utc.datetime_from_str(&format!("{} 00:00:00", bp.date), "%Y-%m-%d %H:%M:%S"); date.unwrap().format("%a %e %b").to_string() } } <br/> {format!("R$ {}", bp.price.unwrap().amount).replace(".", ",")} </div> } } else { html!{ <div class="empty-cell"> { { let date = Utc.datetime_from_str(&format!("{} 00:00:00", bp.date), "%Y-%m-%d %H:%M:%S"); date.unwrap().format("%a %e %b").to_string() } } <br/> {"N/A"} </div> } } } </div> }) }
Nosso map
tem a seguinte aparência, uma célula externa que possui as configurações globais pra todas as células, classe .cell
, que define tamanho, alinhamento e comportamento de display do tipo célula de tabela, table-cell
. Dentro da célula aplicamos um if/else
dependendo se o BestPrice
está available ou não. Para o casa de available = false
retornamos uma célula somente com a data, formatada, e um valor indicando a ausência de preços, N/A
, ambos separados por uma quebra de linha <br/>
. O estilo desta célula será empty-cell
, que é basicamente uma célula mais escura que a célula padrão.
O padrão de data que estamos utilizando é o mesmo do site, que indica o dia da semana seguido pelo dia do mês e o mês correspondente. Para podermos fazer essa modificação vamos utilizar a crate chrono = "0.4.11"
importando ela no módulo best_prices
com use chrono::prelude::*;
. A data que recebemos do best_prices
está no formato ano-mes-dia
e para fazermos o parse
para o Utc
precisamos dp formato ano-mes-dia hora:min:seg
, e fazemos esta modificação utilizando a macro format!
, format!("{} 00:00:00", bp.date)
. Com isso, teremos o formato "%Y-%m-%d %H:%M:%S"
que nos permitirá utilizar a função Utc.datetime_from_str
para executar o parse
da nossa data, bp.date
. Com o resultado desta transformação podemos formatar a date
no padrão dia-da-semana dia-do-mes mes
, "%a %e %b"
.
Para o caso available = true
utilizamos o mesmo padrão de formatação de data, mas em vez de utilizar N/A
vamos formatar o valor de bp.price
para incluir R$
e trocar .
por ,
, format!("R$ {}", bp.price.unwrap().amount).replace(".", ",")
.
Nosso próximo passo é compor todas as recomendações.
Componente de Recomendações
A query que vamos utilizar neste capítulo será a de recommendations
, que contém muitas mais informações que a de best_prices
, mas será útil para compor os componentes faltantes. A baixo a query de recommendations
:
{
recommendations(departure: "2020-06-28",
origin: "POA",
destination: "GRU") {
data{
recommendedFlightCode
flights {
flightCode
flightDuration
stops
arrival {
cityName
airportName
airportCode
dateTime
}
departure {
cityName
airportName
airportCode
dateTime
}
segments {
flightNumber
equipment {
name
code
}
}
cabins {
code
displayPrice
availabilityCount
}
}
}
}
}
Adicionamos esta query a função fetch_gql
que executará ambas as queries simultaneamente. E já modificamos a struct GqlField
para conter o campo recommendations
:
#![allow(unused)] fn main() { pub fn fetch_gql() -> Value { json!({ "query": "{ recommendations(departure: \"2020-06-28\", origin: \"POA\", destination: \"GRU\") { data{ recommendedFlightCode flights { flightCode flightDuration stops arrival { cityName airportName airportCode dateTime } departure { cityName airportName airportCode dateTime } segments { flightNumber equipment { name code } } cabins { code displayPrice availabilityCount } } } } bestPrices(departure: \"2020-06-28\", origin: \"POA\", destination: \"GRU\") { bestPrices { date available price {amount} } } }" }) } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct GqlFields { best_prices: BestPrices, recommendations: Recommendations, } }
Esta query retorna o seguinte Json:
{
"data": {
"recommendations": {
"data": [
{
"recommendedFlightCode": "LA3068",
"flights": [
{
"flightCode": "LA3068",
"flightDuration": "PT1H35M",
"stops": 0,
"arrival": {
"cityName": "São Paulo",
"airportName": "Guarulhos Intl.",
"airportCode": "GRU",
"dateTime": "2020-06-28T22:15-03:00"
},
"departure": {
"cityName": "Porto Alegre",
"airportName": "Salgado Filho",
"airportCode": "POA",
"dateTime": "2020-06-28T20:40-03:00"
},
"segments": [
{
"flightNumber": "3068",
"equipment": {
"name": "Airbus 320-200",
"code": "320"
}
}
],
"cabins": [
{
"code": "Y",
"displayPrice": 582.03,
"availabilityCount": 33
},
{
"code": "W",
"displayPrice": 1072.03,
"availabilityCount": 9
}
]
},
{
"flightCode": "LA3297",
"flightDuration": "PT1H40M",
"stops": 0,
"arrival": {
"cityName": "São Paulo",
"airportName": "Guarulhos Intl.",
"airportCode": "GRU",
"dateTime": "2020-06-28T10:45-03:00"
},
"departure": {
"cityName": "Porto Alegre",
"airportName": "Salgado Filho",
"airportCode": "POA",
"dateTime": "2020-06-28T09:05-03:00"
},
"segments": [
{
"flightNumber": "3297",
"equipment": {
"name": "Airbus 320-200",
"code": "320"
}
}
],
"cabins": [
{
"code": "Y",
"displayPrice": 842.03,
"availabilityCount": 1
},
{
"code": "W",
"displayPrice": 1137.03,
"availabilityCount": 4
}
]
}
]
}
]
}
}
}
Ou seja, nosso backend retornará um campo data
, que pode ser um array vazio, pois pode retornar sem voos. O campo data por sua vez, retornará uma estrutura de RecommendedFLights
que é um vetor com o voo recomendado, recommendedFlightCode
, e todos os possíveis voos em flights
. No primeiro momento, vamos focar apenas a estrutura do seguinte Json:
{
"flightCode": "LA3068",
"flightDuration": "PT1H35M",
"stops": 0,
"arrival": {
"cityName": "São Paulo",
"airportName": "Guarulhos Intl.",
"airportCode": "GRU",
"dateTime": "2020-06-28T22:15-03:00"
},
"departure": {
"cityName": "Porto Alegre",
"airportName": "Salgado Filho",
"airportCode": "POA",
"dateTime": "2020-06-28T20:40-03:00"
},
}
Modelando Recommendations
A modelagem do domínio vai seguir o mesmo formato do capítulo anterior e da parte anterior, assim podemos resumir a modelagem de Recommendations
inicialmente da seguinte forma:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Recommendations { data: Vec<RecommendedFlight> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct RecommendedFlight { recommended_flight_code: String, flights: Vec<Flight> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct Flight { flight_code: String, flight_duration: String, stops: i32, arrival: OriginDestination, departure: OriginDestination, segments: Vec<Segment> } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct OriginDestination { city_name: String, airport_name: String, airport_code: String, date_time: String, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct Segment { flight_number: String, equipment: Equipment } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct Equipment { name: String, code: String } }
Agora podemos começar a implementar o componente da struct Recommendations
.
Implementando a view
para Recommendations
Para este componente vamos utilizar uma forma diferente de criação de view
, pois vamos passar as propriedades do nível de superior para que está view gerencie elas. Isso nos permitirá alterar o estado da aplicação de dentro do nosso componente de Recommendations
. Agora, na implementação da struct GqlResponse
precisamos adicionar uma função que facilite acesso ao campo privado recommendations
, fazemos isso com:
#![allow(unused)] fn main() { impl GqlResponse { pub fn best_prices(self) -> BestPrices { self.data.best_prices } pub fn recommendations(self) -> Recommendations { self.data.recommendations } } }
Com isso, podemos chamar a mesma estratégia que usamos com best_prices
e chamar a função view
de Recommendations
dentro da view
de Airline
. Para podemos chamar dois componentes Html
dentro de outro, vamos preciisar separar em várias div
s, pois cada conjunto de tag permiite a utilização de apenas um Html
do yew
. fazemos isso modificandoa função view
de app.rs
:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { html! { <div class="loading-margin"> <div class="loader"></div> </div> } } else { html! { <div class="body"> { if let Some(data) = &self.graphql_response { html!{<div> <div> {data.clone().best_prices().view()} </div> <div> { data.clone().recommendations().view() } </div> </div> } } else { html!{ <p class="failed-fetch"> {"Failed to fetch"} </p> } } } </div> } } } }
Agora precisamos definir a função view
para a struct Recommendations
, que será invocada em app.rs
:
#![allow(unused)] fn main() { impl Recommendations { pub fn view(&self) -> Html { html!{ <div class="flight-container"> { self.data[0].clone().flights.into_iter() .map(|r| html!{ <div class="flight"> {"Hello from flights"} </div> } ) .collect::<Html>() } </div>} } } }
Compondo a view
Anteriormente descrevemos a função view
da seguinte forma:
#![allow(unused)] fn main() { pub fn view(&self) -> Html { html!{ <div class="flight-container"> { self.data[0].clone().flights.into_iter() .map(|r| html!{ <div class="flight"> // ... </div> } ) .collect::<Html>() } </div>} } }
A primeira coisa que gostaria de salientar é a presenção do clone
logo após acessarmos o vetor com [0]
, isso se deve ao fato de que temos apenas a referência de um valor, que não vai nos possibilitar executar o move
para a macro, conforme o erro a seguir:
--> src/reccomendation.rs:74:17
|
74 | self.data[0].flights.into_iter()
| ^^^^^^^^^^^^^^^^^^^^ move occurs because value has type `std::vec::Vec<reccomendation::Flight>`, which does not implement the `Copy` trait
|
A criação do flight-container
é algo bastante simples, pois basta incluirmos a div
dentro da macro html!
, mas dentro do container precisamos iterar sobre cada um dos voos utilizando self.data[0].clone().flights.into_iter()
. Note que o campo data
poderia ser Option
e neste caso deveriamos utilizar um if let Some(flights)
para extrair valor de Some(flights)
, ou poderia ser um vetor vazio, e neste caso seriia melhor utilizar a função first
e aplicar um match
. Uma vez que temos nosso into_iter
podemos applicar um map
que vai criar nossos Htmls
, para depois colecionarmos em um collect::<Html>()
. Nosso map
começa com um html!
que cria a div correspondete a todo o bloco com informações do voo, conforme a imagem a seguir:
O css para está parte é este:
.flight-container {
width: 70%;
margin-top: 5rem;
transform: translate(35%, 0%);
background-color: #f1f0f0;
}
.flight {
display: table-row;
border: 1px solid lightgrey;
}
.flight-cell {
padding: 2rem 1rem;
display: table-cell;
border-bottom: 1px solid lightgray;
}
.origins-destinations {
width: 22rem;
display: flex;
}
.arrow {
font-size: 30px;
font-weight: bold;
color: red;
margin-left: 2rem;
margin-right: 2rem;
}
.origin-destination {
font-size: 26px;
display: flex;
}
.time {
font-weight: bold;
color: black;
}
.airport {
color: rgb(75, 74, 74);
margin-left: 1rem;
}
.duration {
padding: 1rem 2rem;
font-size: 20px;
color: gray;
}
.stops {
padding: 1rem 2rem;
font-size: 20px;
color: blue;
}
.price {
font-size: 26px;
font-weight: bold;
padding: 1rem 2rem;
margin-left: 1rem;
border-left: 1px groove lightgray;
}
Assim, podemos começar a implementar cada uma das células que formam um voo. Fazemos isso utilizando várias div
s com suas configurações definidas no CSS:
#![allow(unused)] fn main() { <div class="flight"> <div class="flight-cell origins-destinations"> <div class="origin-destination"> <div class="time"> {{ let date = Utc.datetime_from_str(&r.departure.date_time[..16],"%Y-%m-%dT%H:%M"); date.unwrap().format("%H:%M").to_string() }} </div> <div class="airport"> {r.departure.airport_code} </div> </div> <div class="arrow">{">"}</div> <div class="origin-destination"> <div class="time"> {{ let date = Utc.datetime_from_str(&r.arrival.date_time[..16],"%Y-%m-%dT%H:%M"); date.unwrap().format("%H:%M").to_string() }} </div> <div class="airport"> {r.arrival.airport_code} </div> </div> </div> <div class="flight-cell duration"> { r.flight_duration.replace("PT","").replace("H", "h ").replace("M", "min") } </div> <div class="flight-cell stops"> { if r.stops == 0 { html!{<p>{"Direto"}</p>} } else { html!{<p>{r.stops.to_string()}</p>} } } </div> <div class="flight-cell price"> {"R$ 582,03"}</div> </div> }
A primeira célula, origins-destinations
, possui três grandes campos, origem, seta e destino. Origem e destino são iguais em organizacão, mudando apenas o campo para acessar as iinformações de oirgem, departuure
, e de destino, arrival
. Existe uma pegadiinha no tiipo date_time
, que torna parsear ele um pouco complicado para um parser normal, pois ele não apresenta o campo de segundos no horário, mas apresenta o timezone -3:00
. A forma como podemos lidar com isso é enviando um formater que vai parsear apenas a parte da String de dataTime
que temos controle, "%Y-%m-%dT%H:%M"
, ou seja, os 16 primeiros caracteres, por isso do slice r.departure.date_time[..16]
.
Nos campos faltantes, vamos aplicar algumas funções para alterar seu valor. Por exemplo, o campo flight_duration
aparece no formato PT1H40M
, mas queremos exibir o formato 1h 40min
, por isso removemos PT
, fazemos o replace de H
por h
e modificamos M
para min
. Quanto ao campo stops
, a úncia coisa que precisamos garantir é que se a quantidade for zero, vamos escrever Direto
, caso não for, mostramos quantas escalas são. O preço veremos a seguir.
Introduzindo cabines
O objetivo de introduzir cabins
é podermos exibir diferentes preços para diferentes conjuntos de Voos. No exemplo que estamos utilizando possuimos dois tipos de cabin
, a Y
que corresponde a classe economy
e a W
que corresponde a premium economy
. Para utilizarmos cabins
vamos precisar criar o modelo para cabins
dentro da struct Flight
. O Json correspondente é:
{"cabins": [
{
"code": "Y",
"displayPrice": 842.03,
"availabilityCount": 1
},
{
"code": "W",
"displayPrice": 1137.03,
"availabilityCount": 4
}
]
}
Com isso, podemos notar que o campo cabins
é bastante simples, pois é composto por um vetor de Cabin
que pode ser definida da seguinte forma:
#![allow(unused)] fn main() { #[derive(Properties, Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct Cabin { code: String, display_price: f64, availability_count: i32 } }
Agora precisamos filtrar os voos pela Cabin
com code
igual a Y
para exibirmos o display_price
, caso a contagem de assentos, availability_count
, seja maior que zero. Para fazermos isso precisamos adicionar o campo de filtro no estado de Airline
. Isso pode ser feito da seguinte forma:
#![allow(unused)] fn main() { pub struct Airline { // ... graphql_response: Option<GqlResponse>, filter_cabin: String, } }
E adicionar este campo nos obriga a adicionar um valor padrão na função create
de Airline
:
#![allow(unused)] fn main() { fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { Airline { fetch: FetchService::new(), link: link, fetch_task: None, fetching: true, graphql_url: "http://localhost:4000/graphql".to_string(), graphql_response: None, filter_cabin: String::from("Y"), } } }
Com isso, precisamos definir uma mensagem para executar updates
. Essa mensagem definiremos como Cabin(String)
:
#![allow(unused)] fn main() { pub enum Msg { FetchGql(Option<Text>), Fetching(bool), Cabin(String) } }
E precisamos definir como o update vai se comportar quando receber a mensagem Cabin
:
#![allow(unused)] fn main() { fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::FetchGql(data) => { // ... }, Msg::Fetching(fetch) => { self.fetching = fetch; }, Msg::Cabin(c) => { self.filter_cabin = c } } true } }
Assim, nosso update
vai atualizar o campo filter_cabin
com o valor recebido dentro da mensagem Cabin
. O próximo passo seria enviar para a função view
de Recommendations
o ComponentLink<Airline>
e o filter_cabin
como referências emprestadas, fazemos isso utilizando o &
:
#![allow(unused)] fn main() { fn view(&self) -> Html { if self.fetching { // ... } else { html! { <div class="body"> { if let Some(data) = &self.graphql_response { html!{<div> <div> {data.clone().best_prices().view()} </div> <div> { data.clone().recommendations().view(&self.link, &self.filter_cabin) } </div> </div> } } else { // ... } } </div> } } } }
Agora que sabemos o que devemos passar para a view
de Recommendations
podemos atualizar a funcão com:
#![allow(unused)] fn main() { use crate::app::{Airline, Msg}; // ... impl Recommendations { pub fn view(&self, link: &ComponentLink<Airline>, filter_cabin: &str) -> Html { html!{ <div> <div class="cabins"> <div class="cabin" onclick=link.callback(move |_| Msg::Cabin("Y".to_string()))> {"Economy"}</div> <div class="cabin" onclick=link.callback(move |_| Msg::Cabin("W".to_string()))> {"Premium Economy"}</div> </div> <div class="flight-container"> { self.data[0].clone().flights.into_iter() .map(|r| html!{ <div class="flight"> <div class="flight-cell origins-destinations"> <div class="origin-destination"> <div class="time"> {{ let date = Utc.datetime_from_str(&r.departure.date_time[..16],"%Y-%m-%dT%H:%M"); date.unwrap().format("%H:%M").to_string() }} </div> <div class="airport"> {r.departure.airport_code} </div> </div> <div class="arrow">{">"}</div> <div class="origin-destination"> <div class="time"> {{ let date = Utc.datetime_from_str(&r.arrival.date_time[..16],"%Y-%m-%dT%H:%M"); date.unwrap().format("%H:%M").to_string() }} </div> <div class="airport"> {r.arrival.airport_code} </div> </div> </div> <div class="flight-cell duration"> { r.flight_duration.replace("PT","").replace("H", "h ").replace("M", "min") } </div> <div class="flight-cell stops"> { if r.stops == 0 { html!{<p>{"Direto"}</p>} } else { html!{<p>{r.stops.to_string()}</p>} } } </div> <div class="flight-cell price"> {{ let cabin = r.cabins.into_iter() .filter(|c| c.availability_count > 0 && &c.code == filter_cabin) .collect::<Vec<Cabin>>(); match cabin.first() { Some(c) => format!("R$ {:?}", c.display_price), None => String::from("N/A") } }}</div> </div> } ) .collect::<Html>() } </div> </div> } } } }
Os estilos do código anterior são:
.cabins {
text-align: center;
vertical-align: middle;
color: rgb(110, 110, 110);
display: table-row;
transform: translate(195%, 185%);
font-size: 20px;
}
.cabin {
display: table-cell;
border: 1px solid gray;
padding: 9px 12px;
}
A primeira mudança que vamos entender é a adição do display_price
para a Cabin
:
#![allow(unused)] fn main() { <div class="flight-cell price"> {{ let cabin = r.cabins.into_iter() .filter(|c| c.availability_count > 0 && &c.code == filter_cabin) .collect::<Vec<Cabin>>(); match cabin.first() { Some(c) => format!("R$ {:?}", c.display_price), None => String::from("N/A") } }}</div> }
Iteramos sobre o valor de cabins
com r.cabins.into_iter()
que nos permite aplicar um filter
para garantir que os voos estão disponíveis, c.availability_count > 0
e para garantir q o código da cabin
, c.code
, é igiual ao filter_cabin
que enviamos, &c.code == filter_cabin
. Depois, coletamos os calores filtrados com .collect::<Vec<Cabin>>()
. Este vetor de Cabin
deve conter no máximo um elemento, assim aplicar um first
é uma forma segura de garantir que não vamos perder valores. Aplicando um match
em abin.first()
lidamos com o fato de que podemos não ter uma cabin
disponível para aquelec código, retornando String::from("N/A")
. Para o caso de termos um Some
com valor de cabin
executamos um format
para disponibilizar o valor da passagem, Some(c) => format!("R$ {:?}", c.display_price)
.
O próximo passo é criar os links
de callbacks
que alterem o estado da aplicação:
#![allow(unused)] fn main() { <div class="cabins"> <div class="cabin" onclick=link.callback(move |_| Msg::Cabin("Y".to_string()))> {"Economy"}</div> <div class="cabin" onclick=link.callback(move |_| Msg::Cabin("W".to_string()))> {"Premium Economy"}</div> </div> }
Criamos uma div
que possui as duas cabines que conhecemos, Economy
e Premium Economy
, dentro de suas prórpias div
s. Cada uma das dvi
s vai ter um callback
com JavScript para alterar o estado de fiilter_cabin
. Fazemos isso adicionando onclick=link.callback(move |_| Msg::Cabin("Y".to_string()))
a div
de Economy
e onclick=link.callback(move |_| Msg::Cabin("W".to_string()))
a div
de Premium Economy
.
Nosso próximo passo é defiiniir uma forma de receber os parâmetros da query GraphQL via rota da URL e que possamos atualizar esses dados de forma a executar um novo request com os novos parâmetros.
Aplicando um Router
Neste capítulo vamos aprender a utilizar o Router
da YewStack
, pois Routers
em Single Page Apps (SPA)manipulam a exibição de entidades diferentes, dependendo da URL
. Em vez do comportamento padrão de solicitar um recurso remoto diferente quando um link é clicado, o roteador define a URL localmente para apontar para uma rota válida em seu aplicativo. O roteador detecta essa alteração e decide o que renderizar.
Vamos utilizar o YewRouter
, adicionando yew-router = "0.13.0"
ao Cargo.toml
. o YewRouter
possui alguns elementos centrais que valem mencionar:
Route
: Contém umaString
contendo tudo que aparece após o domínio naURL
.RouteService
: Comunica com o brrowser para receber e enviar asRoutes
.RouteAgent
: Dono doRouteService
e é usado para coordenar os updates quando as rotas mudam, tanto dentro da aplicação quanto por eventos do browser.Switch
: É uma trait utilizada na conversão deRoute
.Router
: ORouter
comunica com oRouteAgent
e automaticamente resolve aRoute
que recebe doRouteAgent
para uma das implementações doSwitch
.
Explicando como funciona o Router
Router
funciona de forma a aplicar um pattern macthing na url recebida pelo browser que instancia um tipo que implementa a trait Switch
para a rota correspondente. Uma coisa importante de salientar é que utilizar tags de referência, <a href=...></a>
, para a rota que você deseja não irá funcionar imediatamente e, no melhor cenário, irá fazer o servidor recarregar todo o bunde do App. No pior cenário, retorna apenas um código 404 - Not Found
, caso o serviço não esteja bem configurado. Assim, utilizar RouteService, RouteAgent, RouterButton, RouterLink
para definir a rota via history.push_state()
modificará a rota, sem recarregar todo o App novamente.
Configuração do Servidor
Para que um link externo funcione com o App, o servidor precisa estar configurado para retornar o index.html
para qualquer request GET
, senão o retorno será sempre 404
. Além disso, não pode ser um redirecionamento 3xx
para o index.html
, pois isso modificará a url no browser causando uma falha de roteamento. Assim, é preciso que a resposta seja um 200
que contém o index.html
. Uma vez que o conteúdo de index.html
carregar, resultando no carregamento de todos os assets para iniciar o App, que detectará a rota atual definindo o estado do App para o estado correspondente.
- O miserve que estamos utilizando já aponta para o diretório
static/
e para o índiceindex.html
,miniserve ./static --index index.html
. - Se você quiser servir seu App do mesmo servidor que sua API está localizada, uma recomendação é definir a rota da API como
/api
e montar seus assets sob a rota/
fazendo com que ela retorne o conteúdo deindex.html
. - É possivel também configurar o webpack dev server para apontar seus arquivos para
index.hmtl
. Mais informações em https://webpack.js.org/configuration/dev-server/#devserverhistoryapifallback.
Neste livro vamos abordar s estratégias 1
, mas caso você prefira a estratégia 2
, poderiamos utilizar o serviço anterior, com a crate actix-files = "0.2.2"
, para retornar nosso index.html
executando um GET
na rota "/"
. O serviço fica configurado da seguinte forma:
use actix_files::NamedFile; use actix_web::{get, middleware, web, App, Error, HttpResponse, HttpServer}; // Caminho para o diretório `static/` const ASSETS_DIR: &str = "../../static"; async fn serve_index_html() -> Result<NamedFile, Error> { const INDEX_HTML: &str = "index.html"; let index_file = format!("{}/{}", ASSETS_DIR, INDEX_HTML); Ok(NamedFile::open(index_file)?) } #[actix_rt::main] async fn main() -> std::io::Result<()> { std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info"); env_logger::init(); let localhost: &str = "0.0.0.0"; let port: u16 = 8000; let addr = (localhost, port); HttpServer::new(move || { App::new() .wrap(middleware::Logger::default()) .service(actix_files::Files::new("/", ASSETS_DIR).index_file("index.html")) .default_service(web::get().to(serve_index_html)) }) .bind(addr)? .workers(4) .run() .await }
Entendendo as Rotas
Agora vamos entender um exemplo genérico de como configurar rotas, pois isto nos permitirá estender a lógica para o caso que vamos utilizar. A primeira coisa que precisamos fazer é definir um enum
que seja responsável pelas rotas, no exemplo chamamos de enum AppRoute
. Esse enum
deve implementar a trait Switch
, que vai correlacionar cada rota to
a um elemente do enum
, como o exemplo do Index
. O exemplo do AppRoute::Index
aponta para a rota /
por conta da derivação #[to = "/"]
, o mesmo vale para Profile(u32)
que aponta para #[to = "/profile/{id}"]
, na qual {id}
é um valor do tipo u32
. O caso de Forum(ForumRoute),
é um pouco mais complicado, pois ForumRoute
é uma sub rota associada a outro enum que implementa suas próprias rotas, mas seria possível deestrutura esses valores em uma struct. Importante também salientar que o pattern matching ocorre de forma sequencial, então se Index
fosse o primeiro, nenhuma das outras rotas aconteceria, pois todas as rotas seriam "/"
.
#![allow(unused)] fn main() { #[derive(Switch, Debug)] pub enum AppRoute { #[to = "/profile/{id}"] Profile(u32), #[to = "/forum{*:rest}"] Forum(ForumRoute), #[to = "/"] Index, } #[derive(Switch, Debug)] pub enum ForumRoute { #[to = "/{subforum}/{thread_slug}"] SubForumAndThread{subforum: String, thread_slug: String} #[to = "/{subforum}"] SubForum{subforum: String} } html! { <Router<AppRoute, ()> render = Router::render(|switch: AppRoute| { match switch { AppRoute::Profile(id) => html!{<ProfileComponent id = id/>}, AppRoute::Index => html!{<IndexComponent/>}, AppRoute::Forum(forum_route) => html!{<ForumComponent route = forum_route/>}, } }) /> } }
Por último, a construção do Router
se da através da tag Router
que recebe como propridade <AppRoute, ()>
e render
que é a implementação da função Router::render
no enum AppRouter
.
Potencializando o componente Airline
A ideia agora é tornar nosso componente de Airline
mais flexível para tirarmos maior proveito do Router
, nesse sentido vamos lidar com as propriedades departure, origin, destination
para no componente poder fazer queries customizadas. Para iniciarmos este processo, precisamos criar a struct Props
, que conterá estes campos em app.rs
:
#![allow(unused)] fn main() { #[derive(Properties, Clone)] pub struct Props { pub departure: String, pub origin: String, pub destination: String } }
Note a presença da macro Properties
, ela é quem nos permite tornar esses campos utilizáveis como propriedades do componente e o fato de que todas as propriedades são pub
para poderem ser acessadas de fora durante a declaração do componente. Agora precisamos definir Props
como o tipo Properties
da trait Component
, fazemos isso na implementação da trait:
#![allow(unused)] fn main() { impl Component for Airline { type Message = Msg; type Properties = Props; // ... } }
Podemos seguir com o próximo passo que é salvar as propriedades no estado de Airline
, que pode ser feito simplesmente adicionando os campos a struct:
#![allow(unused)] fn main() { pub struct Airline { // ... filter_cabin: String, departure: String, origin: String, destination: String } }
Esta etapa nos obriga a salvar as propriedades no estado, podemos fazer isso na função create
da trait Component
, que agora terá o nome props
para o argumento Self::Properties
, que antes estava como _: Self::Properties
:
#![allow(unused)] fn main() { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { Airline { fetch: FetchService::new(), link: link, fetch_task: None, fetching: true, graphql_url: "http://localhost:4000/graphql".to_string(), graphql_response: None, filter_cabin: String::from("Y"), departure: props.departure, origin: props.origin, destination: props.destination } } }
Esta alteração nos permite passar como argumento para a função fetch_gql
, chamada pela função fetch_data
, os campos departure, origin, destination
, que nos permitem flexibilizar o request para o servidor GraphQL. Em fetch_data
a chama de fetch_gql
passa a ser da sehguinte forma let request = fetch_gql(self.departure.clone(), self.origin.clone(), self.destination.clone());
. Agora a implementação da função fetch_gql
no módulo gql
muda para comportar os novos argumentos:
#![allow(unused)] fn main() { pub fn fetch_gql(departure: String, origin: String, destination: String) -> Value { json!({ // ... }) } }
Estes novos argumentos devem ser passados para a query
através da chave variables
que conterá um mapa na qual cada chave é o nome da variável que vamos passar para query
. Além disso, na chave query
agora devemos adicionar os argumentos de query, fazemos isso colocando query($departure: String!, $origin: String!, $destination: String!)
antes da primeira chave de abertura, {
. Perceba que o nome dos campos possui um $
na frente, que nos permite definir como uma variável para utilizar dentro da query
, como recommendations(departure: $departure, origin: $origin, destination: $destination)
. O exemplo a seguir mostra como a modificação fica:
#![allow(unused)] fn main() { pub fn fetch_gql(departure: String, origin: String, destination: String) -> Value { json!({ "variables": { "departure": departure, "origin": origin, "destination": destination }, "query": "query($departure: String!, $origin: String!, $destination: String!) { recommendations(departure: $departure, origin: $origin, destination: $destination) { data{ // ... } } bestPrices(departure: $departure, origin: $origin, destination: $destination) { bestPrices { date available price {amount} } } }" }) } }
Para utilizamos esta nova query
, precisamos passar Props
como argumento em run_app
, fazemos isso da seguinte forma:
#![allow(unused)] #![recursion_limit="1024"] fn main() { mod app; mod gql; mod best_prices; mod reccomendation; use wasm_bindgen::prelude::*; use yew::prelude::App; use app::Props; #[wasm_bindgen(start)] pub fn run_app() { App::<app::Airline>::new().mount_as_body_with_props(Props { origin: "POA".to_string(), destination: "GRU".to_string(), departure: "2020-08-21".to_string(), }); } }
Criando o Router
Para começarmos o sistema de roteamento vamos precisar de um novo componente chamado Model
que estará localizado no módulo index
. Assim, a primeira coisa que vamos fazer é definir o Model
e declarar as rotas no enum AppRoutes
:
#![allow(unused)] fn main() { use yew_router::Switch; use yew::prelude::*; #[derive(Switch, Debug, Clone)] pub enum AppRoute { #[to = "/oneway?departure={departure}&origin={origin}&destination={destination}"] Oneway {departure: String, origin: String, destination: String}, #[to = "/"] Index } #[derive(Debug)] pub struct Model {} }
Nosso AppRoute
possui duas rotas, a primeira é a rota inicial, que renderiza logo que acessamos localhost:8080
, já a segunda é a rota que navegamos, uma vez que os parâmetros tenham sido definidos. Os parâmetros da rota Oneway
são os mesmos da struct Props
, e são definidos na declaração da opção. Note que a url possui o nome dos campos dentro de chaves, /oneway?departure={departure}&origin={origin}&destination={destination}
, é dessa forma que a macro Switch
consegue executar as substituições de valores. Precisamos adicioanr ao estado de Model
os campos de Oneway
, pois comente assim poderemos altera-los para executar a navegação entre rotas, para isso adicionamos os seguintes campos a Model
:
#![allow(unused)] fn main() { #[derive(Debug)] pub struct Model { route_service: RouteService<()>, route: Route<()>, link: ComponentLink<Self>, origin: String, destination: String, departure: String } }
Os campos origin, destination, departure
já falamos sobre eles, mas adicionamos outros campos. link
já mencionamos quando criamos os callbacks para BestPrices
, mas route
terá a função de receber uma das rotas que AppRoute
define, que poderá ser utilizada em pattern matching depois e route_service
serve para fazer a navegação entre as rotas. Além disso, precisamos da diretiva use yew_router::{route::Route, service::RouteService, Switch}
para comportar os novos campos. Para começarmos a implementar a trait Component
vamos precisar de um enum de mensagens, que também vamos chamar de Msg
e conterá, inicialmente, dois campos:
#![allow(unused)] fn main() { pub enum Msg { RouteChanged(Route<()>), ChangeRoute(AppRoute), // ... } }
RouteChanged
será responsável por avisar ao componente que a rota mudou, enquando ChangeRoute
será responsável por efetivamente mudar a rota. Com isso, podemos iniciar a implementação da trait Component
:
#![allow(unused)] fn main() { impl Component for Model { type Message = Msg; type Properties = (); fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { let mut route_service: RouteService<()> = RouteService::new(); let route = route_service.get_route(); let callback = link.callback(Msg::RouteChanged); route_service.register_callback(callback); Model { route_service, route, link, origin: String::new(), destination: String::new(), departure: String::new() } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::RouteChanged(route) => self.route = route, Msg::ChangeRoute(route) => { self.route = route.into(); self.route_service.set_route(&self.route.route, ()); } } true } fn change(&mut self, _: Self::Properties) -> ShouldRender { false } fn view(&self) -> VNode { html! { <div> { "route" } </div> } } } }
A função create
começa um pouco diferente que o usual:
#![allow(unused)] fn main() { let mut route_service: RouteService<()> = RouteService::new(); let route = route_service.get_route(); let callback = link.callback(Msg::RouteChanged); route_service.register_callback(callback); }
Nela declaramos o route_service
como um RouteService::new()
e definimos a route
como o atual estado de route_service
, route_service.get_route()
. Depois disso, criamos o callback
que será chamado quando Msg::RouteChanged
for enviada para Model
e registramos esse callback
em route_service
com route_service.register_callback(callback)
.
Nosso update
vai fazer pattern matching nas duas mensagens, na qual RouteChanged
atualiza self.route
e ChangeRoute
define a rota para navegar com self.route_service.set_route(&self.route.route, ())
.
#![allow(unused)] fn main() { fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::RouteChanged(route) => self.route = route, Msg::ChangeRoute(route) => { self.route = route.into(); self.route_service.set_route(&self.route.route, ()); } } true } }
Definindo as rotas na view
Agora podemos elaborar nossa view
e para fazermos isso vamos applicar um match
na rota, self.route
, através a função AppRoute::switch
que converte a Route<()>
em um Option<AppRoute>
. Para o caso None
, retornamo 404
com VNode::from("404")
, para o caso Some(Index)
vamos apresentar a tela inicial para a pessoa inserir origin, destination, departure
, para a Rota Some(AppRoute::Oneway{departure, origin, destination})
vamos criar o componente Airline
com as propriedades anteriores utilizando html!{<Airline departure = departure origin = origin destination = destination />},
. As propriedades são passadas no formato propriedade = valor
.
#![allow(unused)] fn main() { fn view(&self) -> VNode { html! { <div> { match AppRoute::switch(self.route.clone()) { Some(AppRoute::Index) => self.view_index(), Some(AppRoute::Oneway{departure, origin, destination}) => html!{<Airline departure = departure origin = origin destination = destination />}, None => VNode::from("404") } } </div> } } }
Agora para a função self.view_index()
. Ela deve ser implementada em um impl Model
e conterá o HTML a ser exibido para a rota Index
:
#![allow(unused)] fn main() { impl Model { fn change_route(&self, app_route: AppRoute) -> Callback<MouseEvent> { self.link.callback(move |_| { let route = app_route.clone(); Msg::ChangeRoute(route) }) } fn view_index(&self) -> Html { html!{ <div class="index"> <div class="row"> <div class="input-cell"> <p> {"Origin"} </p> <input type = "text", value = &*self.origin, oninput = self.link.callback(|e: InputData| Msg::UpdateOrigin(e.value)), /> </div> <div class="input-cell"> <p> {"Destination"} </p> <input type = "text", value = &*self.destination, oninput = self.link.callback(|e: InputData| Msg::UpdateDestination(e.value)), /> </div> </div> <div class="row"> <div class="input-cell"> <p> {"Departure"} </p> <input type = "text", value = &*self.departure, oninput = self.link.callback(|e: InputData| Msg::UpdateDeparture(e.value)), /> </div> <div class="input-cell submit"> <button onclick=&self.change_route(AppRoute::Oneway {departure: self.departure.clone(), origin: self.origin.clone(), destination: self.destination.clone()}) > {"Submit"} </button> </div> </div> </div> } } } }
A view está dividida basicamente uma tabela de duas linhas com dois elementos cada. Os 3 primeiro elementos são tags HTML para inserção de texto e farão isso para as propriedades origin, destination, departure
. A estrutura é bastante simples, definimos o tipo do input
como type = "text"
, o valor, value
como &self.<propriedade>
e a ação oninput
como um callback
para uma mensagem que definimos no padrão Update<Propriedade>
que recebe o valor de InputData
. A última célula da tabela é uma tag button
com a ação de navegar para a nova rota. A ação é onclick
e recebe a função change_route
com uma rota Oneway
.
A mensagem de Update poderia receber um novo parâmetro como um enum que indicasse qual a propriedade e fazer um pattern matching interno.
#![allow(unused)] fn main() { <div class="row"> <div class="input-cell"> <p> {"Origin"} </p> <input type = "text", value = &*self.origin, oninput = self.link.callback(|e: InputData| Msg::Update(Prop::Origin, e.value)), /> </div> <div class="input-cell"> <p> {"Destination"} </p> <input type = "text", value = &*self.destination, oninput = self.link.callback(|e: InputData| Msg::Update(Prop:Destination, e.value)), /> </div> </div> <div class="row"> <div class="input-cell"> <p> {"Departure"} </p> <input type = "text", value = &*self.departure, oninput = self.link.callback(|e: InputData| Msg::Update(Prop::Departure, e.value)), /> </div> </div> }
change_route
tem uma execução bastante simples, pois define a nova rota através de let route: AppRoute = app_route.clone();
, cria a mensagem Msg::ChangeRoute
com a rota criada e atribui isso a um callback
. Isso nos força a expandirmos Msg
para os novos casos que descrevemos:
#![allow(unused)] fn main() { pub enum Msg { RouteChanged(Route<()>), ChangeRoute(AppRoute), UpdateOrigin(String), UpdateDestination(String), UpdateDeparture(String) } }
ou
#![allow(unused)] fn main() { pub enum Prop { Origin, Destination, Departure } pub enum Msg { RouteChanged(Route<()>), ChangeRoute(AppRoute), Update(Prop, String) } }
E como última tarefa precisamos adicionar as novas mensagens a função update
. Tanto origin
, quanto destination
limitei fracamente em três caracteres pois códigos IATAs possuem apenas três caracteres.
#![allow(unused)] fn main() { fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::RouteChanged(route) => self.route = route, Msg::ChangeRoute(route) => { self.route = route.into(); self.route_service.set_route(&self.route.route, ()); }, Msg::UpdateOrigin(origin) => self.origin = origin[0..3].to_string(), Msg::UpdateDestination(destination) => self.destination = destination[0..3].to_string(), Msg::UpdateDeparture(departure) => self.departure = departure } true } }
Seguindo o uso de Prop
:
#![allow(unused)] fn main() { fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::RouteChanged(route) => self.route = route, Msg::ChangeRoute(route) => { self.route = route.into(); self.route_service.set_route(&self.route.route, ()); }, Msg::Update(prop, value) => match prop { Prop::Origin => self.origin = value[0..3].to_string(), Prop::Destinationj => self.destination = value[0..3].to_string(), Prop::Departure => self.departure = value, } } true } }
Para finalizar o css utilizado em view_index
:
.index {
display: table;
height: 20%;
width: auto;
background-color: darkblue;
color: wheat;
transform: translate(150%, 20%);
}
.row {
display: table-row;
}
.input-cell {
display: table-cell;
padding: 1rem;
}
.submit {
vertical-align: middle;
text-align: center;
}
E uma imagem com o resultado de view_index
na rota "/"
:
Agora basta subir, no diretório do GraphQL, o Redis com make redis
, subir o GraphQL com make run
e subir, no diretório wasm, o wasm com make run
adicionar suas rotas desejada e datas desejadas e procurar seu próximo voo em sua própria API.
Esta parte nos trouxe alguns conceitos de desenvolvimento front-end com Rust e Wasm, nos permitindo consultar a API que criamos na parte anterior. Aprendemos a criar um componente com a trait COmponent1
que realiza um fetch logo no primeiro render da página, comunicando-se por mensagens que alteram o front-end entre um loader
e os dados que queremos exibir. Aprendemos a navegar através de um Router
e Properties
de um componente ao outro e aprendemos a criar componentes the recebem inputs
de valores textuais, assim como, componentes que tomam ações com vase em cliques, como o button
. Além disso, para o serviço que criamos executar com nosso front-end local precisamos utilizar a crate actix-cors
para configurar o CORS
da API.
Na API que atendia ao front-end utilizamos a crate juniper
para configurar o GraphQL em cima do actix-web
, assim como a crate reqwest
para realizar requests HTTP para uma API externa e utilizamos a crate redis
para configurar a comunicação com um container Redis. Já no nosso outro serviço, aprendemos a configurar middlewares, como Logger, para o Actix, sistema de tolerância a falhas com bastion/fort
, uuids, configurações de json com serde
, configuramos um DynamoDB com a crate rusoto
, assim como um Postgres
com a crate diesel
, utilizamos a crate chrono
para gerenciar datas, fizemos sistemas de autenticação que utilizavam jwt
e bcrypt
para parsear tokens e senhas, além de testes extensivos para estes cenários, tanto unitários quanto de integração. Vale mencionar também que configuramos um Travis-CI para este cenário.
Agora você já pode começar a investir em serviços e front-ends Rust para demonstrar seu poder e levar a palavra do Rust a diante.
Apêndice A - Benchmarks
Techempower
Os benchmarks do Techempower são abertos a comunidade desenvolver seus serviços conforme acreditarem ser melhor e consistem em 5 categorias:
- Serialização Json, neste teste cada resposta é uma instância com a chave
message
e o valorHello, World!
para ser serializada como Json. Actix em quinto lugar (rust em terceiro). - Única Query, neste teste cada request é processado fazendo fetch de uma única linha em uma simples tabela no bancl de dadps. A linha é então serializada em JSON. Actix em primeiro lugar.
- Multiplas Queries, neste teste cada request processa multiplos fetchs em multiplas linhas de uma tabela simples e serializa essas linhas em Json. Actix em primeiro lugar.
- Fortuna, neste teste, o ORM do framework é utilizado para fazer fetch de todas as linhas de uma tabela contendo um número desconhecido de mensagens de biscoitos da sorte. Um biscoito extra é adicionado na lista durante execução e após isso a lista é ordenada alfabeticamente. Actix em primeiro lugar.
- Atualização de dados, neste teste verificamos a escrita em banco de dados. Cada request processa multiplos fetchs em multiplas linhas de uma tabela simples, guardamos em memória, modificamos os valores em memória, atualizamos cada linha individualmente e depois serializamos os valores em memória para retornar como Json. Actix em primeiro lugar.
- Texto puro, neste teste o framework responde com
plaintext
"hello, World"
. Actix em quarto lugar (rust em primeiro e segundo).
Referência https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=fortune
Express vs Actix
Maxim Vorobjov criou seu próprio benchmark comparando comparando Express Node com Actix Rust e obteve resultados bastante interessantes. Ele comparou a performance, a estabilidade e o custo de um simples microserviço escrito com Express e com Actix. O setup estava limitado a 1 core.
O cenário era um microserviço que permite os clientes procurarem por task
que podem ou não estar associadas a workers
. Assim, o banco de dados possui apenas duas tabelas, WORKER
e TASK
, na qual TASK
possui um relação com WORKER
:
CREATE TABLE worker (
id SERIAL PRIMARY KEY,
name varchar(255) NOT NULL,
email varchar(255) NULL,
score integer DEFAULT 0
);
CREATE TABLE task (
id SERIAL PRIMARY KEY,
summary varchar(255) NOT NULL,
description text NOT NULL,
assignee_id integer NULL REFERENCES worker,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Teste de Carga
Teste de performance foi feito em um Ubuntu 18 Xeon E5–2660 com 40 cores, e o banco de dados foi inicializado com 100_000 TASK
aleatórias com 1000 WORKER
s relacionadas. Os testes foram executados com https://github.com/wg/wrk e os resultados são os seguintes:
Continue lendo em https://medium.com/@maxsparr0w/performance-of-node-js-compared-to-actix-web-37f20810fb1a
Apêndice B - Requests
Todo Server
Signup
POST http://localhost:4000/auth/signup
Headers
{
"Content-Type": "application/json",
"x-customer-id": "d8f98c2e-07df-4ed6-8645-7f0b25536fdf"
}
Body
{
"email": "my@email.com",
"password": "My cr4zy p@ssw0rd My cr4zy p@ssw0rd"
}
Login
POST http://localhost:4000/auth/login
Headers
{
"Content-Type": "application/json",
"x-customer-id": "d8f98c2e-07df-4ed6-8645-7f0b25536fdf"
}
Body
{
"email": "my@email.com",
"password": "My cr4zy p@ssw0rd My cr4zy p@ssw0rd"
}
Logout
DELETE http://localhost:4000/auth/logout
Headers
{
"Content-Type": "application/json",
"x-customer-id": "d8f98c2e-07df-4ed6-8645-7f0b25536fdf",
"x-auth": "eyJhbGciOiJIUzI1NiIsImRhdGUiOiIyMDIwLTAyLTI4IDAxOjQxOjU2LjA2NjYxNTQwMCBVVEMiLCJ0eXAiOiJqd3QifQ.eyJlbWFpbCI6Im15QGVtYWlsLmNvbSIsImV4cGlyZXNfYXQiOiIyMDIwLTAyLTI5VDAxOjQxOjU2LjA2MzI2ODgwMCIsImlkIjoiZDdjNTk1MTItYjlhYS00NzBhLWEwNjUtZTAwYTYxMTcxYmE0In0.gIycarcQhbbcjvYIHDW_9fVgCFrFs1LjlJFMZGIm_kw"
}
Body
{
"email": "my@email.com"
}
Create Todo
POST http://localhost:4000/api/create
Headers
{
"Content-Type": "application/json",
"x-customer-id": "d8f98c2e-07df-4ed6-8645-7f0b25536fdf",
"x-auth": "eyJhbGciOiJIUzI1NiIsImRhdGUiOiIyMDIwLTAyLTI4IDAxOjQxOjU2LjA2NjYxNTQwMCBVVEMiLCJ0eXAiOiJqd3QifQ.eyJlbWFpbCI6Im15QGVtYWlsLmNvbSIsImV4cGlyZXNfYXQiOiIyMDIwLTAyLTI5VDAxOjQxOjU2LjA2MzI2ODgwMCIsImlkIjoiZDdjNTk1MTItYjlhYS00NzBhLWEwNjUtZTAwYTYxMTcxYmE0In0.gIycarcQhbbcjvYIHDW_9fVgCFrFs1LjlJFMZGIm_kw"
}
Body
{
{
"title": "title",
"description": "descrition",
"state": "Done",
"owner": "90e700b0-2b9b-4c74-9285-f5fc94764995",
"tasks": [
{
"is_done": true,
"title": "blob"
},
{
"is_done": false,
"title": "blob2"
}]
}
}
Get All Todos
GET http://localhost:4000/api/index
Headers
{
"Content-Type": "application/json",
"x-customer-id": "d8f98c2e-07df-4ed6-8645-7f0b25536fdf",
"x-auth": "eyJhbGciOiJIUzI1NiIsImRhdGUiOiIyMDIwLTAyLTI4IDAxOjQxOjU2LjA2NjYxNTQwMCBVVEMiLCJ0eXAiOiJqd3QifQ.eyJlbWFpbCI6Im15QGVtYWlsLmNvbSIsImV4cGlyZXNfYXQiOiIyMDIwLTAyLTI5VDAxOjQxOjU2LjA2MzI2ODgwMCIsImlkIjoiZDdjNTk1MTItYjlhYS00NzBhLWEwNjUtZTAwYTYxMTcxYmE0In0.gIycarcQhbbcjvYIHDW_9fVgCFrFs1LjlJFMZGIm_kw"
}
Recomendations GraphQL
GraphQL:
- Graphich interface: http://localhost:4000/graphiql
- Para requests:
API Externa
-
Recomendações de voos: https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/recommendations/oneway?departure=
&origin= &destination= &cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode= -
Melhores preços https://bff.latam.com/ws/proxy/booking-webapp-bff/v1/public/revenue/bestprices/oneway?departure=
&origin= &destination= &cabin=Y&country=BR&language=PT&home=pt_br&adult=1&promoCode=
Bibliografia
- Livro de Rust da Casa do Código
- Livro Oficial de Rust
- Livro de Programaçnao Funcional e Concorrente em Rust
- Rust por Exemplo
- Instalação do Rust
- Adopting Elixir
- Web Development with Clojure
- Modern C++ Programming with Test-Driven Development
- Programming Phoenix 1.4
- Código do servidor de tarefas
- Código do servidor de passagens
- Código do frontend para visualização de passagens
- Actix
- Guia do Diesel
- How to Graphql
- Livro GraphQL da Casa do Código