Manual para Crear una REST API en Rust con MongoDB, JWT, Bcrypt y Swagger

1. Introducción

Este manual detalla cómo construir una API RESTful compleja en Rust, similar a GitHub a pequeña escala, con:

Requisitos previos: Rust instalado (1.60+), MongoDB (4.4+), conocimientos básicos de Rust y conceptos de APIs REST.

2. Configuración Inicial del Proyecto

2.1 Crear un nuevo proyecto Rust

cargo new github-like-api --bin
cd github-like-api

2.2 Configurar Cargo.toml

[package]
name = "github-like-api"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.0"          # Framework web
actix-cors = "0.6"         # Manejo de CORS
serde = { version = "1.0", features = ["derive"] } # Serialización
serde_json = "1.0"         # Manejo de JSON
mongodb = { version = "2.0", features = ["sync"] } # Driver MongoDB
bcrypt = "0.13"            # Hashing de contraseñas
jsonwebtoken = "8.0"       # JWT
chrono = { version = "0.4", features = ["serde"] } # Manejo de fechas
dotenv = "0.15"            # Variables de entorno
uuid = { version = "1.0", features = ["v4", "serde"] } # Generación de UUIDs
thiserror = "1.0"          # Manejo de errores
futures = "0.3"            # Para programación asíncrona
utoipa = { version = "1.0", features = ["actix_extras"] } # Documentación Swagger
utoipa-swagger-ui = "1.0"  # UI para Swagger

3. Estructura del Proyecto

github-like-api/
├── src/
│   ├── main.rs            # Punto de entrada
│   ├── models/            # Modelos de datos
│   │   ├── user.rs        # Modelo de usuario
│   │   ├── repository.rs  # Modelo de repositorio
│   │   └── mod.rs         # Exportación de modelos
│   ├── handlers/          # Manejadores de rutas
│   │   ├── auth.rs        # Autenticación
│   │   ├── user.rs        # Operaciones de usuario
│   │   ├── repo.rs        # Operaciones de repositorio
│   │   └── mod.rs         # Exportación de handlers
│   ├── db/                # Conexión a MongoDB
│   │   ├── connection.rs  # Manejo de conexión
│   │   └── mod.rs         # Exportación de db
│   ├── utils/             # Utilidades
│   │   ├── auth.rs        # Utilidades de autenticación
│   │   └── mod.rs         # Exportación de utils
│   ├── errors.rs          # Manejo de errores personalizados
│   └── app_state.rs       # Estado compartido de la aplicación
├── .env                   # Variables de entorno
├── Cargo.toml             # Configuración del proyecto
└── README.md              # Documentación

4. Configuración Básica del Servidor

4.1 Configurar variables de entorno (.env)

DATABASE_URL=mongodb://localhost:27017
DATABASE_NAME=github_like
JWT_SECRET=tu_super_secreto_jwt_muy_seguro
JWT_EXPIRATION_HOURS=24
BCRYPT_COST=12
SERVER_ADDRESS=127.0.0.1:8080

4.2 Estado de la aplicación (app_state.rs)

use mongodb::Database;
use std::sync::Arc;

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

4.3 Punto de entrada principal (main.rs)

use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use mongodb::{options::ClientOptions, Client};
use std::sync::Arc;

mod app_state;
mod db;
mod errors;
mod handlers;
mod models;
mod utils;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::init();

    // Configurar MongoDB
    let db_uri = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let db_name = std::env::var("DATABASE_NAME").expect("DATABASE_NAME must be set");
    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    
    let mut client_options = ClientOptions::parse(&db_uri).await.unwrap();
    client_options.app_name = Some("GitHub Like API".to_string());
    
    let client = Client::with_options(client_options).unwrap();
    let db = client.database(&db_name);
    let db = Arc::new(db);
    
    // Crear estado de la aplicación
    let app_state = web::Data::new(app_state::AppState {
        db: Arc::clone(&db),
        jwt_secret,
    });

    // Configurar e iniciar servidor
    let server_address = std::env::var("SERVER_ADDRESS").unwrap_or("127.0.0.1:8080".to_string());
    
    println!("🚀 Servidor iniciado en http://{}", server_address);
    
    HttpServer::new(move || {
        App::new()
            .app_data(app_state.clone())
            .configure(handlers::config)
    })
    .bind(server_address)?
    .run()
    .await
}

5. Modelos de Datos para MongoDB

5.1 Modelo de Usuario (models/user.rs)

use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct User {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    pub id: Option<ObjectId>,
    pub username: String,
    pub email: String,
    #[serde(skip_serializing)]
    pub password: String,
    pub name: Option<String>,
    pub avatar_url: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
    pub id: String,
    pub username: String,
    pub email: String,
    pub name: Option<String>,
    pub avatar_url: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateUser {
    pub username: String,
    pub email: String,
    pub password: String,
    pub name: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginUser {
    pub email: String,
    pub password: String,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUser {
    pub name: Option<String>,
    pub avatar_url: Option<String>,
}

5.2 Modelo de Repositorio (models/repository.rs)

use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct Repository {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    pub id: Option<ObjectId>,
    pub name: String,
    pub description: Option<String>,
    pub is_public: bool,
    pub owner_id: ObjectId,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    pub stars: u32,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RepositoryResponse {
    pub id: String,
    pub name: String,
    pub description: Option<String>,
    pub is_public: bool,
    pub owner_id: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
    pub stars: u32,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateRepository {
    pub name: String,
    pub description: Option<String>,
    pub is_public: bool,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateRepository {
    pub name: Option<String>,
    pub description: Option<String>,
    pub is_public: Option<bool>,
}

6. Implementación de Autenticación

6.1 Utilidades de autenticación (utils/auth.rs)

use crate::errors::ServiceError;
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String, // ID del usuario
    pub exp: usize,  // Tiempo de expiración
    pub iat: usize,  // Tiempo de emisión
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenResponse {
    pub token: String,
    pub expires_in: usize,
}

// Hashear contraseña
pub fn hash_password(password: &str) -> Result<String, ServiceError> {
    let cost = env::var("BCRYPT_COST")
        .unwrap_or_else(|_| "12".to_string())
        .parse()
        .unwrap_or(DEFAULT_COST);
    
    hash(password, cost).map_err(|_| ServiceError::InternalServerError)
}

// Verificar contraseña
pub fn verify_password(password: &str, hashed: &str) -> Result<bool, ServiceError> {
    verify(password, hashed).map_err(|_| ServiceError::Unauthorized)
}

// Generar JWT
pub fn create_jwt(user_id: &str) -> Result<TokenResponse, ServiceError> {
    let secret = env::var("JWT_SECRET").map_err(|_| ServiceError::InternalServerError)?;
    let expiration_hours = env::var("JWT_EXPIRATION_HOURS")
        .unwrap_or_else(|_| "24".to_string())
        .parse()
        .unwrap_or(24);
    
    let now = Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + Duration::hours(expiration_hours)).timestamp() as usize;
    
    let claims = Claims {
        sub: user_id.to_string(),
        exp,
        iat,
    };
    
    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_ref()),
    ).map_err(|_| ServiceError::InternalServerError)?;
    
    Ok(TokenResponse {
        token,
        expires_in: expiration_hours * 3600,
    })
}

// Validar JWT
pub fn validate_jwt(token: &str) -> Result<Claims, ServiceError> {
    let secret = env::var("JWT_SECRET").map_err(|_| ServiceError::InternalServerError)?;
    
    let decoded = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_ref()),
        &Validation::new(Algorithm::HS256),
    ).map_err(|_| ServiceError::Unauthorized)?;
    
    Ok(decoded.claims)
}

6.2 Manejador de autenticación (handlers/auth.rs)

use actix_web::{post, web, HttpResponse, Responder};
use bcrypt::verify;
use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::{
    app_state::AppState,
    errors::ServiceError,
    models::user::{LoginUser, User},
    utils::auth::{create_jwt, verify_password},
};

#[utoipa::path(
    post,
    path = "/api/auth/register",
    request_body = CreateUser,
    responses(
        (status = 201, description = "Usuario registrado exitosamente"),
        (status = 400, description = "Datos de usuario inválidos"),
        (status = 409, description = "Usuario o email ya existe"),
        (status = 500, description = "Error interno del servidor"),
    )
)]
#[post("/register")]
pub async fn register(
    data: web::Json<CreateUser>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    // Verificar si el usuario ya existe
    let existing_user = state
        .db
        .collection::<User>("users")
        .find_one(doc! { "email": &data.email }, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?;
    
    if existing_user.is_some() {
        return Err(ServiceError::Conflict("El email ya está registrado".into()));
    }
    
    // Hashear la contraseña
    let hashed_password = hash_password(&data.password)?;
    
    // Crear nuevo usuario
    let new_user = User {
        id: None,
        username: data.username.clone(),
        email: data.email.clone(),
        password: hashed_password,
        name: data.name.clone(),
        avatar_url: None,
        created_at: chrono::Utc::now(),
        updated_at: chrono::Utc::now(),
    };
    
    // Insertar en la base de datos
    let result = state
        .db
        .collection::<User>("users")
        .insert_one(new_user, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?;
    
    Ok(HttpResponse::Created().json({
        #[derive(Serialize)]
        struct Response {
            id: String,
        }
        Response {
            id: result.inserted_id.as_object_id().unwrap().to_hex(),
        }
    }))
}

#[utoipa::path(
    post,
    path = "/api/auth/login",
    request_body = LoginUser,
    responses(
        (status = 200, description = "Login exitoso", body = TokenResponse),
        (status = 401, description = "Credenciales inválidas"),
        (status = 500, description = "Error interno del servidor"),
    )
)]
#[post("/login")]
pub async fn login(
    data: web::Json<LoginUser>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    // Buscar usuario por email
    let user = state
        .db
        .collection::<User>("users")
        .find_one(doc! { "email": &data.email }, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?
        .ok_or(ServiceError::Unauthorized)?;
    
    // Verificar contraseña
    if !verify_password(&data.password, &user.password)? {
        return Err(ServiceError::Unauthorized);
    }
    
    // Generar JWT
    let token = create_jwt(&user.id.unwrap().to_hex())?;
    
    Ok(HttpResponse::Ok().json(token))
}

7. Middleware de Autenticación

7.1 Extractor de JWT (utils/auth.rs)

use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use futures::future::{ready, Ready};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::env;

pub struct AuthenticatedUser {
    pub user_id: String,
}

impl FromRequest for AuthenticatedUser {
    type Error = Error;
    type Future = Ready<Result<Self, Self::Error>>;
    
    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
        let auth_header = req.headers().get("Authorization");
        
        if auth_header.is_none() {
            return ready(Err(ServiceError::Unauthorized.into()));
        }
        
        let token = auth_header.unwrap().to_str().unwrap().replace("Bearer ", "");
        let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
        
        match decode::<Claims>(
            &token,
            &DecodingKey::from_secret(secret.as_ref()),
            &Validation::new(Algorithm::HS256),
        ) {
            Ok(decoded) => {
                ready(Ok(AuthenticatedUser {
                    user_id: decoded.claims.sub,
                }))
            }
            Err(_) => ready(Err(ServiceError::Unauthorized.into())),
        }
    }
}

8. Manejadores para Usuarios

8.1 handlers/user.rs

use actix_web::{get, patch, web, HttpResponse, Responder};
use mongodb::bson::{doc, oid::ObjectId};
use utoipa::ToSchema;

use crate::{
    app_state::AppState,
    errors::ServiceError,
    models::user::{UpdateUser, User, UserResponse},
    utils::auth::AuthenticatedUser,
};

#[utoipa::path(
    get,
    path = "/api/users/me",
    responses(
        (status = 200, description = "Datos del usuario actual", body = UserResponse),
        (status = 401, description = "No autorizado"),
        (status = 404, description = "Usuario no encontrado"),
        (status = 500, description = "Error interno del servidor"),
    ),
    security(
        ("bearerAuth" = [])
    )
)]
#[get("/me")]
pub async fn get_current_user(
    auth_user: AuthenticatedUser,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    let user_id = ObjectId::parse_str(&auth_user.user_id)
        .map_err(|_| ServiceError::BadRequest("ID de usuario inválido".into()))?;
    
    let user = state
        .db
        .collection::<User>("users")
        .find_one(doc! { "_id": user_id }, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?
        .ok_or(ServiceError::NotFound)?;
    
    Ok(HttpResponse::Ok().json(UserResponse {
        id: user.id.unwrap().to_hex(),
        username: user.username,
        email: user.email,
        name: user.name,
        avatar_url: user.avatar_url,
        created_at: user.created_at,
    }))
}

#[utoipa::path(
    patch,
    path = "/api/users/me",
    request_body = UpdateUser,
    responses(
        (status = 200, description = "Usuario actualizado", body = UserResponse),
        (status = 401, description = "No autorizado"),
        (status = 404, description = "Usuario no encontrado"),
        (status = 500, description = "Error interno del servidor"),
    ),
    security(
        ("bearerAuth" = [])
    )
)]
#[patch("/me")]
pub async fn update_current_user(
    auth_user: AuthenticatedUser,
    data: web::Json<UpdateUser>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    let user_id = ObjectId::parse_str(&auth_user.user_id)
        .map_err(|_| ServiceError::BadRequest("ID de usuario inválido".into()))?;
    
    let update_doc = doc! {
        "$set": {
            "name": data.name.as_deref(),
            "avatar_url": data.avatar_url.as_deref(),
            "updated_at": chrono::Utc::now(),
        }
    };
    
    let options = mongodb::options::FindOneAndUpdateOptions::builder()
        .return_document(mongodb::options::ReturnDocument::After)
        .build();
    
    let updated_user = state
        .db
        .collection::<User>("users")
        .find_one_and_update(doc! { "_id": user_id }, update_doc, options)
        .await
        .map_err(|_| ServiceError::InternalServerError)?
        .ok_or(ServiceError::NotFound)?;
    
    Ok(HttpResponse::Ok().json(UserResponse {
        id: updated_user.id.unwrap().to_hex(),
        username: updated_user.username,
        email: updated_user.email,
        name: updated_user.name,
        avatar_url: updated_user.avatar_url,
        created_at: updated_user.created_at,
    }))
}

9. Manejadores para Repositorios

9.1 handlers/repo.rs

use actix_web::{delete, get, post, put, web, HttpResponse};
use mongodb::bson::{doc, oid::ObjectId};
use utoipa::ToSchema;

use crate::{
    app_state::AppState,
    errors::ServiceError,
    models::repository::{
        CreateRepository, Repository, RepositoryResponse, UpdateRepository,
    },
    utils::auth::AuthenticatedUser,
};

#[utoipa::path(
    post,
    path = "/api/repos",
    request_body = CreateRepository,
    responses(
        (status = 201, description = "Repositorio creado", body = RepositoryResponse),
        (status = 401, description = "No autorizado"),
        (status = 500, description = "Error interno del servidor"),
    ),
    security(
        ("bearerAuth" = [])
    )
)]
#[post("")]
pub async fn create_repository(
    auth_user: AuthenticatedUser,
    data: web::Json<CreateRepository>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    let owner_id = ObjectId::parse_str(&auth_user.user_id)
        .map_err(|_| ServiceError::BadRequest("ID de usuario inválido".into()))?;
    
    let new_repo = Repository {
        id: None,
        name: data.name.clone(),
        description: data.description.clone(),
        is_public: data.is_public,
        owner_id,
        created_at: chrono::Utc::now(),
        updated_at: chrono::Utc::now(),
        stars: 0,
    };
    
    let result = state
        .db
        .collection::<Repository>("repositories")
        .insert_one(new_repo, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?;
    
    let inserted_id = result.inserted_id.as_object_id().unwrap();
    
    let repo = state
        .db
        .collection::<Repository>("repositories")
        .find_one(doc! { "_id": inserted_id }, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?
        .ok_or(ServiceError::NotFound)?;
    
    Ok(HttpResponse::Created().json(RepositoryResponse {
        id: repo.id.unwrap().to_hex(),
        name: repo.name,
        description: repo.description,
        is_public: repo.is_public,
        owner_id: repo.owner_id.to_hex(),
        created_at: repo.created_at,
        updated_at: repo.updated_at,
        stars: repo.stars,
    }))
}

#[utoipa::path(
    get,
    path = "/api/repos/{id}",
    params(
        ("id" = String, Path, description = "ID del repositorio")
    ),
    responses(
        (status = 200, description = "Datos del repositorio", body = RepositoryResponse),
        (status = 401, description = "No autorizado"),
        (status = 403, description = "No tienes permiso para ver este repositorio"),
        (status = 404, description = "Repositorio no encontrado"),
        (status = 500, description = "Error interno del servidor"),
    ),
    security(
        ("bearerAuth" = [])
    )
)]
#[get("/{id}")]
pub async fn get_repository(
    auth_user: AuthenticatedUser,
    path: web::Path<String>,
    state: web::Data<AppState>,
) -> Result<HttpResponse, ServiceError> {
    let repo_id = ObjectId::parse_str(&path.into_inner())
        .map_err(|_| ServiceError::BadRequest("ID de repositorio inválido".into()))?;
    
    let repo = state
        .db
        .collection::<Repository>("repositories")
        .find_one(doc! { "_id": repo_id }, None)
        .await
        .map_err(|_| ServiceError::InternalServerError)?
        .ok_or(ServiceError::NotFound)?;
    
    // Verificar permisos
    let user_id = ObjectId::parse_str(&auth_user.user_id)
        .map_err(|_| ServiceError::BadRequest("ID de usuario inválido".into()))?;
    
    if !repo.is_public && repo.owner_id != user_id {
        return Err(ServiceError::Forbidden);
    }
    
    Ok(HttpResponse::Ok().json(RepositoryResponse {
        id: repo.id.unwrap().to_hex(),
        name: repo.name,
        description: repo.description,
        is_public: repo.is_public,
        owner_id: repo.owner_id.to_hex(),
        created_at: repo.created_at,
        updated_at: repo.updated_at,
        stars: repo.stars,
    }))
}

