REST API en Rust con MongoDB, JWT, Bcrypt y Swagger

GuĂ­a completa para construir una API segura y documentada con MongoDB

Tabla de Contenidos

1. IntroducciĂłn

En este manual construiremos una REST API completa en Rust con MongoDB como base de datos, que incluye:

TecnologĂ­as principales:

2. ConfiguraciĂłn del Proyecto

2.1. Crear el proyecto y dependencias

# Crear nuevo proyecto
cargo new rust_mongodb_api --bin
cd rust_mongodb_api

# Editar Cargo.toml para añadir dependencias
[dependencies]
actix-web = "4.0"
actix-rt = "2.0"  # Runtime para actix
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"  # Para variables de entorno
config = "0.13"  # Para gestiĂłn de configuraciĂłn
mongodb = { version = "2.0", features = ["sync"] }  # Driver de MongoDB
bcrypt = "0.14"  # Para hashing de contraseñas
jsonwebtoken = "8.0"  # Para JWT
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
validator = { version = "2.0", features = ["derive"] }  # Para validaciĂłn
utoipa = { version = "2.0", features = ["actix_extras"] }  # Para Swagger
utoipa-swagger-ui = "2.0"  # UI para Swagger
log = "0.4"  # Para logging
env_logger = "0.9"  # Logger para desarrollo
thiserror = "1.0"  # Para manejo de errores

2.2. ConfiguraciĂłn de la aplicaciĂłn

config/default.toml
[server]
host = "127.0.0.1"
port = 8080

[database]
url = "mongodb://localhost:27017"
name = "rust_api"
timeout_ms = 5000  # Timeout de conexiĂłn

[auth]
jwt_secret = "secret_key_should_be_long_and_complex"
jwt_expires_in = 1440  # minutos (24 horas)
jwt_maxage = 1440
salt_rounds = 12  # Coste para bcrypt
src/config.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
}

#[derive(Debug, Deserialize)]
pub struct DatabaseConfig {
    pub url: String,
    pub name: String,
    pub timeout_ms: u64,
}

#[derive(Debug, Deserialize)]
pub struct AuthConfig {
    pub jwt_secret: String,
    pub jwt_expires_in: i64,
    pub jwt_maxage: i64,
    pub salt_rounds: u32,
}

#[derive(Debug, Deserialize)]
pub struct Config {
    pub server: ServerConfig,
    pub database: DatabaseConfig,
    pub auth: AuthConfig,
}

impl Config {
    pub fn from_env() -> Result {
        let mut cfg = config::Config::builder();
        
        // Cargar configuraciĂłn por defecto
        cfg = cfg.add_source(config::File::with_name("config/default"));
        
        // Sobrescribir con variables de entorno (con prefijo APP)
        cfg = cfg.add_source(
            config::Environment::with_prefix("APP")
                .separator("__")
                .list_separator(",")
                .with_list_parse_key("auth.jwt_secret"),
        );
        
        cfg.build()?.try_deserialize()
    }
}

3. Estructura del Proyecto

rust_mongodb_api/
├── Cargo.toml
├── config/
│   └── default.toml
├── src/
│   ├── main.rs          # Punto de entrada
│   ├── config.rs        # Configuración
│   ├── models/          # Modelos de datos
│   │   ├── mod.rs
│   │   ├── user.rs
│   ├── schemas/         # Esquemas para validación
│   │   ├── mod.rs
│   │   ├── user.rs
│   ├── errors/          # Manejo de errores
│   │   ├── mod.rs
│   │   ├── api_error.rs
│   ├── utils/           # Utilidades
│   │   ├── mod.rs
│   │   ├── auth.rs
│   │   ├── db.rs
│   ├── handlers/        # Controladores
│   │   ├── mod.rs
│   │   ├── auth.rs
│   │   ├── user.rs
│   ├── routes/          # Definición de rutas
│   │   ├── mod.rs
│   │   ├── auth.rs
│   │   ├── user.rs
│   └── app_state.rs     # Estado de la aplicación
└── .env                 # Variables de entorno

4. ConfiguraciĂłn de MongoDB

4.1. ConexiĂłn a MongoDB

src/utils/db.rs
use mongodb::{
    sync::{Client, Database},
    options::{ClientOptions, ResolverConfig},
};
use std::time::Duration;
use crate::config::DatabaseConfig;

pub fn connect_mongodb(config: &DatabaseConfig) -> Result {
    // Configurar opciones del cliente
    let mut client_options = ClientOptions::parse_with_resolver_config(
        &config.url,
        ResolverConfig::cloudflare(),
    )?;
    
    // Configurar timeout
    client_options.connect_timeout = Some(Duration::from_millis(config.timeout_ms));
    client_options.server_selection_timeout = Some(Duration::from_millis(config.timeout_ms));
    
    // Conectar al cliente
    let client = Client::with_options(client_options)?;
    
    // Verificar conexiĂłn
    client
        .database("admin")
        .run_command(doc! {"ping": 1}, None)?;
    
    // Seleccionar base de datos
    Ok(client.database(&config.name))
}

4.2. Estado de la aplicaciĂłn

src/app_state.rs
use mongodb::sync::Database;
use std::sync::Arc;
use crate::{config::{Config, AuthConfig}, errors::ApiError};

#[derive(Clone)]
pub struct AppState {
    pub db: Arc<Database>,
    pub auth_config: AuthConfig,
}

impl AppState {
    pub fn new(config: &Config) -> Result<Self, ApiError> {
        let db = Arc::new(
            crate::utils::db::connect_mongodb(&config.database)
                .map_err(|e| ApiError::new(500, format!("Error de base de datos: {}", e)))?
        );
        
        Ok(Self {
            db,
            auth_config: config.auth.clone(),
        })
    }
}

5. Modelos y Schemas

5.1. Modelo de Usuario

src/models/user.rs
use mongodb::bson::{
    oid::ObjectId,
    DateTime,
    doc,
};
use serde::{Serialize, Deserialize};
use validator::Validate;

#[derive(Debug, Serialize, Deserialize)]
pub struct User {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    pub id: Option<ObjectId>,
    pub email: String,
    #[serde(skip_serializing)]  // Nunca devolver el hash de la contraseña
    pub password: String,
    pub first_name: Option<String>,
    pub last_name: Option<String>,
    pub is_active: bool,
    pub is_staff: bool,
    pub is_superuser: bool,
    pub thumbnail: Option<String>,
    pub date_joined: DateTime,
    pub last_login: Option<DateTime>,
}

impl User {
    pub fn collection(db: &mongodb::sync::Database) -> mongodb::sync::Collection<Self> {
        db.collection::<Self>("users")
    }
    
    pub fn create_indexes(db: &mongodb::sync::Database) -> Result<(), mongodb::error::Error> {
        let collection = Self::collection(db);
        
        // ĂŤndice Ăşnico para email
        collection.create_index(
            doc! { "email": 1 },
            mongodb::options::IndexOptions::builder()
                .unique(true)
                .build(),
        )?;
        
        Ok(())
    }
}

#[derive(Debug, Deserialize, Validate)]
pub struct NewUser {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8))]
    pub password: String,
    #[validate(length(min = 2))]
    pub first_name: Option<String>,
    #[validate(length(min = 2))]
    pub last_name: Option<String>,
}

5.2. Schemas para validaciĂłn

src/schemas/user.rs
use serde::{Serialize, Deserialize};
use validator::Validate;

#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct RegisterUserSchema {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8))]
    pub password: String,
    #[validate(length(min = 2))]
    pub first_name: String,
    #[validate(length(min = 2))]
    pub last_name: String,
}

#[derive(Debug, Serialize, Deserialize, Validate)]
pub struct LoginUserSchema {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8))]
    pub password: String,
}

6. AutenticaciĂłn (JWT + Bcrypt)

6.1. ConfiguraciĂłn de JWT

src/utils/auth.rs
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
use serde::{Serialize, Deserialize};
use crate::config::AuthConfig;

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,  // user_id
    pub exp: usize,   // expiry
    pub is_staff: bool,
    pub is_superuser: bool,
}

pub fn create_jwt_token(
    user_id: &str,
    is_staff: bool,
    is_superuser: bool,
    auth_config: &AuthConfig,
) -> Result<String, jsonwebtoken::errors::Error> {
    let expiration_time = Utc::now()
        .checked_add_signed(Duration::minutes(auth_config.jwt_expires_in))
        .expect("Invalid timestamp")
        .timestamp();
    
    let claims = Claims {
        sub: user_id.to_string(),
        exp: expiration_time as usize,
        is_staff,
        is_superuser,
    };
    
    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(auth_config.jwt_secret.as_bytes()),
    )
}

pub fn decode_jwt_token(
    token: &str,
    auth_config: &AuthConfig,
) -> Result<Claims, jsonwebtoken::errors::Error> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(auth_config.jwt_secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
}

6.2. Hashing con Bcrypt

ContinuaciĂłn de src/utils/auth.rs
use bcrypt::{hash, verify, DEFAULT_COST};
use crate::config::AuthConfig;

pub fn hash_password(password: &str, salt_rounds: u32) -> Result<String, bcrypt::BcryptError> {
    hash(password, salt_rounds)
}

pub fn verify_password(password: &str, hashed_password: &str) -> Result<bool, bcrypt::BcryptError> {
    verify(password, hashed_password)
}

7. Rutas y Controladores

7.1. Rutas de autenticaciĂłn

src/routes/auth.rs
use actix_web::{web, Scope};
use crate::handlers::auth::{login_handler, register_handler, logout_handler, me_handler};

pub fn auth_routes() -> Scope {
    web::scope("/auth")
        .service(
            web::resource("/register")
                .route(web::post().to(register_handler))
        )
        .service(
            web::resource("/login")
                .route(web::post().to(login_handler))
        )
        .service(
            web::resource("/logout")
                .route(web::post().to(logout_handler))
        )
        .service(
            web::resource("/me")
                .route(web::get().to(me_handler))
        )
}
src/handlers/auth.rs
use actix_web::{web, HttpResponse};
use mongodb::bson::{doc, DateTime};
use serde_json::json;
use validator::Validate;

use crate::{
    models::user::User,
    schemas::user::{RegisterUserSchema, LoginUserSchema},
    utils::auth::{create_jwt_token, hash_password, verify_password},
    errors::ApiError,
    AppState,
};

pub async fn register_handler(
    state: web::Data<AppState>,
    body: web::Json<RegisterUserSchema>,
) -> Result<HttpResponse, ApiError> {
    // Validar los datos de entrada
    body.validate()
        .map_err(|e| ApiError::new(400, e.to_string()))?;
    
    let db = &state.db;
    let collection = User::collection(db);
    
    // Verificar si el usuario ya existe
    let existing_user = collection.find_one(
        doc! { "email": &body.email },
        None,
    )?;
    
    if existing_user.is_some() {
        return Err(ApiError::Conflict("User already exists".into()));
    }
    
    // Hashear la contraseña
    let hashed_password = hash_password(&body.password, state.auth_config.salt_rounds)?;
    
    // Crear nuevo usuario
    let new_user = User {
        id: None,
        email: body.email.clone(),
        password: hashed_password,
        first_name: Some(body.first_name.clone()),
        last_name: Some(body.last_name.clone()),
        is_active: true,
        is_staff: false,
        is_superuser: false,
        thumbnail: None,
        date_joined: DateTime::now(),
        last_login: None,
    };
    
    // Insertar en la base de datos
    let insert_result = collection.insert_one(new_user, None)?;
    let user_id = insert_result
        .inserted_id
        .as_object_id()
        .ok_or_else(|| ApiError::new(500, "Error getting user ID"))?;
    
    // Crear token JWT
    let token = create_jwt_token(
        &user_id.to_hex(),
        false,
        false,
        &state.auth_config,
    )?;
    
    Ok(HttpResponse::Ok().json(json!({
        "status": "success",
        "token": token,
        "user_id": user_id.to_hex()
    })))
}

pub async fn login_handler(
    state: web::Data<AppState>,
    body: web::Json<LoginUserSchema>,
) -> Result<HttpResponse, ApiError> {
    // Validar los datos de entrada
    body.validate()
        .map_err(|e| ApiError::new(400, e.to_string()))?;
    
    let db = &state.db;
    let collection = User::collection(db);
    
    // Buscar usuario por email
    let user = collection.find_one(
        doc! { "email": &body.email },
        None,
    )?
    .ok_or(ApiError::Unauthorized("Invalid credentials".into()))?;
    
    // Verificar contraseña
    if !verify_password(&body.password, &user.password)? {
        return Err(ApiError::Unauthorized("Invalid credentials".into()));
    }
    
    // Actualizar last_login
    collection.update_one(
        doc! { "_id": user.id },
        doc! { "$set": { "last_login": DateTime::now() } },
        None,
    )?;
    
    // Crear token JWT
    let token = create_jwt_token(
        &user.id.unwrap().to_hex(),
        user.is_staff,
        user.is_superuser,
        &state.auth_config,
    )?;
    
    Ok(HttpResponse::Ok().json(json!({
        "status": "success",
        "token": token,
        "user_id": user.id.unwrap().to_hex()
    })))
}

8. Middleware de AutenticaciĂłn

src/utils/auth.rs (extensiĂłn)
use actix_web::{dev::ServiceRequest, Error, http, web};
use actix_web_httpauth::extractors::bearer::{BearerAuth, Config};
use actix_web_httpauth::extractors::AuthenticationError;
use jsonwebtoken::errors::ErrorKind;
use mongodb::bson::oid::ObjectId;

use crate::{AppState, errors::ApiError};

pub async fn validator(
    req: ServiceRequest,
    credentials: BearerAuth,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
    let app_state = req.app_data::<web::Data<AppState>>().unwrap();
    
    match decode_jwt_token(credentials.token(), &app_state.auth_config) {
        Ok(claims) => {
            // Verificar que el usuario todavĂ­a existe
            let user_id = ObjectId::parse_str(&claims.sub)
                .map_err(|_| {
                    let error = AuthenticationError::from(Config::default())
                        .with_error("Invalid user ID in token");
                    (error.into(), req)
                })?;
            
            let db = &app_state.db;
            let user_exists = User::collection(db)
                .count_documents(
                    doc! { "_id": user_id },
                    None,
                )
                .map_err(|e| {
                    let error = AuthenticationError::from(Config::default())
                        .with_error(format!("Database error: {}", e));
                    (error.into(), req)
                })? > 0;
            
            if !user_exists {
                let error = AuthenticationError::from(Config::default())
                    .with_error("User no longer exists");
                return Err((error.into(), req));
            }
            
            // Añadir claims a las extensiones de la petición
            req.extensions_mut().insert(claims);
            Ok(req)
        },
        Err(e) => {
            let config = req.app_data::<Config>().cloned().unwrap_or_default();
            
            let error = match e.kind() {
                ErrorKind::ExpiredSignature => {
                    AuthenticationError::from(config).with_error("Token expired")
                },
                _ => AuthenticationError::from(config).with_error("Invalid token"),
            };
            
            Err((error.into(), req))
        }
    }
}

9. DocumentaciĂłn con Swagger

9.1. Configurar Utoipa para Swagger

src/main.rs (extracto)
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

#[derive(OpenApi)]
#[openapi(
    paths(
        handlers::auth::login_handler,
        handlers::auth::register_handler,
        handlers::auth::me_handler,
    ),
    components(
        schemas(
            schemas::user::RegisterUserSchema,
            schemas::user::LoginUserSchema,
            models::user::User,
            errors::ApiError,
        )
    ),
    tags(
        (name = "Auth", description = "Authentication endpoints")
    )
)]
struct ApiDoc;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // ... configuraciĂłn previa ...
    
    let app = HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(app_state.clone()))
            .configure(routes::config)
            .service(
                SwaggerUi::new("/swagger-ui/{_:.*}")
                    .url("/api-docs/openapi.json", ApiDoc::openapi()),
            )
    })
    .bind((config.server.host, config.server.port))?
    .run();
    
    app.await
}

9.2. Documentar endpoints

src/handlers/auth.rs (extracto documentado)
use utoipa::ToSchema;

#[utoipa::path(
    post,
    path = "/api/auth/register",
    tag = "Auth",
    request_body = RegisterUserSchema,
    responses(
        (status = 200, description = "User created successfully", body = inline(RegisterResponse)),
        (status = 400, description = "Validation error", body = ApiError),
        (status = 409, description = "User already exists", body = ApiError),
        (status = 500, description = "Internal server error", body = ApiError),
    )
)]
pub async fn register_handler(
    state: web::Data<AppState>,
    body: web::Json<RegisterUserSchema>,
) -> Result<HttpResponse, ApiError> {
    // ... implementaciĂłn ...
}

/// Respuesta al registrar un usuario
#[derive(Serialize, ToSchema)]
struct RegisterResponse {
    status: String,
    token: String,
    user_id: String,
}

10. Pruebas y Despliegue

10.1. Pruebas de integraciĂłn

tests/auth_test.rs
use actix_web::{test, App};
use serde_json::json;
use your_app::{
    routes::config,
    models::user::User,
    utils::auth::hash_password,
};

#[actix_rt::test]
async fn test_register_and_login() {
    // Configurar aplicaciĂłn de prueba
    let app = test::init_service(App::new().configure(config)).await;
    
    // Datos de prueba
    let user_data = json!({
        "email": "test@example.com",
        "password": "password123",
        "first_name": "Test",
        "last_name": "User"
    });
    
    // Registrar usuario
    let req = test::TestRequest::post()
        .uri("/auth/register")
        .set_json(&user_data)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
    
    // Login con credenciales correctas
    let login_data = json!({
        "email": "test@example.com",
        "password": "password123"
    });
    
    let req = test::TestRequest::post()
        .uri("/auth/login")
        .set_json(&login_data)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
    
    // Login con credenciales incorrectas
    let bad_login_data = json!({
        "email": "test@example.com",
        "password": "wrongpassword"
    });
    
    let req = test::TestRequest::post()
        .uri("/auth/login")
        .set_json(&bad_login_data)
        .to_request();
    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status().as_u16(), 401);
}

10.2. Despliegue en Linux con Docker

Dockerfile para producciĂłn
# Stage 1: Builder
FROM rust:1.60 as builder

# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
    cmake \
    libssl-dev \
    pkg-config \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . .

# Build estático con musl para imagen minimal
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release

# Stage 2: Runtime image
FROM alpine:latest

# Instalar certificados SSL
RUN apk --no-cache add ca-certificates

# Copiar binario
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust_mongodb_api /usr/local/bin/rust_mongodb_api

# Variables de entorno
ENV RUST_LOG=info
ENV APP__DATABASE__URL=mongodb://mongo:27017
ENV APP__DATABASE__NAME=rust_api
ENV APP__AUTH__JWT_SECRET=your_secure_secret_key

EXPOSE 8080
CMD ["rust_mongodb_api"]
docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - mongo
    environment:
      - APP__DATABASE__URL=mongodb://mongo:27017
      - APP__DATABASE__NAME=rust_api
      - APP__AUTH__JWT_SECRET=your_secure_secret_key
      - APP__AUTH__SALT_ROUNDS=12
    restart: unless-stopped

  mongo:
    image: mongo:5.0
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=password
    volumes:
      - mongodb_data:/data/db
    ports:
      - "27017:27017"
    restart: unless-stopped

  mongo-express:
    image: mongo-express:0.54
    restart: always
    ports:
      - "8081:8081"
    environment:
      - ME_CONFIG_MONGODB_ADMINUSERNAME=admin
      - ME_CONFIG_MONGODB_ADMINPASSWORD=password
      - ME_CONFIG_MONGODB_SERVER=mongo
    depends_on:
      - mongo

volumes:
  mongodb_data:

Consejos para producciĂłn: