GuĂa completa para construir una API segura y documentada en Rust
En este manual construiremos una REST API completa en Rust con:
TecnologĂas principales: Actix-web (framework web), JSON Web Tokens (JWT), Bcrypt (hashing), Diesel (ORM), Utoipa (Swagger), Config (gestiĂłn de configuraciĂłn)
# Crear nuevo proyecto
cargo new rust_rest_api --bin
cd rust_rest_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
diesel = { version = "2.0", features = ["postgres", "r2d2", "chrono"] }
diesel_migrations = "2.0"
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
[server]
host = "127.0.0.1"
port = 8080
[database]
url = "postgres://user:password@localhost/rust_api"
pool_max_size = 10
[auth]
jwt_secret = "secret_key_should_be_long_and_complex"
jwt_expires_in = 1440 # minutos (24 horas)
jwt_maxage = 1440
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 pool_max_size: u32,
}
#[derive(Debug, Deserialize)]
pub struct AuthConfig {
pub jwt_secret: String,
pub jwt_expires_in: i64,
pub jwt_maxage: i64,
}
#[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_rest_api/
├── Cargo.toml
├── config/
│ └── default.toml
├── migrations/
│ └── (migraciones de Diesel)
├── 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
│ ├── 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
# Instalar CLI de Diesel (requiere libpq)
cargo install diesel_cli --no-default-features --features postgres
# Crear archivo .env con la URL de la base de datos
echo DATABASE_URL=postgres://user:password@localhost/rust_api > .env
# Configurar Diesel y crear migraciones
diesel setup
diesel migration generate create_users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
is_staff BOOLEAN DEFAULT FALSE,
is_superuser BOOLEAN DEFAULT FALSE,
thumbnail VARCHAR(255),
date_joined TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP WITH TIME ZONE
);
use chrono::{DateTime, Utc};
use diesel::{Queryable, Insertable};
use serde::{Serialize, Deserialize};
use uuid::Uuid;
#[derive(Debug, Queryable, Serialize, Identifiable)]
pub struct User {
pub id: Uuid,
pub email: String,
#[serde(skip_serializing)] // Nunca devolver el hash de la contraseña
pub password: String,
pub first_name: Option,
pub last_name: Option,
pub is_active: bool,
pub is_staff: bool,
pub is_superuser: bool,
pub thumbnail: Option,
pub date_joined: DateTime,
pub last_login: Option>,
}
#[derive(Debug, Insertable, Deserialize)]
#[diesel(table_name = users)]
pub struct NewUser {
pub email: String,
pub password: String,
pub first_name: Option,
pub last_name: Option,
}
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 {
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 {
decode::(
token,
&DecodingKey::from_secret(auth_config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
}
use bcrypt::{hash, verify, DEFAULT_COST};
pub fn hash_password(password: &str) -> Result {
hash(password, DEFAULT_COST)
}
pub fn verify_password(password: &str, hashed_password: &str) -> Result {
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, HttpRequest};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use bcrypt::hash;
use diesel::prelude::*;
use jsonwebtoken::errors::ErrorKind;
use serde_json::json;
use uuid::Uuid;
use crate::{
models::user::{User, NewUser},
utils::auth::{create_jwt_token, hash_password, verify_password},
errors::ApiError,
schemas::user::{RegisterUserSchema, LoginUserSchema},
AppState,
};
pub async fn register_handler(
state: web::Data,
body: web::Json,
) -> Result {
let db = &mut state.db_pool.get()?;
// Verificar si el usuario ya existe
let existing_user = User::find_by_email(db, &body.email)?;
if existing_user.is_some() {
return Err(ApiError::Conflict("User already exists".into()));
}
// Hashear la contraseña
let hashed_password = hash_password(&body.password)?;
// Crear nuevo usuario
let new_user = NewUser {
email: body.email.clone(),
password: hashed_password,
first_name: body.first_name.clone(),
last_name: body.last_name.clone(),
};
let user = User::create(db, new_user)?;
// Crear token JWT
let token = create_jwt_token(
&user.id.to_string(),
user.is_staff,
user.is_superuser,
&state.auth_config,
)?;
Ok(HttpResponse::Ok().json(json!({
"status": "success",
"token": token,
"user": user
})))
}
pub async fn login_handler(
state: web::Data,
body: web::Json,
) -> Result {
let db = &mut state.db_pool.get()?;
// Buscar usuario por email
let user = User::find_by_email(db, &body.email)?
.ok_or(ApiError::Unauthorized("Invalid credentials".into()))?;
// Verificar contraseña
if !verify_password(&body.password, &user.password)? {
return Err(ApiError::Unauthorized("Invalid credentials".into()));
}
// Crear token JWT
let token = create_jwt_token(
&user.id.to_string(),
user.is_staff,
user.is_superuser,
&state.auth_config,
)?;
Ok(HttpResponse::Ok().json(json!({
"status": "success",
"token": token,
"user": user
})))
}
use actix_web::{dev::ServiceRequest, Error};
use actix_web_httpauth::extractors::bearer::{BearerAuth, Config};
use actix_web_httpauth::extractors::AuthenticationError;
use jsonwebtoken::errors::ErrorKind;
pub async fn validator(
req: ServiceRequest,
credentials: BearerAuth,
) -> Result {
let app_state = req.app_data::>().unwrap();
match decode_jwt_token(credentials.token(), &app_state.auth_config) {
Ok(claims) => {
// Puedes añadir más validaciones aquĂ
req.extensions_mut().insert(claims);
Ok(req)
},
Err(e) => {
let config = req.app_data::().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 = User),
(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,
body: web::Json,
) -> Result {
// ... implementaciĂłn ...
}
use actix_web::{test, App};
use serde_json::json;
use your_app::{
routes::config,
models::user::NewUser,
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_rest_api /usr/local/bin/rust_rest_api
# Variables de entorno
ENV RUST_LOG=info
ENV APP__DATABASE__URL=postgres://user:password@db/rust_api
ENV APP__AUTH__JWT_SECRET=your_secure_secret_key
EXPOSE 8080
CMD ["rust_rest_api"]
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- db
environment:
- APP__DATABASE__URL=postgres://postgres:password@db/rust_api
- APP__AUTH__JWT_SECRET=your_secure_secret_key
restart: unless-stopped
db:
image: postgres:13
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=rust_api
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
volumes:
postgres_data:
Consejos para producciĂłn: