DEV Community

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

Posted on

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

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:

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

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

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

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

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

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? 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)