Important Notice: this service will be discontinued by the end of 2024 because for multiple years now, Plume is no longer under active/continuous development. Sadly each time there was hope, active development came to a stop again. Please consider using our Writefreely instance instead.

Following the Go tutorial on making a wiki, in Rust, using Rocket

I'll be following the Writing Web Applications tutorial, where we make a simple web server letting users view and edit a simple wiki. But instead of implementing it in Go, I'll be writing Rust πŸ¦€! Since, unlike Go, Rust doesn't come with an HTTP server implementation in its standard library, I'll be using Rocket πŸš€, an awesome & popular Rust web framework.

Of course, this is a bit of apples-to-oranges comparison, since we're comparing a high-level, ergonomic framework to an HTTP implementation shipped with Go. There are high-level web frameworks for Go as well; for example, check out Echo.

This post is designed to be read side-by-side with the Go tutorial. Here's the link again. All the code, with step-by-step commits, will be available in this repo on my GitHub.

Getting started

Let's create a repo:

$ cargo new rustwiki
$ cd rustwiki

cargo new has generated a Hello World for us, so let's compile and run it:

$ cargo run
   Compiling rustwiki v0.1.0 (/home/sergey/dev/rustwiki)
    Finished dev [unoptimized + debuginfo] target(s) in 0.89s
     Running `target/debug/rustwiki`
Hello, world!

Data Structures

Let's define a struct to represent a wiki page:

#[derive(Debug, PartialEq, Eq)]
struct Page {
    title: String,
    body: String,
}

and two methods to load and save it to and from a text file:

use std::io::{self, Read, Write};
use std::fs::File;

impl Page {
    fn load(title: String) -> io::Result<Page> {
        let file_name = format!("{}.txt", title);
        let mut file = File::open(file_name)?;
        let mut body = String::new();
        file.read_to_string(&mut body)?;
        Ok(Page { title, body })
    }

    fn save(&self) -> io::Result<()> {
        let file_name = format!("{}.txt", self.title);
        let mut file = File::create(file_name)?;
        write!(file, "{}", self.body)
    }
}

Now, in main(), let's create a page, save it to a text file and then load it back:

fn main() -> io::Result<()> {
    let page = Page {
        title: String::from("Test"),
        body: String::from("This is a sample page"),
    };
    page.save()?;
    let page = Page::load(String::from("Test"))?;
    println!("{:#?}", page);
    Ok(())
}

