From 9c58d5e1d6d48e0ec931f458afd4c466dc9db4f3 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 30 May 2026 22:30:58 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(pagetop):=20Migra=20de=20?= =?UTF-8?q?actix-web=20a=20Axum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye el módulo `service` por `web` y adapta toda la API al modelo de Axum: router inmutable, extractores via `FromRequestParts` y servicios Tower para archivos estáticos. - `HttpRequest` pasa a ser un tipo propio, mínimo y clonable. - `configure_services` pasa a `configure_routes`. - `EmbeddedFilesService` pasa a `ServeEmbedded`. - Elimina `session_lifetime` de `Server` (va a `pagetop-auth`). - Actualiza tests y ejemplos a la nueva API. --- Cargo.toml | 157 +++++++----- extensions/pagetop-aliner/Cargo.toml | 5 +- extensions/pagetop-bootsier/Cargo.toml | 5 +- extensions/pagetop-seaorm/Cargo.toml | 19 +- helpers/pagetop-build/Cargo.toml | 4 +- helpers/pagetop-macros/Cargo.toml | 10 +- helpers/pagetop-minimal/Cargo.toml | 8 +- helpers/pagetop-statics/Cargo.toml | 18 +- src/app.rs | 147 +++++------ src/config.rs | 44 ++-- src/core.rs | 40 ++- src/global.rs | 11 +- src/html.rs | 2 +- src/lib.rs | 16 +- src/prelude.rs | 8 +- src/response.rs | 2 - src/service.rs | 128 ---------- src/util.rs | 38 ++- src/web.rs | 340 +++++++++++++++++++++++++ 19 files changed, 612 insertions(+), 390 deletions(-) delete mode 100644 src/service.rs create mode 100644 src/web.rs diff --git a/Cargo.toml b/Cargo.toml index 48bc600c..269f752f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,63 +1,3 @@ -[package] -name = "pagetop" -version = "0.5.0" -edition = "2021" - -description = """ - Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables. -""" -categories = ["web-programming::http-server"] -keywords = ["pagetop", "web", "framework", "frontend", "ssr"] - -repository.workspace = true -homepage.workspace = true -license.workspace = true -authors.workspace = true - -[dependencies] -chrono = "0.4" -colored = "3.1" -config = { version = "0.15", default-features = false, features = ["toml"] } -figlet-rs = "1.0" -getter-methods = "2.0" -itoa = "1.0" -indexmap = "2.14" -parking_lot = "0.12" -substring = "1.4" -terminal_size = "0.4" - -tracing = "0.1" -tracing-appender = "0.2" -tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } -tracing-actix-web = "0.7" - -fluent-templates = "0.14" -unic-langid = { version = "0.9", features = ["macros"] } - -actix-web = { workspace = true, default-features = true } -actix-session = { version = "0.11", features = ["cookie-session"] } -actix-web-files = { package = "actix-files", version = "0.6" } - -serde.workspace = true - -pagetop-macros.workspace = true -pagetop-minimal.workspace = true -pagetop-statics.workspace = true - -[features] -default = [] -testing = [] - -[dev-dependencies] -tempfile = "3.27" -serde_json = "1.0" -pagetop-aliner.workspace = true -pagetop-bootsier.workspace = true - -[build-dependencies] -pagetop-build.workspace = true - - [workspace] resolver = "2" members = [ @@ -75,12 +15,45 @@ members = [ [workspace.package] repository = "https://git.cillero.es/manuelcillero/pagetop" homepage = "https://pagetop.cillero.es" +edition = "2024" license = "MIT OR Apache-2.0" authors = ["Manuel Cillero "] [workspace.dependencies] -actix-web = { version = "4.13", default-features = false } +async-trait = "0.1" +axum = { version = "0.8" } +change-detection = "1.2" +chrono = "0.4" +colored = "3.1" +concat-string = "1.0" +config = { version = "0.15", default-features = false, features = ["toml"] } +figlet-rs = "1.0" +fluent-templates = "0.14" +getter-methods = "2.0" +grass = "0.13" +indexmap = "2.14" +indoc = "2.0" +itoa = "1.0" +mime_guess = "2.0" +parking_lot = "0.12" +pastey = "0.2" +path-slash = "0.2" +proc-macro2 = "1.0" +proc-macro2-diagnostics = { version = "0.10", default-features = false } +quote = "1.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits"] } +tempfile = "3.27" +terminal_size = "0.4" +tokio = { version = "1", features = ["full"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["fs"] } +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } +unic-langid = { version = "0.9", features = ["macros"] } +url = "2.5" # Helpers pagetop-build = { version = "0.3", path = "helpers/pagetop-build" } pagetop-macros = { version = "0.3", path = "helpers/pagetop-macros" } @@ -92,3 +65,65 @@ pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" } pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" } # PageTop pagetop = { version = "0.5", path = "." } + +[workspace.dependencies.sea-orm] +version = "1.1" +features = ["debug-print", "macros", "runtime-tokio-native-tls"] +default-features = false + +[workspace.dependencies.sea-schema] +version = "0.16" + + +[package] +name = "pagetop" +version = "0.5.0" + +description = """ + Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables. +""" +categories = ["web-programming::http-server"] +keywords = ["pagetop", "web", "framework", "frontend", "ssr"] + +repository.workspace = true +homepage.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +default = [] +testing = [] + +[dependencies] +axum.workspace = true +chrono.workspace = true +colored.workspace = true +config.workspace = true +figlet-rs.workspace = true +fluent-templates.workspace = true +getter-methods.workspace = true +indexmap.workspace = true +itoa.workspace = true +parking_lot.workspace = true +pagetop-macros.workspace = true +pagetop-minimal.workspace = true +pagetop-statics.workspace = true +serde.workspace = true +terminal_size.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-appender.workspace = true +tracing-subscriber.workspace = true +unic-langid.workspace = true + +[dev-dependencies] +pagetop-aliner.workspace = true +pagetop-bootsier.workspace = true +serde_json.workspace = true +tempfile.workspace = true + +[build-dependencies] +pagetop-build.workspace = true diff --git a/extensions/pagetop-aliner/Cargo.toml b/extensions/pagetop-aliner/Cargo.toml index 00deda3e..d2828b3f 100644 --- a/extensions/pagetop-aliner/Cargo.toml +++ b/extensions/pagetop-aliner/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-aliner" version = "0.1.0" -edition = "2021" description = """ Tema de PageTop que muestra esquemáticamente la composición de las páginas HTML @@ -11,11 +10,15 @@ keywords = ["pagetop", "theme", "css"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true [dependencies] pagetop.workspace = true +[dev-dependencies] +tokio.workspace = true + [build-dependencies] pagetop-build.workspace = true diff --git a/extensions/pagetop-bootsier/Cargo.toml b/extensions/pagetop-bootsier/Cargo.toml index 6e6fc66b..44b6d248 100644 --- a/extensions/pagetop-bootsier/Cargo.toml +++ b/extensions/pagetop-bootsier/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-bootsier" version = "0.1.1" -edition = "2021" description = """ Tema de PageTop basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles. @@ -11,6 +10,7 @@ keywords = ["pagetop", "theme", "bootstrap", "css", "js"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true @@ -18,5 +18,8 @@ authors.workspace = true pagetop.workspace = true serde.workspace = true +[dev-dependencies] +tokio.workspace = true + [build-dependencies] pagetop-build.workspace = true diff --git a/extensions/pagetop-seaorm/Cargo.toml b/extensions/pagetop-seaorm/Cargo.toml index 66034137..6e2b6fc7 100644 --- a/extensions/pagetop-seaorm/Cargo.toml +++ b/extensions/pagetop-seaorm/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-seaorm" version = "0.0.4" -edition = "2021" description = """ Proporciona a PageTop acceso basado en SeaORM a bases de datos relacionales. @@ -11,6 +10,7 @@ keywords = ["pagetop", "database", "sql", "orm", "ssr"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true @@ -20,17 +20,10 @@ postgres = ["sea-orm/sqlx-postgres"] sqlite = ["sea-orm/sqlx-sqlite"] [dependencies] +async-trait.workspace = true pagetop.workspace = true +sea-orm.workspace = true +sea-schema.workspace = true serde.workspace = true - -async-trait = "0.1" -futures = "0.3" -url = "2.5" - -[dependencies.sea-orm] -version = "1.1" -features = ["debug-print", "macros", "runtime-async-std-native-tls"] -default-features = false - -[dependencies.sea-schema] -version = "0.16" +tokio.workspace = true +url.workspace = true diff --git a/helpers/pagetop-build/Cargo.toml b/helpers/pagetop-build/Cargo.toml index aa37e1af..a06bc9ca 100644 --- a/helpers/pagetop-build/Cargo.toml +++ b/helpers/pagetop-build/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-build" version = "0.3.2" -edition = "2021" description = """ Prepara un conjunto de archivos estáticos o archivos SCSS compilados para ser incluidos en el @@ -12,9 +11,10 @@ keywords = ["pagetop", "build", "assets", "resources", "static"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -grass = "0.13" +grass.workspace = true pagetop-statics.workspace = true diff --git a/helpers/pagetop-macros/Cargo.toml b/helpers/pagetop-macros/Cargo.toml index b34d2ec1..13b5d387 100644 --- a/helpers/pagetop-macros/Cargo.toml +++ b/helpers/pagetop-macros/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-macros" version = "0.3.0" -edition = "2021" description = """ Una colección de macros que mejoran la experiencia de desarrollo con PageTop. @@ -11,6 +10,7 @@ keywords = ["pagetop", "macros", "proc-macros", "codegen"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ authors.workspace = true proc-macro = true [dependencies] -proc-macro2 = "1.0" -proc-macro2-diagnostics = { version = "0.10", default-features = false } -quote = "1.0" -syn = { version = "2.0", features = ["full", "extra-traits"] } +proc-macro2.workspace = true +proc-macro2-diagnostics.workspace = true +quote.workspace = true +syn.workspace = true diff --git a/helpers/pagetop-minimal/Cargo.toml b/helpers/pagetop-minimal/Cargo.toml index 39b7d10d..dfb37a9d 100644 --- a/helpers/pagetop-minimal/Cargo.toml +++ b/helpers/pagetop-minimal/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-minimal" version = "0.1.0" -edition = "2021" description = """ Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones @@ -12,10 +11,11 @@ keywords = ["pagetop", "build", "assets", "resources", "static"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true [dependencies] -concat-string = "1.0" -indoc = "2.0" -pastey = "0.2" +concat-string.workspace = true +indoc.workspace = true +pastey.workspace = true diff --git a/helpers/pagetop-statics/Cargo.toml b/helpers/pagetop-statics/Cargo.toml index da967c31..503511eb 100644 --- a/helpers/pagetop-statics/Cargo.toml +++ b/helpers/pagetop-statics/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "pagetop-statics" version = "0.1.3" -edition = "2021" description = """ Librería para automatizar la recopilación de recursos estáticos en PageTop. @@ -11,6 +10,7 @@ keywords = ["pagetop", "build", "static", "resources", "file"] repository.workspace = true homepage.workspace = true +edition.workspace = true license.workspace = true authors.workspace = true @@ -19,15 +19,11 @@ default = ["change-detection"] sort = [] [dependencies] -change-detection = { version = "1.2", optional = true } -mime_guess = "2.0" -path-slash = "0.2" - -actix-web.workspace = true -derive_more = "0.99.17" -futures-util = { version = "0.3", default-features = false, features = ["std"] } +change-detection = { workspace = true, optional = true } +mime_guess.workspace = true +path-slash.workspace = true [build-dependencies] -change-detection = { version = "1.2", optional = true } -mime_guess = "2.0" -path-slash = "0.2" +change-detection = { workspace = true, optional = true } +mime_guess.workspace = true +path-slash.workspace = true diff --git a/src/app.rs b/src/app.rs index 6a266edc..d24a03d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,24 +6,20 @@ use crate::core::{extension, extension::ExtensionRef}; use crate::html::Markup; use crate::locale::Locale; use crate::response::page::{ErrorPage, ResultPage}; -use crate::service::HttpRequest; -use crate::{global, service, trace, PAGETOP_VERSION}; - -use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle}; -use actix_session::storage::CookieSessionStore; -use actix_session::SessionMiddleware; - -use substring::Substring; +use crate::web::{HttpRequest, Router}; +use crate::{PAGETOP_VERSION, global, trace}; +use std::future::Future; use std::io::Error; use std::sync::LazyLock; /// Punto de entrada de una aplicación PageTop. /// -/// No almacena datos, **encapsula** el inicio completo de configuración y puesta en marcha. Para -/// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare). -/// Después sólo hay que llamar a [`run()`](Application::run) para ejecutar la aplicación (o a -/// [`test()`](Application::test) si se está preparando un entorno de pruebas). +/// No almacena datos, **encapsula** el inicio completo de la configuración y puesta en marcha de la +/// aplicación. Para instanciarla se puede usar [`new()`](Application::new) o +/// [`prepare()`](Application::prepare). Después sólo hay que llamar a [`run()`](Application::run) +/// para ejecutar la aplicación (o a [`test()`](Application::test) si se está preparando un entorno +/// de pruebas). pub struct Application; impl Default for Application { @@ -33,24 +29,24 @@ impl Default for Application { } impl Application { - /// Crea una instancia de la aplicación. + /// Crea una instancia mínima de la aplicación, sin extensión raíz. + /// + /// Útil para verificar que el servidor arranca correctamente. Para una aplicación real, usa + /// [`prepare()`](Application::prepare) con una extensión raíz. pub fn new() -> Self { Self::internal_prepare(None) } /// Prepara una instancia de la aplicación a partir de una extensión raíz. /// - /// Esa extensión suele declarar: - /// - /// - Sus propias dependencias (que se habilitarán automáticamente). - /// - Una lista de extensiones que deben deshabilitarse si estuvieran activadas. - /// - /// Esto simplifica el arranque en escenarios complejos. + /// Las dependencias se habilitan en orden: primero las que no dependen de ninguna otra, luego + /// las que dependen de extensiones ya habilitadas, y así sucesivamente hasta dejar habilitada + /// la extensión raíz. pub fn prepare(root_extension: ExtensionRef) -> Self { Self::internal_prepare(Some(root_extension)) } - /// Método interno para preparar la aplicación, opcionalmente con una extensión. + // Secuencia de arranque común a new() y prepare(). fn internal_prepare(root_extension: Option) -> Self { // Al arrancar muestra una cabecera para la aplicación. Self::show_banner(); @@ -73,10 +69,10 @@ impl Application { Self } - /// Muestra una cabecera para la aplicación basada en la configuración. + // Muestra la cabecera de arranque si está habilitada en la configuración. fn show_banner() { use colored::Colorize; - use terminal_size::{terminal_size, Width}; + use terminal_size::{Width, terminal_size}; if global::SETTINGS.app.startup_banner != global::StartupBanner::Off { // Nombre de la aplicación, ajustado al ancho del terminal si es necesario. @@ -85,8 +81,8 @@ impl Application { if let Some((Width(term_width), _)) = terminal_size() { if term_width >= 80 { let maxlen: usize = ((term_width / 10) - 2).into(); - let mut app = app_name.substring(0, maxlen).to_string(); - if app_name.len() > maxlen { + let mut app: String = app_name.chars().take(maxlen).collect(); + if app_name.chars().count() > maxlen { app = format!("{app}..."); } if let Some(ff) = figfont::FIGFONT.convert(&app) { @@ -103,7 +99,7 @@ impl Application { // Descripción de la aplicación. if !global::SETTINGS.app.description.is_empty() { println!("{}", global::SETTINGS.app.description.cyan()); - }; + } // Versión de PageTop. println!( @@ -114,72 +110,55 @@ impl Application { } } + // Construye el router con las rutas de todas las extensiones habilitadas. + fn build_router() -> Router { + let router = extension::all::configure_routes(Router::new()); + router.fallback(route_not_found) + } + /// Arranca el servidor web de la aplicación. /// - /// Devuelve [`std::io::Error`] si el *socket* no puede enlazarse (por puerto en uso, permisos, - /// etc.). - pub fn run(self) -> Result { - // Genera clave secreta para firmar y verificar cookies. - let secret_key = service::cookie::Key::generate(); - - // Prepara el servidor web. - Ok(service::HttpServer::new(move || { - Self::service_app() - .wrap(tracing_actix_web::TracingLogger::default()) - .wrap( - SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone()) - .session_lifecycle(match global::SETTINGS.server.session_lifetime { - 0 => SessionLifecycle::BrowserSession(BrowserSession::default()), - _ => SessionLifecycle::PersistentSession( - PersistentSession::default().session_ttl( - service::cookie::time::Duration::seconds( - global::SETTINGS.server.session_lifetime, - ), - ), - ), - }) - .build(), - ) - }) - .bind(format!( + /// Enlaza el puerto del servidor web de forma síncrona (puede fallar con [`std::io::Error`] si + /// el puerto ya está en uso o el proceso carece de permisos) y devuelve un [`Future`] que + /// ejecuta el bucle de atención de peticiones. El patrón habitual es: + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// + /// struct MyApp; + /// + /// impl Extension for MyApp {} + /// + /// #[pagetop::main] + /// async fn main() -> std::io::Result<()> { + /// Application::prepare(&MyApp).run()?.await + /// } + /// ``` + pub fn run(self) -> Result>, Error> { + let addr = format!( "{}:{}", - &global::SETTINGS.server.bind_address, - &global::SETTINGS.server.bind_port - ))? - .run()) + global::SETTINGS.server.bind_address, + global::SETTINGS.server.bind_port + ); + + // Enlaza el puerto de forma síncrona para detectar errores antes del *await*. + let std_listener = std::net::TcpListener::bind(&addr)?; + std_listener.set_nonblocking(true)?; + + let router = Self::build_router(); + + Ok(async move { + let listener = tokio::net::TcpListener::from_std(std_listener)?; + axum::serve(listener, router).await + }) } - /// Prepara el servidor web de la aplicación para pruebas. - pub fn test( - self, - ) -> service::App< - impl service::Factory< - service::Request, - Config = (), - Response = service::Response, - Error = service::Error, - InitError = (), - >, - > { - Self::service_app() - } - - /// Configura el servicio web de la aplicación. - fn service_app() -> service::App< - impl service::Factory< - service::Request, - Config = (), - Response = service::Response, - Error = service::Error, - InitError = (), - >, - > { - service::App::new() - .configure(extension::all::configure_services) - .default_service(service::web::route().to(service_not_found)) + /// Devuelve el servidor web configurado para usarlo en pruebas de integración. + pub fn test(self) -> Router { + Self::build_router() } } -async fn service_not_found(request: HttpRequest) -> ResultPage { +async fn route_not_found(request: HttpRequest) -> ResultPage { Err(ErrorPage::NotFound(request)) } diff --git a/src/config.rs b/src/config.rs index 9b7b43d2..9c687a0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,11 +7,11 @@ //! **código** de la **configuración**, lo que permite tener configuraciones diferentes para cada //! despliegue, como *dev*, *staging* o *production*, sin modificar el código fuente. //! -//! //! # Orden de carga //! //! Si tu aplicación necesita archivos de configuración, crea un directorio `config` en la raíz del -//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación. +//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación. Puedes +//! cambiar esta ubicación mediante la variable de entorno `CONFIG_DIR`. //! //! PageTop carga en este orden, y siempre de forma opcional, los siguientes archivos TOML: //! @@ -42,7 +42,6 @@ //! Los archivos se combinan en el orden anterior, cada archivo sobrescribe a los anteriores en caso //! de conflicto. //! -//! //! # Cómo añadir opciones de configuración a tu código //! //! Añade [*serde*](https://docs.rs/serde) en tu archivo *Cargo.toml* con la *feature* `derive`: @@ -91,7 +90,6 @@ //! //! Las estructuras de configuración son de **sólo lectura** durante la ejecución. //! -//! //! # Usando tus opciones de configuración //! //! ```rust,ignore @@ -131,9 +129,14 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( let dir = env::var_os("CONFIG_DIR").unwrap_or_else(|| DEFAULT_CONFIG_DIR.into()); let config_dir = util::resolve_absolute_dir(&dir).unwrap_or_else(|_| PathBuf::from(&dir)); - // Modo de ejecución según la variable de entorno PAGETOP_RUN_MODE. Si no está definida, se usa - // por defecto DEFAULT_RUN_MODE (p. ej. PAGETOP_RUN_MODE=production). - let rm = env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into()); + // Modo de ejecución. Con la *feature* `testing` activa (usada por `cargo ts` y `cargo tw`), se + // fija en "test" en tiempo de compilación, sin manipular el entorno. En caso contrario se lee + // de PAGETOP_RUN_MODE, o se usa DEFAULT_RUN_MODE si la variable no está definida. + let rm = if cfg!(feature = "testing") { + "test".to_string() + } else { + env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into()) + }; Config::builder() // 1. Configuración común para todos los entornos (common.toml). @@ -158,7 +161,7 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( /// Hay que añadir en nuestra librería el siguiente código: /// /// ```rust,ignore -/// include_config!(SETTINGS: Settings => [ +/// include_config!(SETTINGS_NAME: SettingsType => [ /// "ruta.clave" => valor, /// // ... /// ]); @@ -168,8 +171,8 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( /// /// * **`SETTINGS_NAME`** es el nombre de la variable global que se usará para referenciar los /// ajustes. Se recomienda usar `SETTINGS`, aunque no es obligatorio. -/// * **`Settings_Type`** es la referencia a la estructura que define los tipos para deserializar la -/// configuración. Debe implementar `Deserialize` (derivable con `#[derive(Deserialize)]`). +/// * **`SettingsType`** es la estructura que define los tipos para deserializar la configuración. +/// Debe implementar `Deserialize` (derivable con `#[derive(Deserialize)]`). /// * **Lista de pares** con las claves TOML que requieran valores por defecto. Siguen la notación /// `"seccion.subclave"` para coincidir con el árbol TOML. /// @@ -211,7 +214,7 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( /// * **Secciones únicas**. Agrupa tus claves dentro de una sección exclusiva (p. ej. `[blog]`) para /// evitar colisiones con otras librerías. /// -/// * **Solo lectura**. La variable generada es inmutable durante toda la vida del programa. Para +/// * **Sólo lectura**. La variable generada es inmutable durante toda la vida del programa. Para /// configurar distintos entornos (*dev*, *staging*, *prod*) usa los archivos TOML descritos en la /// documentación de [`config`](crate::config). /// @@ -220,8 +223,8 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( /// /// # Requisitos /// -/// * Dependencia `serde` con la *feature* `derive`. -/// * Las claves deben coincidir con los campos (*snake case*) de tu estructura `Settings_Type`. +/// * Las claves deben coincidir con los campos (*snake case*) de la estructura de ajustes. +/// * Añade `serde` con la *feature* `derive` en *Cargo.toml*: /// /// ```toml /// [dependencies] @@ -229,10 +232,10 @@ pub static CONFIG_VALUES: LazyLock> = LazyLock::new( /// ``` #[macro_export] macro_rules! include_config { - ( $SETTINGS_NAME:ident : $Settings_Type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => { + ( $SETTINGS_NAME:ident : $settings_type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => { #[doc = concat!( "Ajustes de configuración y **valores por defecto** para ", - "[`", stringify!($Settings_Type), "`]." + "[`", stringify!($settings_type), "`]." )] #[doc = ""] #[doc = "Valores predeterminados que se aplican en ausencia de configuración:"] @@ -241,17 +244,18 @@ macro_rules! include_config { #[doc = concat!($k, " = ", stringify!($v))] )* #[doc = "```"] - pub static $SETTINGS_NAME: std::sync::LazyLock<$Settings_Type> = + pub static $SETTINGS_NAME: std::sync::LazyLock<$settings_type> = std::sync::LazyLock::new(|| { let mut settings = $crate::config::CONFIG_VALUES.clone(); $( - settings = settings.set_default($k, $v).unwrap(); + settings = settings.set_default($k, $v) + .expect(concat!("Failed to set default for key ", $k)); )* settings .build() - .expect(concat!("Failed to build config for ", stringify!($Settings_Type))) - .try_deserialize::<$Settings_Type>() - .expect(concat!("Error parsing settings for ", stringify!($Settings_Type))) + .expect(concat!("Failed to build config for ", stringify!($settings_type))) + .try_deserialize::<$settings_type>() + .expect(concat!("Error parsing settings for ", stringify!($settings_type))) }); }; } diff --git a/src/core.rs b/src/core.rs index 8a47848e..03e32a94 100644 --- a/src/core.rs +++ b/src/core.rs @@ -30,28 +30,22 @@ impl TypeInfo { } } - /// Extrae un rango de segmentos de `type_name` (tokens separados por `::`). - /// - /// Los argumentos `start` y `end` identifican los índices de los segmentos teniendo en cuenta: - /// - /// * Los índices positivos cuentan **desde la izquierda**, empezando en `0`. - /// * Los índices negativos cuentan **desde la derecha**, `-1` es el último. - /// * Si `end` es `None`, el corte llega hasta el último segmento. - /// * Si la selección resulta vacía por índices desordenados o segmento inexistente, se devuelve - /// la cadena vacía. - /// - /// Ejemplos (con `type_name = "alloc::vec::Vec"`): - /// - /// | Llamada | Resultado | - /// |------------------------------|--------------------------| - /// | `partial(..., 0, None)` | `"alloc::vec::Vec"` | - /// | `partial(..., 1, None)` | `"vec::Vec"` | - /// | `partial(..., -1, None)` | `"Vec"` | - /// | `partial(..., 0, Some(-2))` | `"alloc::vec"` | - /// | `partial(..., -5, None)` | `"alloc::vec::Vec"` | - /// - /// La porción devuelta vive tanto como `'static` porque `type_name` es `'static` y sólo se - /// presta. + // Extrae un rango de segmentos de `type_name` (tokens separados por `::`). + // + // Los argumentos `start` y `end` identifican los índices de los segmentos teniendo en cuenta: + // + // * Los índices positivos cuentan desde la izquierda, empezando en 0. + // * Los índices negativos cuentan desde la derecha; -1 es el último. + // * Si `end` es `None`, el corte llega hasta el último segmento. + // * Si la selección resulta vacía por índices desordenados o segmento inexistente, devuelve "". + // + // Ejemplos con type_name = "alloc::vec::Vec": + // + // partial(..., 0, None) => "alloc::vec::Vec" + // partial(..., 1, None) => "vec::Vec" + // partial(..., -1, None) => "Vec" + // partial(..., 0, Some(-2)) => "alloc::vec" + // partial(..., -5, None) => "alloc::vec::Vec" fn partial(type_name: &'static str, start: isize, end: Option) -> &'static str { let maxlen = type_name.len(); @@ -59,7 +53,7 @@ impl TypeInfo { let mut segments = Vec::new(); let mut segment_start = 0; // Posición inicial del segmento actual. let mut angle_brackets = 0; // Profundidad dentro de '<...>'. - let mut previous_char = '\0'; // Se inicializa a carácter nulo, no hay aún carácter previo. + let mut previous_char = '\0'; // Control, ningún carácter previo aún. for (idx, c) in type_name.char_indices() { match c { diff --git a/src/global.rs b/src/global.rs index 8bf753e3..953dfb6d 100644 --- a/src/global.rs +++ b/src/global.rs @@ -39,9 +39,8 @@ include_config!(SETTINGS: Settings => [ "log.format" => "Full", // [server] - "server.bind_address" => "localhost", - "server.bind_port" => 8080, - "server.session_lifetime" => 604_800, + "server.bind_address" => "localhost", + "server.bind_port" => 8080, ]); // **< Settings >*********************************************************************************** @@ -116,7 +115,7 @@ pub struct Log { pub enabled: bool, /// Opciones, o combinación de opciones separadas por comas, para filtrar las trazas: *"Error"*, /// *"Warn"*, *"Info"*, *"Debug"* o *"Trace"*. - /// Ejemplo: *"Error,actix_server::builder=Info,tracing_actix_web=Debug"*. + /// Ejemplo: *"Error,tower_http=Debug,axum::rejection=trace"*. pub tracing: String, /// Muestra los mensajes de traza en el terminal (*"Stdout"*) o los vuelca en archivos con /// rotación: *"Daily"*, *"Hourly"*, *"Minutely"* o *"Endless"*. @@ -136,8 +135,4 @@ pub struct Server { pub bind_address: String, /// Puerto de escucha del servidor web. pub bind_port: u16, - /// Duración de la cookie de sesión en segundos (p. ej., `604_800` para una semana). - /// - /// El valor `0` indica que la cookie permanecerá activa hasta que se cierre el navegador. - pub session_lifetime: i64, } diff --git a/src/html.rs b/src/html.rs index 21809c08..6020627d 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,7 +1,7 @@ //! HTML en código. mod maud; -pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE}; +pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, display, html, html_private}; mod route; pub use route::RoutePath; diff --git a/src/lib.rs b/src/lib.rs index 0213e61e..918ecd02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,8 +53,8 @@ use pagetop::prelude::*; struct HelloWorld; impl Extension for HelloWorld { - fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { - scfg.route("/", service::web::get().to(hello_world)); + fn configure_router(&self, router: Router) -> Router { + router.route("/", web::get(hello_world)) } } @@ -117,10 +117,10 @@ use std::ops::Deref; /// fn before_render_page_body(&self, page: &mut Page) { /// page /// .alter_assets(AssetsOp::AddStyleSheet( -/// StyleSheet::from("/css/normalize.css").with_version("8.0.1"), +/// StyleSheet::from("/pagetop/css/normalize.css").with_version("8.0.1"), /// )) /// .alter_assets(AssetsOp::AddStyleSheet( -/// StyleSheet::from("/css/basic.css").with_version(PAGETOP_VERSION), +/// StyleSheet::from("/pagetop/css/basic.css").with_version(PAGETOP_VERSION), /// )) /// .alter_assets(AssetsOp::AddStyleSheet( /// StyleSheet::from("/mytheme/styles.css").with_version(env!("CARGO_PKG_VERSION")), @@ -132,9 +132,9 @@ use std::ops::Deref; /// referencia a la versión del *crate* que lo usa. pub const PAGETOP_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub use pagetop_macros::{builder_fn, html, main, test, AutoDefault}; +pub use pagetop_macros::{AutoDefault, builder_fn, html, main, test}; -pub use pagetop_statics::{resource, StaticResource}; +pub use pagetop_statics::{StaticResource, resource}; pub use getter_methods::Getters; @@ -198,8 +198,8 @@ pub mod datetime; pub mod core; // Respuestas a peticiones web en sus diferentes formatos. pub mod response; -// Gestión del servidor y servicios web. -pub mod service; +// Gestión del servidor y rutas web. +pub mod web; // Reúne acciones, componentes, extensiones y temas predefinidos. pub mod base; // Prepara y ejecuta la aplicación. diff --git a/src/prelude.rs b/src/prelude.rs index 818bfc91..32ce68b7 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -14,7 +14,7 @@ pub use crate::{AutoDefault, CowStr, Getters, StaticResources, UniqueId, Weight} pub use crate::include_config; // crate::locale pub use crate::include_locales; -// crate::service +// crate::web pub use crate::static_files_service; // crate::core::action pub use crate::actions; @@ -35,8 +35,8 @@ pub use crate::locale::*; pub use crate::datetime::*; -pub use crate::service; -pub use crate::service::{HttpMessage, HttpRequest, HttpResponse}; +pub use crate::web; +pub use crate::web::{HttpRequest, IntoResponse, Json, Path, Query, Router}; pub use crate::core::{AnyCast, AnyInfo, TypeInfo}; @@ -45,7 +45,7 @@ pub use crate::core::component::*; pub use crate::core::extension::*; pub use crate::core::theme::*; -pub use crate::response::{json::*, page::*, redirect::*, ResponseError}; +pub use crate::response::{page::*, redirect::*}; pub use crate::base::action; pub use crate::base::component::*; diff --git a/src/response.rs b/src/response.rs index 4078d420..55150b71 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,7 +1,5 @@ //! Respuestas a las peticiones web en sus diferentes formatos. -pub use actix_web::ResponseError; - pub mod page; pub mod json; diff --git a/src/service.rs b/src/service.rs deleted file mode 100644 index 10665413..00000000 --- a/src/service.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Gestión del servidor y servicios web (con [Actix Web](https://docs.rs/actix-web)). - -pub use actix_session::Session; -pub use actix_web::body::BoxBody; -pub use actix_web::dev::Server; -pub use actix_web::dev::ServiceFactory as Factory; -pub use actix_web::dev::ServiceRequest as Request; -pub use actix_web::dev::ServiceResponse as Response; -pub use actix_web::{cookie, http, rt, web}; -pub use actix_web::{App, Error, HttpMessage, HttpRequest, HttpResponse, HttpServer}; -pub use actix_web_files::Files as ActixFiles; - -pub use pagetop_statics::ResourceFiles; - -#[doc(hidden)] -pub use actix_web::test; - -// **< static_files_service! >********************************************************************** - -/// Configura un servicio web para publicar archivos estáticos. -/// -/// La macro ofrece tres modos para configurar el servicio: -/// -/// - **Sistema de ficheros o embebido** (`[$path, $bundle]`): trata de servir los archivos desde -/// `$path`; y si es una cadena vacía, no existe o no es un directorio, entonces usará el conjunto -/// de recursos `$bundle` integrado en el binario. -/// - **Sólo embebido** (`[$bundle]`): sirve siempre desde el conjunto de recursos `$bundle` -/// integrado en el binario. -/// - **Sólo sistema de ficheros** (`$path`): sin usar corchetes, sirve únicamente desde el sistema -/// de ficheros si existe; en otro caso no registra el servicio. -/// -/// # Argumentos -/// -/// * `$scfg` - Instancia de [`ServiceConfig`](crate::service::web::ServiceConfig) donde aplicar la -/// configuración. -/// * `$path` - Ruta al directorio local con los archivos estáticos. -/// * `$bundle` - Nombre del conjunto de recursos que esta macro integra en el binario. -/// * `$route` - Ruta URL base desde la que se servirán los archivos. -/// -/// # Ejemplos -/// -/// ```rust,ignore -/// # use pagetop::prelude::*; -/// pub struct MyExtension; -/// -/// impl Extension for MyExtension { -/// fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { -/// // Forma 1) Sistema de ficheros o embebido. -/// static_files_service!(scfg, ["/var/www/static", assets] => "/public"); -/// -/// // Forma 2) Siempre embebido. -/// static_files_service!(scfg, [assets] => "/public"); -/// -/// // Forma 3) Sólo sistema de ficheros (no requiere `assets`). -/// static_files_service!(scfg, "/var/www/static" => "/public"); -/// } -/// } -/// ``` -#[macro_export] -macro_rules! static_files_service { - // Forma 1: primero intenta servir desde el sistema de ficheros; si falla, sirve embebido. - ( $scfg:ident, [$path:expr, $bundle:ident] => $route:expr $(,)? ) => {{ - let span = $crate::trace::debug_span!( - "Configuring static files (file system or embedded)", - mode = "fs_or_embedded", - route = $route, - ); - let _ = span.in_scope(|| { - let mut serve_embedded: bool = true; - if !::std::path::Path::new(&$path).as_os_str().is_empty() { - if let Ok(absolute) = $crate::util::resolve_absolute_dir($path) { - $scfg.service($crate::service::ActixFiles::new($route, absolute)); - serve_embedded = false; - } - } - if serve_embedded { - $crate::util::paste! { - mod [] { - include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs")); - } - $scfg.service($crate::service::ResourceFiles::new( - $route, - []::$bundle(), - )); - } - } - }); - }}; - // Forma 2: sirve siempre embebido. - ( $scfg:ident, [$bundle:ident] => $route:expr $(,)? ) => {{ - let span = $crate::trace::debug_span!( - "Configuring static files (using embedded only)", - mode = "embedded", - route = $route, - ); - let _ = span.in_scope(|| { - $crate::util::paste! { - mod [] { - include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs")); - } - $scfg.service($crate::service::ResourceFiles::new( - $route, - []::$bundle(), - )); - } - }); - }}; - // Forma 3: intenta servir desde el sistema de ficheros. - ( $scfg:ident, $path:expr => $route:expr $(,)? ) => {{ - let span = $crate::trace::debug_span!( - "Configuring static files (file system only)", - mode = "fs", - route = $route, - ); - let _ = span.in_scope(|| match $crate::util::resolve_absolute_dir($path) { - Ok(absolute) => { - $scfg.service($crate::service::ActixFiles::new($route, absolute)); - } - Err(e) => { - $crate::trace::warn!( - "Static dir not found or invalid for route `{}`: {:?} ({e})", - $route, - $path, - ); - } - }); - }}; -} diff --git a/src/util.rs b/src/util.rs index 6ea4b70d..5c1fff49 100644 --- a/src/util.rs +++ b/src/util.rs @@ -58,14 +58,15 @@ pub enum NormalizeAsciiError { /// # use pagetop::util; /// assert_eq!(util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), "foo bar cli"); /// ``` -pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsciiError> { +pub fn normalize_ascii(input: &str) -> Result, NormalizeAsciiError> { let bytes = input.as_bytes(); if bytes.is_empty() { return Err(NormalizeAsciiError::IsEmpty); } - let mut start = 0usize; - let mut end = 0usize; + // Primera pasada, determina si se necesita asignación y calcula los límites del contenido. + let mut start = 0; + let mut end = 0; let mut needs_alloc = false; let mut needs_alloc_ws = false; @@ -110,6 +111,7 @@ pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsci return Ok(Cow::Borrowed(slice)); } + // Segunda pasada, construye la cadena normalizada. let mut output = String::with_capacity(slice.len()); let mut prev_sep = true; @@ -132,8 +134,8 @@ pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsci /// /// - Devuelve `Some(Cow)` si la entrada es válida ASCII (normalizada a minúsculas). /// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. -/// - Devuelve `None` si la entrada contiene bytes non-ASCII; y emite un `trace::debug!` con el -/// campo `target`. +/// - Devuelve `None` si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con el campo +/// `target`. #[inline] pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option> { match normalize_ascii(input) { @@ -171,15 +173,25 @@ pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Opt /// println!("{:#?}", util::resolve_absolute_dir("/var/www")); /// ``` pub fn resolve_absolute_dir>(path: P) -> io::Result { + resolve_absolute_dir_with_base(path, env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)) +} + +/// Auxiliar de [`resolve_absolute_dir`] expuesta para tests. +/// +/// Permite probar la lógica de resolución inyectando el directorio base explícitamente, sin +/// modificar variables de entorno globales. No forma parte de la API pública. +#[doc(hidden)] +pub fn resolve_absolute_dir_with_base>( + path: P, + base: Option, +) -> io::Result { let path = path.as_ref(); let candidate = if path.is_absolute() { path.to_path_buf() } else { - // Directorio base CARGO_MANIFEST_DIR si está disponible; o current_dir() en su defecto. - env::var_os("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .or_else(|| env::current_dir().ok()) + // Directorio base proporcionado, o current_dir() en su defecto. + base.or_else(|| env::current_dir().ok()) .unwrap_or_else(|| PathBuf::from(".")) .join(path) }; @@ -191,10 +203,8 @@ pub fn resolve_absolute_dir>(path: P) -> io::Result { if absolute_dir.is_dir() { Ok(absolute_dir) } else { - Err({ - let msg = format!("path \"{}\" is not a directory", absolute_dir.display()); - trace::warn!(msg); - io::Error::new(io::ErrorKind::InvalidInput, msg) - }) + let msg = format!("path \"{}\" is not a directory", absolute_dir.display()); + trace::warn!(msg); + Err(io::Error::new(io::ErrorKind::InvalidInput, msg)) } } diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 00000000..fb8a4883 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,340 @@ +//! Servidor web y rutas de la aplicación (basado en [Axum](https://docs.rs/axum)). +//! +//! Define rutas y manejadores: el [`Router`], las operaciones HTTP ([`get`], [`post`], [`put`], +//! [`delete`], [`patch`]), los extractores ([`Path`], [`Query`]), [`Json`] e [`IntoResponse`], y +//! re-exporta el módulo `http` para tipos de bajo nivel como `StatusCode`, `HeaderName` o `Method`. +//! También incluye servicios para gestionar archivos estáticos como [`ServeDir`] y +//! [`ServeEmbedded`]. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::task::{Context, Poll}; + +use axum::body::Body; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::{HeaderMap, Request, Response, StatusCode, Uri}; + +// Infraestructura del router. +pub use axum::Router; +pub use axum::http; + +// Extractores de petición. +pub use axum::extract::{Path, Query}; + +// Tipos de respuesta. +pub use axum::Json; +pub use axum::response::IntoResponse; + +// Verbos HTTP para registrar rutas. +pub use axum::routing::{delete, get, patch, post, put}; + +// Servicios para archivos estáticos (disco y embebidos). +pub use pagetop_statics::StaticResource; +pub use tower_http::services::ServeDir; + +// **< HttpRequest >******************************************************************************** + +/// Representa una petición HTTP. +/// +/// Almacena los datos necesarios para negociar el idioma y renderizar las páginas de error, +/// incluyendo la URI completa y las cabeceras de la petición original. +/// +/// Puede declararse directamente como parámetro en un *handler* para pasarlo al +/// [`Context`](crate::core::component::Context) de renderizado y a las variantes de +/// [`ErrorPage`](crate::response::page::ErrorPage): +/// +/// ```rust,ignore +/// async fn my_handler(request: HttpRequest) -> ResultPage { ... } +/// ``` +#[derive(Clone, Debug)] +pub struct HttpRequest { + uri: Uri, + headers: HeaderMap, +} + +impl HttpRequest { + /// Devuelve la URI completa de la petición, incluyendo la *query string* si la hay. + pub fn uri(&self) -> &str { + self.uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/") + } + + /// Devuelve la ruta (*path*) de la petición, sin la *query string*. + pub fn path(&self) -> &str { + self.uri.path() + } + + /// Devuelve la cadena de consulta (*query string*) de la petición, sin el carácter `?`. + /// + /// Devuelve una cadena vacía si la petición no tiene *query string*. + pub fn query_string(&self) -> &str { + self.uri.query().unwrap_or("") + } + + /// Devuelve las cabeceras HTTP de la petición. + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} + +impl FromRequestParts for HttpRequest { + type Rejection = Infallible; + + // Implementa el extractor de Axum para poder declarar `HttpRequest` como parámetro. + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(HttpRequest { + uri: parts.uri.clone(), + headers: parts.headers.clone(), + }) + } +} + +// **< ServeEmbedded >****************************************************************************** + +/// Permite servir archivos estáticos embebidos en el binario. +/// +/// Creado por la macro [`crate::static_files_service!`] cuando se pide servir recursos embebidos. +/// Los recursos se indexan por ruta relativa sin la barra inicial (p. ej. `"css/style.css"`). Si se +/// solicita la raíz o un directorio, devuelve `index.html` si existe. +/// +/// Es [`Clone`] para clonar el servicio por petición, pero internamente comparte el mapa de +/// recursos con un [`Arc`](std::sync::Arc) para evitar copias innecesarias. +#[derive(Clone)] +pub struct ServeEmbedded { + files: std::sync::Arc>, +} + +impl ServeEmbedded { + /// Crea un nuevo servicio a partir del mapa de recursos embebidos generado por `build.rs`. + pub fn new(files: HashMap<&'static str, StaticResource>) -> Self { + Self { + files: std::sync::Arc::new(files), + } + } +} + +impl tower::Service> for ServeEmbedded { + type Response = Response; + type Error = Infallible; + type Future = std::future::Ready>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + use axum::http::header; + + // Axum elimina el prefijo de montaje: la ruta restante puede o no comenzar con '/'. + let path = req.uri().path().trim_start_matches('/'); + + // Busca la ruta exacta; si es raíz o directorio, intenta index.html. + let resource = self.files.get(path).or_else(|| { + if path.is_empty() || path.ends_with('/') { + self.files.get("index.html") + } else { + None + } + }); + + let response = match resource { + Some(r) => Response::builder() + .header(header::CONTENT_TYPE, r.mime_type) + .body(Body::from(r.data)) + .unwrap(), + None => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(), + }; + + std::future::ready(Ok(response)) + } +} + +// **< static_files_service! >********************************************************************** + +/// Configura un servicio web para publicar archivos estáticos. +/// +/// La macro añade rutas al [`Router`] de Axum pasado como primer argumento y ofrece tres modos: +/// +/// - **Sistema de ficheros o embebido** (`[$path, $bundle]`): intenta servir desde `$path`; si es +/// vacío, no existe o no es un directorio, usa el conjunto de recursos `$bundle` embebido. +/// - **Sólo embebido** (`[$bundle]`): sirve siempre desde el conjunto de recursos embebido. +/// - **Sólo sistema de ficheros** (`$path`): sin corchetes, sirve únicamente desde disco si existe. +/// +/// # Argumentos +/// +/// * `$router` — Variable mutable de tipo [`Router`] donde registrar el servicio. +/// * `$path` — Ruta al directorio local con los archivos estáticos. +/// * `$bundle` — Nombre del conjunto de recursos embebidos generado por `build.rs`. +/// * `$route` — Ruta URL base desde la que se servirán los archivos. +/// +/// # Ejemplos +/// +/// ```rust,ignore +/// # use pagetop::prelude::*; +/// pub struct MyExtension; +/// +/// impl Extension for MyExtension { +/// fn configure_router(&self, mut router: Router) -> Router { +/// // Forma 1) Sistema de ficheros o embebido. +/// static_files_service!(router, ["/var/www/static", assets] => "/public"); +/// +/// // Forma 2) Siempre embebido. +/// static_files_service!(router, [assets] => "/public"); +/// +/// // Forma 3) Sólo sistema de ficheros (no requiere `assets`). +/// static_files_service!(router, "/var/www/static" => "/public"); +/// +/// router +/// } +/// } +/// ``` +#[macro_export] +macro_rules! static_files_service { + // Forma 1: primero intenta servir desde el sistema de ficheros; si falla, sirve embebido. + ( $router:ident, [$path:expr, $bundle:ident] => $route:expr $(,)? ) => {{ + let span = $crate::trace::debug_span!( + "static_files_service", + mode = "filesystem_or_embedded", + route = $route, + ); + let _guard = span.enter(); + let mut served_from_fs = false; + if !::std::path::Path::new(&$path).as_os_str().is_empty() { + if let Ok(absolute) = $crate::util::resolve_absolute_dir($path) { + $router = $router.nest_service($route, $crate::web::ServeDir::new(absolute)); + served_from_fs = true; + } + } + if !served_from_fs { + $crate::util::paste! { + mod [] { + include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs")); + } + $router = $router.nest_service( + $route, + $crate::web::ServeEmbedded::new( + []::$bundle(), + ), + ); + } + } + }}; + // Forma 2: sirve siempre embebido. + ( $router:ident, [$bundle:ident] => $route:expr $(,)? ) => {{ + let span = $crate::trace::debug_span!( + "static_files_service", + mode = "embedded_only", + route = $route, + ); + let _guard = span.enter(); + $crate::util::paste! { + mod [] { + include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs")); + } + $router = $router.nest_service( + $route, + $crate::web::ServeEmbedded::new( + []::$bundle(), + ), + ); + } + }}; + // Forma 3: intenta servir desde el sistema de ficheros. + ( $router:ident, $path:expr => $route:expr $(,)? ) => {{ + let span = $crate::trace::debug_span!( + "static_files_service", + mode = "filesystem_only", + route = $route, + ); + let _guard = span.enter(); + match $crate::util::resolve_absolute_dir($path) { + Ok(absolute) => { + $router = $router.nest_service($route, $crate::web::ServeDir::new(absolute)); + } + Err(e) => { + $crate::trace::warn!( + "Static dir not found or invalid for route `{}`: {:?} ({e})", + $route, + $path, + ); + } + } + }}; +} + +// **< Utilidades de test >************************************************************************* + +/// Utilidades para escribir pruebas de integración con PageTop sobre Axum. +#[doc(hidden)] +pub mod test { + use axum::Router; + use axum::body::Body; + use axum::http::{Method, Request}; + use axum::response::Response; + use tower::ServiceExt; + + /// Devuelve el router tal como se recibe, listo para usarse en pruebas de integración. + pub fn init_router(router: Router) -> Router { + router + } + + /// Constructor de peticiones HTTP para pruebas. + pub struct TestRequest { + method: Method, + uri: String, + } + + impl TestRequest { + /// Crea una petición GET. + pub fn get() -> Self { + Self { + method: Method::GET, + uri: "/".to_owned(), + } + } + + /// Crea una petición POST. + pub fn post() -> Self { + Self { + method: Method::POST, + uri: "/".to_owned(), + } + } + + /// Establece la URI de la petición. + pub fn uri(mut self, uri: impl Into) -> Self { + self.uri = uri.into(); + self + } + + /// Construye la petición HTTP de Axum (para enviar al router en tests de integración). + pub fn to_request(self) -> Request { + Request::builder() + .method(self.method) + .uri(self.uri) + .body(Body::empty()) + .unwrap() + } + + /// Construye un [`HttpRequest`](super::HttpRequest) listo para pasarlo a + /// [`Context::new`](crate::core::component::Context::new) en tests unitarios de componentes. + pub fn to_http_request(self) -> super::HttpRequest { + let uri = self.uri.parse().unwrap(); + super::HttpRequest { + uri, + headers: axum::http::HeaderMap::new(), + } + } + } + + /// Envía una petición al router y devuelve la respuesta. + pub async fn send_request(router: &Router, req: Request) -> Response { + router.clone().oneshot(req).await.unwrap() + } +}