API REST en Rust con Actix-web, SQLite y JWT
Proyecto práctico: montamos una API mínima en Rust que permite registro, login (contraseñas hasheadas con bcrypt) y una ruta protegida por JWT. Usamos Actix-web para el servidor, sqlx + SQLite para la persistencia y jsonwebtoken para los tokens.
Requisitos previos
- Rust (stable) y cargo
- sqlite3 (opcional, para inspeccionar la DB)
- Conocimientos básicos de HTTP y JSON
Estructura de carpetas
my-rust-api/
├─ Cargo.toml
└─ src/
├─ main.rs
├─ db.rs
├─ models.rs
├─ auth.rs
└─ handlers.rs
Cargo.toml
[package]
name = "my_rust_api"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-rustls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
dotenvy = "0.15"
bcrypt = "0.13"
jsonwebtoken = "8"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
Explicación breve del enfoque
- Usamos sqlx con SQLite para mantener dependencias y setup simples — basta un fichero DB.
- No usamos migraciones avanzadas aquí; la app crea la tabla si no existe en el arranque.
- Las contraseñas se hashean con bcrypt y nunca se devuelven en respuestas.
- JWT contiene el id del usuario (sub) y una exp; la firma usa HMAC-SHA256 con una clave secreta.
Archivos principales (código completo)
src/main.rs
use actix_web::{web, App, HttpServer};
use dotenvy::dotenv;
use std::env;
mod db;
mod handlers;
mod auth;
mod models;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./data.db".to_string());
let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "change_me_to_a_strong_secret".to_string());
let pool = db::init_pool(&db_url).await.expect("DB pool");
db::init_db(&pool).await.expect("init DB");
// share pool and secret with handlers
let data = handlers::AppState { pool, jwt_secret };
println!("Server running at http://127.0.0.1:8080");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(data.clone()))
.service(
web::scope("/api")
.route("/register", web::post().to(handlers::register))
.route("/login", web::post().to(handlers::login))
.route("/me", web::get().to(handlers::me)),
)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
src/db.rs
use sqlx::SqlitePool;
pub async fn init_pool(database_url: &str) -> Result {
let pool = SqlitePool::connect(database_url).await?;
Ok(pool)
}
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
// Tabla users: id (text, PK), username (unique), password (hashed)
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);",
)
.execute(pool)
.await?;
Ok(())
}
src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct RegisterPayload {
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct MeResponse {
pub id: String,
pub username: String,
}
src/auth.rs
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
pub fn create_jwt(user_id: &str, secret: &str, hours_valid: i64) -> Result {
let exp = (Utc::now().timestamp() + hours_valid * 3600) as usize;
let claims = Claims { sub: user_id.to_string(), exp };
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))?;
Ok(token)
}
pub fn verify_jwt(token: &str, secret: &str) -> Result {
let validation = Validation::default();
let token_data = decode::(token, &DecodingKey::from_secret(secret.as_ref()), &validation)?;
Ok(token_data.claims.sub)
}
src/handlers.rs
use crate::db;
use crate::models::{LoginPayload, MeResponse, RegisterPayload};
use crate::auth;
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use sqlx::SqlitePool;
use serde_json::json;
use uuid::Uuid;
use bcrypt::{hash, verify, DEFAULT_COST};
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
pub jwt_secret: String,
}
pub async fn register(data: web::Data, payload: web::Json) -> impl Responder {
let pool = &data.pool;
let username = payload.username.trim();
let password = payload.password.trim();
if username.is_empty() || password.is_empty() {
return HttpResponse::BadRequest().json(json!({"error": "username and password required"}));
}
// Check if user exists
let existing = sqlx::query!("SELECT id FROM users WHERE username = ?", username)
.fetch_optional(pool)
.await;
match existing {
Ok(Some(_)) => return HttpResponse::BadRequest().json(json!({"error": "username taken"})),
Ok(None) => (),
Err(e) => return HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
}
let user_id = Uuid::new_v4().to_string();
let password_hash = match hash(password, DEFAULT_COST) {
Ok(h) => h,
Err(_) => return HttpResponse::InternalServerError().json(json!({"error": "hash error"})),
};
let res = sqlx::query("INSERT INTO users (id, username, password) VALUES (?, ?, ?)")
.bind(&user_id)
.bind(username)
.bind(&password_hash)
.execute(pool)
.await;
match res {
Ok(_) => HttpResponse::Ok().json(json!({"id": user_id, "username": username})),
Err(e) => HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
}
}
pub async fn login(data: web::Data, payload: web::Json) -> impl Responder {
let pool = &data.pool;
let username = payload.username.trim();
let password = payload.password.trim();
let row = sqlx::query!("SELECT id, password FROM users WHERE username = ?", username)
.fetch_optional(pool)
.await;
let row = match row {
Ok(Some(r)) => r,
Ok(None) => return HttpResponse::Unauthorized().json(json!({"error": "invalid credentials"})),
Err(e) => return HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
};
let valid = match verify(password, &row.password) {
Ok(v) => v,
Err(_) => false,
};
if !valid {
return HttpResponse::Unauthorized().json(json!({"error": "invalid credentials"}));
}
let token = match auth::create_jwt(&row.id, &data.jwt_secret, 24) {
Ok(t) => t,
Err(_) => return HttpResponse::InternalServerError().json(json!({"error": "token error"})),
};
HttpResponse::Ok().json(json!({"token": token}))
}
pub async fn me(data: web::Data, req: HttpRequest) -> impl Responder {
let pool = &data.pool;
let header = req.headers().get("Authorization");
let token = match header {
Some(hv) => {
let s = match hv.to_str() {
Ok(v) => v,
Err(_) => return HttpResponse::BadRequest().json(json!({"error": "invalid auth header"})),
};
if !s.starts_with("Bearer ") {
return HttpResponse::BadRequest().json(json!({"error": "invalid auth header"}));
}
s.trim_start_matches("Bearer ").trim()
}
None => return HttpResponse::Unauthorized().json(json!({"error": "missing authorization"})),
};
let user_id = match auth::verify_jwt(token, &data.jwt_secret) {
Ok(id) => id,
Err(_) => return HttpResponse::Unauthorized().json(json!({"error": "invalid token"})),
};
let row = sqlx::query!("SELECT id, username FROM users WHERE id = ?", user_id)
.fetch_optional(pool)
.await;
match row {
Ok(Some(r)) => HttpResponse::Ok().json(MeResponse { id: r.id, username: r.username }),
Ok(None) => HttpResponse::NotFound().json(json!({"error": "user not found"})),
Err(e) => HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
}
}
Variables de entorno
Puedes crear un archivo .env en el proyecto con:
DATABASE_URL=sqlite://./data.db
JWT_SECRET=una_clave_secreta_larga_y_segura
Cómo ejecutar
cargo run
Pruebas rápidas con curl
Registro:
curl -X POST http://127.0.0.1:8080/api/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"s3cret"}'
Login:
curl -X POST http://127.0.0.1:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"s3cret"}'
# Respuesta: {"token":"..."}
Ruta protegida (/me):
curl http://127.0.0.1:8080/api/me \
-H "Authorization: Bearer "
Por qué se hizo así (decisiones técnicas)
- Actix-web: rendimiento y ergonomía con extractores y rutas, buena integración async.
- sqlx + SQLite: simplicidad para ejemplos. Para producción cambia a Postgres/MySQL con pool y migraciones.
- bcrypt: hashing probado para contraseñas; ajustar el cost según hardware.
- JWT: útil para APIs stateless; aquí la claims contiene solo el id. En producción, añade jti, aud, iss según necesidades.
- Creación de tabla en arranque: conveniente en ejemplos; para proyectos reales usa migraciones versionadas.
Próximos pasos recomendados
- Agregar validación más estricta (longitud de contraseña, sanitización del username).
- Agregar refresh tokens y revocación (guardar jti en DB para invalidación).
- Implementar pruebas unitarias y de integración (actix-web test server).
- Migrar a Postgres en producción y añadir Diesel o sqlx migrations.
Consejo avanzado: no uses un JWT largo plazo sin mecanismo de revocación; guarda identificadores de token (jti) y una lista de revocación si necesitas invalidar tokens rápidamente. Además, guarda la clave secreta en un gestor de secretos y rota periódicamente.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación