CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 4
Tags: webdev, rust, api, tutorial
Published: 2024-01-09 02:10:34

## Introduction

[Part 3][0] laid some foundations and this part will build on them to build a CRUD system for questions and answers.

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/cryptoflow %}

## Implementation

### Step: "Ask Question" feature

To hit the ground running, we want to implement the endpoint where questions about cryptocurrency can be asked. Keeping in mind the idea of modularity, all the handlers about the Q&A service will be domiciled in `backend/src/routes/qa`. As seen before, the module will only expose the composed routes of the handlers.

Now to `backend/src/routes/qa/ask.rs`:

```rust
use crate::models::{CreateQuestion, NewQuestion};
use crate::startup::AppState;
use crate::utils::{CustomAppError, CustomAppJson};
use axum::{extract::State, response::IntoResponse};
use axum_extra::extract::PrivateCookieJar;

#[axum::debug_handler]
#[tracing::instrument(name = "ask_question", skip(state, cookies, new_question))]
pub async fn ask_question(
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
    CustomAppJson(new_question): CustomAppJson<NewQuestion>,
) -> Result<impl IntoResponse, CustomAppError> {
    if new_question.title.is_empty() {
        return Err(CustomAppError::from((
            "Title cannot be empty".to_string(),
            crate::utils::ErrorContext::BadRequest,
        )));
    }

    if new_question.content.is_empty() {
        return Err(CustomAppError::from((
            "Content cannot be empty".to_string(),
            crate::utils::ErrorContext::BadRequest,
        )));
    }

    if new_question.tags.is_empty() {
        return Err(CustomAppError::from((
            "Tags cannot be empty".to_string(),
            crate::utils::ErrorContext::BadRequest,
        )));
    }

    // Process tags
    let mut tag_ids: Vec<String> = new_question
        .tags
        .split(",")
        .map(|s| s.trim().to_string())
        .collect();

    // Check if tags are more than 4
    if tag_ids.len() > 4 {
        return Err(CustomAppError::from((
            "Tags cannot be more than 4".to_string(),
            crate::utils::ErrorContext::BadRequest,
        )));
    }

    // Sort and deduplicate tags
    tag_ids.sort();
    tag_ids.dedup();

    // Validate tags
    state.db_store.validate_tags(&tag_ids).await?;

    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    // Create question
    let create_question = CreateQuestion {
        slug: crate::utils::slugify(&new_question.title).await,
        title: new_question.title,
        content: crate::utils::convert_markdown_to_html(&new_question.content).await,
        raw_content: new_question.content,
        author: user_uuid,
        tags: tag_ids,
    };

    let question = state
        .db_store
        .create_question_in_db(create_question)
        .await?;

    Ok(axum::Json(question).into_response())
}
```

