Rocket People and groups



examples/rocket/people-and-groups/src/db.rs
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;

const NAMESPACE: &str = "rust-slides";
const PERSON: &str = "person";
const GROUP: &str = "group";
const MEMBERSHIP: &str = "membership";

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Person {
    pub id: Thing,
    pub name: String,
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Group {
    pub id: Thing,
    pub name: String,
    pub owner: Thing,
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct Membership {
    pub id: Thing,
    pub person: Thing,
    pub group: Thing,
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MembershipWithPersonAndGroup {
    pub id: Thing,
    pub person: Person,
    pub group: Group,
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct GroupWithOwner {
    pub id: Thing,
    pub name: String,
    pub owner: Person,
}

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

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

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

        dbh.use_ns(NAMESPACE).use_db(database).await.unwrap();

        let query = format!(
            "DEFINE INDEX membership_ids ON TABLE {MEMBERSHIP} COLUMNS {PERSON}, {GROUP} UNIQUE"
        );
        dbh.query(query).await.unwrap();

        rocket.manage(dbh)
    })
}

pub async fn clear(dbh: &Surreal<Client>, database: String) -> surrealdb::Result<()> {
    rocket::info!("Clearing database");
    let result = dbh
        .query(format!("REMOVE DATABASE IF EXISTS `{database}`;"))
        .await?;
    result.check()?;

    Ok(())
}

pub async fn add_person(dbh: &Surreal<Client>, name: &str) -> surrealdb::Result<Person> {
    let entry = Person {
        id: Thing::from((PERSON, Id::ulid())),
        name: name.to_owned(),
    };

    dbh.create(Resource::from(PERSON))
        .content(entry.clone())
        .await?;

    Ok(entry)
}

pub async fn add_group(dbh: &Surreal<Client>, name: &str, uid: &str) -> surrealdb::Result<Group> {
    let entry = Group {
        id: Thing::from((GROUP, Id::ulid())),
        name: name.to_owned(),
        owner: Thing::from((PERSON, Id::from(uid))),
    };

    dbh.create(Resource::from(GROUP))
        .content(entry.clone())
        .await?;

    Ok(entry)
}

pub async fn add_member(dbh: &Surreal<Client>, uid: &str, gid: &str) -> surrealdb::Result<()> {
    let entry = Membership {
        id: Thing::from((MEMBERSHIP, Id::ulid())),
        person: Thing::from((PERSON, Id::from(uid))),
        group: Thing::from((GROUP, Id::from(gid))),
    };

    dbh.create(Resource::from(MEMBERSHIP))
        .content(entry.clone())
        .await?;

    Ok(())
}

pub async fn get_memberships(
    dbh: &Surreal<Client>,
) -> surrealdb::Result<Vec<MembershipWithPersonAndGroup>> {
    let mut response = dbh
        .query("SELECT * FROM type::table($table) FETCH person, group")
        .bind(("table", MEMBERSHIP))
        .await?;

    let entries: Vec<MembershipWithPersonAndGroup> = response.take(0)?;
    rocket::info!("Response: {:?}", entries);
    Ok(entries)
}

pub async fn get_memberships_of_person(
    dbh: &Surreal<Client>,
    uid: &str,
) -> surrealdb::Result<Vec<MembershipWithPersonAndGroup>> {
    let mut response = dbh
        .query("SELECT * FROM type::table($table) WHERE person=type::thing($person_table, $uid) FETCH person, group")
        .bind(("table", MEMBERSHIP))
        .bind(("person_table", PERSON))
        .bind(("uid", uid.to_owned()))
        .await?;

    let entries: Vec<MembershipWithPersonAndGroup> = response.take(0)?;
    rocket::info!("Response: {:?}", entries);
    Ok(entries)
}

pub async fn delete_membership(dbh: &Surreal<Client>, id: &str) -> surrealdb::Result<()> {
    let res = dbh
        .query("DELETE type::table($table) WHERE id=type::thing($table, $id)")
        .bind(("table", MEMBERSHIP))
        .bind(("id", id.to_owned()))
        .await?;
    res.check()?;

    Ok(())
}

pub async fn get_people(dbh: &Surreal<Client>) -> surrealdb::Result<Vec<Person>> {
    let mut response = dbh
        .query("SELECT * FROM type::table($table);")
        .bind(("table", PERSON))
        .await?;
    let entries: Vec<Person> = response.take(0)?;
    Ok(entries)
}

pub async fn get_person(dbh: &Surreal<Client>, id: &str) -> surrealdb::Result<Option<Person>> {
    dbh.select((PERSON, id)).await
}

pub async fn get_person_with_groups(
    dbh: &Surreal<Client>,
    id: &str,
) -> surrealdb::Result<Option<(Person, Vec<Group>, Vec<MembershipWithPersonAndGroup>)>> {
    let person = get_person(dbh, id).await?;
    match person {
        None => Ok(None),
        Some(person) => {
            let groups = get_groups_by_owner(dbh, id).await?;
            let memberships = get_memberships_of_person(dbh, id).await?;
            Ok(Some((person, groups, memberships)))
        }
    }
}

pub async fn get_groups_by_owner(
    dbh: &Surreal<Client>,
    uid: &str,
) -> surrealdb::Result<Vec<Group>> {
    rocket::info!("Getting groups for {}", uid);
    let mut response = dbh
        .query(
            "SELECT * FROM type::table($group_table) WHERE owner=type::thing($user_table, $uid);",
        )
        .bind(("group_table", GROUP))
        .bind(("uid", uid.to_owned()))
        .bind(("user_table", PERSON))
        .await?;
    let entries: Vec<Group> = response.take(0)?;
    Ok(entries)
}

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

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
struct GroupId {
    group: Thing,
}

pub async fn get_groups_not(dbh: &Surreal<Client>, uid: &str) -> surrealdb::Result<Vec<Group>> {
    let mut response = dbh
        .query(format!(
            "SELECT group FROM {MEMBERSHIP} WHERE person = type::thing($person_table, $uid)"
        ))
        .bind(("person_table", PERSON))
        .bind(("uid", uid.to_owned()))
        .await?;

    let entries: Vec<GroupId> = response.take(0)?;
    println!("ids: {:?}", entries);

    let mut response = dbh.query(format!("SELECT * FROM {GROUP} WHERE {GROUP}.id IN (SELECT group FROM {MEMBERSHIP} WHERE person = type::thing($person_table, $uid))"))
    .bind(("person_table", PERSON))
    .bind(("uid", uid.to_owned())).await?;
    let entries: Vec<Group> = response.take(0)?;
    Ok(entries)
}

pub async fn get_group(dbh: &Surreal<Client>, id: &str) -> surrealdb::Result<Option<Group>> {
    dbh.select((GROUP, id)).await
}

pub async fn get_group_with_owner(
    dbh: &Surreal<Client>,
    id: &str,
) -> surrealdb::Result<Option<GroupWithOwner>> {
    let mut response = dbh
        .query("SELECT * FROM type::table($group_table) WHERE id=type::thing($id) FETCH owner")
        .bind(("group_table", GROUP))
        .bind(("id", format!("{GROUP}:{id}")))
        .await?;

    let entries: Vec<GroupWithOwner> = response.take(0)?;
    rocket::info!("Response: {:?}", entries);
    Ok(entries.first().cloned())
}

examples/rocket/people-and-groups/src/mytera.rs
use std::collections::HashMap;

use rocket_dyn_templates::tera::Tera;
use tera::{to_value, Result, Value};

fn object2id(val: &Value, _map: &HashMap<String, Value>) -> Result<Value> {
    let s = val["id"]["String"].as_str().unwrap();
    Ok(to_value(s).unwrap())
}

pub fn customize(tera: &mut Tera) {
    tera.register_filter("object2id", object2id);
}

examples/rocket/people-and-groups/src/main.rs
#[macro_use]
extern crate rocket;

use std::vec;

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

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

struct Config {
    database: String,
}
pub mod db;

mod mytera;

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

#[derive(FromForm)]
struct AddGroup<'r> {
    name: &'r str,
    uid: &'r str,
}

#[derive(FromForm)]
struct AddMember<'r> {
    gid: &'r str,
    uid: &'r str,
}

// ----------------------------------

#[get("/")]
async fn get_index(dbh: &State<Surreal<Client>>) -> Template {
    let people = db::get_people(dbh).await.unwrap();
    let groups = db::get_groups(dbh).await.unwrap();
    Template::render(
        "index",
        context! {
            title: "People and Groups",
            people: people,
            groups: groups,
        },
    )
}

#[get("/clear")]
async fn clear_db(dbh: &State<Surreal<Client>>, config: &State<Config>) -> Template {
    rocket::info!("Clearing database {}", config.database);
    db::clear(dbh, config.database.clone()).await.unwrap();
    Template::render(
        "database_cleared",
        context! {
            title: "Database cleared",
        },
    )
}

#[get("/people")]
async fn get_people(dbh: &State<Surreal<Client>>) -> Template {
    let people = db::get_people(dbh).await.unwrap();
    rocket::info!("People: {:?}", people);

    Template::render(
        "people",
        context! {
            title: "People",
            people,
        },
    )
}

#[post("/add-person", data = "<input>")]
async fn post_add_person(dbh: &State<Surreal<Client>>, input: Form<AddPerson<'_>>) -> Template {
    let name = input.name.trim();
    rocket::info!("Add  person called '{name}'");
    let person = db::add_person(dbh, name).await.unwrap();

    Template::render(
        "person_added",
        context! {
            title: "Person added",
            person,
        },
    )
}

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

    None
}

