Desenvolvimento Web com Rust

Por Julia Naomi Boeira.


Patreon link

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.

  1. 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.
  2. 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, como PutItem e DeleteItem, pode conter até 25 itens e um total de 16MB de dados.
  • Query e Scan 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 de actors do Rust, e tem como framework web o actix-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 com n = 3 seria 9 * 5 * 6 = 270, e o maior produto para n = 5 seria 7 * 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:

Projeto de pacote básico do Cargo

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 Result por conta do tamanho da window 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:

  1. Endpoints de monitoramento:
    • ping que funciona como health check.
    • ~/ready que funciona como disponibilidade do servico, readiness probe.
  2. Endpoints para salvar as informações dos TODOs, create show show-by-id e update.
  3. Sistema de logs, headers padrão e middlewares de autenticação.
  4. Endpoints de autenticação, com signup, login e logout utilizando tokens JWT e banco de dados Postgres via Diesel.
  5. Bastion para tornar o sistema tolerante a falhas.
  6. Dockerização de todos os serviços.
  7. CI executando as pipelines de teste.
  8. Serde para serialização e deserialização de Json.

Código do capítulo

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:

  1. todo-server/src/main.rs
  2. todo-server/Cargo.lock
  3. 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:

Endpoint /ping

Endpoint Not Found para a rota /

Agora podemos começar a descrever o endpoint /ping:

  1. A primeira coisa que vemos é a diretiva use associada a lib actix_web. Essa diretiva nos permite disponibilizar no nosso código as funções e estruturas de actix_web para uso posterior, assim a diretiva use actix_web::HttpServer disponibilizaria a estrutura HttpServer para usarmos.
  2. Depois vemos a função async fn ping() -> impl Responder. Essa função é uma função assíncrona, devido as palavras reservadas async fn, cujo nome é ping, recebe nenhum argumento () e como tipo de resposta implementa a trait Responder, que tem como tipo de retorno Future<Output = Result<Response, Self::Error>>. A resposta de ping é um status code Ok() com um body("pong"), porém seria possível também implementar com a função with_status da trait Responder, ficando "pong".with_status(StatusCode::Ok), que seria classificado como um CustomResponder, ou um Responder customizado.
  3. 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 como async no runtime de actix.
  4. Agora temos a função de execução main como async fn main() -> std::io::Result<()> . Assim, essa macro gera o código necessário para que nossa função main esteja conforme o padrão de funções main do Rust .
  5. A linha HttpServer::new(|| {..}) permite criar um servidor HTTP com uma application factory, assim como permite configurar a instância do servidor, como workers e bind, que veremos a seguir.
  6. A linha App::new().service(..) é um application builder baseado no padrão builder para o App, que é uma struct correspondente a aplicação do actix-web, seu objetivo é configurar rotas e settings padrões. A função service registra um serviço no servidor.
  7. A rota do serviço ping é definida pela macro #[get("/ping")].
  8. O módulo web possui uma série de funções auxiliares e e tipos auxiliares para o actix-web.
  9. Depois disso, vemos workers(6), uma função de HttpServer que define a quantidade de threads trabalhadoras que estarão envolvidas nesse executável. Por padrão, o valor de workers é a quantidade de CPUs lógicas disponíveis.
  10. Agora temos o bind, que recebe o IP e a porta a qual esse servidor se conectará.
  11. run e await para executar o serviço e esperar pelo async 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:

  1. main.rs, que contém todas as informações de configuração do servidor, ou seja, a própria instância do servidor.
  2. todo_api, que contém todos os módulos responsáveis por lógica e banco de dados.
  3. 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;
}

