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.