DEV Community

loading...

A Web App in Rust - 08 Submitting a New Post

krowemoh profile image Nivethan Originally published at nivethan.dev Updated on ・7 min read

Welcome back! Now we have a website that we can register on and even log in to. Now let's add another major piece to our app, submitting new posts!

In this chapter we'll be building the post submission logic. Let's outline this first so that we have a roadmap of what we'll be doing.

  1. We need to create a model to extract the form data, we already have this.
  2. We need to create a model of the true database table so that we can get ids and timestamps.
  3. We need another model to set the author and the timestamp
  4. We need to insert into our database
  5. That's it!

Let's get started.

Gating the Submission Page

The first thing we'll do is gate our submission page. We only want logged in users making posts.

./src/main.rs

...
async fn submission(tera: web::Data<Tera>, id: Identity) -> impl Responder {
    let mut data = Context::new();
    data.insert("title", "Submit a Post");

    if let Some(id) = id.identity() {
        let rendered = tera.render("submission.html", &data).unwrap();
        return HttpResponse::Ok().body(rendered);
    }

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

We will check the id and if the user is logged in, we will let them access the submission page. If they aren't we'll return a 401 - Unauthorized response.

You should now be able to navigate to 127.0.0.1:8000/submission. Depending on if you are logged in or not, you should get a different page.

Timestamps for Our Posts

Now before we create our models, we will need to add a new crate to our project. We don't want to get into the messiness of keeping track of time such as our post's created_at field. We will instead use the chrono crate and we will also add in the feature for serde so we can use serialize and deserialize with chrono.

./Cargo.toml

...
actix-identity = "0.3.1"
chrono = { version = "0.4", features = ["serde"] }
...
Enter fullscreen mode Exit fullscreen mode

Now that we have chrono have our timestamps, we will also need to let diesel know about it. We will need to enable the chrono feature in diesel.

./Cargo.toml

...
diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
...
Enter fullscreen mode Exit fullscreen mode

Now we have the chrono crate available and we have it ready to work with our orm, diesel.

The Models

First, let's rename our Post struct we have in our main.rs to something else, this is really just a struct to extract out form data.

./src/main.rs

...
#[derive(Deserialize)]
struct PostForm {
    title: String,
    link: String,
}
...
Enter fullscreen mode Exit fullscreen mode

PostForm is a better name for this or even PostFormExtractor to be a little bit more obvious about what we use this for.

This renaming would have broken our index function as it was using this struct. For now let's just stub out our index function and we'll worry about it in the next chapter.

./src/main.rs

...
async fn index(tera: web::Data<Tera>) -> impl Responder {
    let mut data = Context::new();

    let posts = "";

    data.insert("title", "Hacker Clone");
    data.insert("posts", &posts);

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

This is the index function stubbed out for now.

Next let's write our first model, the one that will reflect our post table. But before that let's update our includes to also use the post table.

./src/models.rs

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

Now we have the posts table available to us.

Now for our Post struct.

./src/models.rs

...
#[derive(Debug, Queryable)]
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

Once again because this struct has the Queryable trait we need to make sure our struct matches both the order of fields and the types in our schema file.

./src/schema.rs

...
table! {
    posts (id) {
        id -> Int4,
        title -> Varchar,
        link -> Nullable<Varchar>,
        author -> Int4,
        created_at -> Timestamp,
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Here is a mapping for the types:

https://kotiri.com/2018/01/31/postgresql-diesel-rust-types.html

In our struct, the only strange field is the link as a post could just be a title. We signified this in our SQL file by not giving the "NOT NULL" condition. In our schema file it appears as Nullable and in our struct it should be Option.

The other thing to note is that our created_at is a type from the chrono crate. These types aren't included in serde so if we didn't enable serde in our chrono crate we would have issues with the Serialization and Deserialization traits.

Now we need one more struct, we need a struct that will act as our Insertable.

./src/models.rs

...
#[derive(Serialize, Insertable)]
#[table_name="posts"]
pub struct NewPost {
    pub title: String,
    pub link: String,
    pub author: i32,
    pub created_at: chrono::NaiveDateTime,
}
...
Enter fullscreen mode Exit fullscreen mode

Our NewPost struct contains all the fields we want to set when we go to insert into our posts table. The 2 extra fields here are author and created_at both of which we will not extra from the form. This is why we need a 3rd struct. What we will do is convert our existing PostForm to a NewPost and then insert that into our table.

To do this we will implement a method for NewPost.

./src/models.rs

...
impl NewPost {
    pub fn from_post_form(title: String, link: String, uid: i32) -> Self {
        NewPost {
            title: title,
            link: link,
            author: uid,
            created_at: chrono::Local::now().naive_utc(),
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

This creates a function that will build a NewPost object from a title, link and user id we pass in.

With that we have all the models we need!

Let's update our main.rs to handle submissions now.

Submitting New Posts

The first thing we'll do is update the top of our file to include our newly created models.

./src/main.rs

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

Now with our models included, we can dive into the guts of our process_submission function.

./src/main.rs

...
async fn process_submission(data: web::Form<PostForm>, id: Identity) -> impl Responder {
    if let Some(id) = id.identity() {
        use schema::users::dsl::{username, users};

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

        match user {
            Ok(u) => {
                let new_post = NewPost::from_post_form(data.title.clone(), data.link.clone(), u.id);

                use schema::posts;

                diesel::insert_into(posts::table)
                    .values(&new_post)
                    .get_result::<Post>(&connection)
                    .expect("Error saving post.");

                return HttpResponse::Ok().body("Submitted.");
            }
            Err(e) => {
                println!("{:?}", e);
                return HttpResponse::Ok().body("Failed to find user.");
            }
        }
    }
    HttpResponse::Unauthorized().body("User not logged in.")
}
...
Enter fullscreen mode Exit fullscreen mode

The first thing to note is that in our process_submission function, we've updated our form extractor type to PostForm and also added id as a parameter. We should do some checking just to make sure the submission is coming from a logged in user.

Once we confirm that the session is valid we bring in the domain specific language or dsl for the users table. The first step in processing a submission is to figure out who the user is.

In our case, the session token is the username so we can reverse it to a user id easily by querying the user table. Had our token been a random string that we kept matched to the user, we would need to first go to that table to get the user id.

Once we have the User we make sure we have a valid result and then we convert our PostForm to a NewPost.

let new_post = NewPost::from_post_form(data.title.clone(), data.link.clone(), u.id);
Enter fullscreen mode Exit fullscreen mode

This line admittedly does bother me as we are doing a clone to pass the data. I did not figure out what the borrowing rules here should be.

  • Note: Comment below has a better way of handling and passing data to our from_post_form which is much cleaner than doing a clone. We can pass the data back by calling data.into_inner() which will pass the entire PostForm object. For now we'll leave the code as is but feel free to use the better way!

The next step is to bring in the posts table which we do with the use schema::posts line.

Next we insert our NewPost object into our posts table, reusing the connection we setup earlier in our function.

And with that! We should have submissions working!

Verifying a Submission

We can navigate to 127.0.0.1:8000/submission and if we're still logged in we can submit a new post.

Once submitted, our browser should show our submission message.

We can verify that the submission made it all the way to postgres through our rust application by checking postgres manually.

> sudo -u postgres psql postgres
psql (12.4 (Ubuntu 12.4-0ubuntu0.20.04.1))
Type "help" for help.

postgres=# \c hackerclone
hackerclone=# select * from posts;
 id | title | link | author |         created_at
----+-------+------+--------+----------------------------
  1 | Test  | 123  |      1 | 2020-10-19 03:29:05.063197
(1 row)

hackerclone=#
Enter fullscreen mode Exit fullscreen mode

The first thing we need to do is switch into the postgres user, then run psql for the postgres user.

Once we do that, we are in postgres and need to connect to a database. In our case the database we want to connect to is hackerclone.

Once we connect to our database the prompt will change to reflect that.

Then to list the entries in a table all we need to do is a run a select against a specific table.

We can see that our submission worked perfectly!

Whew! We did a lot these past few chapters. We now have registering users, logging in, sessions, and making new posts all functioning. The next chapter will hopefully be a breather, we'll work on making our index page functional and making our website slightly easier to navigate instead of having to type in urls.

See you soon!

Discussion (2)

pic
Editor guide
Collapse
romanlevin profile image
Roman Levin

This line admittedly does bother me as we are doing a clone to pass the data. I did not figure out what the borrowing rules here should be.

Pretty sure the answer here is to actually do what the method name implies, make a NewPost from the PostForm:

pub fn from_post_form(form: PostForm, id: i32) {
    ...
}

...

let new_post = NewPost::from_post_form(data.into_inner(), user.id);
Enter fullscreen mode Exit fullscreen mode

Since you don't need the Form<PostForm>, there's no need to clone anything, just pass ownership of the entire object.

Collapse
krowemoh profile image
Nivethan Author

Ah! This is good, feels much better, because we don't use data later on we can give ownership away. I also should have read the WebForm doc, into_inner() really simplified things.

Thank you! I'll add a note to take a look at this comment.