Sistema de arquivos após a refatoração

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] do Cargo.toml como uuid = { 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:

  1. Controller create_todo.
  2. O controller recebe um Json do tipo TodoCard , que precisa ser deserializável com a macro #[derive(Deserialize)].
  3. 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; em lib.rs e em main.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 do Option<T> são os tipos possíveis dentro do DynamoDB. Veja que alguns tipos são bem fáceis de perceber como bool, Vec<AttributeValue> e HashMap<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 chaves s, ss, n e ns. As chaves dos tipos b e bs são para valores binários como "B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk", além disso o tipo n serve para representar um tipo numérico, enquanto o tipo s serve para tipos String. Os tipos ss e ns são as versões vetores de s e de n, 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 um TodoCardResponse, que contém um Id e a segunda é modificarmos a TodoCard para conter um campo id: Option<Uuid>. Nós vamos seguir a segunda abordagem, cuja única mudança será adicionar id: None, no teste converts_json_to_db encontrado em src/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 TodoCards 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 TodoCards 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:

Obtendo todos nossos TodoCards.

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_webem 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 ambiente FOO, 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 caso dynamodb.
  • 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 chamado Dockerfile dockerfile: Dockerfile.
  • command: executamos o comando cargo run para essa aplicação.
  • environment: para executar o DynamoDB dessa forma precisamos adicionar algumas variáveis de ambiente para que o client 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 ambiente DYNAMODB_ENDPOINT para saber qual address iremos usar quando inicializarmos o dynamodb client na nossa API. Faremos a seguinte mudança na função get_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, assim dynamodb é inicializado antes de web
  • links: forma legada de fazer com que dois serviços estejam conectados, atualmente bastaria o networks, mas coloquei como exemplo. No caso de links e networks 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):

  1. Instale a diesel_cli, pois este binário ajuda a gerenciar o projeto. Utilize cargo install diesel_cli para isso. Para compilar o diesel_cli é preciso ter a lib libpq, no MacOS podemos fazer isso com brew install postgresql, brew install libpq e depois cargo install diesel_cli --no-default-features --features postgres para instalar somente o conector de postgres.
  2. 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
  3. Para utilizar o diesel_cli executamos o comando diesel setup, mas para isso precisamos da url do postgress em um arquivo .env. Para isso precisamos executar echo DATABASE_URL=postgres://auth:secret@localhost/auth_db > .env. Agora executamos diesel setup --migration-dir src/migrations para estabelecer a conexão.
  4. Depois podemos criar nossas migrações com diesel migration generate create_auth, note a pasta migrations com duas subpastas cada uma contendo um up.sql e um down.sql.
  5. Na segunda pasta vamos criar a tabela auth_user em up.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;
  1. Agora basta executar as migrations com diesel migration run --migration-dir src/migrations, caso você queira reverter as migrations basta executar diesel migration redo. Note a criação de um arquivo src/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.

  1. Tipicamente um schema não é criado na mão e sim gerado pelo binário diesel. Quando executamos diesel setup, um arquivo diesel.toml é criado para indicar ao Diesel para manter o arquivo src/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 suas main 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:

  1. hash recebe um password do tipo genérico P, no nosso caso password do tipo String e um custo, no caso DEFAULT_COST.
  2. verify verifica se o password enviado é igual a hashenviada.
  3. bcrypt é similar ao hash, porém o segundo argumento é um salt 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 Chrono

Muitas 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 que DbExecutor não consegue encontrar a DATABASE_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, nosso make 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 e migration run) para podermos executar nossos testes sem quebrar o DbExecutor. Pode ser necessário adicionar um sleep 3 depois de test: db para dar tempo do container executar. A última linha iniciada em docker ps serve para remover o container que executamos. Além disso, DbExecutor depende de dotenv, assim, devemos incluir dotenv().ok() antes de executar os testes e incorporar o dotenv no escopo com use 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_userque 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:

  1. Ok(true) -> Caso na qual o password enviado é uma hash válida.
  2. Ok(false) -> Caso na qual o password não é válido.
  3. 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 via RSA ou ECDSA. 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 e assinatura, assim o formato acaba sendo algo como hhhhh.pppppp.aaaaa. Usualmente o header possui duas partes o tipo, usualmente "typ": "jwt" e o algoritmo que pode ser HMAC SHA256 ou RSA, algo como "alg": "HS256". payload é onde as informações que queremos trocar estão armazenadas. E assinatura, ou signature, é uma informação de como entender esses dados. Com o algoritmo HMAC SHA256 a criação de um JWT o seguinte formato HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret), note que payload e header estão em um formato base64.

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 handlesimplesmente 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 targete 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 emails, 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 em mod.rs e model.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 de DefaultHeaders 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:

  1. Usuário está ativo com user.is_active.
  2. Data atual é inferior a data expires_at do token com validate_jwt_date(user.expires_at).
  3. 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:

  1. executa o target db, que sobe um container docker com postgres.
  2. sleep 2 pausa o processo por 2 segundos até que o container se estabilize.
  3. diesel setup, falamos anteriormente, mas ajuda o diesel a configurar o banco recém gerado (pode não ser necessário).
  4. disel migration run, executa as migrações para o banco.
  5. cargo test, os testes em si.
  6. diesel migration redo, não precisa estar presente no CI, pois o contaienr com postgres será destruido depois da execução, não necessitando fazer rollback 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 ao diesel setup e nos permite criar a base de dados auth_db no postgres.
  • echo "DATABASE_URL=postgres://postgres@localhost/auth_db" > .env é preciso ter o campo DATABASE_URL configurado em seu .env para executar o DbExecutor, assim utilizamos o echo <campo exportado> > .env para enviar o campo exportado para .env.
  • cargo install diesel_cli --no-default-features --features=postgres instalamos o diesel_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:

Resultado do Travis CI para o Todo Server

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:

  1. HTTP autenticado em show/{id} com o método GET.
  2. HTTP autenticado em update/{id} com o método PUT 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:

  1. Ambos existem retorna "SET description = :d, state_db = :s").
  2. Somente state existe retorna "SET state_db = :s".
  3. Somente description existe retorna "SET description = :d".
  4. 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));
    }
}
}

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:

  1. Descrever seus dados via tipos:
type Project {
  name: String
  tagline: String
  contributors: [User]
}
  1. Receber um request com os dados a serem consumidos:
{
  project(name: "GraphQL") {
    tagline
  }
}
  1. Realizar as consultas a todos os serviços/APIs necessários
  2. 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:

página interativa do graphiql

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:

QUery ping em /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:

bestPrices basic query

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_formate 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.

Usando variables com o Postman

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 redis 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:

  1. Começamos com o wasm-pack, uma ferramenta para construir, testar e publicar WebAssembly gerado por Rust. Basta executar o comando curl 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 o cargo install wasm-pack.
  2. O segundo passo é instalar o cargo-generate, que pode ser feito através do comando cargo install cargo-generate. Sua função é facilitar a subida e execução de novos projetos e seus tempaltes em Rust.
  3. 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.

  1. wasm-bindgen pode ser encontrado como dependencies.
  2. Para instalar o cargo-web você precisa executar o comando cargo install cargo-web. Para o build basta utilizar cargo web build e o run utilizar cargo web run. Necessário para stdweb.
  3. Para o exemplo teste precisamos instalar o rollup execute npm install --global rollup
  4. Para o nosso exemplo de teste vamos utilozar Python, ou Python3 se você preferir, e o módulo http isntalado com pip install http. Outros servidores também seriam possíveis como o miniserve do cargo, que pode ser obtido através do comando cargo +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

  1. Para buildar o projeto execute wasm-pack build --target web.
  2. Para fazer o bundle utilize rollup ./main.js --format iife --file ./pkg/bundle.js.
  3. Para expor seu bundle no browser execute o comando python -m SimpleHTTPServer para Python2 e python3 -m http.server para Python3, depois acesse em seu browser localhost:8000 para ver um bome velho Hello world!.

Opção 2

  1. Crie a pasta static através de um mkdir static e inclua o seguinte arquivo 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>
  1. 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();
}
}
  1. Execute o comando wasm-pack build --target web --out-name wasm --out-dir ./static para buildar seu projeto.
  2. Disponibilize o bundle no browser com miniserve ./static --index index.html. E pronto, basta acessar localhost: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 arquivo static/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.

Imagem do componente de Best Prices

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 o fetch em JavaScript. Para inicializar este valor basta executar FetchService::new().
  • link: ComponentLink<Self>: como já falamos é responsável por fazer as conexões do Component com callbacks. É inicializado com pelo próprio Component.
  • fetch_task: Option<FetchTask>: é basicamente um handler para os request. Se seu estado é None nada é executado, se seu estado é Some com alguma FetchTask ele a executa. Inicializado com None.
  • fetching: true,: Indica se a aplicação está fazendo um request. Inicializado com true 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:

Carrosel de Best Prices

/* ... */
.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 divs, 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:

Componente com cada uma das recomendações de voo

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 divs 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 divs. Cada uma das dvis 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 uma String contendo tudo que aparece após o domínio na URL.
  • RouteService: Comunica com o brrowser para receber e enviar as Routes.
  • RouteAgent: Dono do RouteService 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 de Route.
  • Router: O Router comunica com o RouteAgent e automaticamente resolve a Route que recebe do RouteAgent para uma das implementações do Switch.

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.

  1. O miserve que estamos utilizando já aponta para o diretório static/ e para o índice index.html, miniserve ./static --index index.html.
  2. 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 de index.html.
  3. É 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 "/":

View da 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:

  1. Serialização Json, neste teste cada resposta é uma instância com a chave message e o valor Hello, World! para ser serializada como Json. Actix em quinto lugar (rust em terceiro).
  2. Ú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.
  3. 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.
  4. 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.
  5. 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.
  6. Texto puro, neste teste o framework responde com plaintext "hello, World". Actix em quarto lugar (rust em primeiro e segundo).

Techempower 2018 Fortunes 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 WORKERs relacionadas. Os testes foram executados com https://github.com/wg/wrk e os resultados são os seguintes:

Requests por segundo, maior é melhor

Tempo médio de resposta, ms, menor é melhor

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

  1. Livro de Rust da Casa do Código
  2. Livro Oficial de Rust
  3. Livro de Programaçnao Funcional e Concorrente em Rust
  4. Rust por Exemplo
  5. Instalação do Rust
  6. Adopting Elixir
  7. Web Development with Clojure
  8. Modern C++ Programming with Test-Driven Development
  9. Programming Phoenix 1.4
  10. Código do servidor de tarefas
  11. Código do servidor de passagens
  12. Código do frontend para visualização de passagens
  13. Actix
  14. Guia do Diesel
  15. How to Graphql
  16. Livro GraphQL da Casa do Código