2024-11-29
The goal of this blog post is to help beginners grasp the basics of using SurrealDB 2 and Axum for web development.
I find the technology fascinating, but readers should be aware that SurrealDB v1 upset many users due to the lack of documentation and poor performance
However, version 2 fixes several performance and bug issues. One such improvement is that the in-memory storage is now powered by SurrealKV, which is concurrent, meaning that read/write operations scale with the number of CPUs.
SurrealDB separates its query language (called SurrealQL) from its data storage backend. This flexibility allows you to choose the best storage option for your project. While I'll be using SurrealKV locally for simplicity, other options are available, such as TikV for distributed storage or other in-memory solutions.
SurrealQL from the data storage. I'll be using SurrealKV locally but there are other options including TikV for distributed and in memory to name a few.
Check out the SurrealDB getting started page to start up an in memory data store. I would recommend clicking on "Using CLI" to learn how to connect to the database in the terminal.
I found the book very helpful in learning the language.
Before getting started, make sure you have the following installed:
cargo new webapp
add dependencies
cd webapp
cargo add axum
cargo add serde --features derive
cargo add surrealdb --features kv-surrealkv
cargo add thiserror
cargo add tokio --features full
cargo add serde_json
Now start up a SurrealKV storage engine at localhost:8080
surreal start --user root --pass password --bind 127.0.0.1:8080 surrealkv://mydb
In depth examples of using surreal start here. Make sure to select V2.x
From a second terminal, connect to the engine and set the database to test_db and namespace to test_ns
surreal sql --endpoint http://127.0.0.1:8080 --user root --pass password --ns test_ns --db test_db
In depth examples of using surreal sql here
After the last surreal sql ... statement you should be connected:
test_ns/test_db>
I will create a TABLE called users and set the table to have structured, predictable data types with a Schema.
DEFINE TABLE IF NOT EXISTS users SCHEMAFULL;
Scrolling down in that last Schema link will show the user that at any point we can add a field to our table that is schemaless using FLELXIBLE. Perhaps for metatdata.
Let's add a username FIELD to the table that's only allowed to be under 14 characters long
DEFINE FIELD username ON TABLE users TYPE string ASSERT string::len($value) < 14;
Next we can define an INDEX for the username to make it a unique field.
Reading through the docs tells us that indexes are used to speed up query execution times dramatically.
DEFINE INDEX usernameIndex ON TABLE users COLUMNS username UNIQUE;
Now let's CREATE a user
CREATE users SET username = "User1";
and we can see from the output that SurrealDB automatically gives us an id field
[[{ id: users:2272f7xnqi502d9it9q4, username: 'User1' }]]
we could have also created the unique id with this command (skip, just for example):
CREATE users:1 SET username = "User1";
Open src/main.rs and grab all the dependencies we added
use axum::{extract::State, routing::get, Router, Json, extract::Path, response::IntoResponse };
use surrealdb::Surreal;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
use surrealdb::engine::any::Any;
We should pause here and take a look at the official SurrealDB example of using the Rust SDK
Ok so it says we should convert Surreal errors into Axum responses (copy paste the boilerplate)
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 {
match self {
Error::Db=> (StatusCode::INTERNAL_SERVER_ERROR, Json("An error has occurred. Please try again later.".to_string())).into_response(),
}
}
}
impl From<surrealdb::Error> for Error {
fn from(error: surrealdb::Error) -> Self {
eprintln!("{error}");
Self::Db
}
}
}
Alright with the boilerplate out of the way I want to discuss surrealdb::engine::any
I mentioned that I would be using SurrealKV as the database engine, but really SurrealDB doesn't force us to stick with one. The link right above explains how the query language is completely separate from the database engine.
// make sure our database is accessible from all routes
#[derive(Clone)]
struct AppState {
db: Arc<Mutex<Surreal<Any>>>,
}
#[tokio::main]
async fn main() -> Result<(), error::Error>{
// any allows us to swap between rocksdb://mydb, mem://, etc.
let db = surrealdb::engine::any::connect("surrealkv://mydb").await?;
// authenticate
db.signin(surrealdb::opt::auth::Root {
username: "root",
password: "password",
}).await?;
// select a namespace and a database inside that namespace
db.use_ns("test_ns").use_db("test_db").await?;
// for routes to access the database, Axum provides AppState
let app_state = AppState {
db: Arc::new(Mutex::new(db))
};
// define our routes to create, delete, get users
let app = Router::new()
.route("/", get(get_users))
.route("/create/:uname", get(create_user))
.route("/delete/:uname", get(delete_user))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}
For brevity I'll just use get requests with the data directly in the path.
However, you can use RESTful APIs such as delete, post, etc. Then access the endpoints with curl
curl -I http://localhost:8080/status
HTTP/1.1 200 OK
content-length: 0
vary: origin, access-control-request-method, access-control-request-headers
access-control-allow-origin: *
surreal-version: surrealdb-2.0.0+20240910.8f30ee08
server: SurrealDB
x-request-id: 3dedcc96-4d8a-451e-b60d-4eaac14fa3f8
date: Wed, 11 Sep 2024 00:52:49 GMT
Now we should write a database query to define our table if it doesn't exist. Just like we did manually at the start of the tutorial.
db.query(
"DEFINE TABLE IF NOT EXISTS users SCHEMAFULL;
DEFINE FIELD IF NOT EXISTS username ON TABLE users TYPE string ASSERT string::len($value) < 14;
DEFINE INDEX IF NOT EXISTS usernameIndex ON TABLE users COLUMNS username UNIQUE;
"
).await?;
And now we create the routes
// schema to insert or get users
#[derive(Serialize, Deserialize, Clone)]
pub struct User {
username: String,
}
// retrieve all users
// 127.0.0.1:3000/
async fn get_users(State(state): State<AppState>) -> Result<Json<Vec<User>>, error::Error>{
let db = state.db.lock().await;
let mut response = db.query("SELECT * FROM users").await?;
let result: Vec<User> = response.take(0)?;
Ok(Json(result))
}
Now to create a user at localhost:3000/create/newusername
async fn create_user(Path(uname): Path<String>, State(state): State<AppState>) -> Result<impl IntoResponse, error::Error>{
let db = state.db.lock().await;
let newUser: Option<User> = db.create("users").content(User {
username: uname,
}).await?;
Ok("Success creating new user")
}
And we can delete a user at localhost:3000/delete/newusername
async fn delete_user(Path(uname): Path<String>, State(state): State<AppState>) -> Result<impl IntoResponse, error::Error>{
let db = state.db.lock().await;
db.query("DELETE FROM users WHERE username = $username")
.bind(("username", uname)).await?;
Ok("Success deleting user")
}
With this basic setup in place, you can start experimenting with more complex queries, adding authentication, or introducing new features to your app. The full code is available on Github
Happy coding!