Rocket that has a few endpoints:
person
table in a database,First, open up a terminal window and use the following command to start an empty database.
surreal start --user root --pass root
You can also use the Start serving button on Surrealist to do the same if you have it installed locally.
The database initiated by the surreal start command stores data in memory by default, which then disappears every time the database is shut down. As such, you can simply use Ctrl+C every time you want to start the database anew with no existing definitions or data. To save data to disk which will persist after shutting down, add a positional argument for one of the storage backends such as rocksdb://mydatabase
or surrealkv://mydatabase
.
With the database running, we will now connect to the database “test” located in the namespace “test”. You can connect to it by creating a connection inside Surrealist, or by using the following command to start an interactive shell.
surreal sql --user root --pass root --ns test --db test --pretty
Next, create a new Rust project with the command cargo new your_project_name
, go into the newly created directory, and use cargo add
to add each of the following dependencies:
surrealdb
(of course),rocket
,serde
, for serializing and deserializing Rust structs passed to and from the database and Rocket,thiserror
, to make it easy to convert between SurrealDB’s error type, other errors and Rocket’s response types,rand
and faker_rand
, to create random user names that can be used to sign in to the database as a record user.The serde
crate will need the derive
flag, and rocket
will need the json
flag enabled. Your cargo.toml
dependencies should look like this:
faker_rand = "0.1.1" rand = "0.8.5" rocket = { version = "0.5.1", features = ["json"] } serde = { version = "1.0.209", features = ["derive"] } surrealdb = "2.0.4" thiserror = "1.0.64"
The first thing to do is a bit of groundwork to convert database errors into an error type of our own. Implementing From<surrealdb::Error>
for this type will let it be used with the ?
operator when handling results. Finally, it will also need to implement Rocket’s Responder
trait so that it can be used as output for the server. All of this can be done manually if you prefer, but the thiserror
crate saves a certain amount of typing.
mod error { use rocket::http::Status; use rocket::response::{self, Responder, Response}; use rocket::Request; use thiserror::Error; pub enum Error { Db, } impl<'r> Responder<'r, 'static> for Error { fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { let error_message = format!(r#"{{ "error": "{self}" }}"#); Response::build() .status(Status::InternalServerError) .header(rocket::http::ContentType::JSON) .sized_body(error_message.len(), std::io::Cursor::new(error_message)) .ok() } } impl From<surrealdb::Error> for Error { fn from(error: surrealdb::Error) -> Self { eprintln!("{error}"); Self::Db } } }
Next, we will put the database client together. Rocket provides a .manage()
method when starting a router that would give us access to the database inside its functions. However, for simplicity we can instead wrap the client inside a LazyLock
to make it into a global static.
use std::sync::LazyLock; static DB: LazyLock<Surreal<Client>> = LazyLock::new(Surreal::init);
Inside a method called init()
to initiate the database, we will do the following:
localhost:8000
surreal start
command.query()
method to pass in a few definitions for the database.
async fn init() -> Result<(), surrealdb::Error> { DB.connect::<Ws>("localhost:8000").await?; DB.signin(Root { username: "root", password: "root", }) .await?; DB.use_ns("namespace").use_db("database").await?; DB.query( " DEFINE TABLE person SCHEMALESS PERMISSIONS FOR CREATE, SELECT WHERE $auth, FOR UPDATE, DELETE WHERE created_by = $auth; DEFINE FIELD name ON TABLE person TYPE string; DEFINE FIELD created_by ON TABLE person VALUE $auth READONLY; DEFINE INDEX unique_name ON TABLE user FIELDS name UNIQUE; DEFINE ACCESS account ON DATABASE TYPE RECORD SIGNUP ( CREATE user SET name = $name, pass = crypto::argon2::generate($pass) ) SIGNIN ( SELECT * FROM user WHERE name = $name AND crypto::argon2::compare(pass, $pass) ) DURATION FOR TOKEN 15m, FOR SESSION 12h ;", ) .await?; Ok(()) }
The first item that stands out with the definitions above is that they all contain a IF NOT EXISTS
clause. As the DEFINE
statements will be executed every time the app starts, it is possible that they might be executed on a database that already has the definitions in place. Since version 2.0, SurrealDB simply returns an error if a definition already exists, requiring the OVERWRITE
clause if a definition needs to be redone. Without IF NOT EXISTS
, the message “The table ‘person’ already exists” will be returned.
Note that this would not affect our app, as this would still be a successful usage of the .query()
method. Instead, its output would contain a number of error results that could be handled individually:
Response { client: Surreal { router: OnceLock(Router { sender: Sender { .. }, last_id: 4, features: {LiveQueries} }), engine: PhantomData<surrealdb::api::engine::any::Any> }, results: {0: (Stats { execution_time: Some(252.625µs) }, Err(Api(Query("The table 'person' already exists")))), 1: (Stats { execution_time: Some(79.167µs) }, Err(Api(Query("The field 'name' already exists")))), 2: (Stats { execution_time: Some(69.5µs) }, Err(Api(Query("The field 'created_by' already exists")))), 3: (Stats { execution_time: Some(73.625µs) }, Err(Api(Query("The index 'unique_name' already exists")))), 4: (Stats { execution_time: Some(73.583µs) }, Err(Api(Query("The access method 'account' already exists in the database 'test'"))))}, live_queries: {} }
However, adding IF NOT EXISTS
is a nice way to change the results from errors into successful results, and to avoid the rare case in which they end up applied to some other version 1.x database that would rewrite its definitions if IF NOT EXISTS
is present. So while not necessary in our case, it is a good practice to follow and makes for cleaner output.
Now let’s go over each of the definitions to see what they do.
The first three statements define a person
table. This table is schemaless, but has one required field name
, which must be present and must be a string. This table has defined permissions by which a record user is able to use CREATE
and SELECT
on the person
table, but can only UPDATE
and DELETE
records that it has created. The root user, however, is not subject to permissions rules.
The way these permissions are set is by using the $auth
parameter. This parameter has a value whenever a record user is set as the authorized used for the database. The WHERE $auth
clause simply means “where a value exists for the parameter $auth
” (WHERE $auth IS NOT NONE
would also work in this case). But for UPDATE
and DELETE
queries, it is not enough for $auth
to just be present, the created_by
field of a person
record must also match the ID of the currently authenticated user.
This created_by
field is automatically generated from its definition in the DEFINE FIELD
statement. It is given the value of $auth
, and is READONLY
and thus cannot be changed. When logged in as a system user (like a root user), its value will be NONE
. But when logged in as a record user, its value will be something like user:qx2apv5oc8mh03wtah0q
.
DEFINE TABLE IF NOT EXISTS person SCHEMALESS PERMISSIONS FOR CREATE, SELECT WHERE $auth, FOR UPDATE, DELETE WHERE created_by = $auth; DEFINE FIELD IF NOT EXISTS name ON TABLE person TYPE string; DEFINE FIELD IF NOT EXISTS created_by ON TABLE person VALUE $auth READONLY;
So where does an ID like user:qx2apv5oc8mh03wtah0q
come from? This is thanks to the following definitions that set the signup and signin behaviour of the record users. A typical DEFINE ACCESS
statement will create some sort of record on signup (in this case, a user
) record, and will compare it against a password during signin. Note that the access has a name that we gave it (account
), so that it can be referenced elsewhere.
In addition, a DEFINE INDEX
statement with a UNIQUE
clause is used to ensure that no two users can have the same name.
DEFINE INDEX IF NOT EXISTS unique_name ON TABLE user FIELDS name UNIQUE; DEFINE ACCESS IF NOT EXISTS account ON DATABASE TYPE RECORD SIGNUP ( CREATE user SET name = $name, pass = crypto::argon2::generate($pass) ) SIGNIN ( SELECT * FROM user WHERE name = $name AND crypto::argon2::compare(pass, $pass) ) DURATION FOR TOKEN 15m, FOR SESSION 12h
For an actual user in production, you would probably want to require an email and some other fields. Functions like string::is::email
can be used to ensure that the value passed in is valid.
DEFINE FIELD email ON TABLE user TYPE string ASSERT $value.is_email();
However, for this simple example, each user will simply have a unique name and a password. The password will be stored in hashed and salted form on the database, making it unique and unreadable every time it is generated. The only way to check if it is correct is by using a compare function of the output with an attempted password. Here is a short SurrealQL sample to show how the process works.
LET $hash1 = crypto::argon2::generate("myPaSSWord"); LET $hash2 = crypto::argon2::generate("myPaSSWord"); RETURN [$hash1, $hash2]; -- First returns true, second returns false RETURN [ crypto::argon2::compare($hash1, "myPaSSWord") crypto::argon2::compare($hash1, "Wrongpassword") ];
The last step is where the majority of the work takes place: setting up the paths for Rocket to handle, and writing the functions that handle the endpoints and (usually) access the database to handle the request. To start, we’ll create a function for the "/"
root path to display a helpful message to anybody giving the server a try via the browser or an app like curl or Postman. These paths and curl examples can all be seen on the page for SurrealDB’s HTTP endpoints.
pub async fn paths() -> &'static str { r#" ----------------------------------------------------------------------------------------------------------------------------------------- PATH | SAMPLE COMMAND ----------------------------------------------------------------------------------------------------------------------------------------- /session: See session data | curl -X GET -H "Content-Type: application/json" http://localhost:8080/session | /person/{id}: | Create a person | curl -X POST -H "Content-Type: application/json" -d '{"name":"John Doe"}' http://localhost:8080/person/one Get a person | curl -X GET -H "Content-Type: application/json" http://localhost:8080/person/one Update a person | curl -X PUT -H "Content-Type: application/json" -d '{"name":"Jane Doe"}' http://localhost:8080/person/one Delete a person | curl -X DELETE -H "Content-Type: application/json" http://localhost:8080/person/one | /people: List all people | curl -X GET -H "Content-Type: application/json" http://localhost:8080/people /new_user: Create a new record user /new_token: Get instructions for a new token if yours has expired"# }
Each of these functions will be put into a mod called routes
, leading to the following code inside Rocket’s rocket()
function (its equivalent of main()
).
pub async fn rocket() -> _ { std::env::set_var("ROCKET_PORT", "8080"); init().await.expect("Something went wrong, shutting down"); rocket::build().mount( "/", routes![ routes::create_person, routes::read_person, routes::update_person, routes::delete_person, routes::list_people, routes::paths, routes::make_new_user, routes::get_new_token, routes::session ], ) }
Many functions require some JSON data from the user, which will be deserialized into a PersonData
struct. The database can then use it in methods like .create().content()
. The output returned will now have a name
and an id
, which the Person
struct holds.
pub struct PersonData { name: String, } pub struct Person { name: String, id: RecordId, }
Each of these functions are pretty straightforward: obtain some user input, initiate a query, feed the user input into it, and return it as JSON.
const PERSON: &str = "person"; pub async fn create_person( id: String, person: Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB .create((PERSON, &*id)) .content(person.into_inner()) .await?; Ok(Json(person)) } pub async fn read_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn update_person( id: String, person: Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB .update((PERSON, &*id)) .content(person.into_inner()) .await?; Ok(Json(person)) } pub async fn delete_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.delete((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn list_people() -> Result<Json<Vec<Person>>, Error> { let people = DB.select(PERSON).await?; Ok(Json(people)) }
The session()
function is also quite small, and is just a convenience for a user curious about the current session data. As the .query()
method can take more than one statement, it returns each of these responses in order with an index for each (starting at 0). The .take()
method can then be used to access the response at that index, and turn it into anything that can be deserialized back into a Rust type. In our case, a String
is all we need here as the output will only be used to show the user the current session info.
pub async fn session() -> Result<Json<String>, Error> { let res: Option<String> = DB.query("RETURN <string>$session").await?.take(0)?; Ok(Json(res.unwrap_or("No session data found!".into()))) }
Two most interesting function is the one used to create a new record user. To make it really easy to try out the experience of logging in as a record user, this function will use create a random name and password each time it is accessed. It will then pass in a Record
struct which is used to sign up a new record user. Note the following:
"account"
, which is the name we chose in the DEFINE ACCESS
statement above.params
field takes anything that implements Serialize
, in this case a struct we put together called Params
..signup()
method returns a redacted Jwt
by default. To make the token visible, you can use the .into_insecure_token()
method as we have done here. As a small guide to getting started, this example is not concerned about security. However, if you are looking to create something more production-worthy, do take a look at the security section of the documentation and the security best practices page.The function will then end with an output showing the username, password, token, and instructions for how to log in using the CLI. This can be copied and pasted to begin making queries immediately.
struct Params<'a> { name: &'a str, pass: &'a str, } pub async fn make_new_user() -> Result<String, Error> { let name = rand::random::<FirstName>().to_string(); let pass = rand::random::<FirstName>().to_string(); let jwt = DB .signup(Record { access: "account", namespace: "namespace", database: "database", params: Params { name: &name, pass: &pass, }, }) .await? .into_insecure_token(); Ok(format!("New user created!\n\nName: {name}\nPassword: {pass}\nToken: {jwt}\n\nTo log in, use this command:\n\nsurreal sql --namespace namespace --database database --pretty --token \"{jwt}\"")) }
A record user with an expired token can use the /signin
endpoint to get a new token. Since this requires passing in a username and password, we’ll just have this function return a String
that contains a curl example to get a new token.
pub async fn get_new_token() -> String { let command = r#"curl -X POST -H "Accept: application/json" -d '{"ns":"namespace","db":"database","ac":"account","user":"your_username","pass":"your_password"}' http://localhost:8000/signin"#; format!("Need a new token? Use this command:\n\n{command}\n\nThen log in with surreal sql --namespace namespace --database database --pretty --token YOUR_TOKEN_HERE") }
The final code looks like this:
extern crate rocket; use std::sync::LazyLock; use surrealdb::engine::remote::ws::Client; use surrealdb::engine::remote::ws::Ws; use surrealdb::opt::auth::Root; use surrealdb::Surreal; static DB: LazyLock<Surreal<Client>> = LazyLock::new(Surreal::init); mod error { use rocket::http::Status; use rocket::response::{self, Responder, Response}; use rocket::Request; use thiserror::Error; pub enum Error { Db, } impl<'r> Responder<'r, 'static> for Error { fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { let error_message = format!(r#"{{ "error": "{self}" }}"#); Response::build() .status(Status::InternalServerError) .header(rocket::http::ContentType::JSON) .sized_body(error_message.len(), std::io::Cursor::new(error_message)) .ok() } } impl From<surrealdb::Error> for Error { fn from(error: surrealdb::Error) -> Self { eprintln!("{error}"); Self::Db } } } mod routes { use faker_rand::en_us::names::FirstName; use surrealdb::opt::auth::Record; use crate::error::Error; use crate::DB; use rocket::serde::json::Json; use rocket::{delete, get, post, put}; use serde::{Deserialize, Serialize}; use surrealdb::RecordId; const PERSON: &str = "person"; struct Params<'a> { name: &'a str, pass: &'a str, } pub struct PersonData { name: String, } pub struct Person { name: String, id: RecordId, } pub async fn paths() -> &'static str { r#" ----------------------------------------------------------------------------------------------------------------------------------------- PATH | SAMPLE COMMAND ----------------------------------------------------------------------------------------------------------------------------------------- /session: See session data | curl -X GET -H "Content-Type: application/json" http://localhost:8080/session | /person/{id}: | Create a person | curl -X POST -H "Content-Type: application/json" -d '{"name":"John Doe"}' http://localhost:8080/person/one Get a person | curl -X GET -H "Content-Type: application/json" http://localhost:8080/person/one Update a person | curl -X PUT -H "Content-Type: application/json" -d '{"name":"Jane Doe"}' http://localhost:8080/person/one Delete a person | curl -X DELETE -H "Content-Type: application/json" http://localhost:8080/person/one | /people: List all people | curl -X GET -H "Content-Type: application/json" http://localhost:8080/people /new_user: Create a new record user /new_token: Get instructions for a new token if yours has expired"# } pub async fn session() -> Result<Json<String>, Error> { let res: Option<String> = DB.query("RETURN <string>$session").await?.take(0)?; Ok(Json(res.unwrap_or("No session data found!".into()))) } pub async fn create_person( id: String, person: Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB .create((PERSON, &*id)) .content(person.into_inner()) .await?; Ok(Json(person)) } pub async fn read_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn update_person( id: String, person: Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB .update((PERSON, &*id)) .content(person.into_inner()) .await?; Ok(Json(person)) } pub async fn delete_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.delete((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn list_people() -> Result<Json<Vec<Person>>, Error> { let people = DB.select(PERSON).await?; Ok(Json(people)) } pub async fn make_new_user() -> Result<String, Error> { let name = rand::random::<FirstName>().to_string(); let pass = rand::random::<FirstName>().to_string(); let jwt = DB .signup(Record { access: "account", namespace: "namespace", database: "database", params: Params { name: &name, pass: &pass, }, }) .await? .into_insecure_token(); Ok(format!("New user created!\n\nName: {name}\nPassword: {pass}\nToken: {jwt}\n\nTo log in, use this command:\n\nsurreal sql --namespace namespace --database database --pretty --token \"{jwt}\"")) } pub async fn get_new_token() -> String { let command = r#"curl -X POST -H "Accept: application/json" -d '{"ns":"namespace","db":"database","ac":"account","user":"your_username","pass":"your_password"}' http://localhost:8000/signin"#; format!("Need a new token? Use this command:\n\n{command}\n\nThen log in with surreal sql --namespace namespace --database database --pretty --token YOUR_TOKEN_HERE") } } async fn init() -> Result<(), surrealdb::Error> { DB.connect::<Ws>("localhost:8000").await?; DB.signin(Root { username: "root", password: "root", }) .await?; DB.use_ns("namespace").use_db("database").await?; DB.query( " DEFINE TABLE person SCHEMALESS PERMISSIONS FOR CREATE, SELECT WHERE $auth, FOR UPDATE, DELETE WHERE created_by = $auth; DEFINE FIELD name ON TABLE person TYPE string; DEFINE FIELD created_by ON TABLE person VALUE $auth READONLY; DEFINE INDEX unique_name ON TABLE user FIELDS name UNIQUE; DEFINE ACCESS account ON DATABASE TYPE RECORD SIGNUP ( CREATE user SET name = $name, pass = crypto::argon2::generate($pass) ) SIGNIN ( SELECT * FROM user WHERE name = $name AND crypto::argon2::compare(pass, $pass) ) DURATION FOR TOKEN 15m, FOR SESSION 12h ;", ) .await?; Ok(()) } pub async fn rocket() -> _ { std::env::set_var("ROCKET_PORT", "8080"); init().await.expect("Something went wrong, shutting down"); rocket::build().mount( "/", routes![ routes::create_person, routes::read_person, routes::update_person, routes::delete_person, routes::list_people, routes::paths, routes::make_new_user, routes::get_new_token, routes::session ], ) }
As the database client is logged in as a root user, the /person/
routes can be used to perform any operation on the person
records of the database.
You can also log in to the CLI or Surrealist as a root user and separately as a record user using the output of the /new_user
endpoint to compare the experience between the two.
For example, the output when creating a person
record as a root user will look like this:
test/test> CREATE person SET name = 'Aeon'; -- Query 1 [ { id: person:hdl0unwts4atic65nh7l, name: 'Aeon' } ]
But as a record user, it will include a created_by
field, set by the value found at the $auth
paremeter.
test/test> CREATE person SET name = 'Aeon'; -- Query 1 [ { created_by: user:qx2apv5oc8mh03wtah0q, id: person:8syfiq2ovztn2tbr8mhb, name: 'Aeon' } ]
As a result, a DELETE person RETURN BEFORE
statement (which deletes all person
records and returns the records deleted) used by a record user will only delete the single record that it created earlier. The following SELECT
statement shows that the person
record created by the root user cannot be deleted or modified by the record user.
test/test> DELETE person RETURN BEFORE; -- Query 1 [ { created_by: user:qx2apv5oc8mh03wtah0q, id: person:8y06y06jmmb7e58trckz, name: 'Aeon' } ] test/test> SELECT * FROM person; -- Query 1 [ { id: person:hdl0unwts4atic65nh7l, name: 'Aeon' } ] test/test> UPDATE person SET name = "Yogurt"; -- Query 1 []
Also note that the root user is able to see the user
tables and their information. A record user cannot, as a record user by default has no permissions except what it is given by the PERMISSIONS
clause. If you create a record user using the /new_user
endpoint, the root user will be able to view it. However, the password has been obscured by the crypto::argon2::generate
function so that nobody else can use it.
[ { id: user:qx2apv5oc8mh03wtah0q, name: 'Gerard', pass: '$argon2id$v=19$m=19456,t=2,p=1$j0ktTqUxRjOWYnwS5LoMFQ$2NcGkf5+IuLml6NorPy/Le6T8RppYXTXakwY5cDiZPY' } ]
Now that you have a running Rocket server with SurrealDB as the backend, here are some other ideas that you might want to explore.
AUTHENTICATE
clause inside the DEFINE ACCESS
statement. This will result in increased performance thanks to only being executed once, compared to permissions checks which are executed for each query.