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.
cargo new github-like-api --bin
cd github-like-api
[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
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
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
use mongodb::Database;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Database>,
pub jwt_secret: String,
}
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
}
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>,
}
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>,
}
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)
}
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))
}
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())),
}
}
}
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,
}))
}
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,
}))
}
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
Una vez iniciado el servidor, puedes acceder a la interfaz de Swagger en:
http://localhost:8080/swagger-ui/
# Modo desarrollo (con hot-reload)
cargo watch -x run
# Modo producción (optimizado)
cargo build --release
./target/release/github-like-api
Para producción, considera usar:
Nota final: Esta API implementa las características básicas de un sistema como GitHub, pero puedes extenderla con más funcionalidades como: