GuĂa completa para construir una API segura y documentada con MongoDB
En este manual construiremos una REST API completa en Rust con MongoDB como base de datos, que incluye:
TecnologĂas principales:
# 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
[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
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()
}
}
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
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))
}
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(),
})
}
}
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>,
}
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,
}
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)
}
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)
}
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))
)
}
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()
})))
}
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))
}
}
}
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
}
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,
}
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);
}
# 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"]
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: