Rocket Review

Posted

I recently launched a web service using Rocket (0.5.0-rc.1). After about a month, I migrated it to actix-web (although using only a small amount of the framework). I thought it would be interesting to document my rationale and thoughts about Rocket, as well as a little of how it compares to actix-web and some general ideas for web servers in Rust.

If you want the TL;DR, the problem that made me ditch Rocket is No Request Object.

Rocket Pros

First, let’s talk about the things that I liked about Rocket. Overall it was a nice framework and got FeedMail off the ground quickly.

Query Routing

Rocket had something that I haven’t seen in any other framework, the ability to route based on query parameters. For example, I can have the following two routes; the first will be called if there is no url parameter passed and the second will be called if there is.

#[rocket::get("/subscriptions")]
fn subscriptions() { ... }
#[rocket::get("/subscriptions/new?<url>")]
fn subscriptions_new_url(url: &str) { ... }

This is very useful when the presence or absence of a parameter dramatically changes the logic of an endpoint. Of course, Rocket still supports optional parameters, so you don’t need to use this approach if the parameter only has a minor effect on the handler.

#[rocket::get("/subscriptions/new?<url>")]
fn subscriptions_new_url(url: Option<&str>) { ... }

While this was a really nice feature it seems to be under-developed. Rocket sometimes complains about conflicting routes where I think the precedence should be obvious. This requires adding manual ordering to resolve.

For example the following routes conflict and require manual ordering to resolve.

I assume this is because while the parameters were defined as &str they could have been defined as Option<&str> which would be a conflict. It’s a real shame that the router isn’t aware of this though and requires adding rank=1 to one of the routes. Also, the ordering of the rank parameter confuses me and I always end up with them in the wrong order the first time.

URL Generation

The thing I will miss the most about Rocket is the type-safe URL generation. The following code should serve as a good example:

#[rocket::get("/subscriptions/new?<url>")]
fn subscriptions_new_url(url: &str) { ... }

#[rocket::get("/subscriptions/<id>")]
fn subscriptions_get(id: i64) { ... }

fn test() {
	assert!(
		rocket::uri!(subscriptions_new(url="https://kevincox.ca")).to_string(),
		"/subscriptions/new?url=https%3A%2F%2Fkevincox.ca")
	assert!(
		rocket::uri!(subscriptions_get(id=5)).to_string(),
		"/subscriptions/5")
}

It seems trivial but there are huge benefits. For example:

Even if changing paths and parameters should be a rare operation, the last couple of points are useful for ongoing development and make broken URLs incredibly rare.

It is also easy to generate absolute URLs. This is useful for generating email and other non-web content.

rocket::uri!(
	global.config.host.clone(),
	crate::subscriptions_get(id=subscription))

My only real complaint is that due to the heavy type-inference it can be quite noisy at times. But while a handful to type or read, the code is still quite simple. So overall, I am happy with the API and wish it was available in other frameworks.

rocket::uri!(
	// This needs to be cloned.
	global.config.host.clone(),
	login_reset_password(
		// These type hints are necessary.
		email=None::<&str>,
		err=None::<&str>))

Rocket Cons

No Request Object

This is the reason that I ultimately left Rocket. Instead of giving you a “Request” object in handlers to access information such as the URL, query parameters and headers; Rocket forces you to use their FromRequest API inside handlers. While the API is convenient for a lot of cases, I found that it was too limiting to build reusable APIs upon and didn’t allow easy composition of helpers in handlers.

Good Example

One place where this worked really well was for user login. I could write a “guard” that checked a user was logged in and extracted session information.

#[rocket::get("/subscriptions")]
fn subscriptions_list(user: crate::User) -> String {
	format!("User {} is logged in!", user.id)
}

This works quite well, and using the FromRequest API I was allowed to respond with a “401 Forbidden” response code if the user was not logged in. Then I could use a “catcher” to redirect to the login page which would redirect the user back to the original page after being logged in.

