DEV Community

Nivethan
Nivethan

Posted on • Edited on • Originally published at nivethan.dev

A Web App in Rust - 10 Commenting

Welcome back! We currently have a website that we can register on and submit new posts to. The next major piece we need to wire up is our comments. Let's dive right into it!

The first thing we need to do is create our post page, this will be where our comments will show.

The Post Page

In the previous chapter we set up our index page to display all of the posts made on our website, we also added a url for our comments.

./templates/index.html

...
<small><a href="/posts/{{p.id}}">comments</a></small>
...
Enter fullscreen mode Exit fullscreen mode

Here we are attempting to retrieve the page under post with the id of the post. Now let's move to rust and process this new route.

./src/main.rs

...
            .route("/submission", web::post().to(process_submission))
            .service(
                web::resource("/post/{post_id}")
                    .route(web::get().to(post_page))
            )
...
Enter fullscreen mode Exit fullscreen mode

The first thing we need to do is register our route, but we can't use our trusty route option anymore as we're trying to also pass in data via the url. Now we can register a service which allows us to do more configuration on the route. This way we can have wildcards and dynamic variables in our paths and still process them.

Now let's take a look at the post_page function gets run on a HTTP Get.

./src/main.rs

...
async fn post_page(tera: web::Data<Tera>, 
    id: Identity, 
    web::Path(post_id): web::Path<i32>
) -> impl Responder {

    use schema::posts::dsl::{posts};
    use schema::users::dsl::{users};

    let connection = establish_connection();

    let post :Post = posts.find(post_id)
        .get_result(&connection)
        .expect("Failed to find post.");

    let user :User = users.find(post.author)
        .get_result(&connection)
        .expect("Failed to find user.");

    let mut data = Context::new();
    data.insert("title", &format!("{} - HackerClone", post.title));
    data.insert("post", &post);
    data.insert("user", &user);

    if let Some(_id) = id.identity() {
        data.insert("logged_in", "true");
    } else {
        data.insert("logged_in", "false");
    }

    let rendered = tera.render("post.html", &data).unwrap();
    HttpResponse::Ok().body(rendered)
}
...
Enter fullscreen mode Exit fullscreen mode

For now, we're just going to load the Post and User so that we can display something, we'll work on comments afterwards.

The first thing we do is bring in the tables we need, then we set up a connection to our database.

We then execute a find for our post. We can use the find option because we know the id of the post we are looking for. Once we have the Post, we can also run a find for the User that created this post, this way we can display their name on the post page. Here we could do a join as well like we did in the index page.

The next thing we do is a build a tera context object with the post and user information. We also added a new piece of information to our context object.

We are now passing in whether the user is logged in or not. This way we can modify our post page, if the user is logged in, show the comment box. If they aren't log in, hide the comment box.

We then pass all this data to our post.html template file. Let's create it!

./templates/post.html

{% extends "base.html" %}

{% block content %}

<table>
    <tr>
        <td>
            <a href="{{ post.link }}">{{ post.title }}</a>
            <br>
            <small>
                submitted by 
                <a href="/user/{{user.username}}">
                    {{ user.username }}
                </a>
            </small>
            - {{ post.created_at }}
        </td>
    </tr>
</table>


<form action="" method="POST">
    <div>
        <label for="comment">Comment</label>
        <textarea name="comment"></textarea>
    </div>
    <br>
    <input type="submit" value="submit">
</form>

<div>
    Our comments will go here.
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

The top portion of this page will display what we showed on the index page with some minor modifications.

Our form is a POST to the current page, this means that when we submit a top level comment, we'll be POSTing it to our current url.

At this point we should be able to go to our index page, 127.0.0.1:8000/ and click on comments to get to our post page.

We should see our post's information and a comment box! With our Post Page functioning, let's add our comment functionality.

Submitting New Comments

The first thing we need to do is set our application to handle this route.

./src/main.rs

...
            .route("/submission", web::post().to(process_submission))
            .service(
                web::resource("/post/{post_id}")
                    .route(web::get().to(post_page))
                    .route(web::post().to(comment))
            )
