♻️ (pagetop): Migra de actix-web a Axum

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.
This commit is contained in:
Manuel Cillero 2026-05-30 22:30:58 +02:00
parent 026448e511
commit 9c58d5e1d6
19 changed files with 612 additions and 390 deletions

View file

@ -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 <manuel@cillero.es>"]
[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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<ExtensionRef>) -> 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<service::Server, Error> {
// 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<impl Future<Output = Result<(), Error>>, 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<service::BoxBody>,
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<service::BoxBody>,
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<Markup, ErrorPage> {
async fn route_not_found(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Err(ErrorPage::NotFound(request))
}

View file

@ -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<ConfigBuilder<DefaultState>> = 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<ConfigBuilder<DefaultState>> = 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<ConfigBuilder<DefaultState>> = 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<ConfigBuilder<DefaultState>> = 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<ConfigBuilder<DefaultState>> = 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<ConfigBuilder<DefaultState>> = 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)))
});
};
}

View file

@ -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<i32>"`):
///
/// | Llamada | Resultado |
/// |------------------------------|--------------------------|
/// | `partial(..., 0, None)` | `"alloc::vec::Vec<i32>"` |
/// | `partial(..., 1, None)` | `"vec::Vec<i32>"` |
/// | `partial(..., -1, None)` | `"Vec<i32>"` |
/// | `partial(..., 0, Some(-2))` | `"alloc::vec"` |
/// | `partial(..., -5, None)` | `"alloc::vec::Vec<i32>"` |
///
/// 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<i32>":
//
// partial(..., 0, None) => "alloc::vec::Vec<i32>"
// partial(..., 1, None) => "vec::Vec<i32>"
// partial(..., -1, None) => "Vec<i32>"
// partial(..., 0, Some(-2)) => "alloc::vec"
// partial(..., -5, None) => "alloc::vec::Vec<i32>"
fn partial(type_name: &'static str, start: isize, end: Option<isize>) -> &'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 {

View file

@ -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,
}

View file

@ -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;

View file

@ -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.

View file

@ -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::*;

View file

@ -1,7 +1,5 @@
//! Respuestas a las peticiones web en sus diferentes formatos.
pub use actix_web::ResponseError;
pub mod page;
pub mod json;

View file

@ -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 [<static_files_ $bundle>] {
include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs"));
}
$scfg.service($crate::service::ResourceFiles::new(
$route,
[<static_files_ $bundle>]::$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 [<static_files_ $bundle>] {
include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs"));
}
$scfg.service($crate::service::ResourceFiles::new(
$route,
[<static_files_ $bundle>]::$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,
);
}
});
}};
}

View file

@ -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<Cow<'a, str>, NormalizeAsciiError> {
pub fn normalize_ascii(input: &str) -> Result<Cow<'_, str>, 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<Cow<'a, str>, 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<Cow<'a, str>, 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<Cow<'a, str>> {
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<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
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<P: AsRef<Path>>(
path: P,
base: Option<PathBuf>,
) -> io::Result<PathBuf> {
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<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
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))
}
}

340
src/web.rs Normal file
View file

@ -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<Markup, ErrorPage> { ... }
/// ```
#[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<S: Send + Sync> FromRequestParts<S> 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<Self, Self::Rejection> {
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<HashMap<&'static str, StaticResource>>,
}
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<Request<Body>> for ServeEmbedded {
type Response = Response<Body>;
type Error = Infallible;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request<Body>) -> 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 [<static_files_ $bundle>] {
include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs"));
}
$router = $router.nest_service(
$route,
$crate::web::ServeEmbedded::new(
[<static_files_ $bundle>]::$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 [<static_files_ $bundle>] {
include!(concat!(env!("OUT_DIR"), "/", stringify!($bundle), ".rs"));
}
$router = $router.nest_service(
$route,
$crate::web::ServeEmbedded::new(
[<static_files_ $bundle>]::$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<String>) -> 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<Body> {
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<Body>) -> Response {
router.clone().oneshot(req).await.unwrap()
}
}