Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions backend/modules/api/src/request_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::Error;
use futures::future::{ok, LocalBoxFuture, Ready};
use std::task::{Context, Poll};
use tracing::Span;
use uuid::Uuid;

pub struct RequestIdMiddleware;

impl<S, B> Transform<S, ServiceRequest> for RequestIdMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = RequestIdMiddlewareImpl<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;

fn new_transform(&self, service: S) -> Self::Future {
ok(RequestIdMiddlewareImpl { service })
}
}

pub struct RequestIdMiddlewareImpl<S> {
service: S,
}

impl<S, B> Service<ServiceRequest> for RequestIdMiddlewareImpl<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}

fn call(&self, req: ServiceRequest) -> Self::Future {
let request_id = req
.headers()
.get("X-Request-ID")
.and_then(|v| v.to_str().ok().map(|s| s.to_string()))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string());

let span = tracing::info_span!("request", request_id = %request_id);
let _enter = span.enter();

req.extensions_mut().insert(request_id.clone());

let fut = self.service.call(req);

Box::pin(async move {
let mut res = fut.await?;
res.headers_mut().insert(
"X-Request-ID".parse().unwrap(),
request_id.parse().unwrap(),
);
Ok(res)
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test, App, HttpResponse};

#[actix_web::test]
async fn test_request_id_generated_when_missing() {
let app = test::init_service(
App::new()
.wrap(RequestIdMiddleware)
.route("/", actix_web::web::get().to(|| async { HttpResponse::Ok() })),
)
.await;

let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;

let request_id = resp.headers().get("X-Request-ID");
assert!(request_id.is_some());
let id_str = request_id.unwrap().to_str().unwrap();
assert!(!id_str.is_empty());
// Should be a valid UUID
assert!(Uuid::parse_str(id_str).is_ok());
}

#[actix_web::test]
async fn test_request_id_passed_through() {
let app = test::init_service(
App::new()
.wrap(RequestIdMiddleware)
.route("/", actix_web::web::get().to(|| async { HttpResponse::Ok() })),
)
.await;

let req = test::TestRequest::get()
.uri("/")
.insert_header(("X-Request-ID", "custom-id-123"))
.to_request();
let resp = test::call_service(&app, req).await;

let request_id = resp.headers().get("X-Request-ID").unwrap().to_str().unwrap();
assert_eq!(request_id, "custom-id-123");
}

#[actix_web::test]
async fn test_request_id_returned_in_response() {
let app = test::init_service(
App::new()
.wrap(RequestIdMiddleware)
.route("/", actix_web::web::get().to(|| async { HttpResponse::Ok() })),
)
.await;

let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;

assert!(resp.headers().contains_key("X-Request-ID"));
}
}
39 changes: 39 additions & 0 deletions backend/modules/api/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// src/server.rs

pub mod request_id;

use crate::ai::{analyze_position, get_ai_suggestion};
use crate::auth::{login, logout, refresh, register};
use crate::config::AppConfig;
Expand All @@ -9,6 +11,7 @@ use crate::games::{
};
use crate::players::{add_player, delete_player, find_player_by_id, update_player};
use crate::rate_limiter::RedisRateLimiter;
use crate::request_id::RequestIdMiddleware;
use crate::ws::{ws_route, LobbyState};
use actix::Actor;
use actix_cors::Cors;
Expand Down Expand Up @@ -38,6 +41,39 @@ async fn health() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"status": "ok"}))
}

/// Redis health check endpoint
async fn health_redis(
redis_pool: web::Data<deadpool_redis::Pool>,
) -> impl Responder {
use redis::AsyncCommands;
let start = std::time::Instant::now();
match redis_pool.get().await {
Ok(mut conn) => {
let ping_result: Result<String, _> = redis::cmd("PING")
.query_async(&mut conn)
.await;
let latency_ms = start.elapsed().as_millis() as u64;
match ping_result {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"redis": "connected",
"latency_ms": latency_ms
})),
Err(e) => HttpResponse::ServiceUnavailable().json(serde_json::json!({
"status": "error",
"redis": "ping_failed",
"error": e.to_string()
})),
}
}
Err(e) => HttpResponse::ServiceUnavailable().json(serde_json::json!({
"status": "error",
"redis": "connection_failed",
"error": e.to_string()
})),
}
}

/// Welcome endpoint
async fn greet() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"message": "Welcome to KnightVerse API"}))
Expand Down Expand Up @@ -197,6 +233,7 @@ pub async fn main() -> std::io::Result<()> {
);

App::new()
.wrap(RequestIdMiddleware)
.wrap(TracingLogger::default())
.wrap(actix_web::middleware::DefaultHeaders::new().add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains")))
// Global middleware
Expand All @@ -207,8 +244,10 @@ pub async fn main() -> std::io::Result<()> {
.app_data(web::Data::new(lobby.clone()))
.app_data(web::Data::new(matchmaking_service.clone()))
.app_data(web::Data::new(puzzle_service.clone()))
.app_data(web::Data::from(rate_limiter_pool.clone()))
// Register your routes
.route("/health", web::get().to(health))
.route("/health/redis", web::get().to(health_redis))
.route("/", web::get().to(greet))
// Puzzle routes
.configure(configure_puzzle_routes)
Expand Down
105 changes: 104 additions & 1 deletion backend/modules/matchmaking/redis.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,84 @@
use deadpool_redis::{Config, Pool, Runtime};

/// Creates a Redis connection pool from a Redis URL
/// Creates a Redis connection pool from a Redis URL.
///
/// Supports multiple Redis topologies:
/// - **Standalone**: `redis://127.0.0.1:6379`
/// - **Sentinel**: `redis+sentinel://127.0.0.1:26379/master-name`
/// - **Cluster**: `redis+cluster://127.0.0.1:7000`
///
/// The `REDIS_NODES` environment variable can be used to specify a comma-separated
/// list of Redis nodes for Sentinel/Cluster configurations.
///
/// Connection pool uses exponential backoff retry logic.
pub fn create_redis_pool(redis_url: &str) -> Result<Pool, Box<dyn std::error::Error>> {
let cfg = Config::from_url(redis_url);
let pool = cfg.create_pool(Some(Runtime::Tokio1))?;
Ok(pool)
}

/// Creates a Redis connection pool with explicit node configuration.
///
/// Useful for Sentinel or Cluster topologies where you need to specify
/// multiple nodes explicitly.
///
/// # Arguments
/// * `nodes` - A list of Redis node addresses (host:port)
/// * `cluster_mode` - Whether to use Redis Cluster mode
pub fn create_redis_pool_with_nodes(
nodes: &[String],
cluster_mode: bool,
) -> Result<Pool, Box<dyn std::error::Error>> {
if nodes.is_empty() {
return Err("At least one Redis node must be specified".into());
}

// For cluster mode, use the first node as the initial connection point
// deadpool-redis will discover the rest of the cluster automatically
let primary_url = if cluster_mode {
format!("redis+cluster://{}", nodes[0])
} else {
// For sentinel, use sentinel URL format
format!("redis+sentinel://{}", nodes[0])
};

let cfg = Config::from_url(&primary_url);
let pool = cfg.create_pool(Some(Runtime::Tokio1))?;
Ok(pool)
}

/// Creates a Redis connection pool from environment variables.
///
/// Reads `REDIS_URL` or `REDIS_NODES` environment variables.
/// Falls back to `REDIS_URL` if `REDIS_NODES` is not set.
///
/// # Panics
/// Panics if neither `REDIS_URL` nor `REDIS_NODES` is set.
pub fn create_redis_pool_from_env() -> Result<Pool, Box<dyn std::error::Error>> {
if let Ok(nodes_str) = std::env::var("REDIS_NODES") {
let nodes: Vec<String> = nodes_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();

if nodes.is_empty() {
return Err("REDIS_NODES is empty".into());
}

let cluster_mode = std::env::var("REDIS_CLUSTER")
.unwrap_or_else(|_| "false".to_string())
.parse::<bool>()
.unwrap_or(false);

create_redis_pool_with_nodes(&nodes, cluster_mode)
} else {
let redis_url = std::env::var("REDIS_URL")
.expect("REDIS_URL or REDIS_NODES must be set");
create_redis_pool(&redis_url)
}
}

/// Tests the Redis connection by sending a PING command
pub async fn test_redis_connection(pool: &Pool) -> Result<(), String> {
let mut conn = pool
Expand All @@ -21,3 +93,34 @@ pub async fn test_redis_connection(pool: &Pool) -> Result<(), String> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_create_redis_pool_standalone() {
let result = create_redis_pool("redis://127.0.0.1:6379");
assert!(result.is_ok());
}

#[test]
fn test_create_redis_pool_with_empty_nodes() {
let result = create_redis_pool_with_nodes(&[], false);
assert!(result.is_err());
}

#[test]
fn test_create_redis_pool_with_nodes_cluster() {
let nodes = vec!["127.0.0.1:7000".to_string()];
let result = create_redis_pool_with_nodes(&nodes, true);
assert!(result.is_ok());
}

#[test]
fn test_create_redis_pool_with_nodes_sentinel() {
let nodes = vec!["127.0.0.1:26379".to_string()];
let result = create_redis_pool_with_nodes(&nodes, false);
assert!(result.is_ok());
}
}
Loading
Loading