Building Rust Services with ScoutQuest

Learn how to build high-performance Rust services with ScoutQuest service discovery. This tutorial covers service registration, discovery, and communication patterns using Rust.

Prerequisites

  • Rust 1.70+ installed
  • Cargo package manager
  • ScoutQuest server running
  • Basic knowledge of Rust and async programming

Project Setup

Create a new Rust project and add ScoutQuest dependencies:

Create new project

cargo new --bin rust-service-example
cd rust-service-example

Cargo.toml

[package]
name = "rust-service-example"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1.0", features = ["full"] }
axum = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
scoutquest-rust = "1.0"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1.0"

Basic Rust Service

Let's build a user service using Axum web framework:

src/main.rs

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
    routing::{get, post},
    Router,
};
use scoutquest_rust::ServiceDiscoveryClient;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use tracing::{info, warn};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

type UserStore = Arc>>;

#[derive(Clone)]
struct AppState {
    users: UserStore,
    scout_client: ServiceDiscoveryClient,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize tracing
    tracing_subscriber::init();

    // Initialize ScoutQuest client
    let scout_client = ServiceDiscoveryClient::new("http://localhost:8080", None)?;

    // Create application state
    let state = AppState {
        users: Arc::new(Mutex::new(create_sample_users())),
        scout_client,
    };

    // Build our application with routes
    let app = Router::new()
        .route("/health", get(health_check))
        .route("/users", get(get_users).post(create_user))
        .route("/users/:id", get(get_user))
        .route("/users/:id/orders", get(get_user_orders))
        .layer(CorsLayer::permissive())
        .with_state(state.clone());

    // Start the server
    let port = std::env::var("PORT")
        .unwrap_or_else(|_| "3001".to_string())
        .parse::()?;

    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;

    info!("🚀 User service starting on port {}", port);

    // Register with ScoutQuest
    tokio::spawn(register_service(state.scout_client.clone(), port));

    // Start serving
    axum::serve(listener, app).await?;

    Ok(())
}

async fn register_service(client: ServiceDiscoveryClient, port: u16) {
    match client.register_service(
        "user-service",
        "localhost",
        port,
        Some(vec!["api".to_string(), "users".to_string(), "v1".to_string()]),
        Some([("version".to_string(), "1.0.0".to_string())].into()),
    ).await {
        Ok(_) => info!("✅ Registered with ScoutQuest"),
        Err(e) => warn!("❌ Failed to register with ScoutQuest: {}", e),
    }
}

fn create_sample_users() -> HashMap {
    let mut users = HashMap::new();
    users.insert(1, User {
        id: 1,
        name: "John Doe".to_string(),
        email: "john@example.com".to_string(),
    });
    users.insert(2, User {
        id: 2,
        name: "Jane Smith".to_string(),
        email: "jane@example.com".to_string(),
    });
    users
}

async fn health_check() -> Json {
    Json(serde_json::json!({
        "status": "healthy",
        "service": "user-service",
        "timestamp": chrono::Utc::now().to_rfc3339()
    }))
}

async fn get_users(State(state): State) -> Json> {
    let users = state.users.lock().await;
    let user_list: Vec = users.values().cloned().collect();
    Json(user_list)
}

async fn get_user(
    Path(id): Path,
    State(state): State,
) -> Result, StatusCode> {
    let users = state.users.lock().await;
    match users.get(&id) {
        Some(user) => Ok(Json(user.clone())),
        None => Err(StatusCode::NOT_FOUND),
    }
}

async fn create_user(
    State(state): State,
    Json(payload): Json,
) -> Result, StatusCode> {
    let mut users = state.users.lock().await;
    let id = users.len() as u32 + 1;

    let user = User {
        id,
        name: payload.name,
        email: payload.email,
    };

    users.insert(id, user.clone());
    Ok(Json(user))
}

async fn get_user_orders(
    Path(user_id): Path,
    State(state): State,
) -> Result, StatusCode> {
    // Check if user exists
    let users = state.users.lock().await;
    if !users.contains_key(&user_id) {
        return Err(StatusCode::NOT_FOUND);
    }
    drop(users);

    // Call order service using ScoutQuest
    match state.scout_client.get::(
        "order-service",
        &format!("/orders/user/{}", user_id)
    ).await {
        Ok(orders) => Ok(Json(orders)),
        Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE),
    }
}

