Simple TODO list with Rocket and SurrealDB


Setup server:


docker volume create my-surreal-db
docker run --detach --restart always --name surrealdb -p 127.0.0.1:8000:8000 --user root -v my-surreal-db:/database surrealdb/surrealdb:v2.0.1 start --user root --pass root --log trace file://database

Dependencies


examples/rocket/simple-todo-with-surrealdb/Cargo.toml
[package]
name = "todo"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = { version = "0.5", features = ["secrets", "uuid"] }
rocket_dyn_templates = { version = "0.1", features = ["tera"] }
surrealdb = "2.0"
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
chrono = "0.4.38"
serde = { version = "1.0", features = ["derive"] }

examples/rocket/simple-todo-with-surrealdb/Rocket.toml
[debug]
port=8001

examples/rocket/simple-todo-with-surrealdb/src/main.rs
#[macro_use]
extern crate rocket;

use rocket::form::Form;
use rocket::State;
use rocket_dyn_templates::{context, Template};

use surrealdb::engine::remote::ws::Client;
use surrealdb::Surreal;

pub mod db;
use crate::db::Item;

#[derive(FromForm)]
struct AddForm<'r> {
    title: &'r str,
}

#[derive(FromForm)]
struct UpdateForm<'r> {
    id: &'r str,
    title: &'r str,
    text: &'r str,
}

async fn form_and_list(dbh: &State<Surreal<Client>>) -> Template {
    let items: Vec<Item> = db::get_items(dbh).await.unwrap();
    for item in &items {
        rocket::info!("{} {}", item.id.id, item.title);
    }

    let pairs = items
        .iter()
        .map(|item| {
            let id = item.id.id.to_string();
            (id, item)
        })
        .collect::<Vec<_>>();

    Template::render(
        "index",
        context! {
            title: "TODO",
            items: pairs,
        },
    )
}

#[get("/")]
async fn get_index(dbh: &State<Surreal<Client>>) -> Template {
    form_and_list(dbh).await
}

#[get("/clear")]
async fn clear_db(dbh: &State<Surreal<Client>>) -> Template {
    db::clear(dbh).await.unwrap();
    form_and_list(dbh).await
}

#[post("/", data = "<input>")]
async fn post_index(dbh: &State<Surreal<Client>>, input: Form<AddForm<'_>>) -> Template {
    rocket::info!("Add '{}'", input.title);
    let title = input.title.trim();
    db::add_item(dbh, title).await.unwrap();
    form_and_list(dbh).await
}

#[get("/item/<id>")]
async fn get_item(dbh: &State<Surreal<Client>>, id: String) -> Option<Template> {
    if let Some(item) = db::get_item(dbh, &id).await.unwrap() {
        return Some(Template::render(
            "item",
            context! {
                title: item.title.clone(),
                id: item.id.clone().id.to_string(),
                item: item,
            },
        ));
    }

    None
}

#[post("/update", data = "<input>")]
async fn update_item(dbh: &State<Surreal<Client>>, input: Form<UpdateForm<'_>>) -> Template {
    rocket::info!("Update '{}'", input.id);
    let id = input.id.trim();
    let title = input.title.trim();
    let text = input.text.trim();
    db::update_item(dbh, id, title, text).await.unwrap();

    form_and_list(dbh).await
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount(
            "/",
            routes![clear_db, get_index, post_index, get_item, update_item],
        )
        .attach(Template::fairing())
        .attach(db::fairing())
}

examples/rocket/simple-todo-with-surrealdb/src/db.rs
use chrono::{DateTime, Utc};
use rocket::fairing::AdHoc;
use serde::{Deserialize, Serialize};
use surrealdb::engine::remote::ws::Client;
use surrealdb::engine::remote::ws::Ws;
use surrealdb::opt::auth::Root;
use surrealdb::opt::Resource;
use surrealdb::sql::{Id, Thing};
use surrealdb::Surreal;

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Item {
    pub id: Thing,
    pub title: String,
    pub text: Option<String>,
    pub date: DateTime<Utc>,
}

/// # Panics
///
/// Panics when it fails to connect to the database
#[must_use]
pub fn fairing() -> AdHoc {
    AdHoc::on_ignite("Managed Database Connection", |rocket| async {
        let address = "127.0.0.1:8000";
        let username = "root";
        let password = "root";
        let db_namespace = "todo";
        let db_database = "todo";

        let dbh = Surreal::new::<Ws>(address).await.unwrap();

        dbh.signin(Root { username, password }).await.unwrap();

        dbh.use_ns(db_namespace).use_db(db_database).await.unwrap();

        rocket.manage(dbh)
    })
}

pub async fn clear(dbh: &Surreal<Client>) -> surrealdb::Result<()> {
    dbh.query("DELETE FROM items;").await?;
    Ok(())
}

pub async fn add_item(dbh: &Surreal<Client>, title: &str) -> surrealdb::Result<()> {
    let utc: DateTime<Utc> = Utc::now();

    let id = Id::ulid();

    let entry = Item {
        id: Thing::from(("items", id)),
        title: title.to_owned(),
        text: None,
        date: utc,
    };

    dbh.create(Resource::from("items")).content(entry).await?;

    Ok(())
}

pub async fn update_item(
    dbh: &Surreal<Client>,
    id: &str,
    title: &str,
    text: &str,
) -> surrealdb::Result<()> {
    rocket::info!("Update '{}' '{}'", id, title);

    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
    struct UpdateItem {
        title: String,
        text: String,
    }

    let _item: Option<Item> = dbh
        .update(("items", id))
        .merge(UpdateItem {
            title: title.to_owned(),
            text: text.to_owned(),
        })
        .await?;

    Ok(())
}

pub async fn get_items(dbh: &Surreal<Client>) -> surrealdb::Result<Vec<Item>> {
    let mut response = dbh.query("SELECT * FROM items;").await?;
    let entries: Vec<Item> = response.take(0)?;
    Ok(entries)
}

pub async fn get_item(dbh: &Surreal<Client>, id: &str) -> surrealdb::Result<Option<Item>> {
    dbh.select(("items", id)).await
}

examples/rocket/simple-todo-with-surrealdb/templates/index.html.tera
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{title}}</title>
  </head>
  <body>
      <h1><a href="/">{{title}}</a></h1>
       <form method="POST" action="/">
           <input name="title"><br>
           <input type="submit" value="Add"><br>
       </form>
       <ul>
       {% for item in items %}
          <li><a href="/item/{{item.0}}">{{item.1.title}}</a></li>
       {% endfor %}
       </ul>

      <hr>
      <a href="/clear">remove all the items</a>
  </body>
</html>

examples/rocket/simple-todo-with-surrealdb/templates/item.html.tera
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{title}}</title>
  </head>
  <body>
      <h1><a href="/">home</a></h1>
      <h2>Title: {{item.title}}</h2>
      <div>Date: {{item.date}}</div>

      <form method="POST" action="/update">
        <input type="hidden" name="id" value="{{id}}">
        <input name="title" value="{{item.title}}"><br>
        <textarea name="text">{{item.text}}</textarea><br>
        <input type="submit" value="Update"><br>
      </form>

  </body>
</html>