WEB3DEV

Cover image for Rust: Como consumir a API REST e manter os resultados no Postgres
Isabela Curado Nehme
Isabela Curado Nehme

Posted on • Atualizado em

Rust: Como consumir a API REST e manter os resultados no Postgres

24 de Janeiro de 2023

Foto por [Jay Heike](https://unsplash.com/@jayrheike?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) no [Unsplash](https://unsplash.com/photos/Fc-0gi4YylM?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)

Como desenvolvedores corporativos, muitas vezes nos encontramos resolvendo problemas similares de um projeto para o outro. Sem dúvida, trabalhar com banco de dados é um desses. Neste tutorial, mostrarei a você uma maneira de consumir uma API REST paginada com tokio e mantê-la no Postgres. Apesar da Rust ser uma linguagem relativamente nova, já existem muitos crates para manipulação de banco de dados para escolher. Aqui vamos trabalhar com sqlx.

Pré-requisitos

Estou usando a versão 1.66.0 da Rust. Este tutorial pressupõe conhecimento prévio de Rust e se concentra mais no uso prático da linguagem. Verifique a seção Background no meu tutorial anterior para entender melhor a API que estamos trabalhando. Mas, em resumo, é com a ajuda da API ImmutableX que podemos buscar todos os eventos de cunhagem que já aconteceram na blockchain.

Como sempre, as fontes do projeto estão disponíveis no GitHub, encontre o link no final da página.

Existem 3 partes neste tutorial: Postgres e configuração do ambiente, leitura da API e trabalhando com Postgres.

Parte 1. Postgres e configuração do ambiente

Esta seção não é necessária para a parte de codificação Rust deste tutorial. No entanto, para fins de integridade, também gostaria de mostrar a você como configurar seu ambiente local (e potencialmente de produção). Mas, se você estiver interessado apenas na linguagem, sinta-se à vontade para pular para o próximo capítulo.

Para obter um ambiente local sem complicações e reproduzível, usaremos o docker-compose:

services:
  db:
    image: postgres:14.4
    restart: 'no'
    environment:
      POSTGRES_PASSWORD: notsecure
      POSTGRES_USER: data-loader-local
      POSTGRES_DB: illuvium-land
    ports:
      - '5432:5432'
Enter fullscreen mode Exit fullscreen mode

Ainda por cima, teremos dois scripts bash para simplificar ainda mais o processo:

#!/bin/bash
SCRIPT_PATH=$(dirname $(realpath -s $0))
echo "Starting environment..."
docker-compose -p data-loader -f $SCRIPT_PATH/docker-compose.yaml up -d --build
echo
printf "Waiting for DB"
while ! curl http://localhost:5432/ 2>&1 | grep '52' > /dev/null ; do
    printf "."
    sleep 1
done
echo
echo "Everything is up and running"
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash
SCRIPT_PATH=$(dirname $(realpath -s $0))
echo "Stopping environment..."
docker-compose -p data-loader -f $SCRIPT_PATH/docker-compose.yaml down
Enter fullscreen mode Exit fullscreen mode

Agora, você pode executar o script start-local-environment.sh e ele irá buscar o container e iniciá-lo:

Para finalizar a configuração do ambiente, temos uma decisão importante a fazer - como migrar o banco de dados? Existem muitas opções disponíveis para essa tarefa e a escolha final é sua. Se você preferir ficar com as ferramentas escritas em Rust, posso sugerir a refinery, mas aqui vou com a flyway, devido ao seu suporte docker-compose fora do comum.

services:
  db:
    image: postgres:14.4
    restart: 'no'
    environment:
      POSTGRES_PASSWORD: notsecure
      POSTGRES_USER: data-loader-local
      POSTGRES_DB: illuvium-land
    ports:
      - '5432:5432'

  flyway:
    image: flyway/flyway:7.5.1
    command: -url=jdbc:postgresql://db/illuvium-land -schemas=public -user=data-loader-local -password=notsecure -connectRetries=60 migrate
    volumes:
      - ./migrations:/flyway/sql
    depends_on:
      - db
Enter fullscreen mode Exit fullscreen mode

Observação! Como você pode ver, todas as credenciais DB (data base ou banco de dados) são intencionalmente codificadas. Uma vez que é o seu ambiente de desenvolvimento local (ou talvez o pipeline de CI), é considerado certo fazê-lo dessa maneira. Obviamente, para um código de infraestrutura de produção, você deve usar um local seguro para armazenar essas credenciais.

A última parte é criar a pasta migrations onde todos os futuros scripts de migração irão residir:

O ambiente local está agora completo!

Parte 2. Leitura de API

Como mencionado anteriormente, para ler as cunhagens da API o crate tokio será usado. O que difere este tutorial do anterior é que, neste caso, estaremos lendo de forma paginada. Primeiro de tudo, devemos criar um modelo:

use serde::{Deserialize};

#[derive(Deserialize, Debug)]
pub struct Mint {
    pub result: Vec<TheResult>,
    pub cursor: String,
}

#[derive(Deserialize, Debug)]
pub struct TheResult {
    #[serde(rename = "timestamp")]
    pub minted_on: String,
    pub transaction_id: i32,
    pub status: String,
    #[serde(rename = "user")]
    pub wallet: String,
    pub token: Token,
}

#[derive(Deserialize, Debug)]
pub struct Token {
    #[serde(rename = "type")]
    pub the_type: String,
    pub data: Data,
}

#[derive(Deserialize, Debug)]
pub struct Data {
    pub token_id: String,
}
Enter fullscreen mode Exit fullscreen mode

Vamos processar apenas os campos que são usados posteriormente, portanto, nem todos os campos que a API expõe. Agora, vamos criar um módulo responsável por buscar os dados. Para fazer uso da paginação de resposta, o campo cursor será utilizado. Esse campo é retornado na resposta e seu valor aponta para a página anterior, se existir uma. Caso contrário, é uma string vazia.

use log::{error, info};
use serde::de::DeserializeOwned;

use crate::model::mint::Mint;

const MINTS_URL: &str = "https://api.x.immutable.com/v1/mints?token_address=0x9e0d99b864e1ac12565125c5a82b59adea5a09cd&page_size=200";

#[tokio::main]
pub async fn read() {
    let mut cursor = None;
    loop {
        cursor = execute_and_get_cursor(cursor).await;
        if cursor.is_none() {
            break;
        } else {
            info!("Current cursor: {}", cursor.clone().unwrap());
        }
    }
}

async fn execute_and_get_cursor(cursor: Option<String>) -> Option<String> {
    let url = if cursor.is_some() { MINTS_URL.to_owned() + "&cursor=" + cursor.unwrap().as_str() } else { String::from(MINTS_URL) };
    let response = fetch_api_response::<Mint>(url.as_str()).await;
    match response {
        Ok(mint) => {
            info!("Processing mint response");
            if !mint.result.is_empty() {
                // será salvo no banco de dados mais tarde
                info!("{:?}", mint.result);
            }

            if !mint.cursor.is_empty() {
                return Some(mint.cursor);
            }
            None
        }
        Err(e) => {
            error!("Mints API response cannot be parsed! {}", e);
            None
        }
    }
}

async fn fetch_api_response<T: DeserializeOwned>(endpoint: &str) -> reqwest::Result<T> {
    let result = reqwest::get(endpoint)
        .await?.json::<T>()
        .await?;
    return Ok(result);
}
Enter fullscreen mode Exit fullscreen mode

Em algumas outras linguagens, existe um tipo de loop do..while que é idiomaticamente equivalente a

loop {
   call_function();
   if condition {
       break;
   }
}
Enter fullscreen mode Exit fullscreen mode

que foi implementado com a ajuda do tipo Option.

Mais uma vez, gostaria de enfatizar a importância de saber lidar adequadamente com os erros. Você deve limitar o uso da função unwrap para ambos teste/prototipagem ou quando não ter o resultado é o motivo “válido” para travar o aplicativo - embora aqui eu ainda recomendaria usar expect para especificar um erro exato. Ao seguir isso, seu aplicativo executará de forma confiável e provavelmente livre de pane.

Se você executar o aplicativo agora, ele esperançosamente produzirá vários logs. É hora de seguir para a camada de persistência do aplicativo.

Parte 3. Trabalhando com Postgres

Já conhecemos o modelo para manter - mint. Assim, podemos criar um script de migração para ele:

CREATE table mint
(
    transaction_id integer PRIMARY KEY,
    status varchar(50),
    wallet  varchar(255),
    token_type  varchar(15),
    token_id  varchar(15),
    minted_on timestamp
);
Enter fullscreen mode Exit fullscreen mode

Reinicie o ambiente com os scripts bash anteriormente e você deve ver duas tabelas criadas:

Agora vamos criar um módulo responsável por lidar com o banco de dados - db_handler.rs. Eu escolhi sqlx porque prefiro ter total controle sobre os scripts SQL em vez de transferi-los para um ORM (Object-Relational Mapping ou mapeamento objeto-relacional). Da seção anterior, sabemos que a API é consumida em um loop, portanto, devemos open_connection ao banco de dados antes do início do loop save_mints e close_connection, uma vez que todos os mints estiverem salvos. Com tudo isso, podemos definir aquelas três funções para finalizar o mints_reader.rs:

...

#[tokio::main]
pub async fn read() {
    let pool = db_handler::open_connection().await;
    let mut cursor = None;
    loop {
        cursor = execute_and_get_cursor(cursor, &pool).await;
        if cursor.is_none() {
            break;
        } else {
            info!("Current cursor: {}", cursor.clone().unwrap());
        }
    }
    db_handler::close_connection(pool).await;
}

async fn execute_and_get_cursor(cursor: Option<String>, pool: &Pool<Postgres>) -> Option<String> {
    let url = if cursor.is_some() { MINTS_URL.to_owned() + "&cursor=" + cursor.unwrap().as_str() } else { String::from(MINTS_URL) };
    let response = fetch_api_response::<Mint>(url.as_str()).await;
    match response {
        Ok(mint) => {
            info!("Processing mint response");
            if !mint.result.is_empty() {
                db_handler::save_mints(mint.result, pool).await;
            }

            if !mint.cursor.is_empty() {
                return Some(mint.cursor);
            }
            None
        }
        ...
    }
}

...
Enter fullscreen mode Exit fullscreen mode

Em seguida, vamos implementar as funções que estão faltando - começando com gerenciamento de conexão. Para conectar ao banco de dados as credenciais esperadas devem ser fornecidas e existem várias maneiras de fazê-lo. Neste tutorial, usaremos o crate dotenvy que depende do arquivos .env estar presente e preenchido com os valores esperados. Seguindo a documentação oficial da sqlx, a implementação será como a seguir:

pub async fn open_connection() -> Pool<Postgres> {
    let options = PgConnectOptions::new()
        .host(env::var("DB_HOST").expect("DB_HOST should be set").as_str())
        .port(env::var("DB_PORT").expect("DB_PORT should be set").parse().expect("DB_PORT should be a valid u16 value"))
        .database(env::var("DB_DATABASE").expect("DB_DATABASE should be set").as_str())
        .username(env::var("DB_USERNAME").expect("DB_USERNAME should be set").as_str())
        .password(env::var("DB_PASSWORD").expect("DB_PASSWORD should be set").as_str())
        .disable_statement_logging()
        .clone();

    PgPoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .expect("DB is not accessible!")
}

pub async fn close_connection(pool: Pool<Postgres>) {
    pool.close().await;
}
Enter fullscreen mode Exit fullscreen mode

Por último, vamos ter o código para salvar os dados mints no DB (data base ou banco de dados). Cada resposta da API retorna 200 valores e poderíamos inseri-los um a um. Mas, felizmente, não precisamos, já que a sqlx tem um método muito útil QueryBuilder::push_values que gera uma consulta de inserção em massa.

pub async fn save_mints(mint_result: Vec<TheResult>, connection: &Pool<Postgres>) {
    let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
        "insert into mint (transaction_id, status, wallet, token_type, token_id, minted_on) "
    );

    query_builder.push_values(mint_result, |mut builder, res| {
        builder.push_bind(res.transaction_id)
            .push_bind(res.status.clone())
            .push_bind(res.wallet.clone())
            .push_bind(res.token.the_type.clone())
            .push_bind(res.token.data.token_id.clone())
            .push_bind(DateTime::parse_from_rfc3339(&res.minted_on).unwrap());
    });

    let query = query_builder.build();
    match query.execute(connection)
        .await {
        Ok(result) => {
            info!("Inserted {} rows", result.rows_affected())
        }
        Err(e) => {
            error!("Couldn't insert values due to {}", e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Para simplificar, todos os possíveis erros na inserção são tratados da mesma maneira. Caso você precise distinguir uns erros dos outros, os códigos de erro retornados devem corresponder adequadamente. Para saber mais - verifique a documentação oficial sqlx sobre DatabaseErrors (ou erros de banco de dados).

Mais tarde

Saber como trabalhar com banco de dados é uma habilidade essencial que cada desenvolvedor deveria ter em seu conjunto de ferramentas. Com uma abundância de crates, a Rust oferece uma grande variedade de formas de alcançar isso. A escolha é sua.

Todos os códigos podem ser encontrados neste repositório GitHub.

Para mais tutoriais sobre Rust, certifique-se de verificar Rust: How to consume REST API (ou Rust: como consumir a API REST) e Rust: How to create Telegram bot (ou Rust: como criar um bot do Telegram).

Agradecimentos especiais ao meu querido amigo e camarada Andrei Kochemirovskii pela revisão dos códigos e contribuições valiosas.

Apoio

Se você gostou do conteúdo que leu e deseja apoiar o autor, muito obrigado!

Aqui está minha carteira Ethereum para gorjetas:

0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0

Esse artigo foi escrito pela Pudding Entertainment e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.


Image description

Top comments (0)