Building a Modern Dating App Backend with Rust, Async-GraphQL, SurrealDB, and WebSocket
by Daniel Boros
Aug 27
11 min read
53 views
We'll explore how to build a backend for a dating app using Rust. The technologies we'll use include Async-GraphQL for API management, SurrealDB for data storage, and WebSocket for real-time communication. By the end, you'll have a solid understanding of how to create a scalable, efficient, and secure backend for a dating app.
It's important to note that this post is not primarily focused on performance benchmarks or an in-depth analysis of the pros and cons of each technology. Instead, our goal is to introduce and demonstrate a promising tech stack that shows great potential for building modern applications. This stack leverages cutting-edge tools and frameworks that, while still evolving, offer exciting possibilities for developers looking to create robust and future-proof solutions.
Setting Up the Entry Point for Our Rust Backend
To start building our dating app backend, we need to set up the main entry point of our Rust application. This is where the application’s core components are initialized and configured, including the GraphQL schema, database connections, and the HTTP server.
Below is the full implementation of the main.rs
file, which ties together all the critical parts of our application:
mod enums;
mod graphql;
mod models;
mod surreal;
use std::env;
use anyhow::Result;
use async_graphql::{extensions::Logger, Schema};
use async_openai::Client;
use axum::{http::Method, routing::get, Router};
use firebase_auth::FirebaseAuth;
use graphql::{mutations::MutationRoot, queries::QueryRoot, subscriptions::SubscriptionRoot};
use tokio::net::TcpListener;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::TraceLayer,
};
// Define the GraphQL schema by combining Query, Mutation, and Subscription roots
pub type GraphqlSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
// Define the application state that will be shared across handlers
#[derive(Clone)]
pub struct AppState {
firebase_auth: FirebaseAuth,
schema: GraphqlSchema,
}
#[tokio::main]
async fn main() -> Result<()> {
// Load environment variables from the .env file
dotenvy::dotenv().expect("Failed to load .env file");
// Initialize logging with tracing_subscriber for detailed output
tracing_subscriber::fmt()
.with_file(true)
.with_line_number(true)
.with_level(true)
.with_max_level(tracing::Level::INFO)
.init();
// Initialize Redis connection
let _redis = redis::Client::open(env::var("REDIS_URL")?)?;
// Initialize OpenAI client (optional, based on use case)
let openai = Client::new();
// Initialize SurrealDB connection
let surreal = surreal::init().await?;
// Optional: Run database migrations (uncomment if needed)
// surreal::run_migrations(&surreal).await?;
// Log the GraphiQL IDE URL
tracing::info!("GraphiQL IDE: http://localhost:8080");
// Initialize Firebase authentication with the project ID from environment variables
let firebase_auth = FirebaseAuth::new(&std::env::var("FIREBASE_PROJECT_ID")?).await;
// Build the GraphQL schema with all roots and extensions
let schema = Schema::build(
QueryRoot::default(),
MutationRoot::default(),
SubscriptionRoot::default(),
)
.data(_redis) // Inject Redis client into the schema
.data(surreal) // Inject SurrealDB client into the schema
.data(openai) // Inject OpenAI client into the schema (if used)
.data(firebase_auth.clone()) // Inject FirebaseAuth into the schema
.extension(Logger) // Add logging extension to the schema
.finish();
// Define the application state to be shared across requests
let app_state = AppState {
firebase_auth,
schema,
};
// Set up the Axum router with routes and middleware
let app = Router::new()
.route("/", get(graphql::playground).post(graphql::http_handler)) // Route for GraphQL Playground and HTTP handler
.route("/ws", get(graphql::ws_handler)) // Route for WebSocket handler
.layer(
CorsLayer::new() // CORS settings for cross-origin requests
.allow_origin(AllowOrigin::predicate(|_, _| true))
.allow_methods([Method::GET, Method::POST]),
)
.layer(TraceLayer::new_for_http()) // Add HTTP tracing layer
.with_state(app_state); // Share the app state across all routes
// Start the TCP listener on localhost, port 8080
let listener = TcpListener::bind("127.0.0.1:8080").await?;
tracing::debug!("listening on {}", listener.local_addr()?);
// Serve the application using Axum's serve method
axum::serve(listener, app).await?;
Ok(())
}
This setup provides a solid foundation for building a robust, scalable backend for our dating app. It ensures that all essential services are initialized correctly and that the application is ready to handle incoming requests through well-defined GraphQL operations and real-time WebSocket connections.
Integrating SurrealDB
SurrealDB is an innovative and flexible database system that bridges the gap between relational and NoSQL databases. It offers powerful features that make it well-suited for managing complex data relationships, such as those found in a dating app. In our project, we use SurrealDB to store and manage user profiles, messages, and other critical data.
The Future of SurrealDB
SurrealDB is still in its early stages, but it holds great promise. It’s seen as a potential alternative to more established solutions like Hasura, particularly because of its novel approach to data management. However, it’s important to note that SurrealDB currently does not have its own GraphQL API. The platform is under active development, with continuous updates, including a cloud beta and the anticipated release of version 2. You can learn more about SurrealDB’s ongoing progress on their official website.
Example: Initializing SurrealDB
Below is a code snippet that demonstrates how to initialize and configure SurrealDB within our Rust application:
use std::env;
use anyhow::Result;
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal};
use surrealdb_migrations::MigrationRunner;
use crate::graphql::types::SurrealClient;
pub async fn init() -> Result<SurrealClient> {
// Establish a connection to SurrealDB using WebSocket (Ws) protocol
let surreal = Surreal::new::<Ws>("127.0.0.1:8000").await?;
// Sign in to SurrealDB with root credentials provided via environment variables
surreal
.signin(Root {
username: &env::var("SURREAL_ROOT_USER")?, // Root username from environment variables
password: &env::var("SURREAL_ROOT_PASS")?, // Root password from environment variables
})
.await?;
// Log successful sign-in
tracing::info!("Signed in to SurrealDB");
// Select the namespace and database to use, also defined in environment variables
surreal
.use_ns(&env::var("SURREAL_NS")?) // Namespace selection
.use_db(&env::var("SURREAL_DB")?) // Database selection
.await?;
// Return the initialized SurrealDB client
Ok(surreal)
}
// Function to run database migrations using the MigrationRunner
pub async fn run_migrations(surreal: &SurrealClient) -> Result<()> {
// Apply new migrations to the database
MigrationRunner::new(surreal).up().await.unwrap();
// Retrieve and log the list of applied migrations
let applied_migrations = MigrationRunner::new(surreal).list().await.unwrap();
tracing::info!("Applied migrations: {:?}", applied_migrations);
Ok(())
}
Sure! Here’s a more narrative and detailed introduction to the examples, focusing on the chat functionality of a dating app, followed by the examples with the relevant code.
Exploring the Chat Functionality in a Dating App
When building a dating app, one of the most crucial features is the chat functionality. This is where users can communicate with each other, share messages, and build connections. Implementing this feature requires careful consideration of how data is managed, updated, and delivered in real time.
In this section, we'll delve into the code that powers the chat functionality in our dating app. We'll walk through how users can send messages, update or delete them, and even receive real-time updates when new messages are sent in a chat. These examples are implemented using Rust with the Async-GraphQL
library, connecting to a SurrealDB database.
Imagine a scenario where two users, Alice and Bob, start a conversation on your app. Alice sends a message, and Bob immediately sees it thanks to real-time updates. Alice might decide to edit her message or even delete it if she changes her mind. Our backend handles all these operations seamlessly.
Let’s explore the different operations that make this possible.
Sending a Message
When Alice sends a message to Bob, the app needs to record this message in the database. Below is the code that handles the insertion of a new message into the chat:
use async_graphql::{Context, FieldResult, Object};
use serde_json::json;
use crate::{
graphql::types::{surreal_id::SurrealID, SurrealClient},
models::messages::{self, UpdateSetInput},
};
// This struct serves as the root for all mutation operations related to messages.
#[derive(Default)]
pub struct MessageMutationRoot;
// Implementing the mutation operations on the MessageMutationRoot struct.
#[Object]
impl MessageMutationRoot {
// Mutation to insert a new chat message into the database.
// It takes match_id, user_id, and the text of the message as input.
#[graphql(name = "insert_message_one")]
async fn insert_message_one(
&self,
context: &Context<'_>, // The GraphQL context provides access to the SurrealDB client.
#[graphql(name = "match_id")] match_id: String, // The ID of the match this message belongs to.
#[graphql(name = "user_id")] user_id: String, // The ID of the user who sent the message.
text: String, // The text content of the message.
) -> FieldResult<String> {
// Access the SurrealDB client from the context.
let surreal = context.data::<SurrealClient>()?;
// Formulate the query to insert a new message. This creates a relationship between the match and the message.
let query = format!(
r#"RELATE {0}->message->{1} SET text = "{text}";"#,
match_id,
user_id,
text = text
);
// Execute the query against SurrealDB.
let query = surreal.query(query).await;
// Handle any errors that occur during query execution.
if let Err(e) = query {
tracing::error!("Error: {:?}", e);
return Err(e.into());
}
// Return a success message.
Ok(String::from("Chat message inserted"))
}
}
In this example, we see how Alice's message is inserted into the database, linked to the chat between Alice and Bob. The insert_message_one
function ensures that the message is recorded and can be retrieved later.
Updating a Message
Sometimes, Alice might want to edit a message she has already sent. For example, she notices a typo and wants to correct it. Here’s the code that handles updating an existing message:
// Mutation to update an existing chat message by its primary key (ID).
#[graphql(name = "update_message_by_pk")]
async fn update_message_by_pk(
&self,
context: &Context<'_>, // The GraphQL context provides access to the SurrealDB client.
id: String, // The ID of the message to update.
#[graphql(name = "_set")] _set: messages::UpdateSetInput, // The fields to update.
) -> FieldResult<String> {
// Access the SurrealDB client from the context.
let surreal = context.data::<SurrealClient>()?;
// Convert the message ID into a SurrealID type.
let SurrealID(thing) = SurrealID::from(id);
// Execute the update operation.
surreal
.update::<Option<messages::Message>>(&thing)
.merge(_set)
.await?;
// Return a success message.
Ok(String::from("Chat message updated"))
}
This function allows Alice to update her message. By specifying the message ID and the new content, the app updates the message in the database, ensuring that Bob sees the corrected version.
Deleting a Message
What if Alice decides she no longer wants a message to be part of the conversation? Perhaps the message was sent in error, or she changed her mind about what she wrote. The following code handles deleting a message:
// Mutation to delete a chat message by its primary key (ID). This performs a soft delete.
#[graphql(name = "delete_message_by_pk")]
async fn delete_message_by_pk(&self, context: &Context<'_>, id: String) -> FieldResult<String> {
// Access the SurrealDB client from the context.
let surreal = context.data::<SurrealClient>()?;
// Convert the message ID into a SurrealID type.
let SurrealID(thing) = SurrealID::from(id);
// Perform a soft delete by updating the is_deleted field to true.
surreal
.update::<Option<messages::Message>>(thing)
.merge(UpdateSetInput {
is_deleted: Some(true), // Set the is_deleted flag to true.
..Default::default()
})
.await?;
// Return a success message.
Ok(String::from("Chat message deleted"))
}
Here, instead of permanently removing the message, the app performs a "soft delete." This means the message is marked as deleted but remains in the database, allowing for potential recovery if needed.
Receiving Real-Time Updates
In a live chat scenario, it’s essential for Bob to see Alice’s messages as soon as they are sent. This is where subscriptions come in. The following code sets up a subscription that allows Bob to receive real-time updates whenever a new message is sent:
use std::{collections::VecDeque, sync::Arc};
use async_graphql::{Context, Subscription};
use async_stream::stream;
use surrealdb::{Action, Notification};
use tokio::sync::Mutex;
use tokio_stream::Stream;
use crate::{graphql::types::SurrealClient, models::messages};
// This struct serves as the root for all subscription operations related to messages.
#[derive(Default)]
pub struct MessageSubscriptionRoot;
// Implementing the subscription operations on the MessageSubscriptionRoot struct.
#[Subscription]
impl MessageSubscriptionRoot {
// Subscription that listens for new messages in a specific chat.
// It streams updates back to the client whenever a new message is added or an existing message is updated.
#[graphql(name = "select_chat_by_users")]
pub async fn select_chat_by_users<'a>(
&self,
context: &'a Context<'_>, // The GraphQL context provides access to the SurrealDB client.
#[graphql(name = "match_id")] match_id: String, // The ID of the match (chat) to subscribe to.
) -> impl Stream<Item = Option<VecDeque<messages::Message>>> + 'a {
// Access the SurrealDB client from the context.
let surreal = context.data::<SurrealClient>().unwrap();
// Initial query to get the current state of the chat messages.
let mut query_result = surreal
.query(format!(
"SELECT *, out.* as user FROM message WHERE in = {0} ORDER BY created_at DESC;",
match_id
))
.await
.unwrap();
let messages = query_result
.take::<Vec<messages::Message>>(0)
.unwrap_or_default();
// Start a live query to listen for new messages being sent to this chat.
let mut stream_result = surreal
.query(format!(
"LIVE SELECT *, out.* as user FROM message WHERE in = {0}",
match_id
))
.await
.unwrap();
let stream_messages = stream_result
.stream::<Notification<messages::Message>>(0)
.unwrap();
let messages = Arc::new(Mutex::new(VecDeque::from(messages)));
// The async_stream crate is used here to yield updates as they come in real-time.
stream! {
// Yield the initial set of messages.
yield Some(messages.lock().await.clone());
// Loop over incoming notifications and update the message list.
for await result in stream_messages {
yield match result {
Ok(notification) => {
let message = notification.data;
match notification.action {
// Handle new messages by pushing them to the front of the deque.
Action::Create => {
messages.lock().await.push_front(message.clone());
},
// Handle updates to existing messages by finding and replacing them in the deque.
Action::Update => {
let index = messages.lock().await.iter().position(|m| m.id == message.id);
if let Some(index) = index {
messages.lock().await[index] = message.clone();
}
},
// Handle deletions by removing the message from the deque.
Action::Delete => {
let index = messages.lock().await.iter().position(|m| m.id == message.id);
if index.is_some() {
messages.lock().await.remove(index.unwrap());
}
},
_ => {},
}
// Yield the updated message list to the client.
Some(messages.lock().await.clone())
},
// Handle errors in the notification stream.
Err(_) => None,
}
}
}
}
}
This subscription ensures that whenever Alice sends, edits, or deletes a message, Bob’s chat interface is updated in real time. The code uses the async_stream
crate to yield updates as they occur, creating a smooth and responsive user experience.
Conclusion
Building a backend for a modern dating app involves choosing the right technologies that can handle complex data relationships, real-time interactions, and scalable growth. In this post, we've walked through the process of setting up such a backend using Rust, Async-GraphQL, SurrealDB, and WebSocket. Each of these tools plays a crucial role in ensuring the app functions smoothly, from managing user data to providing instant chat updates.
While this stack shows great potential, the choice of technologies should always align with the specific needs and goals of your project. As with any development effort, it's essential to consider not just the current capabilities of these tools, but also how they fit into your long-term strategy. This post has provided a glimpse into one way to approach building a dating app backend, offering insights and examples that can be adapted to suit a variety of projects.