SurrealDB Docs Logo

Enter a search query

SurrealDB with Axum

The following tutorial will set up a server with SurrealDB and Axum 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),
  • axum,
  • serde, for serializing and deserializing Rust structs passed to and from the database and Axum,
  • tokio, for the async runtime used by both Axum and SurrealDB,
  • thiserror, to make it easy to convert between SurrealDB’s error type, other errors and Axum’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 also need a feature flag for its Serialize and Deserialize macros. Your cargo.toml dependencies should look like this:

axum = "0.7.7" faker_rand = "0.1.1" rand = "0.8.5" serde = { version = "1.0.209", features = ["derive"] } surrealdb = "2.0.4" thiserror = "1.0.64" tokio = "1.40.0"

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 Axum’s IntoResponse 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 axum::http::StatusCode; use axum::response::IntoResponse; use axum::response::Response; use axum::Json; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("database error")] Db, } impl IntoResponse for Error { fn into_response(self) -> Response { (StatusCode::INTERNAL_SERVER_ERROR, Json(self.to_string())).into_response() } } impl From<surrealdb::Error> for Error { fn from(error: surrealdb::Error) -> Self { eprintln!("{error}"); Self::Db } } }

Next, we will put the database client together. Axum provides a .with_state() 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 main(), 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.
DB.connect::<Ws>("localhost:8000").await?; DB.signin(Root { username: "root", password: "root", }) .await?; DB.use_ns("test").use_db("test").await?; DB.query( " 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; 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 ;", ) .await?;

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 Axum 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"}' http://localhost:8080/person/one Update a person | curl -X PUT -H "Content-Type: application/json" -d '{"name":"Jane"}' http://localhost:8080/person/one Get a person | curl -X GET -H "Content-Type: application/json" 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 main().

let listener = TcpListener::bind("localhost:8080").await?; let router = Router::new() .route("/", get(routes::paths)) .route("/person/:id", post(routes::create_person)) .route("/person/:id", get(routes::read_person)) .route("/person/:id", put(routes::update_person)) .route("/person/:id", delete(routes::delete_person)) .route("/people", get(routes::list_people)) .route("/session", get(routes::session)) .route("/new_user", get(routes::make_new_user)) .route("/new_token", get(routes::get_new_token)); axum::serve(listener, router).await?;

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"; pub async fn create_person( id: Path<String>, Json(person): Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB.create((PERSON, &*id)).content(person).await?; Ok(Json(person)) } pub async fn read_person(id: Json<String>) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn update_person( id: Path<String>, Json(person): Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB.update((PERSON, &*id)).content(person).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:

  • 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, } 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: "test", database: "test", 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 --ns test --db test --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":"test","db":"test","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 --ns test --db test --pretty --token YOUR_TOKEN_HERE") }

Experimenting with the app

The final code looks like this:

use std::sync::LazyLock; use axum::{Router, routing::{delete, get, post, put}}; use surrealdb::{Surreal, engine::remote::ws::{Client, Ws}, opt::auth::Root}; use tokio::net::TcpListener; static DB: LazyLock<Surreal<Client>> = LazyLock::new(Surreal::init); mod error { use axum::http::StatusCode; use axum::response::IntoResponse; use axum::response::Response; use axum::Json; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("database error")] Db, } impl IntoResponse for Error { fn into_response(self) -> Response { (StatusCode::INTERNAL_SERVER_ERROR, Json(self.to_string())).into_response() } } impl From<surrealdb::Error> for Error { fn from(error: surrealdb::Error) -> Self { eprintln!("{error}"); Self::Db } } } mod routes { use crate::error::Error; use crate::DB; use axum::{extract::Path, Json}; use faker_rand::en_us::names::FirstName; use surrealdb::{RecordId, opt::auth::Record}; use serde::{Deserialize, Serialize}; const PERSON: &str = "person"; #[derive(Serialize, Deserialize, Clone)] pub struct PersonData { name: String, } #[derive(Serialize, Deserialize)] 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"}' http://localhost:8080/person/one Update a person | curl -X PUT -H "Content-Type: application/json" -d '{"name":"Jane"}' http://localhost:8080/person/one Get a person | curl -X GET -H "Content-Type: application/json" 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: Path<String>, Json(person): Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB.create((PERSON, &*id)).content(person).await?; Ok(Json(person)) } pub async fn read_person(id: Path<String>) -> Result<Json<Option<Person>>, Error> { let person = DB.select((PERSON, &*id)).await?; Ok(Json(person)) } pub async fn update_person( id: Path<String>, Json(person): Json<PersonData>, ) -> Result<Json<Option<Person>>, Error> { let person = DB.update((PERSON, &*id)).content(person).await?; Ok(Json(person)) } pub async fn delete_person(id: Path<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)) } #[derive(Serialize, Deserialize)] 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: "test", database: "test", 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 --ns test --db test --pretty --token \"{jwt}\"")) } pub async fn get_new_token() -> String { let command = r#"curl -X POST -H "Accept: application/json" -d '{"ns":"test","db":"test","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 --ns test --db test --pretty --token YOUR_TOKEN_HERE") } } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { DB.connect::<Ws>("localhost:8000").await?; DB.signin(Root { username: "root", password: "root", }) .await?; DB.use_ns("test").use_db("test").await?; DB.query( " 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; 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 ;", ) .await?; let listener = TcpListener::bind("localhost:8080").await?; let router = Router::new() .route("/", get(routes::paths)) .route("/person/:id", post(routes::create_person)) .route("/person/:id", get(routes::read_person)) .route("/person/:id", put(routes::update_person)) .route("/person/:id", delete(routes::delete_person)) .route("/people", get(routes::list_people)) .route("/session", get(routes::session)) .route("/new_user", get(routes::make_new_user)) .route("/new_token", get(routes::get_new_token)); axum::serve(listener, router).await?; Ok(()) }

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 Axum 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