Axum 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),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"
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; pub enum 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:
localhost:8000
surreal start
command.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?;
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 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.
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: 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:
"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: "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") }
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; pub enum 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"; 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"}' 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)) } 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") } } 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' } ]
Now that you have a running Axum 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.