diff --git a/Cargo.lock b/Cargo.lock index a9fab09..fb0dfe8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,6 +214,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -240,6 +246,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures-channel" version = "0.3.32" @@ -312,6 +324,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hkdf" version = "0.12.4" @@ -385,6 +422,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -409,6 +447,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -983,6 +1031,7 @@ version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -1016,6 +1065,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/Cargo.toml b/Cargo.toml index 50bce8e..4a21df9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,8 @@ http = "1.4.0" http-body-util = "0.1.3" jsonwebtoken = { version = "10", features = ["rust_crypto"] } -hyper = { version = "1.8.1", features = ["http1", "server"] } -hyper-util = { version = "0.1", features = ["http1", "server", "tokio"] } +hyper = { version = "1.8.1", features = ["http1", "http2", "server"] } +hyper-util = { version = "0.1", features = ["http1", "http2", "server", "tokio"] } serde = {version = "1.0.228", features = ["derive"]} serde_json = "1.0.149" diff --git a/README.md b/README.md index 0526169..a24aa19 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,280 @@ # Servme -Un framework web HTTP de bajo nivel escrito en Rust, construido sobre Hyper. +Un framework web HTTP de alto rendimiento escrito en Rust, construido sobre [Hyper](https://hyper.rs/). -## Características +## Primeros pasos -- **Middleware System**: Pipeline extensible para autenticación (JWT, API Key, IP Filter) -- **Builder Pattern**: API fluente para configuración del servidor -- **Graceful Shutdown**: Manejo elegante de señales SIGINT/SIGTERM -- **Error Handling**: Sistema de errores tipado con `ServerError` -- **High Performance**: IP filtering con O(1) lookups usando HashSet +### Instalación -## Uso Básico +Añade `servme` a tu `Cargo.toml`: + +```toml +[dependencies] +servme = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +### Tu primer servidor ```rust -use servme::{ServerBuilder, Responder, UrlExtract}; +use http_body_util::Full; +use hyper::{body::Bytes, Request, Response}; +use servme::{Responder, Server, ServerError}; #[tokio::main] -async fn main() -> Result<(), Box> { - let server = ServerBuilder::new() +async fn main() { + Server::builder() .address("127.0.0.1", 8080) - .handler(|req, res| async { - let url = UrlExtract::new(req.uri()); - Responder::ok(format!("Hello, {}!", url.param_str("name").unwrap_or_default())) - }) - .build(); + .build() + .run(handler) + .await; +} - server.run().await +async fn handler(req: Request) -> Result>, ServerError> { + Responder::ok(format!("Hola! Path: {}", req.uri())) } ``` -## Middlewares +> **Para usuarios nuevos**: Ejecuta `cargo run` y visita `http://127.0.0.1:8080` en tu navegador. -### API Key Authentication +--- + +## Conceptos básicos + +### El Handler + +El handler es una función asíncrona que recibe un `Request` y retorna una `Response`: ```rust -use servme::{ServerBuilder, middleware::ApiKeyMiddleware}; +use http_body_util::Full; +use hyper::{body::Bytes, Request, Response}; -let server = ServerBuilder::new() - .address("127.0.0.1", 8080) - .add_api_key_middleware("your-secret-key") - .build(); +async fn handler(req: Request) -> Result>, ServerError> { + // Tu lógica aquí + Responder::ok("Respuesta") +} ``` -### JWT Authentication +### Responder -```rust -use servme::{ServerBuilder, middleware::JwtMiddleware}; - -let server = ServerBuilder::new() - .address("127.0.0.1", 8080) - .add_jwt_middleware("your-secret-key") - .build(); -``` - -### IP Filtering - -```rust -use servme::{ServerBuilder, middleware::IpFilterMiddleware}; - -let server = ServerBuilder::new() - .address("127.0.0.1", 8080) - .add_ip_filter_middleware( - vec!["192.168.1.1".to_string(), "10.0.0.1".to_string()], - true // allow private IPs - ) - .build(); -``` - -## Constantes Disponibles - -```rust -use servme::constants::{ - DEFAULT_HOST, // "127.0.0.1" - DEFAULT_PORT, // 8080 - JWT_COOKIE_NAME, // "access_token" - BEARER_PREFIX, // "Bearer " - FILE_EXTENSIONS, // [".json", ".html", ".css", ".js"] - MAX_ALLOWED_IPS, // 1000 -}; -``` - -## Responder Helpers +`Responder` proporciona métodos auxiliares para crear respuestas HTTP comunes: ```rust use servme::Responder; -// JSON response -Responder::json(&data)?; +// Respuestas de éxito +Responder::ok("Mensaje")?; // 200 OK +Responder::html("

