6 minutes
CRUD with Rocket and Sled
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
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
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
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
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
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
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())??))
If you want to support me, you can buy me a coffee.
If you play games, my PSN is mbuffett, always looking for fun people to play with.
If you're into chess, I've made a repertoire builder. It uses statistics from hundreds of millions of games at your level to find the gaps in your repertoire, and uses spaced repetition to quiz you on them.
Samar Haroon, my girlfriend, has started a podcast where she talks about the South Asian community, from the perspective of a psychotherapist. Go check it out!.