Service Communication

Create a service that communicates with other services:

Order Service Example

use axum::{extract::State, response::Json, routing::post, Router};
use scoutquest_rust::ServiceDiscoveryClient;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u32,
    user_id: u32,
    product_id: u32,
    quantity: u32,
    total: f64,
    status: String,
}

#[derive(Deserialize)]
struct CreateOrder {
    user_id: u32,
    product_id: u32,
    quantity: u32,
}

#[derive(Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[derive(Deserialize)]
struct Product {
    id: u32,
    name: String,
    price: f64,
    stock: u32,
}

async fn create_order(
    State(client): State>,
    Json(payload): Json,
) -> Result, axum::http::StatusCode> {
    // Verify user exists
    let user: User = match client.get("user-service", &format!("/users/{}", payload.user_id)).await {
        Ok(user) => user,
        Err(_) => return Err(axum::http::StatusCode::BAD_REQUEST),
    };

    // Verify product exists and has sufficient stock
    let product: Product = match client.get("product-service", &format!("/products/{}", payload.product_id)).await {
        Ok(product) => product,
        Err(_) => return Err(axum::http::StatusCode::BAD_REQUEST),
    };

    if product.stock < payload.quantity {
        return Err(axum::http::StatusCode::BAD_REQUEST);
    }

    // Update product stock
    let updated_stock = serde_json::json!({ "stock": product.stock - payload.quantity });
    if let Err(_) = client.put("product-service", &format!("/products/{}/stock", payload.product_id), &updated_stock).await {
        return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
    }

    // Create order
    let order = Order {
        id: 1, // In real app, use proper ID generation
        user_id: payload.user_id,
        product_id: payload.product_id,
        quantity: payload.quantity,
        total: product.price * payload.quantity as f64,
        status: "confirmed".to_string(),
    };

    Ok(Json(order))
}

Error Handling and Resilience

Implement proper error handling and resilience patterns:

Custom Error Types

use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ServiceError {
    #[error("Service not found: {0}")]
    ServiceNotFound(String),

    #[error("Service unavailable: {0}")]
    ServiceUnavailable(String),

    #[error("Invalid request: {0}")]
    BadRequest(String),

    #[error("Internal error: {0}")]
    Internal(String),
}

impl IntoResponse for ServiceError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ServiceError::ServiceNotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ServiceError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg),
            ServiceError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ServiceError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
        };

        let body = Json(json!({
            "error": error_message,
            "status": status.as_u16()
        }));

        (status, body).into_response()
    }
}

Retry Logic

use std::time::Duration;
use tokio::time::sleep;

pub struct RetryConfig {
    pub max_attempts: u32,
    pub base_delay: Duration,
    pub max_delay: Duration,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            base_delay: Duration::from_millis(100),
            max_delay: Duration::from_secs(5),
        }
    }
}

pub async fn with_retry(
    operation: F,
    config: RetryConfig,
) -> Result
where
    F: Fn() -> Fut,
    Fut: std::future::Future>,
    E: std::fmt::Debug,
{
    let mut attempt = 0;
    let mut delay = config.base_delay;

    loop {
        attempt += 1;

        match operation().await {
            Ok(result) => return Ok(result),
            Err(e) if attempt >= config.max_attempts => return Err(e),
            Err(e) => {
                tracing::warn!("Attempt {} failed: {:?}, retrying in {:?}", attempt, e, delay);
                sleep(delay).await;
                delay = std::cmp::min(delay * 2, config.max_delay);
            }
        }
    }
}

// Usage example
async fn call_service_with_retry(
    client: &ServiceDiscoveryClient,
    service: &str,
    path: &str,
) -> Result {
    with_retry(
        || client.get::(service, path),
        RetryConfig::default(),
    )
    .await
    .map_err(|e| ServiceError::ServiceUnavailable(e.to_string()))
}

Health Monitoring

Implement comprehensive health checks:

Advanced Health Check

use serde_json::{json, Value};
use std::collections::HashMap;

#[derive(Debug)]
pub struct HealthCheck {
    name: String,
    check_fn: Box std::pin::Pin + Send>> + Send + Sync>,
}

impl HealthCheck {
    pub fn new(name: String, check_fn: F) -> Self
    where
        F: Fn() -> Fut + Send + Sync + 'static,
        Fut: std::future::Future + Send + 'static,
    {
        Self {
            name,
            check_fn: Box::new(move || Box::pin(check_fn())),
        }
    }
}

pub struct HealthChecker {
    checks: Vec,
}

impl HealthChecker {
    pub fn new() -> Self {
        Self {
            checks: Vec::new(),
        }
    }

    pub fn add_check(&mut self, check: HealthCheck) {
        self.checks.push(check);
    }

    pub async fn check_health(&self) -> Value {
        let mut results = HashMap::new();
        let mut overall_healthy = true;

        for check in &self.checks {
            let healthy = (check.check_fn)().await;
            if !healthy {
                overall_healthy = false;
            }
            results.insert(check.name.clone(), healthy);
        }

        json!({
            "status": if overall_healthy { "healthy" } else { "unhealthy" },
            "checks": results,
            "timestamp": chrono::Utc::now().to_rfc3339()
        })
    }
}

// Usage
async fn setup_health_checker(scout_client: ServiceDiscoveryClient) -> HealthChecker {
    let mut checker = HealthChecker::new();

    // Database connectivity check
    checker.add_check(HealthCheck::new(
        "database".to_string(),
        || async {
            // Check database connection
            true // Placeholder
        }
    ));

    // Service discovery check
    checker.add_check(HealthCheck::new(
        "service_discovery".to_string(),
        move || {
            let client = scout_client.clone();
            async move {
                client.list_services().await.is_ok()
            }
        }
    ));

    checker
}

Testing Rust Services

Write comprehensive tests for your Rust services:

tests/integration_tests.rs

use axum::{body::Body, http::{Request, StatusCode}};
use scoutquest_rust::ServiceDiscoveryClient;
use serde_json::json;
use tower::ServiceExt;

#[tokio::test]
async fn test_user_service_integration() {
    // Setup test environment
    let scout_client = ServiceDiscoveryClient::new("http://localhost:8080", None)
        .expect("Failed to create ScoutQuest client");

    let app_state = AppState {
        users: Arc::new(Mutex::new(create_sample_users())),
        scout_client,
    };

    let app = create_app(app_state);

    // Test health check
    let response = app
        .clone()
        .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    // Test get users
    let response = app
        .clone()
        .oneshot(Request::builder().uri("/users").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    // Test create user
    let new_user = json!({
        "name": "Test User",
        "email": "test@example.com"
    });

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/users")
                .header("content-type", "application/json")
                .body(Body::from(serde_json::to_vec(&new_user).unwrap()))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_service_discovery() {
    let client = ServiceDiscoveryClient::new("http://localhost:8080", None)
        .expect("Failed to create client");

    // Register test service
    let result = client
        .register_service(
            "test-service",
            "localhost",
            3999,
            Some(vec!["test".to_string()]),
            None,
        )
        .await;

    assert!(result.is_ok());

    // List services
    let services = client.list_services().await.expect("Failed to list services");
    assert!(services.iter().any(|s| s.name == "test-service"));

    // Cleanup
    let _ = client.deregister_service("test-service").await;
}

Running the Service

Build and run your Rust service:

Development

# Run in development mode with auto-reload
cargo install cargo-watch
cargo watch -x run

# Or just run once
cargo run

Production Build

# Build optimized release
cargo build --release

# Run the optimized binary
./target/release/rust-service-example

Environment Configuration

# Set environment variables
export SCOUT_URL=http://localhost:8080
export PORT=3001
export RUST_LOG=info

cargo run

Performance Considerations

  • Connection Pooling: Reuse HTTP connections for service calls
  • Async/Await: Use Tokio for high-concurrency applications
  • Memory Management: Leverage Rust's zero-cost abstractions
  • Caching: Cache service discovery results when appropriate
  • Metrics: Use libraries like `metrics` for observability

Next Steps