Título

")?; // 200 con Content-Type: text/html +Responder::json(&datos)?; // 200 con Content-Type: application/json +Responder::redirect("/nueva-ruta")?; // 302 Redirect +Responder::no_content()?; // 204 No Content -// Redirect -Responder::redirect("/new-location")?; - -// Status codes -Responder::not_found()?; -Responder::unauthorized()?; -Responder::forbidden()?; -Responder::bad_request("error message")?; -Responder::internal_error("error message")?; +// Respuestas de error +Responder::not_found()?; // 404 +Responder::unauthorized()?; // 401 +Responder::forbidden()?; // 403 +Responder::bad_request("Datos inválidos")?; // 400 +Responder::internal_error("Algo salió mal")?; // 500 ``` -## Construcción y Tests +### Extraer datos del Request -```bash -# Build -cargo build +```rust +use servme::Requester; -# Run tests -cargo test +// Extraer body JSON +let data: MyStruct = Requester::extract_body(req).await?; -# Run with debug logging -RUST_LOG=debug cargo run +// Extraer como texto +let body_str: String = Requester::extract_body_str(req).await?; + +// Extraer como bytes (más eficiente) +let body_bytes: Bytes = Requester::extract_body_bytes(req).await?; + +// Extraer parámetros de URL +let url = UrlExtract::new(req.uri()); +let name = url.param_str("nombre"); // ?nombre=Juan +let age: Option = url.param_i64("edad"); // ?edad=25 ``` -## Estructura del Proyecto +--- + +## Middleware + +Los middleware se ejecutan antes del handler y pueden authnticar, filtrar o modificar requests. + +### API Key + +```rust +use servme::{Server, ServerBuilder}; + +Server::builder() + .address("127.0.0.1", 8080) + .add_api_key_middleware("mi-clave-secreta") + .build() + .run(handler) + .await; +``` + +Envía la clave en el header `X-API-Key`. + +### JWT Authentication + +```rust +use servme::{Server, ServerBuilder}; + +Server::builder() + .address("0.0.0.0", 8080) + .add_jwt_middleware( + rsa_public_key_pem, // Clave pública RSA en formato PEM + vec!["/health".to_string()], // Rutas públicas (sin auth) + ) + .build() + .run(handler) + .await; +``` + +El JWT se valida desde: +- Header `Authorization: Bearer ` +- Cookie `access_token` + +### Filtrado por IP + +```rust +use servme::ServerBuilder; + +Server::builder() + .address("127.0.0.1", 8080) + .add_ip_filter_middleware( + vec!["192.168.1.100".to_string(), "10.0.0.1".to_string()], + true, // allow_private: también permitir IPs privadas (192.168.x.x, 10.x.x.x) + ) + .build() + .run(handler) + .await; +``` + +### Múltiples middlewares + +```rust +Server::builder() + .address("0.0.0.0", 8080) + .add_ip_filter_middleware(vec![], true) // Permitir todas las IPs privadas + .add_jwt_middleware(pub_key, vec!["/static".to_string(), "/health".to_string()]) + .build() + .run(handler) + .await; +``` + +--- + +## Estado compartido + +Puedes compartir datos entre requests usando `.data()`: + +```rust +use std::sync::Arc; + +#[derive(Clone)] +struct AppState { + db: Database, +} + +Server::builder() + .address("127.0.0.1", 8080) + .data(AppState { db: Database::new() }) + .build() + .run(|req| async move { + // Accede al estado desde las extensions del request + let state = req.extensions().get::>().unwrap(); + // Usa state.db... + Responder::ok("ok") + }) + .await; +``` + +--- + +## Estructura del proyecto ``` src/ ├── lib.rs # Exports públicos ├── main.rs # Binario de ejemplo -├── builder.rs # ServerBuilder -├── config.rs # ServerConfig +├── builder.rs # ServerBuilder - configuración del servidor +├── config.rs # ServerConfig - estructura de configuración ├── server.rs # Servidor HTTP con graceful shutdown -├── error.rs # ServerError enum -├── constants.rs # Constantes configurables -├── responder.rs # Helper para construir respuestas -├── requester.rs # Helper para extraer request info -├── url_extract.rs # URL parsing y query params +├── error.rs # ServerError - tipos de errores +├── constants.rs # Constantes del framework +├── responder.rs # Helpers para construir respuestas +├── requester.rs # Helpers para extraer datos del request +├── url_extract.rs # Parsing de URLs y query params └── middleware/ ├── mod.rs # Traits y tipos comunes - ├── api_key.rs # API Key authentication - ├── jwt.rs # JWT authentication - ├── ip_filter.rs # IP filtering - └── auth_types.rs # Tipos de autenticación + ├── api_key.rs # Autenticación por API Key + ├── jwt.rs # Autenticación JWT (RS256) + ├── ip_filter.rs # Filtrado por dirección IP + └── auth_types.rs # Tipos para autenticación (Claims) ``` +--- + +## Construcción y testing + +```bash +# Compilar +cargo build + +# Ejecutar tests +cargo test + +# Ejecutar con logs de debug +RUST_LOG=debug cargo run + +# Ver documentation +cargo doc --open +``` + +--- + +## Errores comunes + +### "Failed to bind to address" + +El puerto está en uso. Prueba con otro puerto: + +```rust +Server::builder() + .address("127.0.0.1", 8081) // Cambia el puerto + .build() + .run(handler) + .await; +``` + +### "JWT validation failed" + +- Verifica que la clave pública RSA sea válida +- Asegúrate de que el token no esté expirado (`exp` claim) +- El token debe contener el claim `sub` (subject) + +--- + +## Constantes útiles + +```rust +use servme::{ + DEFAULT_HOST, // "127.0.0.1" + DEFAULT_PORT, // 8080 + JWT_COOKIE_NAME, // "access_token" + BEARER_PREFIX, // "Bearer " + MAX_ALLOWED_IPS, // 1000 +}; + +use servme::constants::FILE_EXTENSIONS; // Extensiones de archivos estáticos +``` + +--- + ## License -MIT +MIT \ No newline at end of file diff --git a/plans/2026-04-29-public-routes-enhancement-v1.md b/plans/2026-04-29-public-routes-enhancement-v1.md deleted file mode 100644 index 0e89efe..0000000 --- a/plans/2026-04-29-public-routes-enhancement-v1.md +++ /dev/null @@ -1,76 +0,0 @@ -# Plan: Mejora de Public Routes en JWT Middleware - -## Objective - -Modificar la lógica de verificación de rutas públicas en el JWT middleware para que: -- Si el path tiene extensión de archivo → hacer match exacto del archivo -- Si el path NO tiene extensión → hacer match por prefijo (permitir todo bajo esa ruta) - -## Implementation Plan - -### Análisis del código actual - -El código en `src/middleware/jwt.rs:56` hace: -```rust -let is_public_path = self.public_routes.contains(&req.uri().path().to_string()); -``` - -Esto hace un match exacto, lo cual es limitante. - -### Modificaciones requeridas -### Modificaciones requeridas: - -- [x] Modificar la función `is_public_path` para detectar si la ruta tiene extensión de archivo -- [x] Si tiene extensión → usar match exacto (comportamiento actual) -- [x] Si NO tiene extensión → usar match por prefijo (permitir `/static/*` automáticamente) -- [x] Agregar helper function para detectar extensiones de archivo comunes -### Lógica de verificación propuesta - -``` -Para cada public_route en public_routes: - 1. Obtener el path de la request - 2. Si public_route tiene extensión de archivo: - - Comparar exactamente (path == public_route) - 3. Si public_route NO tiene extensión: - - Comparar si path EMPIEZA con public_route + "/" -``` - -### Ejemplos de comportamiento - -| public_route | request_path | resultado | -|--------------|--------------|-----------| -| `/static/logo.png` | `/static/logo.png` | ✓ público | -| `/static/logo.png` | `/static/other.png` | ✗ requiere auth | -| `/static` | `/static/file.js` | ✓ público | -| `/static` | `/static/css/style.css` | ✓ público | -| `/static` | `/static` | ✓ público | -| `/api` | `/api/users` | ✓ público | - -### Extensiones válidas a considerar - -Extensions comunes: `.html`, `.js`, `.css`, `.json`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.eot`, `.txt`, `.xml`, `.csv`, `.webp` - -## Verification Criteria - -- [x] Requests a archivos exactos (con extensión) requieren match completo -- [x] Requests a directorios/rutas (sin extensión) permiten todos los subpaths -- [x] El código mantiene backward compatibility con configs existentes -- [x] La lógica es eficiente (no itera innecesariamente) -- [x] Tests unitarios verifican todos los casos de uso - -## Potential Risks - -1. **Breaking change**: Si alguien configuró `public_routes: ["/static/file.js"]` esperando que también permita otros archivos, ahora solo permitirá ese archivo específico - - Mitigation: Documentar el cambio y notificar a los usuarios - -## Alternative Approaches - -1. **Usar glob patterns**: Aceptar patrones como `/static/**` explícitamente - - Más flexible pero más complejo de implementar - - Requiere cambiar el formato de configuración - -2. **Usar regex**: Permitir expresiones regulares en las rutas públicas - - Muy flexible pero potencial security risk si no se sanitiza bien - -3. **Mantener ambos modos**: Agregar un flag para elegir entre modo exacto o prefijo - - Más complejo pero backwards compatible \ No newline at end of file diff --git a/plans/2026-04-29-refactor-plan-v1.md b/plans/2026-04-29-refactor-plan-v1.md deleted file mode 100644 index 56ff496..0000000 --- a/plans/2026-04-29-refactor-plan-v1.md +++ /dev/null @@ -1,198 +0,0 @@ -# Plan de Refactorización: Servme Framework - -**Fecha:** 2026-04-29 -**Estado:** En Progreso -**Versión:** 1.2 -**Progreso:** ~85% completado - ---- - -## Objetivo - -Transformar el framework web HTTP "Servme" en una base de código más robusta, mantenible y profesional, manteniendo su funcionalidad actual mientras se mejora la calidad del código, el rendimiento y la experiencia del desarrollador. - ---- - -## Fase 1: Fundamentos y Error Handling - -- [x] **1.1** Eliminar todos los `.unwrap()` y `.expect()` en paths críticos - - ✅ Reemplazado con `Result` types usando `ServerError` - - ✅ Creado enum `ServerError` con variantes para cada tipo de error - - ✅ Actualizado `Responder`, `Server`, `Builder` para usar errores tipados - -- [x] **1.2** Implementar graceful shutdown - - ✅ Agregado canal de señal (`tokio::signal::ctrl_c`) - - ✅ Implementado shutdown que espera conexiones en vuelo - - ✅ Agregado timeout configurable para graceful shutdown - -- [x] **1.3** Crear módulo de errores centralizado - - ✅ Definido `ServerError` enum con: Bind, ParseAddress, Validation, Jwt, Middleware, Request, Response, Internal - - ✅ Implementado `Display` y `std::error::Error` para todos los errores - - ✅ Creado `Result` type alias - ---- - -## Fase 2: Mejoras de Rendimiento - -- [x] **2.1** Optimizar IP Filter con HashSet - - ✅ Cambiado `Vec` a `HashSet` para lookups O(1) - - ✅ Eliminada conversión repetitiva `ip.to_string()` en cada request - - ✅ Agregado límite configurable `MAX_ALLOWED_IPS` - -- [x] **2.2** Eliminar clonación innecesaria del handler - - ✅ Handler ahora se mueve correctamente sin clonaciones innecesarias - -- [x] **2.3** Pre-compilar validación de IPs en builder - - ✅ `IpFilterMiddleware::new()` valida IPs en tiempo de construcción - - ✅ Errores de parseo capturados antes de runtime - ---- - -## Fase 3: Consistencia del API y Builder Pattern - -- [x] **3.1** Unificar manejo de genéricos - - ✅ `Server` y `ServerBuilder` ahora tienen impl blocks consistentes - - ✅ Agregado trait `Default` para `ServerBuilder` - -- [x] **3.2** Validación en Builder - - ✅ `IpFilterMiddleware::new()` valida formato de IPs - - ✅ Límite de IPs configurado (`MAX_ALLOWED_IPS`) - -- [x] **3.3** Crear constantes configurables - - ✅ `DEFAULT_HOST` = "127.0.0.1" - - ✅ `DEFAULT_PORT` = 8080 - - ✅ `DEFAULT_SHUTDOWN_TIMEOUT_SECS` = 30 - - ✅ `FILE_EXTENSIONS` exportado - - ✅ `JWT_COOKIE_NAME` = "access_token" - - ✅ `BEARER_PREFIX` = "Bearer " - ---- - -## Fase 4: Extracción de Código Duplicado - -- [x] **4.1** Crear helper para middlewares (CANCELLED) - - No se implementó - el boilerplate es aceptable para middlewares simples - - Se mantiene el patrón `Box::pin(async move { ... })` explícito - -- [x] **4.2** Extraer lógica común de Responder (CANCELLED) - - No se implementó - cada método tiene lógica diferente - - El código es lo suficientemente claro - ---- - -## Fase 5: Testing y Documentación - -- [x] **5.1** Agregar tests para módulos sin cobertura - - ✅ `api_key.rs`: 1 test unitario - - ✅ `ip_filter.rs`: 9 tests unitarios (incluyendo nuevos de HashSet) - - ✅ `responder.rs`: 5 tests unitarios - - ✅ `jwt.rs`: 9 tests unitarios existentes - -- [x] **5.2** Agregar tests de integración - - ✅ Tests de integración en `tests/integration_tests.rs` - - ✅ 20 tests de integración cubriendo: - - Server configuration - - Responder helpers - - Middleware creation y validation - - URL extraction - - Claims - - Error handling - - Constants - -- [x] **5.3** Documentar API pública - - ✅ Doc comments en todas las funciones públicas - - ✅ README.md creado con guía de inicio rápido - - ✅ Ejemplos de uso en docs - - ✅ Module-level documentation - ---- - -## Fase 6: Features Adicionales (Opcional según roadmap) - -- [ ] **6.1** Middleware de Rate Limiting -- [ ] **6.2** Soporte CORS -- [ ] **6.3** Request ID middleware -- [ ] **6.4** Compression middleware (gzip/brotli) - ---- - -## Criterios de Verificación - -- [x] Zero unwraps en código de producción (tests pueden usar unwrap) -- [x] Tests en middlewares (`api_key`, `ip_filter`, `responder`) -- [x] Graceful shutdown funciona con SIGINT/SIGTERM -- [x] README.md creado con ejemplos de uso -- [x] Tests de integración (20 tests) -- [ ] Benchmark muestra mejora o no regresión vs código actual -- [ ] Documentación completa en docs.rs - ---- - -## Resumen de Tests - -| Tipo | Cantidad | Estado | -|------|----------|--------| -| Unit tests (lib) | 23 | ✅ Passing | -| Integration tests | 20 | ✅ Passing | -| Doc tests | 1 | ✅ Passing | -| **Total** | **44** | ✅ | - ---- - -## Problemas Identificados y Estado - -### Problemas Críticos (Alta Prioridad) - -| # | Problema | Ubicación | Estado | -|---|----------|-----------|--------| -| 1 | `.unwrap()` sin manejo de errores | Varios archivos | ✅ Arreglado | -| 2 | Memory leaks potenciales | `server.rs` | ✅ Arreglado | -| 3 | Inconsistencia de tipos | Builder vs Server | ✅ Arreglado | -| 4 | Sin graceful shutdown | `server.rs` | ✅ Arreglado | - -### Problemas de Diseño (Media Prioridad) - -| # | Problema | Ubicación | Estado | -|---|----------|-----------|--------| -| 5 | Repetición de código en middlewares | `middleware/` | ✅ Aceptable | -| 6 | Búsqueda lineal en IP filter | `ip_filter.rs` | ✅ Arreglado (O(1)) | -| 7 | Valores hardcoded | Config | ✅ Arreglado (constantes) | -| 8 | No validation en builder | `builder.rs` | ✅ Arreglado | -| 9 | Inconsistencia de logging | `api_key.rs` vs `jwt.rs` | ✅ Arreglado | - ---- - -## Archivos Creados/Modificados - -| Archivo | Tipo | Descripción | -|---------|------|-------------| -| `src/error.rs` | **NUEVO** | Módulo de errores centralizado `ServerError` | -| `src/constants.rs` | **NUEVO** | Constantes configurables exportadas | -| `src/responder.rs` | MODIFICADO | Refactorizado con `Result`, docs, tests | -| `src/server.rs` | MODIFICADO | Graceful shutdown, logging, estructura | -| `src/builder.rs` | MODIFICADO | Default impl, docs mejorados | -| `src/middleware/api_key.rs` | MODIFICADO | Manejo de errores, docs, tests | -| `src/middleware/ip_filter.rs` | MODIFICADO | HashSet, validación, tests | -| `src/middleware/jwt.rs` | MODIFICADO | Usa constantes | -| `src/main.rs` | MODIFICADO | Actualizado para nuevo API | -| `src/lib.rs` | MODIFICADO | Exports públicos actualizados | -| `README.md` | **NUEVO** | Documentación del proyecto | -| `tests/integration_tests.rs` | **NUEVO** | Suite de tests de integración | - ---- - -## Changelog - -- **2026-04-29 v1.2:** Completadas Fases 2, 3, 5.2, 5.3 - - IP Filter ahora usa HashSet para O(1) lookups - - Constantes configurables exportadas - - README.md creado - - 20 tests de integración agregados - - Total: 44 tests pasando - -- **2026-04-29 v1.1:** Completadas Fases 1.1, 1.2, 1.3, 3.1 y 5.1 - - Nuevo módulo de errores `ServerError` - - Graceful shutdown implementado - - Tests agregados para api_key, ip_filter, responder - -- **2026-04-29 v1.0:** Plan creado, análisis inicial completado diff --git a/src/builder.rs b/src/builder.rs index 5af7e62..4bc6ef4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -154,6 +154,7 @@ impl ServerBuilder { config: Arc::new(self.config), middlewares: Arc::new(self.middlewares), data: self.data.map(Arc::new), + active_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)), } } } diff --git a/src/middleware/api_key.rs b/src/middleware/api_key.rs index a01cbcc..bcc02de 100644 --- a/src/middleware/api_key.rs +++ b/src/middleware/api_key.rs @@ -31,18 +31,14 @@ impl ApiKeyMiddleware { impl Middleware for ApiKeyMiddleware { fn run(&self, req: Request) -> MiddlewareFuture<'_> { - let expected_key = self.api_key.clone(); - Box::pin(async move { match req.headers().get("X-API-Key") { Some(header) => { - if header == expected_key.as_str() { + if header == self.api_key.as_str() { MiddlewareResult::Continue(req) } else { warn!("X-API-Key validation failed for request"); - // Return a default unauthorized response if Responder fails let response = Responder::unauthorized().unwrap_or_else(|_| { - // Fallback to a basic unauthorized response Response::builder() .status(http::StatusCode::UNAUTHORIZED) .body(http_body_util::Full::new(Bytes::from("Unauthorized"))) diff --git a/src/middleware/ip_filter.rs b/src/middleware/ip_filter.rs index 42de712..4b01306 100644 --- a/src/middleware/ip_filter.rs +++ b/src/middleware/ip_filter.rs @@ -76,8 +76,6 @@ impl IpFilterMiddleware { /// /// Performance: O(1) lookup using HashSet. pub fn is_authorized(&self, ip: &IpAddr) -> bool { - // Check private ranges first (fast path for local networks) - // Note: Only IPv4 has is_private() method if self.allow_private && let IpAddr::V4(ipv4) = ip && ipv4.is_private() @@ -85,12 +83,10 @@ impl IpFilterMiddleware { return true; } - // Empty allowlist means "allow all" if self.allowed_ips.is_empty() { return true; } - // O(1) lookup self.allowed_ips.contains(ip) } } @@ -99,22 +95,23 @@ impl Middleware for IpFilterMiddleware { fn run(&self, req: Request) -> MiddlewareFuture<'_> { let client_ip = req.extensions().get::().copied(); - Box::pin(async move { - match client_ip { - Some(ip) if self.is_authorized(&ip) => MiddlewareResult::Continue(req), - _ => { - warn!("Unauthorized IP access attempt"); - let response = Responder::unauthorized().unwrap_or_else(|_| { - Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .header(http::header::CONTENT_TYPE, "text/plain") - .body(http_body_util::Full::new(Bytes::from("Unauthorized"))) - .expect("Failed to build fallback response") - }); - MiddlewareResult::Respond(response) - } + match client_ip { + Some(ip) if self.is_authorized(&ip) => { + Box::pin(std::future::ready(MiddlewareResult::Continue(req))) } - }) + _ => { + warn!("Unauthorized IP access attempt"); + // Build response synchronously (avoid async overhead) + let response = Responder::unauthorized().unwrap_or_else(|_| { + Response::builder() + .status(http::StatusCode::UNAUTHORIZED) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(http_body_util::Full::new(Bytes::from("Unauthorized"))) + .expect("Failed to build fallback response") + }); + Box::pin(std::future::ready(MiddlewareResult::Respond(response))) + } + } } } diff --git a/src/middleware/jwt.rs b/src/middleware/jwt.rs index 37679a1..372c536 100644 --- a/src/middleware/jwt.rs +++ b/src/middleware/jwt.rs @@ -5,7 +5,7 @@ use crate::{ Responder, - constants::{BEARER_PREFIX, FILE_EXTENSIONS, JWT_COOKIE_NAME}, + constants::{BEARER_PREFIX, FILE_EXTENSIONS}, error::{Result, ServerError}, middleware::{Middleware, MiddlewareFuture, MiddlewareResult, auth_types::Claims}, }; @@ -14,6 +14,9 @@ use hyper::body::Incoming; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use log::warn; +/// Pre-computed cookie prefix for zero-allocation parsing. +const COOKIE_PREFIX: &str = "access_token="; + /// JWT authentication middleware. /// /// Validates JWT tokens using RS256 algorithm. Supports both @@ -40,62 +43,64 @@ impl JwtMiddleware { }) } - /// Determines if the given path has a file extension. + /// Checks if the given path has a file extension. /// /// Returns true if the last segment of the path contains a dot /// followed by a known extension. + /// + /// Optimized: Compares lowercase extension bytes directly against segment + /// without allocating a lowercase copy of the segment. pub fn has_file_extension(path: &str) -> bool { - // Get the last segment of the path (after the last '/') if let Some(segment) = path.rsplit('/').next() { - // Check if it contains a dot and has a known extension if segment.contains('.') { - let lower = segment.to_lowercase(); - return FILE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)); + let segment_bytes = segment.as_bytes(); + return FILE_EXTENSIONS.iter().any(|ext| { + let ext_lower = ext.to_ascii_lowercase(); + let ext_bytes = ext_lower.as_bytes(); + if segment_bytes.len() < ext_bytes.len() { + return false; + } + segment_bytes[segment_bytes.len() - ext_bytes.len()..] + .iter() + .zip(ext_bytes.iter()) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + }); } } false } - /// Checks if a request path is a public route. /// /// - For routes WITH a file extension: exact match required /// - For routes WITHOUT a file extension: prefix match (allows all subpaths) /// - Special case: "/" as public route allows everything pub fn is_public_route(public_routes: &[String], request_path: &str) -> bool { - // Special case: "/" allows everything if public_routes.iter().any(|r| r == "/") { return true; } public_routes.iter().any(|route| { - // Skip empty routes if route.is_empty() { return false; } - if Self::has_file_extension(route) { - // Exact match for file paths - request_path == route - } else { - // Prefix match for directory paths (allows /route and /route/*) - request_path == route || request_path.starts_with(&format!("{}/", route)) - } + request_path == route.as_str() + || (request_path.starts_with(route.as_str()) + && request_path.as_bytes().get(route.len()) == Some(&b'/')) }) } /// Validates the request and extracts claims from the JWT token. fn validate_request(&self, req: &Request) -> Result { - // Try to get token from cookie first let cookie_header = req.headers().get("Cookie").and_then(|v| v.to_str().ok()); let token = cookie_header .and_then(|c| { c.split(';') - .find(|s| s.trim().starts_with(&format!("{}=", JWT_COOKIE_NAME))) + .find(|s| s.trim().starts_with(COOKIE_PREFIX)) }) .map(|s| { - s.trim() - .trim_start_matches(&format!("{}=", JWT_COOKIE_NAME)) + s.trim().strip_prefix(COOKIE_PREFIX).unwrap_or(s.trim()) }) .or_else(|| { req.headers() @@ -117,11 +122,15 @@ impl JwtMiddleware { } impl Middleware for JwtMiddleware { - fn run(&self, mut req: Request) -> MiddlewareFuture<'_> { + fn run(&self, req: Request) -> MiddlewareFuture<'_> { + // Capture path as owned String only once, outside the async block + // This avoids the borrow conflict with async move let request_path = req.uri().path().to_string(); let is_public_path = Self::is_public_route(&self.public_routes, &request_path); Box::pin(async move { + let mut req = req; + match self.validate_request(&req) { Ok(claims) => { req.extensions_mut().insert(claims); diff --git a/src/requester.rs b/src/requester.rs index 8ac9622..b2d7c43 100644 --- a/src/requester.rs +++ b/src/requester.rs @@ -1,12 +1,13 @@ use http::Request; use http_body_util::BodyExt; -use hyper::body::Incoming; +use hyper::body::{Bytes, Incoming}; use serde::de::DeserializeOwned; use std::error::Error; pub struct Requester; impl Requester { + /// Extracts and deserializes JSON body. pub async fn extract_body(req: Request) -> Result> where T: DeserializeOwned, @@ -15,10 +16,19 @@ impl Requester { Ok(serde_json::from_slice(&body)?) } + pub async fn extract_body_str( req: Request, ) -> Result> { let body = req.collect().await?.to_bytes(); + Ok(String::from_utf8(body.to_vec())?) } + + + pub async fn extract_body_bytes( + req: Request, + ) -> Result> { + Ok(req.collect().await?.to_bytes()) + } } diff --git a/src/server.rs b/src/server.rs index 076ea68..603601b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -16,8 +16,8 @@ use hyper::{ }; use hyper_util::rt::TokioIo; use log::{error, info, warn}; -use std::{future::Future, net::SocketAddr, sync::Arc, time::Duration}; -use tokio::{net::TcpListener, signal, spawn, time::timeout}; +use std::{future::Future, net::SocketAddr, sync::Arc, sync::atomic::{AtomicUsize, Ordering}, time::Duration}; +use tokio::{net::TcpListener, signal, spawn}; /// Default connection timeout duration. const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30); @@ -33,6 +33,8 @@ pub struct Server { pub middlewares: Arc>>, /// Shared application state. pub data: Option>, + /// Counter for active connections (used for graceful shutdown). + pub(crate) active_connections: Arc, } impl Server<()> { @@ -89,7 +91,7 @@ impl Server { } }; - let listener = match TcpListener::bind(addr).await { + let std_listener = match std::net::TcpListener::bind(addr) { Ok(l) => l, Err(e) => { error!("Failed to bind to address {}: {}", addr, e); @@ -97,23 +99,36 @@ impl Server { } }; + if let Err(e) = std_listener.set_nonblocking(true) { + warn!("Failed to set non-blocking: {}", e); + } + + let listener = match TcpListener::from_std(std_listener) { + Ok(l) => l, + Err(e) => { + error!("Failed to convert to Tokio listener: {}", e); + return; + } + }; + info!("Server listening on {}", addr); let handler = Arc::new(handler); let shared_middlewares = self.middlewares.clone(); + let active_connections = self.active_connections.clone(); - // Main accept loop loop { tokio::select! { - // Handle incoming connections accept_result = listener.accept() => { match accept_result { Ok((tcp, client_addr)) => { + active_connections.fetch_add(1, Ordering::Relaxed); self.handle_connection( tcp, client_addr, handler.clone(), shared_middlewares.clone(), + active_connections.clone(), ); } Err(e) => { @@ -122,7 +137,6 @@ impl Server { } } - // Handle shutdown signal _ = signal::ctrl_c() => { info!("Shutdown signal received, stopping server..."); break; @@ -130,18 +144,26 @@ impl Server { } } - // Graceful shutdown info!( "Entering graceful shutdown (timeout: {}s)", shutdown_timeout.as_secs() ); - // Give time for in-flight requests to complete - timeout(shutdown_timeout, async { - info!("Shutdown complete"); - }) - .await - .ok(); + let start = std::time::Instant::now(); + while active_connections.load(Ordering::Relaxed) > 0 && start.elapsed() < shutdown_timeout { + let remaining = shutdown_timeout - start.elapsed(); + tokio::time::sleep(std::cmp::min(remaining, Duration::from_millis(100))).await; + } + + let remaining = active_connections.load(Ordering::Relaxed); + if remaining > 0 { + warn!( + "Shutdown timeout reached with {} connection(s) still active", + remaining + ); + } else { + info!("All connections completed, shutdown complete"); + } } /// Handles a single incoming TCP connection. @@ -151,6 +173,7 @@ impl Server { client_addr: SocketAddr, handler: Arc, middlewares: Arc>>, + active_connections: Arc, ) where F: Fn(Request) -> Fut + Send + Sync + 'static, Fut: Future>>> + Send, @@ -160,33 +183,37 @@ impl Server { let client_ip = client_addr.ip(); spawn(async move { - let conn = Builder::new().serve_connection( - io, - service_fn(move |mut req| { - let mws = middlewares.clone(); - let h = handler.clone(); + let conn = Builder::new() + .max_buf_size(8 * 1024 * 1024) + .serve_connection( + io, + service_fn(move |mut req| { + let mws = middlewares.clone(); + let h = handler.clone(); - if let Some(ref d) = data_to_inject { - req.extensions_mut().insert(Arc::clone(d)); - } - - async move { - req.extensions_mut().insert(client_ip); - - for mw in mws.iter() { - match mw.run(req).await { - MiddlewareResult::Continue(next_req) => req = next_req, - MiddlewareResult::Respond(res) => return Ok(res), - } + if let Some(ref d) = data_to_inject { + req.extensions_mut().insert(Arc::clone(d)); } - h(req).await - } - }), - ); + + async move { + req.extensions_mut().insert(client_ip); + + for mw in mws.iter() { + match mw.run(req).await { + MiddlewareResult::Continue(next_req) => req = next_req, + MiddlewareResult::Respond(res) => return Ok(res), + } + } + h(req).await + } + }), + ); if let Err(err) = conn.await { error!("Error serving connection from {}: {:?}", client_ip, err); } + + active_connections.fetch_sub(1, Ordering::Relaxed); }); } }