...
Enter fullscreen mode Exit fullscreen mode

We add a route for the HTTP POST request now which will go to our comment function.

Before we set up our comment function however we need to build some models. We need to once again create 2 structs, one to reflect our database and one to reflect a new comment that we will insert.

First we need to include our comments from our schema table.

./src/modes.rs

use super::schema::{users, posts, comments};
Enter fullscreen mode Exit fullscreen mode

Now we can use our comments table when building out structs.

./src/models.rs

...
#[derive(Debug, Serialize, Queryable)]
pub struct Comment {
    pub id: i32,
    pub comment: String,
    pub post_id: i32,
    pub user_id: i32,
    pub parent_comment_id: Option<i32>,
    pub created_at: chrono::NaiveDateTime,
}

#[derive(Serialize, Insertable)]
#[table_name="comments"]
pub struct NewComment {
    pub comment: String,
    pub post_id: i32,
    pub user_id: i32,
    pub parent_comment_id: Option<i32>,
    pub created_at: chrono::NaiveDateTime,
}

impl NewComment {
    pub fn new(comment: String, post_id: i32, 
        user_id: i32, parent_comment_id: Option<i32>) -> Self{
        NewComment {
            comment: comment,
            post_id: post_id,
            user_id: user_id,
            parent_comment_id: parent_comment_id,
            created_at: chrono::Local::now().naive_utc(),
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

This should look similar to our Post and NewPost structs, the only thing to note is that we need to make sure our fields and order match what is in our schema file.

./src/schema.rs

...
table! {
    comments (id) {
        id -> Int4,
        comment -> Varchar,
        post_id -> Int4,
        user_id -> Int4,
        parent_comment_id -> Nullable<Int4>,
        created_at -> Timestamp,
    }
}
...
Enter fullscreen mode Exit fullscreen mode

We will get strange errors if things are out of order!

With that we have our models set up! Now let's write our comment handler function and actually allow comments to get saved.

We first need to include our 2 new structs in our main.rs file.

./src/main.rs

...
use models::{User, NewUser, LoginUser, Post, NewPost, Comment, NewComment};
...
Enter fullscreen mode Exit fullscreen mode

Now we can write our comment function.

./src/main.rs

...
#[derive(Deserialize)]
struct CommentForm {
    comment: String,
}

async fn comment(
    data: web::Form<CommentForm>,
    id: Identity,
    web::Path(post_id): web::Path<i32>
) -> impl Responder {

    if let Some(id) = id.identity() {
        use schema::posts::dsl::{posts};
        use schema::users::dsl::{users, username};

        let connection = establish_connection();

        let post :Post = posts.find(post_id)
            .get_result(&connection)
            .expect("Failed to find post.");

        let user :Result<User, diesel::result::Error> = users
            .filter(username.eq(id))
            .first(&connection);

        match user {
            Ok(u) => {
                let parent_id = None;
                let new_comment = NewComment::new(data.comment.clone(), post.id, u.id, parent_id);

                use schema::comments;
                diesel::insert_into(comments::table)
                    .values(&new_comment)
                    .get_result::<Comment>(&connection)
                    .expect("Error saving comment.");


                return HttpResponse::Ok().body("Commented.");
            }
            Err(e) => {
                println!("{:?}", e);
                return HttpResponse::Ok().body("User not found.");
            }
        }
    }

    HttpResponse::Unauthorized().body("Not logged in.")
}
...
Enter fullscreen mode Exit fullscreen mode

We first need a Form Extractor struct for our comment. Then we get into our actual comment function.

Inside we make sure that the user has a session and then we begin our database lookups. We first check to see if our post exists, then we get the user using the session.

At this point we have all the pieces we need, we have the post, we have the user and we have the comment.

The next step is to make sure our User is valid and if it is we go on to intialize our NewComment struct. Here we set the parent_id to None because top level comments don't have parents.

Once it's been initialized, we insert our NewComment into our comments table.

We then send back a message letting the user know that we received their comment.

With that! we have our comments being saved to the database.

Now we will go back to our post_page function so we can display our comments.

Displaying Comments

We've structured our table so that comments belong to posts, we did this by setting up a foreign key constraint when we first wrote our SQL.

./migrations/2020-10-18-064517_hackerclone/up.sql

...
CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    comment VARCHAR NOT NULL,
    post_id INT NOT NULL,
    user_id INT NOT NULL,
    parent_comment_id INT,
    created_at TIMESTAMP NOT NULL,

    CONSTRAINT fk_post
        FOREIGN KEY(post_id)
            REFERENCES posts(id),

    CONSTRAINT fk_user
        FOREIGN KEY(user_id)
            REFERENCES users(id),

    CONSTRAINT fk_parent_comment
        FOREIGN KEY(parent_comment_id)
            REFERENCES comments(id)
);
...
Enter fullscreen mode Exit fullscreen mode

In previous chapters, we did joins to get data referred to by foreign keys. For comments, instead of a join we will use diesel's association construct.

This way we can get all comments belonging to a particular post.

The first thing we need to do is to expose this relationship in our models.

./src/models.rs

...
#[derive(Debug, Serialize, Queryable, Identifiable, Associations)]
#[belongs_to(Post)]
pub struct Comment {
    pub id: i32,
    pub comment: String,
    pub post_id: i32,
    pub user_id: i32,
    pub parent_comment_id: Option<i32>,
    pub created_at: chrono::NaiveDateTime,
}
...
Enter fullscreen mode Exit fullscreen mode

Comment is our child table and we are adding the traits of Identifiable and Associations to this model. We also add another macro of who this child belongs to.

./src/models.rs

...
#[derive(Debug, Serialize, Queryable, Identifiable)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub link: Option<String>,
    pub author: i32,
    pub created_at: chrono::NaiveDateTime,
}
...
Enter fullscreen mode Exit fullscreen mode

Then in our parent, Post, we simply add the Identifiable trait.

With that we have our relationship set up.

Then in our post_page function we will gather up all the comments that belong to our post.

./src/main.rs

...
    let comments :Vec<(Comment, User)> = Comment::belonging_to(&post)
        .inner_join(users)
        .load(&connection)
        .expect("Failed to find comments.");

