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:
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!
I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:
Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.
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())
}
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())
}
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())
}
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())
}
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),
)
}
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()))
...
}
...
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)
}
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))
}
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())
}
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),
}
}
}
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))
}
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())
...
}
...
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 and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)