#[get("/groups")]
async fn get_groups(dbh: &State<Surreal<Client>>) -> Template {
    let groups = db::get_groups(dbh).await.unwrap();
    rocket::info!("Groups: {:?}", groups);

    Template::render(
        "groups",
        context! {
            title: "Groups",
            groups,
        },
    )
}

#[get("/add-group?<uid>")]
async fn get_add_group(dbh: &State<Surreal<Client>>, uid: String) -> Template {
    let person = db::get_person(dbh, &uid).await.unwrap().unwrap();

    Template::render(
        "add_group",
        context! {
            title: format!("Add Group owned by {}", person.name),
            uid: uid.to_string(),
        },
    )
}

#[post("/add-group", data = "<input>")]
async fn post_add_group(dbh: &State<Surreal<Client>>, input: Form<AddGroup<'_>>) -> Template {
    let name = input.name.trim();
    let uid = input.uid.trim();
    rocket::info!("Add  group called '{name}' to user '{uid}'");
    let group = db::add_group(dbh, name, uid).await.unwrap();

    let person = db::get_person(dbh, uid).await.unwrap().unwrap();

    Template::render(
        "group_added",
        context! {
            title: format!("Group called {} owned by {} was added", name, person.name),
            uid: uid.to_string(),
            group,
        },
    )
}

#[get("/add-membership?<uid>")]
async fn get_add_membership(dbh: &State<Surreal<Client>>, uid: String) -> Template {
    let person = db::get_person(dbh, &uid).await.unwrap().unwrap();

    //let groups = db::get_groups(dbh).await.unwrap();
    let groups = db::get_groups_not(dbh, &uid).await.unwrap();
    //let memberships = db::get_memberships_of_person(dbh, &person.id.to_string()).await.unwrap();

    // remove the groups that the person already owns or is a member of
    let groups = groups
        .into_iter()
        .filter(|group| group.owner.id != person.id.id)
        .collect::<Vec<_>>();
    Template::render(
        "add_membership",
        context! {
            title: format!("Add {} to one of the groups as a member", person.name),
            uid: uid.to_string(),
            groups
        },
    )
}

#[post("/add-membership", data = "<input>")]
async fn post_add_membership(dbh: &State<Surreal<Client>>, input: Form<AddMember<'_>>) -> Template {
    let gid = input.gid.trim();
    let uid = input.uid.trim();
    rocket::info!("Add  person '{uid}' to group '{gid}'");
    let group = db::get_group(dbh, gid).await.unwrap().unwrap();
    let person = db::get_person(dbh, uid).await.unwrap().unwrap();
    db::add_member(dbh, uid, gid).await.unwrap();

    Template::render(
        "added_to_group",
        context! {
            title: format!("'{}' was added to group '{}'", person.name, group.name),
            person,
            group,
        },
    )
}

#[get("/delete-membership?<id>")]
async fn delete_membership(dbh: &State<Surreal<Client>>, id: String) -> Template {
    db::delete_membership(dbh, &id).await.unwrap();

    Template::render(
        "membership_deleted",
        context! {
            title: "Membership deleted",
        },
    )
}

#[get("/memberships")]
async fn get_membership(dbh: &State<Surreal<Client>>) -> Template {
    let memberships = db::get_memberships(dbh).await.unwrap();

    Template::render(
        "memberships",
        context! {
            title: "Memberships",
            memberships
        },
    )
}

#[get("/group/<id>")]
async fn get_group(dbh: &State<Surreal<Client>>, id: String) -> Option<Template> {
    if let Some(group) = db::get_group_with_owner(dbh, &id).await.unwrap() {
        rocket::info!("Group: {}", group.owner.id);
        rocket::info!("Group: {:?}", group);
        return Some(Template::render(
            "group",
            context! {
                title: group.name.clone(),
                group,
            },
        ));
    }

    None
}

