DEV Community

Cover image for CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 4
John Owolabi Idogun
John Owolabi Idogun

Posted on

CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 4

Introduction

Part 3 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 logo Sirneij / cryptoflow

A Q&A web application to demostrate how to build a secured and scalable client-server application with axum and sveltekit

CryptoFlow

CryptoFlow is a full-stack web application built with Axum and SvelteKit. It's a Q&A system tailored towards the world of cryptocurrency!

Its building process is explained in this series of articles.






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:

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())
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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())
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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())
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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())
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Build the routes

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

// 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),
        )
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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()))
        ...
}
...
Enter fullscreen mode Exit fullscreen mode

Our Q&A service is now up!!!

Step 6: Integrating CoinGecko API

At the start, we said our system would use CoinGecko APIs (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:

// 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)
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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))
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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())
}
Enter fullscreen mode Exit fullscreen mode

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

// 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),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The first utility method returns all the tags (coins) we have saved. The second one uses CoinGecko API'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:

// 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))
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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())
        ...
}
...
Enter fullscreen mode Exit fullscreen mode

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? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I would appreciate it...

Top comments (0)