Ok Example

The other major guard I used was to prevent against CSRF requests. For mutating endpoints I would check that the request was same-origin. Using the guard was a bit more involved and looked like this:

#[rocket::post("/subscriptions/new", data="<subscription>")]
pub async fn subscriptions_new_post(
	sameorigin: Result<crate::SameOrigin, &str>,
	body: rocket::form::Form<SubscribtionNewForm<'_>>,
) {
	if let Err(err) = sameorigin {
		return crate::redirect(
			err,
			rocket::uri!(subscriptions_new_url(url=body.url)))
	}
}

The SameOrigin guard checks that a request is actually same origin otherwise returns an error. However, due to the limited FromRequest API, all I can do is return an arbitrary error—which is usually ignored—and a status code. If I just rely on the status code I need no “error” handling in the handler itself and can install a catcher to manage that. But as far as I am aware, the catcher has no way of knowing which guard failed. This makes it impossible to redirect to a helpful URL or other clever logic (unless I abuse status codes to identify the source guard or particular error condition). So instead you need to manually handle the “error” value here (which in the example is just a &str) so I can more flexibly handle the error.

This starts to make me question the utility of this guard pattern. The hidden “error” path has incredibly limited response capabilities and there is very little customisability as all of the customization needs to be done at the type level. It seems for anything moderately complex you need to force the route to handle the error. So what have you gained from the special guard API?

After migration to actix-web the same “guard” looks like this:

if let Err(err) = crate::same_origin(req) {
	return crate::redirect(
		err,
		// My new URL generation.
		crate::SubscriptionsNewQuery::err(body.url.to_string(), None).to_url())
}

This is a fairly straight-forward conversion of the old guard but keeps the whole thing in code rather than messing with the FromRequest trait. This also means that I can do much more complex things such passing runtime parameters. For example, Rocket has built-in request size limiting, but this is static by “category” such as bytes, form bodies and file uploads. If implemented in the request body it would be easy to customize the limit for different routes or even different users (for example different subscription levels could have different limits).

Bad Example

Using Rocket I failed to implement ETags on a bunch of static pages. I was hoping that I could implement it with a guard. However, this was not possible. Even if implemented with a helper function in the handler it would be very difficult.

I did manage to add ETags for my static assets because they were managed by a macro. But looking at the macro it is clear why it is not worth adding this to other routes.

macro_rules! static_file {
	( $path:expr, $n:ident, $ext:literal, $type:expr ) => {
		#[rocket::get($path)]
		pub fn $n(
			global: &rocket::State<&crate::Global>,
			headers: crate::guards::Headers,
		) -> rocket::Response {
			let body = std::include_bytes!(
				std::concat!("../static/", std::stringify!($n), $ext));

			let mut r = rocket::Response::build();
			r.raw_header("cache-control", "max-age=600");

			if let Some(etag) = &global.etag {
				r.raw_header("etag", etag);
				if headers.get_one("if-none-match") == Some(etag) {
					r.status(rocket::http::Status::NotModified);
					return r.finalize()
				}
			}

			r.header($type);
			r.sized_body(body.len(), std::io::Cursor::new(body));
			r.finalize()
		}
	}
}

static_file!("/a/logo.png", logo, ".png", rocket::http::ContentType::PNG);

I have a number of problems with this approach:

  1. Every route that needs this needs to add the (custom) Headers guard/extractor to their arguments list. In fact if I was doing the “the rocket way” I would have made a more targeted IfNoneMatch guard since you are supposed to extract small bits of the request. But this leads to an explosion of parameters once you add a couple of guards.
  2. You need to set the same headers on the “full” response and the “Not Modified” response (for example Cache-Control). This is difficult when the response object is generated after you do the ETag check.

So while this was worth implementing for the simple case of static files, it wasn’t worth adding to other endpoints.

I find that guards work well for simple cases but fail for more complex cases. For more complex cases you need guards to pull the data from the request, then you pass it to an in-code “guard”. But at this point what is the benefit? You are just repeating yourself twice, once to acquire the values and once to pass them to the “guard”.

It seems like guards are cute and save a tiny amount of typing at best. But since they can’t handle more complex cases I don’t find they justify their complexity. My code on actix-web now just uses regular Rust code which I find both simpler and more flexible.

 fn subscriptions_list(
 	...
-	user: crate::guards::User,
 ) -> crate::Response {
+	let user = crate::guards::user(req)?;

To be fair instead of having the guard in the handler parameters I do need to have a Request object passed in. But that is passed in once for all of the guards and for the handler itself. In Rocket I needed to keep adding new arguments that are copy-pasted into the arguments list of every handler that used an in-code “guard”. While you can argue that this is a violation of functions taking exactly the values they depend on, I would argue that the caller shouldn’t need to know what data each specific guard depends on. For example if I decide that you can authenticate with an Authorization header rather than passing a Cookie I don’t feel like I should need to update the callers.

Now ETag checks look like the following and I can easily apply it to all static routes.

// Set headers that need to be on 304 Not Modified responses.
crate::ui::set_cache(res, "max-age=600");

// Check if the request is conditional and either early return or just set the response header.
crate::guards::version_etag(global, req, res)?;

// Generate page.
crate::ui::page(res, ...)

I just need to add a single function call for ETag handling! The things that make this work are:

  1. The ability to set headers before the check.
  2. The ability to return arbitrary responses from the “guard”.
  3. The ability to set response headers.

None of these could readily be done with Rocket’s FromRequest framework.

Macro Route Error Reporting

In Rocket a minor issue is that you define your Routes using an attribute macro. Unfortunately the Rust complier handles syntax errors very poorly in this case. If the function has a syntax error the syntax macro doesn’t run and you get a ton of errors about the function not existing. This isn’t really Rocket’s fault (it is mostly the fact that the Rust compiler can’t stop on the first error and tends to snowball a lot of errors that don’t really exist because of what it assumed about your code) but it does make development a bit annoying.

405 Method Not Allowed handled incorrectly.

Another minor complaint is that if I declare a route for one method other methods should return 405 Method Not Allowed. Like most Rust frameworks Rocket doesn’t handle this correctly but instead returns a 404. This isn’t a big deal and is mostly just pedantry.

My Own “framework”

As I mentioned at the beginning I’m not using all of actix-web, instead I have created a bit of my own framework inside of it. In fact the main setup of my actix_web::HttpServer shows how little of the framework I’m using. I’m not using multiple “services” instead just using a default and doing routing myself.

actix_web::HttpServer::new(
		move || actix_web::App::new()
			.app_data(actix_web::web::PayloadConfig::new(128 * 1024 * 1024))
			.default_service(actix_web::web::to(handle_request)))
	.bind(std::env::var("FEEDMAIL_BIND").as_deref()
		.unwrap_or("127.0.0.1:8000")).unwrap()
	.shutdown_timeout(if cfg!(dbg) { 0 } else { 600 })
	.run()

I made a couple of interesting decisions in my implementation and thought I would show some examples of what the code looks like.

Passed in Response Object

Most frameworks I saw let you respond with any object that implemented some “Responder” trait. This seemed like a good idea at first, but I found that it was too limiting. Instead, I found it best to pass in an “out parameter” &mut Response that could be updated with the response.

Here are some issues that I had with the other approach:

All Returns Must Be the Same Type

If you had early returns for things like errors or failed “guards” you had to respond with the same type. This was very limiting, so I ended up using a “type erased” object such as rocket::Response. This was such a frequent issue that I just switched to using the same type of response object everywhere. This means that the flexible “Responder” trait was not being used anyway.

Setting Headers is Awkward

In this case I found that I was frequently doing code like this.

let mut r = render_page(...)?;
r.set_header(CacheControl::IMMUTABLE);
Ok(r)

With a passed in response I could just do:

res.set_header(CacheControl::IMMUTABLE);
render_page(res, ...)

ETag Generation is Very Awkward

When responding 304 Not Modified you need to include the Cache-Control header even if you don’t generate the response. If you want to avoid actually generating the page this can be quite difficult. With a response out-parameter you can just set the cache header first, then check for conditional request headers, then generate the response.

One downside here is you need to be careful about setting a cache header before running code that may return a transient error. You might end up with your error being cached when you didn’t want it to be, especially if you issue an ETag based on code version! This could result in an error being stuck in your cache for an extended period of time! When the error handlers generate “fresh” responses this issue naturally doesn’t occur.

Setting Cookies Requires Special Logic

Often when setting cookies they aren’t specifically related to any particular response, you want to return them to the client no matter what else happens whether that is a redirect, error or successful response. Rocket and other frameworks handle this by providing a way to access a “cookie jar” which you can add cookies to, those are then automatically attached to whatever response ends up being returned. With the “out parameter” response you can just set the cookie with no special API. Of course, you can also do this for any other header without needing to roll your own solution.

Using Result for control flow.

Rust has a fantastic ? (try) operator which can simplify error handling. In the future this operator will be usable on custom types but for now it is only available on std::result::Result and std::option::Option. I had to get a bit creative to make everything work well and ended up with this API for handlers:

/// Marker that the response body has been filled.
struct BodySet;

/// Marker that the Result is not actually an error but the response is filled properly.
struct EarlyReturn; // Implements std::error::Error.

type Response = Result<BodySet, anyhow::Error>;

All of my handlers and most of my helper functions return a crate::Response which allows a number of things:

Here is some code examples to show how this works.

fn handle_request(req: actix_web::HttpRequest) -> actix_web::HttpResponse {
	let mut res = actix_web::HttpResponse::new(http::StatusCode::OK);
	match route_request(&req, &mut res) {
		Ok(BodySet) => {}
		Err(e) => if e.root_cause().is::<EarlyExit>() {
			// Do nothing, response was generated by whoever returned the EarlyExit.
		} else {
			log(e);
			// Generate generic error page.
			render_internal_error(&mut res)
				.expect("error generating error page");
		}
	}
	res
}

fn require_user(
	req: &actix_web::HttpRequest,
	res: &mut actix_web::HttpResponse,
) -> anyhow::Result<User> {
	try_user(req, res)
		.or_else(|msg| {
			crate::ui::redirect(
				res,
				msg,
				crate::LoginQuery::msg(msg.into()))?;
			Err(crate::EarlyReturn.into())
		})
}

fn account(
	req: &actix_web::HttpRequest,
	res: &mut actix_web::HttpResponse,
) -> crate::Response {
	let user = require_user(req, res)?;
	html(res, format!("<p>Hello user {}</p>", user.id))
}

I think this code looks quite nice. handle_request is the main handler which calls the router and handles the response. require_user is an example “guard”. It is easy to write, can accept any runtime parameters to customize its behaviour and can return an arbitrary “error” response.

I think once “try_trait” stabilizes I will update this to a custom type but for now it is working really well. The only “ugly” code is in handle_request which exists only once in the whole codebase, the guards are not super pretty but fairly good and the routes which are the most frequent are very nice looking.

Conclusion

I’m very happy with the current state of the codebase on actix-web. I’ll probably continue to work on routing and URL generation to tie them together in a type-safe way, but it isn’t urgent. Simple line counts suggest that there is a slight line-count increase after migrating from Rocket to actix-web. The biggest sources of extra code are URL-generation, more manual routing and form body handling. I suspect that the current code somewhat reflects a direct translation of the “Rocket style” code with FromRequest guards turned into regular function calls. As I refactor I think the line count will become very similar for either version.

Discuss this Article

Subscribe via Email - Send Private Feedback