#[launch]
fn rocket() -> _ {
    start(String::from("people-and-groups"))
}

fn start(database: String) -> rocket::Rocket<rocket::Build> {
    rocket::build()
        .mount(
            "/",
            routes![
                clear_db,
                delete_membership,
                get_add_membership,
                get_index,
                get_people,
                get_person,
                get_groups,
                get_group,
                get_membership,
                get_add_group,
                post_add_group,
                post_add_membership,
                post_add_person,
            ],
        )
        .manage(Config {
            database: database.clone(),
        })
        .attach(Template::custom(|engines| {
            mytera::customize(&mut engines.tera);
        }))
        .attach(db::fairing(database))
}

#[cfg(test)]
mod tests {
    use super::start;

    use rocket::http::{ContentType, Status};
    use rocket::local::blocking::Client;
    //use scraper::{Html, Selector};

    #[test]
    fn test_main() {
        let client = Client::tracked(start(String::from("test-people-and-groups"))).unwrap();

        let response = client.get("/clear").dispatch();
        assert_eq!(response.status(), Status::Ok);

        // Make sure we can clear the database even if it does not exist
        let response = client.get("/clear").dispatch();
        assert_eq!(response.status(), Status::Ok);

        let response = client.get("/").dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"<title>People and Groups</title>"#));
        assert!(html.contains(r#"<div>Number of people: 0</div>"#));
        assert!(html.contains(r#"<div>Number of groups: 0</div>"#));

        let response = client
            .post("/add-person")
            .header(ContentType::Form)
            .body("name=John")
            .dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"Person added:"#));
        let document = scraper::Html::parse_document(&html);
        let selector = scraper::Selector::parse("#added").unwrap();
        assert_eq!(
            &document.select(&selector).next().unwrap().inner_html(),
            "John"
        );
        let john_path = &document
            .select(&selector)
            .next()
            .unwrap()
            .attr("href")
            .unwrap();
        let john_id = john_path.split('/').nth(2).unwrap();

        let response = client
            .post("/add-person")
            .header(ContentType::Form)
            .body("name=Mary Ann")
            .dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"Person added:"#));
        let document = scraper::Html::parse_document(&html);
        let selector = scraper::Selector::parse("#added").unwrap();
        assert_eq!(
            &document.select(&selector).next().unwrap().inner_html(),
            "Mary Ann"
        );
        let mary_ann_path = &document
            .select(&selector)
            .next()
            .unwrap()
            .attr("href")
            .unwrap();
        let mary_ann_id = mary_ann_path.split('/').nth(2).unwrap();

        let response = client.get("/people").dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#">John</a></li>"#));
        assert!(html.contains(r#">Mary Ann</a></li>"#));

        let response = client.get("/").dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"<title>People and Groups</title>"#));
        assert!(html.contains(r#"<div>Number of people: 2</div>"#));
        assert!(html.contains(r#"<div>Number of groups: 0</div>"#));

        let response = client.get(john_path.to_string()).dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"<h2>Name: John</h2>"#));
        let expected = format!(r#"<div><a  href="/add-group?uid={john_id}">add group</a></div>"#);
        assert!(html.contains(&expected));

        let response = client.get(format!("/add-group?uid={john_id}")).dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"<title>Add Group owned by John</title>"#));

        let response = client
            .post("/add-group")
            .header(ContentType::Form)
            .body(format!("name=group one&uid={mary_ann_id}"))
            .dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        println!("html: {}", html);
        assert!(html.contains(r#"<h1>Group called group one owned by Mary Ann was added</h1>"#));
        assert!(html.contains(r#"Group added:"#));

        let document = scraper::Html::parse_document(&html);
        let selector = scraper::Selector::parse("#added").unwrap();
        assert_eq!(
            &document.select(&selector).next().unwrap().inner_html(),
            "group one"
        );
        let group_one_path = &document
            .select(&selector)
            .next()
            .unwrap()
            .attr("href")
            .unwrap();
        let _group_one_id = group_one_path.split('/').nth(2).unwrap();

        let response = client.get(group_one_path.to_string()).dispatch();
        assert_eq!(response.status(), Status::Ok);
        let html = response.into_string().unwrap();
        assert!(html.contains(r#"<h2>Name: group one</h2>"#));
        let expected =
            format!(r#"<h2>Owner name: <a href="/person/{mary_ann_id}">Mary Ann</a></h2>"#);
        assert!(html.contains(&expected));
    }
}

examples/rocket/people-and-groups/Rocket.toml
[debug]
port=8001