I've used the {:#?} format specifier to pretty-print the page. Now run

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/rustwiki`
Page {
    title: "Test",
    body: "This is a sample page",
}
$ ls
blog.md  Cargo.lock  Cargo.toml  src  target  Test.txt

We see it has indeed created the file and read it back correctly. Great; now it's time to actually start using Rocket!

Introducing Rocket (an interlude)

At the moment (Jun 2019), Rocket still requires nightly Rust. Thankfully, that's really easy to set up with rustup:

$ rustup override set nightly
info: override toolchain for '/home/sergey/dev/rustwiki' set to 'nightly-x86_64-unknown-linux-gnu'

this will automatically download and install the nightly Rust toolchain (if you don't have it already) and install it as a default toolchain for this project (directory).

Now, let's set up a Rocket πŸš€ Hello World. Following this guide, let's add add this to the Cargo.toml:

[dependencies]
rocket = "0.4.1"

and this to main.rs:

#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use] extern crate rocket;

// ... Page stuff goes here ...

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

and replace our main() with:

fn main() {
    rocket::ignite().mount("/", routes![index]).launch();
}

Now, running cargo run again causes Cargo to download and compile a number of crates; and then Rocket starts πŸš€:

   Compiling rustwiki v0.1.0 (/home/sergey/dev/rustwiki)
    Finished dev [unoptimized + debuginfo] target(s) in 36.80s
     Running `target/debug/rustwiki`
πŸ”§ Configured for development.
    => address: localhost
    => port: 8000
    => log: normal
    => workers: 16
    => secret key: generated
    => limits: forms = 32KiB
    => keep-alive: 5s
    => tls: disabled
πŸ›°  Mounting /:
    => GET / (index)
πŸš€ Rocket has launched from http://localhost:8000

If you visit http://localhost:8000 in your browser now, you should see "Hello, world!" displayed in the browser and the following appear in the log:

GET / text/html:
    => Matched: GET / (index)
    => Outcome: Success
    => Response succeeded.
GET /favicon.ico image/webp:
    => Error: No matching routes for GET /favicon.ico image/webp.
    => Warning: Responding with 404 Not Found catcher.
    => Response succeeded.

Using Rocket to serve wiki pages

Let's add another route underneath index():

#[get("/view/<title>")]
fn view(title: String) -> io::Result<String> {
    let page = Page::load(title)?;
    let res = format!("<h1>{}</h1><div>{}</div>", page.title, page.body);
    Ok(res)
}

and register it inside main() like this:

fn main() {
    rocket::ignite()
        .mount("/", routes![index, view])
        .launch();
}

Now, if you restart the app and open http://localhost:8000/view/Test, you should see "<h1>Test</h1><div>This is a sample page</div>" in your browser. The reason you're seeing raw, unrendered HTML code is that by default Rocket serves String as text/plain and not text/html.

Let's ask for HTML explicitly by wrapping our String into Html:

use rocket::response::content::Html;

#[get("/view/<title>")]
fn view(title: String) -> io::Result<Html<String>> {
    let page = Page::load(title)?;
    let res = format!("<h1>{}</h1><div>{}</div>", page.title, page.body);
    Ok(Html(res))
}

Now it should work and render like this:

Test

This is a sample page

Nice!

Editing pages

Let's add another route:

#[get("/edit/<title>")]
fn edit(title: String) -> Html<String> {
    let page = Page::load(title.clone())
        .unwrap_or(Page::blank(title));
    let res = format!("
        <h1>Editing {title}</h1>
        <form action=\"/save/{title}\" method=\"POST\">
            <textarea name=\"body\">{body}</textarea><br>
            <input type=\"submit\" value=\"Save\">
        </form>", title = page.title, body = page.body);
    Html(res)
}

Here, I'm using a helper method for creating blank pages, so let's also add that:

impl Page {
    fn blank(title: String) -> Page {
        Page {
            title,
            body: String::new()
        }
    }

    // ...

Don't forget to add it to the list in main()!

fn main() {
    rocket::ignite()
        .mount("/", routes![index, view, edit])
        .launch();
}

Again, if you try opening http://localhost:8000/edit/Test in your browser, you should be able to edit the text in a <textarea>. Submitting it doesn't work though, since we haven't yet implemented /save/<title>. But before we do that, we need to deal with the hardcoded HTML. While it's nice that we're able to use Rust's multi-line string literal and formatting, it would be a lot better to put the template into its own file and use a proper templating engine.

Templates with Tera

Rocket has built-in support for templates; but it doesn't include its own templating engine β€” we can use whichever one we like. The two mentioned in the guide section on templates are Handlebars and Tera. I'm going to use Tera for this post.

Let's put this into templates/edit.html.tera:

<h1>Editing {{title}}</h1>

<form action="/save/{{title}}" method="POST">
    <div>
        <textarea name="body" rows="20" cols="80">{{body}}</textarea>
    </div>
    <div>
        <input type="submit" value="Save">
    </div>
</form>

Then add this to your Cargo.toml:

[dependencies.rocket_contrib]
version = "0.4.1"
default-features = false
features = ["tera_templates"]

(or "handlebars_templates" if you're using Handlebars instead).

Now, let's change our edit() method to use the template:

use rocket_contrib::templates::Template;

#[get("/edit/<title>")]
fn edit(title: String) -> Template {
    let page = Page::load(title.clone())
        .unwrap_or(Page::blank(title));
    Template::render("edit", page)
}

Nice and tidy, isn't it? We don't have to wrap the Template in Html anymore, and we don't have to manually format the string.

We need to do two more things to get this to work. First, we have to make our Page type serializable in order for Template to be able to pass it to the templating engine. To do that, add Serde to Cargo.toml:

[dependencies.serde]
version = "1.0"
features = ["derive"]

and derive the Serialize trait for Page in addition to the ones we already derive:

use serde::Serialize;

#[derive(Debug, PartialEq, Eq, Serialize)]
struct Page {
    title: String,
    body: String,
}

If you run the app now and try accessing /edit/<title>, you'll see this in the log:

GET /edit/Test text/html:
    => Matched: GET /edit/<title> (edit)
    => Error: Attempted to retrieve unmanaged state!
    => Error: Uninitialized template context: missing fairing.
    => To use templates, you must attach `Template::fairing()`.
    => See the `Template` documentation for more information.
    => Outcome: Failure
    => Warning: Responding with 500 Internal Server Error catcher.
    => Response succeeded.

That is the second thing we need to fix β€” we need to attach the template fairing to our Rocket app:

fn main() {
    rocket::ignite()
        .mount("/", routes![index, view, edit])
        .attach(Template::fairing())
        .launch();
}

With this, it will work.

Let's also switch the view page to a Tera template:

#[get("/view/<title>")]
fn view(title: String) -> io::Result<Template> {
    let page = Page::load(title)?;
    let res = Template::render("view", page);
    Ok(res)
}

and in templates/view.html.tera:

<h1>{{title}}</h1>

<p>[<a href="/edit/{{title}}">edit</a>]</p>

<div>{{body}}</div>

Handling non-existent pages

If somebody tries to open a non-existent page, we should suggest them to create it instead of returning an error from failing to open the file. Let's do that:

use rocket::response::Redirect;

#[get("/view/<title>")]
fn view(title: String) -> Result<Template, Redirect> {
    if let Ok(page) = Page::load(title.clone()) {
        let res = Template::render("view", page);
        Ok(res)
    } else {
        Err(Redirect::to(uri!(edit: title)))
    }
}

Notice the uri! macro which allows us to create URIs in a type-safe manner instead of retyping and manually filling in a URI template. This way, we declare how a URI for a route looks like and what arguments it accepts once, and then reference that definition from other places with the uri! macro.

Saving pages

Finally, let's implement /save/<title>. Since the new body is submited to us as an HTML form, we're going to need to define a structure to represent that form and derive FromForm for it:

#[derive(Debug, FromForm)]
struct SaveForm {
    body: String
}

Then we can use it in the route arguments like so:

use rocket::request::Form;

#[post("/save/<title>", data = "<form>")]
fn save(title: String, form: Form<SaveForm>) -> io::Result<Redirect> {
    let form = form.into_inner();
    let page = Page {
        title: title.clone(),
        body: form.body,
    };
    page.save()?;
    Ok(Redirect::to(uri!(view: title)))
}

The first (and the only) thing we do with the Form<> wrapper is we unwrap it using form.into_inner(). Its purpose is to serve as a type guard telling Rocket how to collect the input for this argument (from an HTML form), not to be a fancy container full of functionality. It does implement Deref, so we could use it as-is, but we need to move form.body out of it, so that's what the form.into_inner() call is for.

And again, we need to remember to add the new route to main():

fn main() {
    rocket::ignite()
        .mount("/", routes![index, view, edit, save])
        .attach(Template::fairing())
        .launch();
}

Now you can edit and save some pages!

Error handling

Actually, we don't need to do anything here; we're already dealing with errors properly! Rocket will automatically return an error if we return an Err value of io::Result and if a template fails to render. To verify this works, try changing the requested template name, e.g.

#[get("/edit/<title>")]
fn edit(title: String) -> Template {
    let page = Page::load(title.clone())
        .unwrap_or(Page::blank(title));
    Template::render("foo", page)
}

Template caching

I don't think we have to do anything here, either. Rocket and Tera already handle everything for us. Not only will they preload and cache the templates, they will actually watch the filesystem for changes and live-reload the templates when we edit them.

Validation

This is another one of those things Rocket gives us for free.

Try opening http://localhost:8000/view/foo/bar β€” you'll get a 404. That's because the <title> part in our /view/<title> route only matches a single path segment. If you want to allow passing multiple path segments, you have to write it this way:

#[get("/view/<path..>")]
fn view(path: PathBuf) -> ...

Even then, it's smart enough to not accept paths like ../../etc/passwd. Mindblowing, isn't it?

Side note: if you try opening http://localhost:8000/view/../../etc/passwd in your browser, your browser may decide to automatically collapse that into http://localhost:8000/etc/passwd since it believes the first .. is undoing the view/ part, and the second .. has nothing more to undo and is thus ignored. You can use curl --path-as-is which doesn't do this:

$ curl --path-as-is http://localhost:8000/view/../../etc/passwd

            <!DOCTYPE html>
            <html>
            <head>
                <meta charset="utf-8">
                <title>404 Not Found</title>
            </head>
            <body align="center">
                <div align="center">
                    <h1>404: Not Found</h1>
                    <p>The requested resource could not be found.</p>
                    <hr />
                    <small>Rocket</small>
                </div>
            </body>
            </html>

Check the address Rocket is actually seeing in the Rocket log.

Well, that concludes our tutorial! Again, you can find the repo on GitHub.