    let mut data = Context::new();
    data.insert("title", &format!("{} - HackerClone", post.title));
    data.insert("post", &post);
    data.insert("user", &user);
    data.insert("comments", &comments);
...
Enter fullscreen mode Exit fullscreen mode

Our Comment model now has the belonging_to function added to it via the "#[belonging_to(Post)]" macro and we can now use our foreign key to gather up all our comments for a particular post id.

We then use an inner join to retrieve our users so that we can get our comment's user_ids translated into the usernames.

We then pass our comments into our tera context object where we can now loop through our list and display the comments.

./templates/post.html

...
<form action="" method="POST">
    <div>
        <label for="comment">Comment</label>
        <br>
        <textarea name="comment"></textarea>
    </div>
    <br>
    <input type="submit" value="submit">
</form>

<br>
{% for comment_user in comments %}
{% set comment = comment_user[0] %}
{% set user = comment_user[1] %}
<div>
    {{comment.comment}}
    <br>
    <small> by {{user.username}}</small>
    <hr>
</div>
{% endfor %}
{% endblock %}
...
Enter fullscreen mode Exit fullscreen mode

We loop through our list comments and users and display each piece of information.

Now if we go to our website at 127.0.0.1:8000/ we should be able to navigate to one of our posts and view the the post page.

Voila! We should now be able to post new comments and view those comments on the post page.

Whew! We've just finished up another major piece of our puzzle and learned a little bit more about how diesel works. We saw how the belongs_to function is useful and how we can chain inner_joins. This should also make it clear how the models, macros and routes all come together to form a web application.

Next chapter we're going to build our user page, see you soon!

*** Due to a publishing error, chapter 11 is considered the first part of the series. Sorry!

Top comments (0)