10. Configuración de Swagger/OpenAPI

10.1 Modificar main.rs para incluir Swagger UI

use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

// Agregar al inicio del archivo
#[derive(OpenApi)]
#[openapi(
    paths(
        handlers::auth::register,
        handlers::auth::login,
        handlers::user::get_current_user,
        handlers::user::update_current_user,
        handlers::repo::create_repository,
        handlers::repo::get_repository,
    ),
    components(
        schemas(
            models::user::CreateUser,
            models::user::LoginUser,
            models::user::UserResponse,
            models::user::UpdateUser,
            models::repository::CreateRepository,
            models::repository::RepositoryResponse,
            models::repository::UpdateRepository,
            utils::auth::TokenResponse,
            errors::ServiceError,
        )
    ),
    tags(
        (name = "auth", description = "Autenticación de usuarios"),
        (name = "users", description = "Operaciones con usuarios"),
        (name = "repos", description = "Operaciones con repositorios"),
    ),
    modifiers(&SecurityAddon)
)]
struct ApiDoc;

struct SecurityAddon;

impl utoipa::Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        if let Some(components) = openapi.components.as_mut() {
            components.add_security_scheme(
                "bearerAuth",
                utoipa::openapi::security::SecurityScheme::Http(
                    utoipa::openapi::security::Http::new("bearer")
                        .scheme("bearer")
                        .bearer_format("JWT"),
                ),
            )
        }
    }
}

// Modificar la configuración del servidor para incluir Swagger UI
HttpServer::new(move || {
    App::new()
        .app_data(app_state.clone())
        .service(
            SwaggerUi::new("/swagger-ui/{_:.*}")
                .url("/api-docs/openapi.json", ApiDoc::openapi()),
        )
        .configure(handlers::config)
})
.bind(server_address)?
.run()
.await

10.2 Acceder a la documentación

Una vez iniciado el servidor, puedes acceder a la interfaz de Swagger en:

http://localhost:8080/swagger-ui/

11. Conclusión y Despliegue

11.1 Construir y ejecutar

# Modo desarrollo (con hot-reload)
cargo watch -x run

# Modo producción (optimizado)
cargo build --release
./target/release/github-like-api

11.2 Variables de entorno en producción

Para producción, considera usar:

11.3 Recursos adicionales

Nota final: Esta API implementa las características básicas de un sistema como GitHub, pero puedes extenderla con más funcionalidades como: