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:
- Describe what we’re building (Part 1)
- Create a ReST service using Rocket (this article).
- Create a React application to both display and manage messages (Part 3).
- Integrate the ReST service and React application.
- Deploy the ReST service and React application to a Raspberry Pi.
- 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
[…] Create a ReST service using Rocket (Part 2) […]
[…] Create a ReST service using Rocket (Part 2). […]