In my last project I used Sled and Rocket. I had to piece together how to use the two from a few different places so I put together this quick guide in case it helps others.

The full code for this walkthrough is availabe here, if you want to fast-forward to the finished product. A lot of this ends up being about error handling, so that using sled in endpoints is more ergonomic. If you don’t care to dive into error handling, you may want to skip those sections.

Project setup

Run the usual cargo new <PROJECT_NAME> command, then in your Cargo.toml add these dependencies (it’s easier to get them out of the way now so you don’t have to keep going back later):

[dependencies]
rocket = "0.4.5"
rocket_contrib = "0.4.5"
sled = "0.34.4"
sled-extensions = { version = "0.2.0", features = ["bincode"]}
serde = { version = "1.0.117", features = ["derive"] }
serde_json = "1.0.59"
thiserror = "1.0.21"

Empty routes

See the diff for this section

Let’s get some rocket boilerplate out of the way, we’ll create GET, DELETE, and PUT routes for a User resource:

#![feature(decl_macro)]

#[macro_use]
extern crate rocket;

use rocket_contrib::json::Json;
use serde::{Deserialize, Serialize};
use std::error::Error;

#[derive(Deserialize, Serialize, Clone)]
struct User {
    username: String,
    favorite_food: String,
}

type EndpointResult<T> = Result<T, &'static str>;

#[get("/users/<username>")]
fn get_person(username: String) -> EndpointResult<Json<User>> {
    todo!()
}

#[delete("/users/<username>")]
fn delete_person(username: String) -> EndpointResult<Json<User>> {
    todo!()
}

#[put("/users", data = "<user>")]
fn put_person(user: Json<User>) -> EndpointResult<Json<User>> {
    todo!()
}

fn main() -> Result<(), Box<dyn Error>> {
    rocket::ignite()
        .mount("/api/", routes![get_person, put_person, delete_person])
        .launch();
    Ok(())
}

There’s not much of note here if you’ve used Rocket before, and since this tutorial is intended to be about using Sled in Rocket instead of using Rocket in general, I’ll skip the details here.

Getting Rocket to manage Sled

See the diff for this section

First, in our main function, we’ll open a database connection with Sled:

let db = sled_extensions::Config::default()
    .path("./sled_data")
    .open()
    .expect("Failed to open sled db");

We’re going to create a struct with sled trees inside of it. In a full application you’d usually have a Tree for each type you store. It makes it easier to think about keys, no need to make sure they’re unique across different models.

struct Database {
    users: Tree<User>,
}

The Tree type we’re using here actually comes from sled_extensions::bincode. This makes our lives a bit easier because instead of just being able to store and retrieve Vec<u8> (the default sled behavior), we can store whatever we want (so long as it can be serialized), and the encoding/decoding gets taken care of for us, which is much more ergonomic. We’ll need to add a use for the Tree type:

use sled_extensions::bincode::Tree;

The Rocket builder has a function, manage, that adds a bit of state to any endpoints that request it. Since rocket is multithreaded, managed state needs to implement Send. Fortunately, Sled databases are safe to send across threads, so no problems there, let’s crate and manage our database:

rocket::ignite()
    .manage(Database { // <--
        users: db.open_bincode_tree("users")?, // <--
    }) // <--
    .mount("/api/", routes![get_person, put_person, delete_person])
    .launch();

That open_bincode_tree function comes from the DbExt trait, so let’s use that:

use sled_extensions::DbExt;

Now we can add the managed Database to our endpoints:


#[get("/users/<username>")]
fn get_person(db: State<Database>, username: String) -> EndpointResult<Json<User>> { // <--
    todo!()
}

#[delete("/users/<username>")]
fn delete_person(db: State<Database>, username: String) -> EndpointResult<Json<User>> { // <--
    todo!()
}

#[put("/users", data = "<user>")]
fn put_person(db: State<Database>, user: Json<User>) -> EndpointResult<Json<User>> { //<--
    todo!()
}

The only things added are those db: State<Database> params to each function. We’ll need to use the State type from Rocket:

use rocket::State;

It’s compiling but nothing has changed as far as behavior goes; let’s use our database.

Using our database

See the diff for this section

Let’s implement the PUT handler first:

db.users
    .insert(user.username.as_bytes(), user.clone())
    .unwrap();
Ok(Json(user.0))

Here we’re inserting the user from the PUT request into the users tree, then returning the user, per REST convention. We’re unwrapping for now, but we’ll deal with error handling later, you can now use httpie (or curl, or other software) to verify that the PUT request works:

❯ http PUT localhost:8000/api/users username="marcus" favorite_food="fruit"
HTTP/1.1 200 OK
Content-Length: 45
Content-Type: application/json
Date: Thu, 22 Oct 2020 22:37:16 GMT
Server: Rocket

{
    "favorite_food": "fruit",
    "username": "marcus"
}

Custom error type

See the diff for this section

Unfortunately a large % of this code is going to be error-handling, because the endpoints themselves are so simple. In the interest of doing things the “right way” (or what I think is the right way), let’s create our own error type:

#[derive(thiserror::Error, Debug)]
pub enum ServerError {
    #[error("sled db error")]
    SledError(#[from] sled_extensions::Error),
}

We’ll change our EndpointResult to use this new error type:

type EndpointResult<T> = Result<T, ServerError>;

Now, instead of unwrapping, we can use the ? syntax when using Sled:

db.users.insert(user.username.as_bytes(), user.clone())?;

Deleting a user

See the diff for this section

Let’s implement the DELETE endpoint:

let user = db.users.remove(username.as_bytes())?.unwrap();
Ok(Json(user))

Unfortunately we’re back to unwrapping. If you delete a user that doesn’t exist, the server will 500. It would be nice to be able to use the ? syntax, and have Rocket return a 404. All we have to do for that is implement From<NoneError> for our ServerError, first let’s add the new case:

pub enum ServerError {
    #[error("sled db error")]
    SledError(#[from] sled_extensions::Error),
    #[error("resource not found")] // <--
    NotFound, // <--
}

thiserror can’t implement From<NoneError>, so we’ll add that ourselves:

impl From<NoneError> for ServerError {
    fn from(_: NoneError) -> Self {
        ServerError::NotFound
    }
}

We’ll need to add a new feature to use ? on Option types, and use the NoneError type:

#![feature(try_trait)]
use std::option::NoneError;

This actually compiles just fine, but there’s an issue with how we’ve been doing our error handling, check out what happens when we try to delete a non-existent user:

DELETE /api/users/nonexistent:
    => Matched: DELETE /api/users/<username> (delete_person)
    => Error: Response was a non-`Responder` `Err`: NotFound.
    => Warning: This `Responder` implementation has been deprecated.
    => Warning: In Rocket v0.5, `Result<T, E>` implements `Responder` only if `E` implements `Responder`. For the previous behavior, use `Result<T, Debug<E>>` where `Debug` is `rocket::response::Debug`.
    => Outcome: Failure
    => Warning: Responding with 500 Internal Server Error catcher.
    => Response succeeded.

This is still confusing to me; not that it errors out, but that it happens at runtime. I’m not sure why Rocket couldn’t enforce at compile-time that the error types implement the Responder trait. I’m assuming it has something to do with the catchers that Rocket uses. What does make sense is that Rocket needs to know how to convert our errors into status codes. Here’s what that looks like:

impl<'a> Responder<'a> for ServerError {
    fn respond_to(self, _: &rocket::Request) -> Result<rocket::Response<'a>, Status> {
        match self {
            Self::SledError(_) => Err(Status::InternalServerError),
            Self::NotFound => Err(Status::NotFound),
        }
    }
}

We’ll need to use a couple types from that snippet:

use rocket::{http::Status, response::Responder};

Now when we try to DELETE a user that doesn’t exist, we get the right error response:

DELETE /api/users/nonexistent:
    => Matched: DELETE /api/users/<username> (delete_person)
    => Outcome: Failure
    => Warning: Responding with 404 Not Found catcher.
    => Response succeeded.

Getting a user

See the diff for this section

Since we’ve got all the error boilerplate out of the way, the GET endpoint is trivial:

Ok(Json(db.users.get(username.as_bytes())??))