Introduction
Having built out the system's user management arm, it is time to delve into providing Q&A services for our users. We want the users of our system to ask questions and provide answers (both should support regular and some advanced markdown commands). Questions and answers should also be easily managed (they should be retrievable, deletable and updatable). Before all that, we need to write some utility functions and methods.
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 1: Write Q&A structs and utility functions
To start, we need to set the pace for writing the features our system implements. We'll incept with the models:
// backend/src/models/qa.rs
use serde_json::Value as JsonValue;
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, serde::Serialize, Clone)]
pub struct Question {
pub id: Uuid,
pub title: String,
pub slug: String,
pub content: String,
pub raw_content: String,
pub author: Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize, Debug)]
pub struct NewQuestion {
pub title: String,
pub content: String,
pub tags: String,
}
#[derive(serde::Serialize, Debug)]
pub struct CreateQuestion {
pub title: String,
pub slug: String,
pub content: String,
pub raw_content: String,
pub author: Uuid,
pub tags: Vec<String>,
}
#[derive(serde::Deserialize, Debug)]
pub struct UpdateQuestion {
pub title: String,
pub tags: String,
pub content: String,
}
#[derive(serde::Serialize, Debug)]
pub struct Answer {
pub id: Uuid,
pub content: String,
pub raw_content: String,
pub author: Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize, Debug)]
pub struct NewAnswer {
pub content: String,
}
#[derive(serde::Serialize, Debug)]
pub struct CreateAnswer {
pub content: String,
pub raw_content: String,
pub author: Uuid,
pub question: Uuid,
}
#[derive(serde::Serialize, Debug)]
pub struct UpdateAnswer {
pub content: String,
pub raw_content: String,
pub author: Uuid,
pub answer_id: Uuid,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, FromRow)]
pub struct Tag {
pub id: String,
pub name: String,
pub symbol: String,
}
#[derive(serde::Serialize, Debug)]
pub struct QuestionAuthorWithTags {
pub id: Uuid,
pub title: String,
pub slug: String,
pub content: String,
pub raw_content: String,
pub author: crate::models::UserVisible,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub tags: Vec<Tag>,
}
#[derive(serde::Serialize, Debug)]
pub struct AnswerAuthor {
pub id: Uuid,
pub content: String,
pub raw_content: String,
pub author: crate::models::UserVisible,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(FromRow, Debug)]
pub struct QuestionAuthorWithTagsQueryResult {
// Fields from `questions`
pub id: Uuid,
pub title: String,
pub slug: String,
pub content: String,
pub raw_content: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
// JSON aggregation of tags
pub tags_json: JsonValue,
// Fields from `users`
pub user_id: Uuid,
pub user_email: String,
pub user_first_name: String,
pub user_last_name: String,
pub user_is_active: Option<bool>,
pub user_is_staff: Option<bool>,
pub user_is_superuser: Option<bool>,
pub user_thumbnail: Option<String>,
pub user_date_joined: chrono::DateTime<chrono::Utc>,
}
#[derive(FromRow, Debug)]
pub struct AnswerAuthorQueryResult {
// Fields from `answers`
pub id: Uuid,
pub content: String,
pub raw_content: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
// Fields from `users`
pub user_id: Uuid,
pub user_email: String,
pub user_first_name: String,
pub user_last_name: String,
pub user_is_active: Option<bool>,
pub user_is_staff: Option<bool>,
pub user_is_superuser: Option<bool>,
pub user_thumbnail: Option<String>,
pub user_date_joined: chrono::DateTime<chrono::Utc>,
}
impl From<AnswerAuthorQueryResult> for AnswerAuthor {
fn from(query_result: AnswerAuthorQueryResult) -> Self {
AnswerAuthor {
id: query_result.id,
content: query_result.content,
raw_content: query_result.raw_content,
created_at: query_result.created_at,
updated_at: query_result.updated_at,
author: crate::models::UserVisible {
id: query_result.user_id,
email: query_result.user_email,
first_name: query_result.user_first_name,
last_name: query_result.user_last_name,
is_active: query_result.user_is_active,
is_staff: query_result.user_is_staff,
is_superuser: query_result.user_is_superuser,
thumbnail: query_result.user_thumbnail,
date_joined: query_result.user_date_joined,
},
}
}
}
They're just some simple structs that model the questions
and answers
tables created in part as well as other operations that will be useful in the handlers. Let's write the store operations next:
// backend/src/store/question.rs
use sqlx::Row;
use std::collections::HashMap;
impl crate::store::Store {
#[tracing::instrument(name = "get_question_from_db", skip(question_id))]
pub async fn get_question_from_db(
&self,
transaction: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>,
question_id: uuid::Uuid,
) -> Result<crate::models::QuestionAuthorWithTags, sqlx::Error> {
let query = sqlx::query_as::<_, crate::models::QuestionAuthorWithTagsQueryResult>(
crate::utils::QUESTION_AUTHOR_WITH_TAGS_QUERY,
)
.bind(question_id);
let result = match transaction {
Some(t) => query.fetch_one(&mut **t).await?,
None => query.fetch_one(&self.connection).await?,
};
let question_author_with_tags = crate::models::QuestionAuthorWithTags {
id: result.id,
title: result.title,
slug: result.slug,
content: result.content,
raw_content: result.raw_content,
author: crate::models::UserVisible {
id: result.user_id,
email: result.user_email,
first_name: result.user_first_name,
last_name: result.user_last_name,
is_active: result.user_is_active,
is_staff: result.user_is_staff,
is_superuser: result.user_is_superuser,
thumbnail: result.user_thumbnail,
date_joined: result.user_date_joined,
},
created_at: result.created_at,
updated_at: result.updated_at,
tags: serde_json::from_value(result.tags_json)
.map_err(|e| sqlx::Error::Protocol(e.to_string().into()))?,
};
Ok(question_author_with_tags)
}
#[tracing::instrument(name = "create_question_in_db", skip(create_question))]
pub async fn create_question_in_db(
&self,
create_question: crate::models::CreateQuestion,
) -> Result<crate::models::QuestionAuthorWithTags, sqlx::Error> {
let mut transaction = self.connection.begin().await?;
let q_id = match sqlx::query(
"INSERT INTO questions (title, slug, content, raw_content, author) VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(&create_question.title)
.bind(&create_question.slug)
.bind(&create_question.content)
.bind(&create_question.raw_content)
.bind(&create_question.author).map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("id") })
.fetch_one(&mut *transaction)
.await {
Ok(id) => id,
Err(e) => return Err(e),
};
match sqlx::query("INSERT INTO question_tags (question, tag) SELECT $1, * FROM UNNEST($2)")
.bind(q_id)
.bind(&create_question.tags)
.execute(&mut *transaction)
.await
{
Ok(_) => {
tracing::info!("Tag ids inserted successfully");
}
Err(e) => return Err(e),
}
let question_author_with_tags = self
.get_question_from_db(Some(&mut transaction), q_id)
.await?;
transaction.commit().await?;
Ok(question_author_with_tags)
}
#[tracing::instrument(name = "update_question_in_db", skip(update_question))]
pub async fn update_question_in_db(
&self,
question_id: uuid::Uuid,
update_question: crate::models::CreateQuestion,
) -> Result<crate::models::QuestionAuthorWithTags, sqlx::Error> {
let mut transaction = self.connection.begin().await?;
let q_id = match sqlx::query(
"UPDATE questions SET title = $1, slug = $2, content = $3, raw_content = $4 WHERE id = $5 AND author = $6 RETURNING id",
)
.bind(&update_question.title)
.bind(&update_question.slug)
.bind(&update_question.content)
.bind(&update_question.raw_content)
.bind(question_id)
.bind(&update_question.author)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("id") })
.fetch_one(&mut *transaction)
.await {
Ok(id) => id,
Err(e) => return Err(e),
};
match sqlx::query("DELETE FROM question_tags WHERE question = $1")
.bind(q_id)
.execute(&mut *transaction)
.await
{
Ok(_) => {
tracing::info!("Tag ids deleted successfully");
}
Err(e) => return Err(e),
}
match sqlx::query("INSERT INTO question_tags (question, tag) SELECT $1, * FROM UNNEST($2)")
.bind(q_id)
.bind(&update_question.tags)
.execute(&mut *transaction)
.await
{
Ok(_) => {
tracing::info!("Tag ids inserted successfully");
}
Err(e) => return Err(e),
}
let question_author_with_tags = self
.get_question_from_db(Some(&mut transaction), q_id)
.await?;
transaction.commit().await?;
Ok(question_author_with_tags)
}
#[tracing::instrument(name = "get_all_questions_from_db")]
pub async fn get_all_questions_from_db(
&self,
) -> Result<Vec<crate::models::QuestionAuthorWithTags>, sqlx::Error> {
let results = sqlx::query_as::<_, crate::models::QuestionAuthorWithTagsQueryResult>(
crate::utils::QUESTION_AUTHOR_WITH_TAGS_QUERY_ALL,
)
.fetch_all(&self.connection)
.await?;
let mut questions_map: HashMap<uuid::Uuid, crate::models::QuestionAuthorWithTags> =
HashMap::new();
for result in results {
let tags: Vec<crate::models::Tag> = serde_json::from_value(result.tags_json)
.map_err(|e| sqlx::Error::Protocol(e.to_string().into()))?;
questions_map
.entry(result.id)
.or_insert(crate::models::QuestionAuthorWithTags {
id: result.id,
title: result.title,
slug: result.slug,
content: result.content,
raw_content: result.raw_content,
author: crate::models::UserVisible {
id: result.user_id,
email: result.user_email,
first_name: result.user_first_name,
last_name: result.user_last_name,
is_active: result.user_is_active,
is_staff: result.user_is_staff,
is_superuser: result.user_is_superuser,
thumbnail: result.user_thumbnail,
date_joined: result.user_date_joined,
},
created_at: result.created_at,
updated_at: result.updated_at,
tags,
});
}
Ok(questions_map.into_values().collect())
}
#[tracing::instrument(name = "delete_question_from_db")]
pub async fn delete_question_from_db(
&self,
author_id: uuid::Uuid,
question_id: uuid::Uuid,
) -> Result<(), sqlx::Error> {
let deleted =
sqlx::query("DELETE FROM questions WHERE id = $1 AND author = $2 RETURNING id")
.bind(question_id)
.bind(author_id)
.fetch_optional(&self.connection)
.await?;
if deleted.is_none() {
tracing::warn!(
"Attempt to delete question with id {} by non-author {}",
question_id,
author_id
);
return Err(sqlx::Error::RowNotFound);
}
Ok(())
}
}
These are basic operations. In create_question_in_db
and update_question_in_db
, however, we used the notion of transactions because we have more than one query to execute at a time. To avoid data corruption, we want all of them executed or none to be executed (the basis of atomicity). Transactions guarantee this.
// backend/src/store/answer.rs
use sqlx::Row;
impl crate::store::Store {
#[tracing::instrument(name = "get_an_answer_from_db", skip(transaction, answer_id))]
pub async fn get_an_answer_from_db(
&self,
transaction: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>,
answer_id: uuid::Uuid,
) -> Result<crate::models::AnswerAuthor, sqlx::Error> {
let query = sqlx::query_as::<_, crate::models::AnswerAuthorQueryResult>(
crate::utils::ANSWER_AUTHOR_QUERY,
)
.bind(answer_id);
let query_result = match transaction {
Some(t) => query.fetch_one(&mut **t).await?,
None => query.fetch_one(&self.connection).await?,
};
Ok(query_result.into())
}
#[tracing::instrument(name = "create_answer_in_db", skip(create_answer))]
pub async fn create_answer_in_db(
&self,
create_answer: crate::models::CreateAnswer,
) -> Result<crate::models::AnswerAuthor, sqlx::Error> {
let mut transaction = self.connection.begin().await?;
let a_id = match sqlx::query(
"INSERT INTO answers (content, raw_content, author, question) VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(&create_answer.content)
.bind(&create_answer.raw_content)
.bind(&create_answer.author)
.bind(&create_answer.question)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("id") })
.fetch_one(&mut *transaction)
.await {
Ok(id) => id,
Err(e) => return Err(e),
};
let answer_author = self
.get_an_answer_from_db(Some(&mut transaction), a_id)
.await?;
transaction.commit().await?;
Ok(answer_author)
}
#[tracing::instrument(name = "get_answers_from_db")]
pub async fn get_answers_from_db(
&self,
transaction: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>,
question_id: uuid::Uuid,
) -> Result<Vec<crate::models::AnswerAuthor>, sqlx::Error> {
let query = sqlx::query_as::<_, crate::models::AnswerAuthorQueryResult>(
crate::utils::ANSWER_AUTHOR_QUERY_VIA_QUESTION_ID,
)
.bind(question_id);
let results = match transaction {
Some(t) => query.fetch_all(&mut **t).await?,
None => query.fetch_all(&self.connection).await?,
};
let answers = results.into_iter().map(|result| result.into()).collect();
Ok(answers)
}
#[tracing::instrument(name = "delete_answer_from_db")]
pub async fn delete_answer_from_db(
&self,
author_id: uuid::Uuid,
answer_id: uuid::Uuid,
) -> Result<(), sqlx::Error> {
let deleted = sqlx::query("DELETE FROM answers WHERE id = $1 AND author = $2 RETURNING id")
.bind(answer_id)
.bind(author_id)
.fetch_optional(&self.connection)
.await?;
if deleted.is_none() {
tracing::warn!(
"Attempt to delete question with id {} by non-author {}",
answer_id,
author_id
);
return Err(sqlx::Error::RowNotFound);
}
Ok(())
}
#[tracing::instrument(name = "update_answer_in_db", skip(update_answer))]
pub async fn update_answer_in_db(
&self,
update_answer: crate::models::UpdateAnswer,
) -> Result<crate::models::AnswerAuthor, sqlx::Error> {
let a_id = match sqlx::query(
"UPDATE answers SET content = $1, raw_content = $2 WHERE id = $3 AND author = $4 RETURNING id",
)
.bind(&update_answer.content)
.bind(&update_answer.raw_content)
.bind(&update_answer.answer_id)
.bind(&update_answer.author)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("id") })
.fetch_one(&self.connection)
.await
{
Ok(id) => id,
Err(e) => return Err(e),
};
let answer_author = self.get_an_answer_from_db(None, a_id).await?;
Ok(answer_author)
}
}
Just like for questions, answers also have some simple operations which we are familiar with now. There are also some simple operations for tags:
use sqlx::Row;
impl crate::store::Store {
#[tracing::instrument(name = "get_tag_ids_from_db", skip(tag_names))]
pub async fn get_tag_ids_from_db(
&self,
tag_names: Vec<String>,
) -> Result<Vec<uuid::Uuid>, sqlx::Error> {
match sqlx::query("SELECT id FROM tags WHERE name = ANY($1)")
.bind(&tag_names)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("id") })
.fetch_all(&self.connection)
.await
{
Ok(ids) => Ok(ids),
Err(e) => Err(e),
}
}
#[tracing::instrument(name = "validate_tags", skip(tag_ids))]
pub async fn validate_tags(&self, tag_ids: &[String]) -> Result<(), sqlx::Error> {
if tag_ids.is_empty() {
return Err(sqlx::Error::RowNotFound);
}
let rows = sqlx::query("SELECT id FROM tags WHERE id = ANY($1)")
.bind(&tag_ids)
.fetch_all(&self.connection)
.await?;
if rows.len() == tag_ids.len() {
Ok(())
} else {
Err(sqlx::Error::RowNotFound)
}
}
}
Step 2: Slugify title and compile markdowns
As a platform that allows expressiveness, we want our users to be bold enough to ask and answer questions with either plain text or some markdowns. Compiling markdown to HTML in Rust can be done via the pulldown-cmark crate. We used it in this utility function:
// backend/src/utils/qa.rs
use itertools::Itertools;
pub async fn slugify(title: &str) -> String {
let regex = regex::Regex::new(r#"(?m)[\p{P}\p{S}]"#).unwrap();
let result = regex.replace_all(title, "");
result
.to_ascii_lowercase()
.split_ascii_whitespace()
.join("-")
}
#[tracing::instrument(name = "Convert markdown to HTML", skip(text))]
pub async fn convert_markdown_to_html(text: &str) -> String {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
options.insert(pulldown_cmark::Options::ENABLE_HEADING_ATTRIBUTES);
options.insert(pulldown_cmark::Options::ENABLE_SMART_PUNCTUATION);
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut html_output: String = String::with_capacity(text.len() * 3 / 2);
pulldown_cmark::html::push_html(&mut html_output, parser);
html_output
}
In convert_markdown_to_html
, we enabled a lot of advanced markdown commands including the Github flavoured tables, Github flavoured task lists and strikethrough.
We also used the avenue to sluggify the question title. We used regex
to fish out and replace all occurrences of punctuation and symbol characters with an empty string and using the itertools crate, we joined the words back together into a single string, where each word is separated by a hyphen ("-").
That's it for this article. We'll utilize these things in the next one!
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)