Rocket and Rust Part IV (and a kegerator)

Here we are again, folks! Last time I started building my actual project: Ethel. Ethel will track information about my alcohol collection, hopefully so I can find things easier. I setup my initial implementation of my data structures (subject to change) and have a "random bottle" route which currently returns dummy data. My goal for this post is to implement my other required routes (still using dummy data, for now).

Create

So of course we need a way to create bottles. At the end of my last post, my Bottle struct looked like so:

#[derive(Serialize, Deserialize)]
struct Bottle {
    id: u16,
    name: String,
    category: Category,
    sub_category: Vec<SubCategory>,
    location: Location,
}
Bottle struct code

For our create endpoint to work, we will need to provide all of the above data to the endpoint. Since we are not actually storing any data yet, I plan on just returning the provided information back as the success response. So let's get cracking.

Because we are using serde in our project for serialization of our data structures setting a up a create endpoint to accept json is trivial. I just have to use the data = in my macro for the route!

#[post("/", data = "<bottle>")]
fn create_bottle(bottle: Json<Bottle>) -> Json<Bottle> {
    bottle
}
Create bottle endpoint

Oh, and of course don't forget to mount the route to our "bottle" routes in the rocket launch:

#[launch]
fn rocket() -> _ {
    let rocket= rocket::build();
    
    rocket
      .mount("/", routes![index])
      .mount("/bottles", routes![
        get_random_bottle,
        create_bottle,
        ])
}
Updated rocket route mountings

Time to run our app and test our new route. As an aside, I decided to try a new API REST client. In the past I have used Postman, but in consultation with my rubber ducky, I was recommended to try Insomnia. While I certainly could have use curl from the command line, I really like having collection management for an API project like Ethel.

If we send an empty post to our endpoint POST ~/bottles we will get a 400. In the console we see Data guard Json < Bottle > failed: Parse("", Error("EOF while parsing a value", line: 1, column: 0)).

Bad Request (courtesy of httpstatusdogs)

Makes sense, our application is expecting JSON and we did not add a catcher to our rocket application (meaning it falls back to the Rocket default). If we go ahead and send an empty JSON body, we get a different more interesting error: a 422 Unprocessable Entity. In console we see a slightly different error this time, but still a Data guard. Data guard Json < Bottle > failed: Parse("{}", Error("missing field id", line: 1, column: 2)). In this case we did have a JSON as expected, but we are missing required data to match the data structure we created earlier for our Bottle. Well, let's just copy our JSON output from our random bottle endpoint into the body of our request and try again.

Tada! We get out what we put in for now, as expected. Our basic create is done, let's move on to a list endpoint to get all of the bottles!

List (Get All)

While I will probably want something "smarter" at some point for list (such as the ability to take in parameters for filtering or pagination for large requests) we are going to start simple and get the endpoint working for now. Our list endpoint will be just like our random endpoint, but return multiple bottles! The list endpoints will also be the default route for GET requests to /bottles.

#[get("/")]
fn get_bottles() -> Json<Vec<Bottle>>  {
    Json(vec![
        Bottle {
            id: 1,
            name: "Faretti Biscotti Famosi".to_string(),
            category: Category::Liqueurs,
            sub_category: vec![SubCategory::Sweet],
            location: Location {
                room: Room::LivingRoom,
                storage: Storage::LeftIkea,
                shelf: Shelf::Shelf5
            }
        },
        Bottle {
            id: 2,
            name: "Hibiki 12".to_string(),
            category: Category::Whiskey,
            sub_category: vec![
                SubCategory::Japanese,
                SubCategory::Blend,
                ],
            location: Location {
                room: Room::DiningRoom,
                storage: Storage::Buffet,
                shelf: Shelf::CenterBottom
            }
        },
    ])
}
Get Bottles endpoint with dummy data

Since we already know how to mount the route from create and random, I will not add the code here (just remember we did it). Now let's hit our new endpoint:

GET request to /bottles

Great! I now have a working GET endpoint to list our bottles which returns dummy data. What's next?

Delete

Okay, since we are not doing "real" data yet, delete is kinda boring. Rocket supports the DELETE http method, so it's simply a matter of using the macro and mounting the route:

#[delete("/<id>")]
fn delete_bottle(id: u16) -> status::NoContent { 
    status::NoContent
}
DELETE bottle endpoint
DELETE request to bottle endpoint

Something to note: I chose to use a 204 No Content for my DELETE endpoint. While this is my personal preference, it is perfectly valid to do a 200 (or 202 if not immediately deleting) and provide the id or object back in the response body. Do note, however, I receive a compiler warning from Rust because I am not actually using the id parameter I declared yet. Once we are actually using real data, though, the warning will go away.

204: No Content courtesy of httpstatusdogs

And thus our delete endpoint has been implemented!

Edit/Update

Originally I had planned on only implementing create, list, random, and delete. Then I actually started thinking about using my new project and... I need edit/update. I need to be able to move bottles around and do not want to resort to deleting and re-creating them. So! Let's quickly add an update endpoint using the http PUT method:

#[put("/<id>", data = "<bottle>")]
fn update_bottle(id: u16, bottle: Json<Bottle>) -> Json<Bottle> {
    bottle
}
PUT endpoint for bottles

The code above should look very familiar. It's our create endpoint with a different verb (PUT instead of POST) and it takes an id parameter from our request url (id). Again, like our delete endpoint, I am getting a compiler warning about id not being using (I love you Rust).

Note: I could have used PATCH instead of PUT if I wanted to, which would allow me to only send the updated fields rather than the entire entity. For this project, knowing I planned on implementing a UI at some point, I decided sending the entire entity was easier.

PUT update bottle response

Now we have the basic outline of all the endpoints needed for the project. All of the code was minimal, due to using the macros provided by Rocket and using the serde date structures. Here is our entire main.rs so far:

#![feature(decl_macro)]

#[macro_use] extern crate rocket;
use rocket::{serde::{Serialize, Deserialize, json::Json}, response::status};

#[derive(Serialize, Deserialize)]
enum Room {
    DiningRoom,
    Entry,
    LivingRoom,
    Kitchen,
    Garage,
}

#[derive(Serialize, Deserialize)]
enum Storage {
    BeerFridge,
    Buffet,
    BuiltIn,
    Cabinet,
    Counter,
    Fridge,
    IkeaShelf,
    LeftIkea,
    RightIkea,
    WineFridge,
}

#[derive(Serialize, Deserialize)]
enum Shelf {
    LeftTop,
    LeftBottom,
    CenterTop,
    CenterBottom,
    RightTop,
    RightBottom,
    Top,
    LeftMiddle,
    RightMiddle,
    RightCenter,
    Shelf1,
    Shelf2,
    Shelf3,
    Shelf4,
    Shelf5,
    CenterMiddle,
    BarArea,
    Fridge,
    Shelf,
    LeftKeg,
    RightKeg,
    Small,
    Large,
}

#[derive(Serialize, Deserialize)]
enum Category {
    Whiskey,
    Brandy,
    Vodka,
    Liqueurs,
    Gin,
    Rum,
    Agave,
    Other,
    Beer,
    Wine,
    Sochu,
}

#[derive(Serialize, Deserialize)]
enum SubCategory {
    American,
    SingleMalt,
    Scotch,
    Blend,
    Bitter,
    Sweet,
    Fruit,
    Dairy,
    Cognac,
    Armagnac,
    Calvados,
    CaskStrength,
    NavyStrength,
    Rye,
    Bourbon,
    Aged,
    Silver,
    Dark,
    Flavored,
    Gold,
    Spiced,
    Mezcal,
    Tequila,
    Other,
    Peated,
    Palinka,
    Añejo,
    Reposado,
    Blanco,
    Blackstrap,
    Irish,
    Poitín,
    Japanese,
    NewZealand,
    Australia,
    Wheat,
    Korean,
}