Pretty basic! We just validated the `title`, `content`, and `tags` supplied by the client. If any is empty, we return appropriate messages. Since we expect the tags to be a single string separated by commas like `"bitcoin, bnb, dogecoin"`, we tried to turn that into a vector of strings, that is `["bitcoin", "bnb", "dogecoin"]`, which will then be revalidated (shouldn't be more than 4), sorted and deduplicated. Sorting is required for deduplication to work. Then an instance is created which is sent to the database.

### Step 2: Question management

Having created a question, we still need other ways to manage it such as retrieving, updating and even deleting it. These management handlers are:

```rust
// backend/src/routes/qa/questions.rs
use crate::{
    startup::AppState,
    utils::{CustomAppError, CustomAppJson, ErrorContext},
};
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
};
use axum_extra::extract::PrivateCookieJar;

#[axum::debug_handler]
#[tracing::instrument(name = "all_question", skip(state))]
pub async fn all_questions(
    State(state): State<AppState>,
) -> Result<impl IntoResponse, CustomAppError> {
    let questions = state.db_store.get_all_questions_from_db().await?;

    Ok(axum::Json(questions).into_response())
}

#[axum::debug_handler]
#[tracing::instrument(name = "get_question", skip(state))]
pub async fn get_question(
    Path(question_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
) -> Result<impl IntoResponse, CustomAppError> {
    let question = state
        .db_store
        .get_question_from_db(None, question_id)
        .await?;

    Ok(axum::Json(question).into_response())
}

#[axum::debug_handler]
#[tracing::instrument(name = "delete_a_question", skip(state))]
pub async fn delete_a_question(
    Path(question_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
) -> Result<impl IntoResponse, CustomAppError> {
    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    state
        .db_store
        .delete_question_from_db(user_uuid, question_id)
        .await
        .map_err(|_| {
            CustomAppError::from((
                "Failed to delete question and it's most probably due to not being authorized"
                    .to_string(),
                ErrorContext::UnauthorizedAccess,
            ))
        })?;

    let response = crate::utils::SuccessResponse {
        message: "Question deleted successfully".to_string(),
        status_code: StatusCode::NO_CONTENT.as_u16(),
    };

    Ok(response.into_response())
}

#[axum::debug_handler]
#[tracing::instrument(name = "update_a_question", skip(state))]
pub async fn update_a_question(
    Path(question_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
    CustomAppJson(update_question): CustomAppJson<crate::models::UpdateQuestion>,
) -> Result<impl IntoResponse, CustomAppError> {
    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    // Extract tags from update_question
    let mut tag_ids: Vec<String> = update_question
        .tags
        .split(",")
        .map(|s| s.trim().to_string())
        .collect();

    // Check if tags are more than 4
    if tag_ids.len() > 4 {
        return Err(CustomAppError::from((
            "Tags cannot be more than 4".to_string(),
            crate::utils::ErrorContext::BadRequest,
        )));
    }

    // Sort and deduplicate tags
    tag_ids.sort();
    tag_ids.dedup();

    // Create a question out of update_question
    let new_update_question = crate::models::CreateQuestion {
        slug: crate::utils::slugify(&update_question.title).await,
        title: update_question.title,
        content: crate::utils::convert_markdown_to_html(&update_question.content).await,
        raw_content: update_question.content,
        author: user_uuid,
        tags: tag_ids,
    };

    state
        .db_store
        .update_question_in_db(question_id, new_update_question)
        .await
        .map_err(|_| {
            CustomAppError::from((
                "Failed to update question and it's most probably due to not being authorized"
                    .to_string(),
                ErrorContext::UnauthorizedAccess,
            ))
        })?;

    let response = crate::utils::SuccessResponse {
        message: "Question updated successfully".to_string(),
        status_code: StatusCode::NO_CONTENT.as_u16(),
    };

    Ok(response.into_response())
}
```

It's just the basic stuff. The database operation abstractions previously written made everything else basic.

### Step 3: Answering a question

Questions are fun but getting answers is merrier. People ask questions so they can have answers to them. We will implement the handler for this here:

```rust
// backend/src/routes/qa/answer.rs
use crate::{
    models::{CreateAnswer, NewAnswer},
    startup::AppState,
    utils::{CustomAppError, CustomAppJson},
};
use axum::{
    extract::{Path, State},
    response::IntoResponse,
};
use axum_extra::extract::PrivateCookieJar;

#[axum::debug_handler]
#[tracing::instrument(name = "answer_question", skip(state))]
pub async fn answer_question(
    Path(question_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
    CustomAppJson(new_answer): CustomAppJson<NewAnswer>,
) -> Result<impl IntoResponse, CustomAppError> {
    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    // Create answer
    let create_answer = CreateAnswer {
        content: crate::utils::convert_markdown_to_html(&new_answer.content).await,
        raw_content: new_answer.content,
        author: user_uuid,
        question: question_id,
    };

    let answer = state.db_store.create_answer_in_db(create_answer).await?;

    Ok(axum::Json(answer).into_response())
}
```

As usual, it is basic! Things get basic as soon as there are the "low-level" stuff has been abstracted away!

### Step 4: Answers management

Just like questions, answers need to be managed as well. We provide the basic handlers for those here:

```rust
// backend/src/routes/qa/answers.rs
use crate::{
    models::{NewAnswer, UpdateAnswer},
    startup::AppState,
    utils::{CustomAppError, CustomAppJson, ErrorContext},
};
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
};
use axum_extra::extract::PrivateCookieJar;

#[axum::debug_handler]
#[tracing::instrument(name = "question_answers", skip(state))]
pub async fn question_answers(
    Path(question_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
) -> Result<impl IntoResponse, CustomAppError> {
    let answers = state
        .db_store
        .get_answers_from_db(None, question_id)
        .await?;

    Ok(axum::Json(answers).into_response())
}

#[axum::debug_handler]
#[tracing::instrument(name = "delete_an_answer", skip(state))]
pub async fn delete_an_answer(
    Path(answer_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
) -> Result<impl IntoResponse, CustomAppError> {
    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    state
        .db_store
        .delete_answer_from_db(user_uuid, answer_id)
        .await
        .map_err(|_| {
            CustomAppError::from((
                "Failed to delete answer and it's most probably due to not being authorized"
                    .to_string(),
                ErrorContext::UnauthorizedAccess,
            ))
        })?;

    Ok(crate::utils::SuccessResponse {
        message: "Answer deleted successfully".to_string(),
        status_code: StatusCode::NO_CONTENT.as_u16(),
    }
    .into_response())
}

#[axum::debug_handler]
#[tracing::instrument(name = "update_answer", skip(state))]
pub async fn update_answer(
    Path(answer_id): Path<uuid::Uuid>,
    State(state): State<AppState>,
    cookies: PrivateCookieJar,
    CustomAppJson(new_answer): CustomAppJson<NewAnswer>,
) -> Result<impl IntoResponse, CustomAppError> {
    // Get author id from session
    let (user_uuid, _) =
        crate::utils::get_user_id_from_session(&cookies, &state.redis_store, false).await?;

    let new_answer = UpdateAnswer {
        content: crate::utils::convert_markdown_to_html(&new_answer.content).await,
        raw_content: new_answer.content,
        author: user_uuid,
        answer_id,
    };

    let answer = state
        .db_store
        .update_answer_in_db(new_answer)
        .await
        .map_err(|_| {
            CustomAppError::from((
                "Failed to update answer and it's most probably due to not being authorized"
                    .to_string(),
                ErrorContext::UnauthorizedAccess,
            ))
        })?;

    Ok(CustomAppJson(answer).into_response())
}
```

### Step 5: Build the routes

Having implemented all the handlers, let's tie all of them up to their respective URIs:

```rust
// backend/src/routes/qa/mod.rs
use crate::utils::validate_authentication_session;
use axum::{
    routing::{delete, get, post},
    Router,
};

mod answer;
mod answers;
mod ask;
mod questions;

pub fn qa_routes(state: crate::startup::AppState) -> Router<crate::startup::AppState> {
    Router::new()
        .route("/ask", post(ask::ask_question))
        .route("/answer/:question_id", post(answer::answer_question))
        .route(
            "/questions/:question_id",
            delete(questions::delete_a_question).patch(questions::update_a_question),
        )
        .route(
            "/answers/:answer_id",
            delete(answers::delete_an_answer).patch(answers::update_answer),
        )
        .route_layer(axum::middleware::from_fn_with_state(
            state.clone(),
            validate_authentication_session,
        ))
        .route("/questions", get(questions::all_questions))
        .route("/questions/:question_id", get(questions::get_question))
        .route(
            "/questions/:question_id/answers",
            get(answers::question_answers),
        )
}
```

Notice how we were able to join multiple HTTP methods to a single route? It's powerful, I must confess!

As usual, we need to `nest` this group of routes to our main route:

```rust
// backend/src/startup.rs
...

async fn run(
    listener: tokio::net::TcpListener,
    store: crate::store::Store,
    settings: crate::settings::Settings,
) {
    ...
    // build our application with a route
    let app = axum::Router::new()
        ...
        .nest("/api/qa", routes::qa_routes(app_state.clone()))
        ...
}
...
```

Our Q&A service is now up!!!

### Step 6: Integrating [CoinGecko API][1]

At the start, we said our system would use [CoinGecko APIs][1] (in case you use their premium services, kindly use my referral code `CGSIRNEIJ`) to get the "real-time" price of cryptocurrencies. To make it truly real-time, we would have used WebSockets. However, for now, let's just be getting the data from the API on refresh. Later, we'll talk about how to have a truly "real-time" with WebSockets. The handlers for getting the actual prices will be implemented next. We will also implement getting the list of coins from the API periodically (every 24 hours). Let's start it:

```rust
// backend/src/utils/crypto.rs
use reqwest;
use serde_json::Value;
use std::collections::HashMap;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct CryptoPrice {
    name: String,
    price: f64,
}

pub type CryptoPrices = Vec<CryptoPrice>;

#[tracing::instrument(name = "get_crypto_prices")]
pub async fn get_crypto_prices(
    cryptos: String,
    currency: &str,
) -> Result<CryptoPrices, reqwest::Error> {
    let url = format!(
        "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies={}",
        cryptos,
        currency.to_lowercase()
    );

    let response: HashMap<String, Value> = reqwest::get(&url)
        .await?
        .json::<HashMap<String, Value>>()
        .await?;

    let mut prices = CryptoPrices::new();
    for (name, data) in response {
        if let Some(price) = data.get("usd").and_then(|v| v.as_f64()) {
            prices.push(CryptoPrice { name, price });
        }
    }

    Ok(prices)
}
```

Using the `reqwest` crate and `v3/simple/price?ids={}&vs_currencies={}` endpoint, we wrote a utility function that abstracts away retrieving the current prices of the cryptocurrencies. It's "mostly" hard-coded for now but it's okay.

Next is its use in a handler:

```rust
// backend/src/routes/crypto/price.rs
use crate::utils::{get_crypto_prices, CryptoPrices, CustomAppError, CustomAppJson};
use axum::extract::Query;

#[derive(serde::Deserialize, Debug)]
pub struct CryptoPriceRequest {
    tags: String,
    currency: String,
}

#[axum::debug_handler]
#[tracing::instrument(name = "crypto_price_handler")]
pub async fn crypto_price_handler(
    Query(crypto_req): Query<CryptoPriceRequest>,
) -> Result<CustomAppJson<CryptoPrices>, CustomAppError> {
    // Call the get_crypto_prices function with the tags
    let prices = get_crypto_prices(crypto_req.tags, &crypto_req.currency)
        .await
        .map_err(CustomAppError::from)?;

    // Return the prices wrapped in CustomAppJson
    Ok(CustomAppJson(prices))
}
```

As it's the norm so far, we put all direct crypto-related logic in a routes submodule. The handler above just helps to make available the prices of a list of coins in `USD`.

```rust
// backend/src/routes/crypto/coins.rs
use crate::{startup::AppState, utils::CustomAppError};
use axum::{extract::State, response::IntoResponse};

#[axum::debug_handler]
#[tracing::instrument(name = "all_coins", skip(state))]
pub async fn all_coins(State(state): State<AppState>) -> Result<impl IntoResponse, CustomAppError> {
    let coins = state.db_store.get_all_coins_from_db().await?;

    Ok(axum::Json(coins).into_response())
}
```

This exposes the list of coins supported by [CoinGecko API][1]. We implement the `get_all_coins_from_db` utility method and the other one below:

```rust
// backend/src/store/crypto.rs
impl crate::store::Store {
    #[tracing::instrument(name = "get_all_coins_from_db", skip(self))]
    pub async fn get_all_coins_from_db(&self) -> Result<Vec<crate::models::Tag>, sqlx::Error> {
        let tags = sqlx::query_as::<_, crate::models::Tag>("SELECT * FROM tags")
            .fetch_all(&self.connection)
            .await?;

        Ok(tags)
    }
    pub async fn update_coins(&self) {
        let url = "https://api.coingecko.com/api/v3/coins/list";

        match reqwest::get(url).await {
            Ok(response) => match response.json::<Vec<crate::models::Tag>>().await {
                Ok(coins) => {
                    let ids: Vec<String> = coins.iter().map(|c| c.id.clone()).collect();
                    let names: Vec<String> = coins.iter().map(|c| c.name.clone()).collect();
                    let symbols: Vec<String> = coins.iter().map(|c| c.symbol.clone()).collect();

                    let query = sqlx::query(
                        "INSERT INTO tags (id, name, symbol) SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[]) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, symbol = EXCLUDED.symbol",
                    )
                    .bind(&ids)
                    .bind(&names)
                    .bind(&symbols);

                    if let Err(e) = query.execute(&self.connection).await {
                        tracing::error!("Failed to update coins: {}", e);
                    }
                }
                Err(e) => tracing::error!("Failed to parse coins from response: {}", e),
            },
            Err(e) => tracing::error!("Failed to fetch coins from CoinGecko: {}", e),
        }
    }
}
```

The first utility method returns all the tags (coins) we have saved. The second one uses [CoinGecko API][1]'s `/api/v3/coins/list` route to retrieve the updated list of coins supported on the platform (it's a lot by the way!) and save them in the database!

