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.
Comments
December 21, 2022 08:10
I think there isnโt as much as efficient script it can have with multiple options besides having an HTML tag for getting some body part bold with itโs font properties. CSS on the other hand can best be the option to go with which can just do that with itโs font properties while writing code for applications like HND assignment writing service Y may run with some limitation working with HTML but you would have plenty pf variations and options how you do this.