SurrealDB Docs Logo

Enter a search query

SurrealDB with Rocket

The following tutorial will set up a server with SurrealDB and Rocket that has a few endpoints:

  • Some endpoints to demonstrate how the HTTP endpoints work to create, select, modify etc. a person table in a database,
  • Other endpoints to display some helpful info for the user,
  • Two endpoints to allow signing up and signing in as a record user.

Getting started

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"

Starting the Rust code

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; #[derive(Error, Debug)] pub enum Error { #[error("database 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:

  • Connect to the database running at localhost:8000
  • Sign in as the root user that was created through the surreal start command
  • Use (move to) the namespace “test” and database “test”
  • Use the .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(()) }

What the database definitions do

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 rest of the code

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.

#[get("/")] 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()).

#[launch] 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.

#[derive(Serialize, Deserialize, Clone)] pub struct PersonData { name: String, } #[derive(Serialize, Deserialize)] 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"; #[post("/person/<id>", data = "<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)) } #[get("/person/<id>")] pub async fn read_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } #[put("/person/<id>", data = "<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)) } #[delete("/person/<id>")] pub async fn delete_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.delete((PERSON, &*id)).await?; Ok(Json(person)) } #[get("/people")] 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.

#[get("/session")] 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:

  • The access name is "account", which is the name we chose in the DEFINE ACCESS statement above.
  • The params field takes anything that implements Serialize, in this case a struct we put together called Params.
  • The .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.

#[derive(Serialize, Deserialize)] struct Params<'a> { name: &'a str, pass: &'a str, } #[get("/new_user")] 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.

#[get("/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") }

Experimenting with the app

The final code looks like this:

#[macro_use] 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; #[derive(Error, Debug)] pub enum Error { #[error("database 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"; #[derive(Serialize, Deserialize)] struct Params<'a> { name: &'a str, pass: &'a str, } #[derive(Serialize, Deserialize, Clone)] pub struct PersonData { name: String, } #[derive(Serialize, Deserialize)] pub struct Person { name: String, id: RecordId, } #[get("/")] 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"# } #[get("/session")] 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()))) } #[post("/person/<id>", data = "<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)) } #[get("/person/<id>")] pub async fn read_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } #[put("/person/<id>", data = "<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)) } #[delete("/person/<id>")] pub async fn delete_person(id: String) -> Result<Json<Option<Person>>, Error> { let person = DB.delete((PERSON, &*id)).await?; Ok(Json(person)) } #[get("/people")] pub async fn list_people() -> Result<Json<Vec<Person>>, Error> { let people = DB.select(PERSON).await?; Ok(Json(people)) } #[get("/new_user")] 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}\"")) } #[get("/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") } } 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(()) } #[launch] 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' } ]

Further steps

Now that you have a running Rocket server with SurrealDB as the backend, here are some other ideas that you might want to explore.

  • Using the 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.
  • Adding some interesting behaviour to the database such as changefeeds or events.
© SurrealDB GitHub Discord Community Cloud Features Releases Install