### Step 7: Bundle up `crypto` routes

Let's build the few crypto-related routes we have so far and then `nest` the outcome to the main route:

```rust
// backend/src/routes/crypto/mod.rs
use axum::{routing::get, Router};

mod coins;
mod price;
mod prices;

pub fn crypto_routes() -> Router<crate::startup::AppState> {
    Router::new()
        .route("/prices", get(price::crypto_price_handler))
        .route("/coins", get(coins::all_coins))
}
```

We will now nest the route to the main one. While doing that, we will also spawn a periodic tokio task, running in the background, to fetch and update the list of coins from CoinGecko:

```rust
// backend/src/startup.rs
...
use tokio::time::{sleep, Duration};

...

impl Application {
    ...
    pub async fn build(
        settings: crate::settings::Settings,
        test_pool: Option<sqlx::postgres::PgPool>,
    ) -> Result<Self, std::io::Error> {
        ...
        let store_for_update = store.clone();

        // Update coins
        tokio::spawn(async move {
            loop {
                store_for_update.update_coins().await;
                sleep(Duration::from_secs(
                    settings.interval_of_coin_update * 60 * 60,
                ))
                .await;
            }
        });

        ...
    }
}

async fn run(
    listener: tokio::net::TcpListener,
    store: crate::store::Store,
    settings: crate::settings::Settings,
) {
    ...
    // build our application with a route
    let app = axum::Router::new()
        ...
        .nest("/api/crypto", routes::crypto_routes())
        ...
}
...
```

We need to clone the store because if not, the code won't compile since we'd have succeeded in moving the `store` struct out of scope for `run` to use. Using `tokio`'s sleep and duration, we were able to schedule a 24-hour periodic task that fetches the coin's list from an external API.

With that, we have completed working with the main features of the backend service. There is still some stuff to do but we're fine for now. I may add one article more on the backend stuff or update some here later without having to write a whole new article.

In the next few articles, we'll see how parts of the frontend are written using SvelteKit. Hope to catch you up later!

## Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and Twitter: [Twitter](https://twitter.com/Sirneij).

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

[0]: https://dev.to/sirneij/cryptoflow-building-a-secure-and-scalable-system-with-axum-and-sveltekit-part-3-1bh5 "CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 3"
[1]: https://www.coingecko.com/en/api "Explore the API"