Fridge Door Part 2: Building a Service in Rocket and Rust

This article is part 2 in a multipart series that makes a message board using a Raspberry Pi and a Tidbyt.

Here are the parts in the series:

  1. Describe what we’re building (Part 1)
  2. Create a ReST service using Rocket (this article).
  3. Create a React application to both display and manage messages (Part 3).
  4. Integrate the ReST service and React application.
  5. Deploy the ReST service and React application to a Raspberry Pi.
  6. Create a Tidbyt application to display messages.

Prerequisites

Create the Service

Rocket has a “hello world” tutorial at https://rocket.rs/v0.5-rc/guide/getting-started/#hello-world. Feel free to read through that documentation, or refer to it as you work through this tutorial.

Open a terminal and type:

$ cargo new fridge-door --bin
$ cd fridge-door

This creates a Rust project that produces a binary. Open Cargo.toml and add the following dependencies:

[package]
name = "fridge-door"
version = "0.1.0"
edition = "2021"

[dependencies.rocket]
version = "0.5.0-rc.2"
features = ["json"]

[dependencies.rocket_cors]
git = "https://github.com/lawliet89/rocket_cors"
branch = "master"

[dependencies.rocket_db_pools]
version = "0.1.0-rc.2"
features = ["sqlx_sqlite"]

[dependencies.sqlx]
version = "0.5.13"
features = ["macros", "offline", "migrate", "chrono"]

[dependencies.chrono]
version = "0.4.23"
features = ["serde"]

The dependencies are:

  • Rocket: the web/ReST API framework
  • Rocket CORS: CORS implementation so we can hit the API from pages served from different servers
  • Rocket DB Pools: database connection pooling
  • SQLx: SQLite interactions
  • Chrono: Date/time data

Note that we’re using the master branch of rocket_cors, and an older version of sqlx. As of this writing, those are the versions that work with Rocket.

Create the Database

Inside the src directory, alongside main.rs, create a file called db.rs to house the database connection pool and database interactions. Inside db.rs, create a database pool:

use rocket_db_pools::{sqlx, Database};

#[derive(Database)]
#[database("fridge-door")]
struct Db(sqlx::SqlitePool);

The Database macro generates a database connection pool, and the string passed to database is the name of the database you configure in Rocket.toml. Create a file called Rocket.toml in the root directory of your project and set up the SQLite database URL. We store the development database in the root directory of your project:

[default.databases.fridge-door]
url = "sqlite://fridge-door.db"

The name you pass to database in code must match the name that follows default.databases in the configuration file.

Create a directory called migrations at the root of your project. This directory holds your database migrations. Because sqlx enforces compile-time rules, you must create this directory before the next part will compile.

Go back to db.rs and create the methods that set up your database and run the migrations:

use rocket::fairing::{self, AdHoc};
use rocket::{error, Build, Rocket};

async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
    match Db::fetch(&rocket) {
        Some(db) => match sqlx::migrate!("./migrations").run(&**db).await {
            Ok(_) => Ok(rocket),
            Err(e) => {
                error!("Database migrations failed: {}", e);
                Err(rocket)
            }
        },
        None => Err(rocket),
    }
}

pub fn stage() -> AdHoc {
    AdHoc::on_ignite("SQLx Stage", |rocket| async {
        rocket
            .attach(Db::init())
            .attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
    })
}

You call the stage method directly when Rocket launches. It initializes the database and calls your run_migrations method. The run_migrations method runs all the database migrations from the migrations directory. Note that, when you compile the code, the migration files are compiled into the binary.

Open main.rs and launch Rocket. Notice that you don’t create an explicit main method. The launch macro creates a main method for you. You build a rocket and attach your database by calling its stage method:

#[macro_use]
extern crate rocket;

mod db;

#[launch]
fn rocket() -> _ {
    rocket::build().attach(db::stage())
}

Your project should now build and run. In your terminal, run the project by typing cargo run. You should see output like this:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/fridge-door`
🔧 Configured for debug.
   >> address: 127.0.0.1
   >> port: 8000
   >> workers: 24
   >> ident: Rocket
   >> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
   >> temp dir: /tmp
   >> http/2: true
   >> keep-alive: 5s
   >> tls: disabled
   >> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
   >> log level: normal
   >> cli colors: true
📡 Fairings:
   >> SQLx Stage (ignite)
   >> Shield (liftoff, response, singleton)
   >> SQLx Migrations (ignite)
   >> 'fridge-door' Database Pool (ignite)
🛡️ Shield:
   >> X-Content-Type-Options: nosniff
   >> X-Frame-Options: SAMEORIGIN
   >> Permissions-Policy: interest-cohort=()
🚀 Rocket has launched from http://127.0.0.1:8000

You can hit your service using something like curl or HTTPIe:

$ http :8000                     
HTTP/1.1 404 Not Found
content-length: 383
content-type: text/html; charset=utf-8
date: Fri, 27 Jan 2023 00:57:52 GMT
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>404 Not Found</title>
</head>
<body align="center">
    <div role="main" align="center">
        <h1>404: Not Found</h1>
        <p>The requested resource could not be found.</p>
        <hr />
    </div>
    <div role="contentinfo" align="center">
        <small>Rocket</small>
    </div>
</body>
</html>

We haven’t given your service anything to return yet, so Rocket responds with its default 404 page. But we hit Rocket with an HTTP request, and it returned a response. Success!

Migrate the Database

We want a database table called messages to store the messages we post to the fridge door. From the command line, run:

$ sqlx migrate add create-messages-table

If you get a “command not found” error, you missed the sqlx-cli prerequisite at the top of this article. Scroll up, click the link, and follow the instructions to install.

After successfully running the command, you should find a file in your migrations directory named with date/time leader plus _create-messages-table.sql. Open that file and add the following:

create table messages
(
    id          integer primary key autoincrement,
    text        text not null,
    created_at  datetime default current_timestamp not null,
    expires_at  datetime default (datetime('now', '+3 days'))
)

I hope my lower case SQL doesn’t offend you.

This migration creates a table called messages with:

  • A primary key, auto-incrementing, called id.
  • A text field called text to hold the message text.
  • A date/time called created_at that tracks when this record was created. It defaults to the current date and time.
  • A date/time called expires_at that sets when this message expires. It defaults to 3 days from the moment of creation.

Go back to db.rs and add a data structure to map to your messages table:

use rocket::serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(crate = "rocket::serde")]
struct Message {
    #[serde(skip_deserializing)]
    id: i64,
    text: String,
    #[serde(skip_deserializing)]
    created_at: NaiveDateTime,
    expires_at: Option<NaiveDateTime>,
}

We skip deserialization of id and created_at, as those fields will be created by the database when we insert the record. We’re not accepted values from the user for those fields.

Now create a method that creates a message, and another that returns it by id. The creation message has some complexity; after creation, we retrieve the message using the last_insert_rowid returned from creation, so we can return the message with its id and timestamps to the user. We also run different insert statements depending on whether the JSON payload contains a value for expires_at.

use rocket_db_pools::{sqlx, Connection, Database};
use rocket::response::status::Created;
use rocket::serde::json::Json;

type Result<T, E = rocket::response::Debug<sqlx::Error>> = std::result::Result<T, E>;

#[post("/", data = "<message>")]
async fn create(mut db: Connection<Db>, message: Json<Message>) -> Result<Created<Json<Message>>> {
    let result = (match message.expires_at {
        Some(_) => sqlx::query!(
            "insert into messages (text, expires_at) values (?, ?)",
            message.text,
            message.expires_at
        ),
        None => sqlx::query!("insert into messages (text) values (?)", message.text),
    })
    .execute(&mut *db)
    .await?;

    // Would be really odd if reading the message we just created failed.
    // If we got here, though, it got created, so still return 201 and the message
    // as sent.
    Ok(
        Created::new("/").body(match read(db, result.last_insert_rowid()).await {
            Ok(created) => created.unwrap_or(message),
            Err(_) => message,
        }),
    )
}

#[get("/<id>")]
async fn read(mut db: Connection<Db>, id: i64) -> Result<Option<Json<Message>>> {
    let message = sqlx::query_as!(Message, "select * from messages where id = ?", id)
        .fetch_one(&mut *db)
        .await?;

    Ok(Some(Json(message)))
}

Mount your endpoints to /messages in your stage method:

pub fn stage() -> AdHoc {
    AdHoc::on_ignite("SQLx Stage", |rocket| async {
        rocket
            .attach(Db::init())
            .attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
            .mount("/messages", routes![create, read])
    })
}

Whereas Rocket uses Rocket.toml at runtime to configure the database, sqlx uses an environment variable called DATABASE_URL to enforce compile-time checks. You can create that environment variable however works for you. One option, though, is to use Rocket’s dotenv support. Create a file called .env at the root of your project with these contents:

DATABASE_URL=sqlite://fridge-door.db

Back in the terminal, run your service:

cargo run
   Compiling fridge-door v0.1.0 (/home/rwarner/Development/fdoor)
    Finished dev [unoptimized + debuginfo] target(s) in 3.67s
     Running `target/debug/fridge-door`
🔧 Configured for debug.
   >> address: 127.0.0.1
   >> port: 8000
   >> workers: 24
   >> ident: Rocket
   >> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
   >> temp dir: /tmp
   >> http/2: true
   >> keep-alive: 5s
   >> tls: disabled
   >> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
   >> log level: normal
   >> cli colors: true
📬 Routes:
   >> (create) POST /messages/
   >> (read) GET /messages/<id>
📡 Fairings:
   >> Shield (liftoff, response, singleton)
   >> SQLx Migrations (ignite)
   >> SQLx Stage (ignite)
   >> 'fridge-door' Database Pool (ignite)
🛡️ Shield:
   >> Permissions-Policy: interest-cohort=()
   >> X-Content-Type-Options: nosniff
   >> X-Frame-Options: SAMEORIGIN
🚀 Rocket has launched from http://127.0.0.1:8000

Notice a new section in the outputs: Routes. That section shows you can POST to /messages/ and GET from /messages/<id>. Let’s try it!

Calling the Endpoints

Using curl, HTTPIe, Postman, or whatever you fancy, POST a message to your endpoint:

$ echo '{"text": "Hello from my Fridge Door"}' | http POST :8000/messages/
HTTP/1.1 201 Created
content-length: 113
content-type: application/json
date: Fri, 27 Jan 2023 02:06:39 GMT
location: /
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

{
    "created_at": "2023-01-27T02:06:39",
    "expires_at": "2023-01-30T02:06:39",
    "id": 1,
    "text": "Hello from my Fridge Door"
}

Success! The message was created, the id and timestamps were generated, and we have a message. To confirm, let’s retrieve it:

$ http :8000/messages/1
HTTP/1.1 200 OK
content-length: 113
content-type: application/json
date: Fri, 27 Jan 2023 02:08:40 GMT
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

{
    "created_at": "2023-01-27T02:06:39",
    "expires_at": "2023-01-30T02:06:39",
    "id": 1,
    "text": "Hello from my Fridge Door"
}

It worked!

Listing Messages

We don’t want to have to know the specific message id to get any messages from Fridge Door. We create a method that lists non-expired messages. We accept two parameters: the number of messages we want to receive (count) and the id of the message before the message we want to retrieve (since_id). The method looks like this:

#[get("/?<count>&<since_id>")]
async fn list(
    mut db: Connection<Db>,
    count: Option<u32>,
    since_id: Option<u32>,
) -> Result<Json<Vec<Message>>> {
    let count = count.unwrap_or(10);
    let since_id = since_id.unwrap_or(0);

    let messages = sqlx::query_as!(
        Message,
        "select * from messages where id > ? and (expires_at is null or date('now') < expires_at) limit ?",
        since_id,
        count
    )
    .fetch_all(&mut *db)
    .await?;

    Ok(Json(messages))
}

Add the list method to the mounted routes in stage:

pub fn stage() -> AdHoc {
    AdHoc::on_ignite("SQLx Stage", |rocket| async {
        rocket
            .attach(Db::init())
            .attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
            .mount("/messages", routes![create, read, list])
    })
}

Start your server with cargo run.

Listing Messages

Create three more messages:

$ for msg in "Rust is cool" "Rocket is amazing" "SQLx is great"; do
echo "{\"text\": \"$msg\"}" | http POST :8000/messages/
done
HTTP/1.1 201 Created
content-length: 100
content-type: application/json
date: Fri, 27 Jan 2023 02:24:11 GMT
location: /
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

{
    "created_at": "2023-01-27T02:24:11",
    "expires_at": "2023-01-30T02:24:11",
    "id": 2,
    "text": "Rust is cool"
}


HTTP/1.1 201 Created
content-length: 105
content-type: application/json
date: Fri, 27 Jan 2023 02:24:11 GMT
location: /
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

{
    "created_at": "2023-01-27T02:24:11",
    "expires_at": "2023-01-30T02:24:11",
    "id": 3,
    "text": "Rocket is amazing"
}


HTTP/1.1 201 Created
content-length: 101
content-type: application/json
date: Fri, 27 Jan 2023 02:24:11 GMT
location: /
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

{
    "created_at": "2023-01-27T02:24:12",
    "expires_at": "2023-01-30T02:24:12",
    "id": 4,
    "text": "SQLx is great"
}

List them all:

$ http :8000/messages/ 
HTTP/1.1 200 OK
content-length: 424
content-type: application/json
date: Fri, 27 Jan 2023 02:25:26 GMT
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

[
    {
        "created_at": "2023-01-27T02:23:58",
        "expires_at": "2023-01-30T02:23:58",
        "id": 1,
        "text": "Hello from my Fridge Door"
    },
    {
        "created_at": "2023-01-27T02:24:11",
        "expires_at": "2023-01-30T02:24:11",
        "id": 2,
        "text": "Rust is cool"
    },
    {
        "created_at": "2023-01-27T02:24:11",
        "expires_at": "2023-01-30T02:24:11",
        "id": 3,
        "text": "Rocket is amazing"
    },
    {
        "created_at": "2023-01-27T02:24:12",
        "expires_at": "2023-01-30T02:24:12",
        "id": 4,
        "text": "SQLx is great"
    }
]

Now, get two messages, starting after message 1:

$ http :8000/messages/\?count=2\&since_id=1
HTTP/1.1 200 OK
content-length: 208
content-type: application/json
date: Fri, 27 Jan 2023 02:26:57 GMT
permissions-policy: interest-cohort=()
server: Rocket
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN

[
    {
        "created_at": "2023-01-27T02:24:11",
        "expires_at": "2023-01-30T02:24:11",
        "id": 2,
        "text": "Rust is cool"
    },
    {
        "created_at": "2023-01-27T02:24:11",
        "expires_at": "2023-01-30T02:24:11",
        "id": 3,
        "text": "Rocket is amazing"
    }
]

We have a useful service!

Next Steps

In the next article, we build a simple React application that allows users to create messages. It also displays a rotating billboard of the non-expired messages that have been created.

2 Responses

  1. January 27, 2023

    […] Create a ReST service using Rocket (Part 2) […]

  2. February 4, 2023

    […] Create a ReST service using Rocket (Part 2). […]

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.