#[derive(Serialize, Deserialize)]
struct Location {
    room: Room,
    storage: Storage,
    shelf: Shelf,
}

#[derive(Serialize, Deserialize)]
struct Bottle {
    id: u16,
    name: String,
    category: Category,
    sub_category: Vec<SubCategory>,
    location: Location,
}

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

#[get("/random")]
fn get_random_bottle() -> Json<Bottle>  {
    Json(
        Bottle {
            id: 1,
            name: "Faretti Biscotti Famosi".to_string(),
            category: Category::Liqueurs,
            sub_category: vec![SubCategory::Sweet],
            location: Location {
                room: Room::LivingRoom,
                storage: Storage::LeftIkea,
                shelf: Shelf::Shelf5
            }
        }
    )
}

#[post("/", data = "<bottle>")]
fn create_bottle(bottle: Json<Bottle>) -> Json<Bottle> {
    bottle
}

#[get("/")]
fn get_bottles() -> Json<Vec<Bottle>>  {
    Json(vec![
        Bottle {
            id: 1,
            name: "Faretti Biscotti Famosi".to_string(),
            category: Category::Liqueurs,
            sub_category: vec![SubCategory::Sweet],
            location: Location {
                room: Room::LivingRoom,
                storage: Storage::LeftIkea,
                shelf: Shelf::Shelf5
            }
        },
        Bottle {
            id: 2,
            name: "Hibiki 12".to_string(),
            category: Category::Whiskey,
            sub_category: vec![
                SubCategory::Japanese,
                SubCategory::Blend,
                ],
            location: Location {
                room: Room::DiningRoom,
                storage: Storage::Buffet,
                shelf: Shelf::CenterBottom
            }
        },
    ])
}

#[delete("/<id>")]
fn delete_bottle(id: u16) -> status::NoContent { 
    status::NoContent
}

#[put("/<id>", data = "<bottle>")]
fn update_bottle(id: u16, bottle: Json<Bottle>) -> Json<Bottle> {
    bottle
}

#[launch]
fn rocket() -> _ {
    let rocket= rocket::build();
    
    rocket
      .mount("/", routes![index])
      .mount("/bottles", routes![
        get_random_bottle,
        create_bottle,
        get_bottles,
        delete_bottle,
        update_bottle
        ])
}
main.rs for Ethel so far

Only ~200 lines of code! Wild. Next time, in Part V, I will actually get a database running and attached to our project so we can have some real fun!

Crisply Tart Cider on Tap

I don't belive I have mentioned my kegerator here before... When a friend of mine moved away from the PNW off to the land of kiwis, he really wanted to find a home for his magnificent kegerator. It started life as a fridge, but had been decorated by local graffiti artists as a birthday gift. Naturally, I took it off his hands (and immediately installed a second tap, one for beer, one for cider or sours).

Specifically I bring up the kegerator because over the recent Fourth of July holiday, my folks were in town. During their visit, I took them to The Woods which is the tasting room for Two Beers and Seattle Cider Co. We did a tasting and I let them pick the keg to take home for the BBQ we were having. They settled on Seattle Cider's Honeycrisp, so we packed up a sixlet and took it home to install in the kegerator. We also happened to have a Seattle Cider tap handle from a previous visit, so the kegerator was on brand!

Seattle Cider Tap handle on kegerator

Now I sit here, weeks later, continuing to enjoy the delectable crispness of this cider straight from the tap at home. Yes, I am spoiled, why do you ask? The cider comes in at 5.5% ABV and I find it to be a supremely balanced cider: not to sweet (blek) and nicely tart which makes it very refreshing in the crazy heat we have been experiencing in the PNW the last few weeks! On that note, I just put together two adirondacks myself and plan on enjoying the rest of my cider outside (but definitely in the shade). Cheers!