diff --git a/examples/form-controls.rs b/examples/form-controls.rs index f8be00c7..b88bcfa4 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -23,7 +23,7 @@ async fn form_controls(request: HttpRequest) -> Result { .with_opening(IntroOpening::Custom) .with_title(L10n::t("title", &LOC)) .with_slogan(L10n::t("slogan", &LOC)) - .with_button(None::<(L10n, Route)>) + .with_button(None::<(L10n, FnPathByContext)>) // Bloque 1: casillas, interruptores y botones de opción. .with_child( Block::new() diff --git a/examples/hello-name.rs b/examples/hello-name.rs index a98cf05f..a37051c3 100644 --- a/examples/hello-name.rs +++ b/examples/hello-name.rs @@ -10,8 +10,8 @@ impl Extension for HelloName { } async fn hello_name( - request: HttpRequest, web::Path(name): web::Path, + request: HttpRequest, ) -> Result { Page::new(request) .with_child(Html::with(move |_| { diff --git a/examples/intro-colors.rs b/examples/intro-colors.rs index 0150b248..cde6deed 100644 --- a/examples/intro-colors.rs +++ b/examples/intro-colors.rs @@ -18,7 +18,7 @@ async fn intro_colors(request: HttpRequest) -> Result { .with_opening(IntroOpening::Custom) .with_title(L10n::n("PageTop")) .with_slogan(L10n::t("colors_slogan", &LOC)) - .with_button(None::<(L10n, Route)>) + .with_button(None::<(L10n, FnPathByContext)>) .with_child( Block::new() .with_title(L10n::t("colors_block", &LOC).with_arg("n", "1")) diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 49b44d09..dda79e45 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -20,10 +20,13 @@ impl Extension for SuperMenu { .with_expand(token::BreakPoint::LG) .with_item(bs::navbar::Item::nav( bs::Nav::new() - .with_item(bs::nav::Item::link(L10n::t("menus_item_link", &LOC), "/")) + .with_item(bs::nav::Item::link( + L10n::t("menus_item_link", &LOC), + |cx| cx.route("/"), + )) .with_item(bs::nav::Item::link_blank( L10n::t("menus_item_blank", &LOC), - "https://docs.rs/pagetop", + |_| "https://docs.rs/pagetop".into(), )) .with_item(bs::nav::Item::dropdown( bs::Dropdown::new() @@ -34,15 +37,15 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_dev_getting_started", &LOC), - "/dev/getting-started", + |cx| cx.route("/dev/getting-started"), )) .with_item(bs::dropdown::Item::link( L10n::t("menus_dev_guides", &LOC), - "/dev/guides", + |cx| cx.route("/dev/guides"), )) .with_item(bs::dropdown::Item::link_blank( L10n::t("menus_dev_forum", &LOC), - "https://forum.example.dev", + |_| "https://forum.example.dev".into(), )) .with_item(bs::dropdown::Item::divider()) .with_item(bs::dropdown::Item::header(L10n::t( @@ -51,15 +54,15 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_rust", &LOC), - "/dev/sdks/rust", + |cx| cx.route("/dev/sdks/rust"), )) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_js", &LOC), - "/dev/sdks/js", + |cx| cx.route("/dev/sdks/js"), )) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_python", &LOC), - "/dev/sdks/python", + |cx| cx.route("/dev/sdks/python"), )) .with_item(bs::dropdown::Item::divider()) .with_item(bs::dropdown::Item::header(L10n::t( @@ -68,22 +71,22 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_plugin_auth", &LOC), - "/dev/sdks/rust/plugins/auth", + |cx| cx.route("/dev/sdks/rust/plugins/auth"), )) .with_item(bs::dropdown::Item::link( L10n::t("menus_plugin_cache", &LOC), - "/dev/sdks/rust/plugins/cache", + |cx| cx.route("/dev/sdks/rust/plugins/cache"), )) .with_item(bs::dropdown::Item::divider()) .with_item(bs::dropdown::Item::label(L10n::t("menus_item_label", &LOC))) .with_item(bs::dropdown::Item::link_disabled( L10n::t("menus_item_disabled", &LOC), - "#", + |cx| cx.route("#"), )), )) .with_item(bs::nav::Item::link_disabled( L10n::t("menus_item_disabled", &LOC), - "#", + |cx| cx.route("#"), )), )) .with_item(bs::navbar::Item::nav( @@ -94,11 +97,11 @@ impl Extension for SuperMenu { ))) .with_item(bs::nav::Item::link( L10n::t("menus_item_sign_up", &LOC), - "/auth/sign-up", + |cx| cx.route("/auth/sign-up"), )) .with_item(bs::nav::Item::link( L10n::t("menus_item_login", &LOC), - "/auth/login", + |cx| cx.route("/auth/login"), )), )); diff --git a/extensions/pagetop-aliner/src/lib.rs b/extensions/pagetop-aliner/src/lib.rs index 93882930..d037828a 100644 --- a/extensions/pagetop-aliner/src/lib.rs +++ b/extensions/pagetop-aliner/src/lib.rs @@ -129,7 +129,7 @@ impl Theme for Aliner { .with_weight(-99), )) .alter_child_in( - &DefaultRegions::Footer, + &DefaultRegion::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), ); } diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index ddb256a3..66550cf2 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -6,12 +6,12 @@ use pagetop::prelude::*; /// Generador de respuestas HTML parciales con cabeceras HTMX. /// -/// En una aplicación HTMX, los handlers del servidor devuelven con frecuencia fragmentos HTML +/// En una aplicación HTMX, los *handlers* del servidor devuelven con frecuencia fragmentos HTML /// parciales acompañados de cabeceras especiales que instruyen al cliente sobre qué hacer con la /// respuesta: actualizar la URL del historial, disparar eventos JavaScript, redirigir, etc. /// /// Implementa [`IntoResponse`](pagetop::web::IntoResponse), por lo que puede devolverse -/// directamente desde cualquier handler. +/// directamente desde cualquier *handler*. /// /// # Ejemplo /// diff --git a/extensions/pagetop-seaorm/src/db.rs b/extensions/pagetop-seaorm/src/db.rs index 7e3474cb..ee60a427 100644 --- a/extensions/pagetop-seaorm/src/db.rs +++ b/extensions/pagetop-seaorm/src/db.rs @@ -15,10 +15,10 @@ //! //! Estas funciones integran los valores como literales escapados, no como parámetros de base de //! datos. Para consultas con datos del usuario, el sistema de entidades es más robusto. Si aun así -//! se necesita SQL en crudo con parámetros reales, se puede construir un [`sea_orm::Statement`] -//! directamente con [`sea_orm::Statement::from_sql_and_values`]. +//! se necesita SQL en crudo con parámetros reales, se puede construir un [`api::Statement`] +//! directamente con [`api::Statement::from_sql_and_values`]. //! -//! # Tipos esenciales +//! ## Tipos esenciales //! //! Destacan los siguientes elementos de uso más frecuente: //! @@ -32,7 +32,7 @@ //! - **Errores**: [`DbErr`]. //! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE). //! -//! # Definir una entidad +//! ## Definir una entidad //! //! ```rust,no_run //! use pagetop_seaorm::db::*; @@ -54,7 +54,7 @@ //! impl ActiveModelBehavior for ActiveModel {} //! ``` //! -//! # Operaciones CRUD +//! ## Operaciones CRUD //! //! ```rust,ignore //! use pagetop_seaorm::db::*; @@ -106,19 +106,19 @@ //! //! Para migraciones y definición de esquemas usa [`migration`](crate::migration). //! -//! # Acceso completo a SeaORM +//! ## Acceso completo a SeaORM //! -//! Este módulo re-exporta el crate `sea_orm` íntegro. Úsalo cuando necesites un tipo o función que -//! no esté expuesto directamente en `db::*`: +//! El módulo [`api`] re-exporta el crate `sea_orm` íntegro bajo ese alias. Úsalo cuando necesites +//! un tipo o función que no esté expuesto directamente en `db::*`: //! //! ```rust,no_run -//! use pagetop_seaorm::db::sea_orm; +//! use pagetop_seaorm::db::api; //! //! // Tipos o utilidades no incluidos en db::*: -//! let _: sea_orm::DatabaseBackend = sea_orm::DatabaseBackend::Sqlite; +//! let _: api::DatabaseBackend = api::DatabaseBackend::Sqlite; //! ``` //! -//! # Construcción de consultas en tiempo de ejecución +//! ## Construcción de consultas en tiempo de ejecución //! //! El módulo [`query`] re-exporta `sea_query` para construir las sentencias SQL que se pasan a //! [`fetch_all`] y [`fetch_one`]. Es el compañero natural de esas funciones dentro del módulo `db`: @@ -139,25 +139,17 @@ pub use sea_orm::prelude::*; -// Re-exporta el crate `sea_orm` íntegro como puerta de acceso a su API completa. -// -// Útil para tipos o utilidades que no están expuestos directamente en `db::*`; la inmensa mayoría -// de operaciones no necesitan este módulo, `db::*` cubre los casos habituales. -// -// Por otro lado, este re-export es funcionalmente necesario para que los derive de `sea-orm-macros` -// (`DeriveEntityModel`, `DeriveRelation`...) compilen sin declarar `sea-orm` como dependencia -// directa en el `Cargo.toml` de la extensión o aplicación. Los derive generan rutas `sea_orm::...` -// (vía `quote!`, sin `Span::mixed_site()`), que resuelven contra cualquier item `sea_orm` visible -// en el módulo que invoca la macro. Basta con declarar `use pagetop_seaorm::db::*;` en cada entidad -// para compilar sin errores. -#[doc(hidden)] -pub use sea_orm; - pub use sea_orm::{ - ActiveValue, Condition, DatabaseTransaction, DbBackend, ExecResult, NotSet, Order, QueryOrder, - QuerySelect, Set, TransactionError, TransactionTrait, Unchanged, + ActiveValue, DatabaseTransaction, ExecResult, QueryOrder, QuerySelect, TransactionTrait, }; +/// Re-exporta el crate `sea_orm` íntegro como puerta de acceso a su API completa. +/// +/// Útil para tipos o utilidades que no están expuestos directamente en [`db::*`](self). La inmensa +/// mayoría de operaciones no necesitan este módulo; `db::*` cubre los casos habituales. +#[doc(inline)] +pub use sea_orm as api; + /// Re-exporta `sea_query` para construir sentencias SQL en tiempo de ejecución. /// /// Proporciona los constructores de consultas (`Query`, `Expr`, `Alias`, ...) que se pasan a @@ -215,7 +207,7 @@ pub fn dbconn() -> &'static DatabaseConnection { pub async fn execute(stmt: impl Into) -> Result { let conn = dbconn(); let backend = conn.get_database_backend(); - conn.execute(sea_orm::Statement::from_string(backend, stmt.into())) + conn.execute(api::Statement::from_string(backend, stmt.into())) .await } @@ -256,12 +248,12 @@ pub async fn fetch_all( ) -> Result, DbErr> { let conn = dbconn(); let backend = conn.get_database_backend(); - conn.query_all(sea_orm::Statement::from_string( + conn.query_all(api::Statement::from_string( backend, match backend { - sea_orm::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder), - sea_orm::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder), - sea_orm::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder), + api::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder), + api::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder), + api::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder), }, )) .await @@ -306,12 +298,12 @@ pub async fn fetch_one( ) -> Result, DbErr> { let conn = dbconn(); let backend = conn.get_database_backend(); - conn.query_one(sea_orm::Statement::from_string( + conn.query_one(api::Statement::from_string( backend, match backend { - sea_orm::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder), - sea_orm::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder), - sea_orm::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder), + api::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder), + api::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder), + api::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder), }, )) .await diff --git a/extensions/pagetop-seaorm/src/migration.rs b/extensions/pagetop-seaorm/src/migration.rs index 0c974f06..729f7afb 100644 --- a/extensions/pagetop-seaorm/src/migration.rs +++ b/extensions/pagetop-seaorm/src/migration.rs @@ -128,9 +128,8 @@ pub use manager::*; //pub use migrator::*; //pub use async_trait; -pub use sea_orm; -pub use sea_orm::sea_query; - +//pub use sea_orm; +//pub use sea_orm::sea_query; pub use sea_orm::DbErr; pub trait MigrationName { diff --git a/src/app.rs b/src/app.rs index 9f661ed6..87d81b6c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; use crate::locale::Locale; -use crate::response::{render_error_pages, response_for_panic, route_not_found}; +use crate::response::page::{render_error_pages, response_for_panic, route_not_found}; use crate::web::Router; use crate::{PAGETOP_VERSION, global, trace}; @@ -20,7 +20,7 @@ use std::sync::LazyLock; /// preparando un entorno de pruebas, se usa [`test()`](Application::test). /// /// Los **errores controlados** (403, 404, o un fallo que un handler devuelva explícitamente como -/// [`ErrorPage`](crate::response::ErrorPage)) se renderizan usando el tema activo (ver +/// [`ErrorPage`](crate::response::page::ErrorPage)) se renderizan usando el tema activo (ver /// [`Theme::error_403()`](crate::core::theme::Theme::error_403), /// [`Theme::error_404()`](crate::core::theme::Theme::error_404) y /// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)). @@ -102,6 +102,11 @@ impl Application { print!("\n{app_ff}"); } + // Descripción de la aplicación. + if !global::SETTINGS.app.description.is_empty() { + println!("{}", global::SETTINGS.app.description.cyan()); + } + // Versión de PageTop. println!( "{} {}\n", diff --git a/src/base/action/page.rs b/src/base/action/page.rs index b455913b..b6dbe9ab 100644 --- a/src/base/action/page.rs +++ b/src/base/action/page.rs @@ -1,6 +1,6 @@ //! Acciones para alterar el contenido de las páginas a renderizar. -use crate::response::Page; +use crate::response::page::Page; /// Tipo de función para manipular una página durante su construcción o renderizado. /// diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 9bb74331..9cbe8729 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -21,7 +21,7 @@ use std::sync::Arc; /// }); /// ``` /// -/// Para renderizar contenido que dependa del contexto, se puede acceder a él dentro del closure: +/// Para renderizar contenido que dependa del contexto, se puede acceder a él dentro del *closure*: /// /// ```rust,no_run /// # use pagetop::prelude::*; diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index ab985f6d..f4fdb550 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -48,7 +48,7 @@ pub enum IntroOpening { /// .with_slogan(L10n::l("intro_custom_slogan")) /// .with_button(Some(( /// L10n::l("intro_learn_more"), -/// "/learn-more".into() +/// |_| "/learn-more".into() /// ))); /// ``` /// @@ -57,7 +57,7 @@ pub enum IntroOpening { /// ```rust,no_run /// # use pagetop::prelude::*; /// let intro = Intro::default() -/// .with_button(None::<(L10n, Route)>) +/// .with_button(None::<(L10n, FnPathByContext)>) /// .with_opening(IntroOpening::Custom); /// ``` /// @@ -86,7 +86,7 @@ pub struct Intro { /// Devuelve el eslogan de la entrada. slogan: L10n, /// Devuelve el botón de llamada a la acción, si existe. - button: Option<(L10n, Route)>, + button: Option<(L10n, FnPathByContext)>, /// Devuelve el modo de apertura configurado. opening: IntroOpening, /// Devuelve la lista de componentes hijo de la intro. @@ -100,7 +100,7 @@ impl Default for Intro { Intro { title: L10n::l("intro_default_title"), slogan: L10n::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name), - button: Some((L10n::l("intro_default_button"), BUTTON_LINK.into())), + button: Some((L10n::l("intro_default_button"), |_| BUTTON_LINK.into())), opening: IntroOpening::default(), children: Children::default(), } @@ -159,7 +159,7 @@ impl Component for Intro { div class="intro-button" { a class="intro-button-link" - href=(lnk.resolve(cx)) + href=((lnk)(cx)) target="_blank" rel="noopener noreferrer" { @@ -249,21 +249,21 @@ impl Intro { /// Configura el botón opcional de llamada a la acción. /// - /// - Usa `Some((texto, ruta))` para mostrarlo, donde [`Route`] resuelve la ruta o URL final al - /// pulsar el botón según el contexto de renderizado. + /// - Usa `Some((texto, closure_url))` para mostrarlo, donde [`FnPathByContext`] recibe el + /// [`Context`] y devuelve la ruta o URL final al pulsar el botón. /// - Usa `None` para ocultarlo. /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; - /// // Define un botón con texto y una ruta interna (preserva `lang` si corresponde). - /// let intro = Intro::default().with_button(Some((L10n::n("Start"), "/start".into()))); + /// // Define un botón con texto y una URL fija. + /// let intro = Intro::default().with_button(Some((L10n::n("Start"), |_| "/start".into()))); /// // Descarta el botón de la intro. /// let intro_no_button = Intro::default().with_button(None); /// ``` #[builder_fn] - pub fn with_button(mut self, button: Option<(L10n, Route)>) -> Self { + pub fn with_button(mut self, button: Option<(L10n, FnPathByContext)>) -> Self { self.button = button; self } diff --git a/src/config.rs b/src/config.rs index 7c7580cb..6faedfeb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,21 +92,18 @@ //! //! # Usando tus opciones de configuración //! -//! Los ajustes de cada extensión y los globales de PageTop -//! ([`global::SETTINGS`](crate::global::SETTINGS)) conviven sin conflicto: cada cual vive en su -//! propia sección del TOML y pueden combinarse libremente cuando se necesiten. -//! //! ```rust,ignore //! use pagetop::prelude::*; //! use crate::config; //! //! fn global_settings() { //! println!("Nombre de la app: {}", &global::SETTINGS.app.name); +//! println!("Descripción: {}", &global::SETTINGS.app.description); //! println!("Run mode: {}", &global::SETTINGS.app.run_mode); //! } //! //! fn extension_settings() { -//! println!("{}", &config::SETTINGS.myapp.name); +//! println!("{} - {:?}", &config::SETTINGS.myapp.name, &config::SETTINGS.myapp.description); //! println!("{}", &config::SETTINGS.myapp.width); //! } //! ``` diff --git a/src/core/action/all.rs b/src/core/action/all.rs index 13ad9716..eb531d33 100644 --- a/src/core/action/all.rs +++ b/src/core/action/all.rs @@ -73,7 +73,7 @@ where /// Despacha las funciones asociadas a una [`ActionKey`] con posible salida anticipada. /// -/// Funciona igual que [`dispatch_actions`], pero el closure puede devolver +/// Funciona igual que [`dispatch_actions`], pero el *closure* puede devolver /// [`std::ops::ControlFlow::Continue`] para continuar ejecutando la siguiente acción; o /// [`std::ops::ControlFlow::Break`] para detener la iteración inmediatamente. /// diff --git a/src/core/component.rs b/src/core/component.rs index 07a00e30..740d7d26 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -1,10 +1,6 @@ //! API para construir nuevos componentes. -//! -//! Para cualquier `href`, `action` o redirección que deba preservar el idioma negociado (anexando a -//! la URL el parámetro de consulta `?lang=...`), PageTop no usa `String`/`&str` sueltos, sino que -//! usa [`Context::route()`] cuando ya se tiene el contexto de renderizado a mano, o [`Route`] para -//! campos de componente que se construyen una vez y se resuelven más tarde, en cada petición. La -//! documentación de [`Route`] explica el criterio completo para elegir entre ambos. + +use crate::html::RoutePath; mod error; pub use error::ComponentError; @@ -22,9 +18,6 @@ pub use message::{MessageLevel, StatusMessage}; mod context; pub use context::{AssetsOp, Context, ContextError, Contextual}; -mod route; -pub use route::Route; - /// Alias de función (*callback*) para **determinar si un componente se renderiza o no**. /// /// Puede usarse para permitir que una instancia concreta de un tipo de componente dado decida @@ -78,3 +71,41 @@ pub use route::Route; /// } /// ``` pub type FnIsRenderable = fn(cx: &Context) -> bool; + +/// Alias de función (*callback*) para **resolver una ruta URL** según el contexto de renderizado. +/// +/// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, parámetros, +/// etc.). Devuelve una [`RoutePath`], que representa un *path* base junto con una lista opcional de +/// parámetros de consulta. +/// +/// El caso más común es construir rutas relativas dependientes del contexto, normalmente usando +/// [`Context::route`](crate::core::component::Context::route): +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # let relative_route: FnPathByContext = +/// |cx| cx.route("/path/to/page") +/// # ; +/// ``` +/// +/// También es posible usar rutas estáticas sin asignaciones adicionales: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # let external_route: FnPathByContext = +/// |_| "https://www.example.com".into() +/// # ; +/// ``` +/// +/// O componer rutas dinámicas en tiempo de ejecución: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # let dynamic_route: FnPathByContext = +/// |cx| RoutePath::new("/user").with_param("id", cx.param::("user_id").unwrap().to_string()) +/// # ; +/// ``` +/// +/// Los componentes que acepten un [`FnPathByContext`] invocarán esta función durante el renderizado +/// para obtener la URL final que se asignará al atributo HTML correspondiente. +pub type FnPathByContext = fn(cx: &Context) -> RoutePath; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index cadcb05c..5bf091a1 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -310,8 +310,7 @@ impl Children { } } - // Añade un componente hijo al final de la lista. También lo usa `core::theme::regions` al - // fusionar regiones, fuera de este módulo. + // Añade un componente hijo al final de la lista. #[inline] pub(crate) fn add(&mut self, child: Child) -> &mut Self { self.0.push(child); diff --git a/src/core/component/context.rs b/src/core/component/context.rs index d9dc70a9..4edc8ea3 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -8,13 +8,13 @@ use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::L10n; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::web::HttpRequest; -use crate::{builder_fn, util}; +use crate::{CowStr, builder_fn, util}; use parking_lot::Mutex; -use thiserror::Error; use std::any::{Any, TypeId}; use std::collections::HashMap; +use std::fmt; /// Operaciones para modificar recursos asociados al [`Context`] de un documento. pub enum AssetsOp { @@ -40,15 +40,14 @@ pub enum AssetsOp { } /// Errores de acceso a parámetros dinámicos del contexto. -#[derive(Debug, Error)] +/// +/// - [`ContextError::ParamNotFound`]: la clave no existe. +/// - [`ContextError::ParamTypeMismatch`]: la clave existe, pero el valor guardado no coincide con +/// el tipo solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo +/// realmente guardado (`saved`) para facilitar el diagnóstico. +#[derive(Debug)] pub enum ContextError { - /// La clave no existe. - #[error("parameter not found")] ParamNotFound, - /// La clave existe, pero el valor guardado no coincide con el tipo solicitado. Incluye - /// nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente guardado (`saved`) - /// para facilitar el diagnóstico. - #[error("type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"")] ParamTypeMismatch { key: &'static str, expected: &'static str, @@ -56,6 +55,26 @@ pub enum ContextError { }, } +impl fmt::Display for ContextError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ContextError::ParamNotFound => { + write!(f, "parameter not found") + } + ContextError::ParamTypeMismatch { + key, + expected, + saved, + } => write!( + f, + "type mismatch for parameter \"{key}\": expected \"{expected}\", found \"{saved}\"" + ), + } + } +} + +impl std::error::Error for ContextError {} + /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: @@ -67,7 +86,7 @@ pub enum ContextError { /// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// /// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como -/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page). +/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page). /// /// # Ejemplo /// @@ -236,51 +255,12 @@ pub trait Contextual: LangId { fn remove_param(&mut self, key: &'static str) -> bool; } -// Cómo obtener la plantilla activa del contexto: la del tema (por defecto o de administración), o -// una fijada explícitamente. Se resuelve contra el tema activo al leer `Context::template()`, no al -// asignarla, para que un cambio de tema posterior con `with_theme()` se refleje automáticamente. -enum TemplateSource { - // Plantilla por defecto. - Default, - // Plantilla de administración del tema activo. - Admin, - // Plantilla fijada explícitamente con `with_template()`. - Explicit(TemplateRef), -} - -impl TemplateSource { - fn resolve(&self, theme: ThemeRef) -> TemplateRef { - match self { - Self::Default => theme.default_template(), - Self::Admin => theme.admin_template(), - Self::Explicit(template) => *template, - } - } -} - /// Implementa un **contexto de renderizado** para un documento HTML. /// -/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de -/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)), -/// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición -/// HTTP de origen, el idioma negociado, el usuario autenticado -/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos -/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los -/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con -/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param). -/// -/// La implementación extiende [`Contextual`], que aporta los métodos *builder* (`with_*`) y los -/// *getters* comunes a cualquier estructura que gestione un contexto de renderizado (también los -/// implementa [`Page`](crate::response::Page)). Además, `Context` añade: -/// -/// - [`route()`](Self::route) para construir URLs que preserven `?lang=...` cuando corresponda. -/// - [`build_id()`](Self::build_id)/[`required_id()`](Self::required_id) para generar -/// identificadores HTML únicos por tipo de componente. -/// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular -/// [`StatusMessage`] que mostrar en algún momento del renderizado. -/// - [`render_assets()`](Self::render_assets)/[`render_region_named()`](Self::render_region_named), -/// usados internamente por [`Page`](crate::response::Page) para producir el HTML final del -/// documento. +/// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto, +/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo +/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos +/// tipados** con nuevos métodos. /// /// # Ejemplos /// @@ -326,6 +306,29 @@ impl TemplateSource { /// let _unique_id = cx.build_id::(1); // => "menu-1" si es el primero /// } /// ``` + +// Cómo obtener la plantilla activa del contexto: la del tema (por defecto o de administración), o +// una fijada explícitamente. Se resuelve contra el tema activo al leer `Context::template()`, no al +// asignarla, para que un cambio de tema posterior con `with_theme()` se refleje automáticamente. +enum TemplateSource { + // Plantilla por defecto. + Default, + // Plantilla de administración del tema activo. + Admin, + // Plantilla fijada explícitamente con `with_template()`. + Explicit(TemplateRef), +} + +impl TemplateSource { + fn resolve(&self, theme: ThemeRef) -> TemplateRef { + match self { + Self::Default => theme.default_template(), + Self::Admin => theme.admin_template(), + Self::Explicit(template) => *template, + } + } +} + #[rustfmt::skip] pub struct Context { request : Option, // Petición HTTP de origen. @@ -437,24 +440,17 @@ impl Context { /// Construye una ruta aplicada al contexto actual. /// - /// Acepta cualquier tipo convertible a [`RoutePath`] (un literal, un `String`, un `&str` de - /// cualquier vida, o un [`RoutePath`] ya construido con sus propios parámetros). Si la política - /// de negociación del idioma ([`LangNegotiation`](crate::global::LangNegotiation)) indica que - /// debe propagarse el idioma para esta petición, se añade o actualiza automáticamente el - /// parámetro de *query* `lang=...` con el identificador de idioma definido en el contexto. + /// La ruta resultante se envuelve en un [`RoutePath`], que permite añadir parámetros de + /// consulta de forma tipada. Si la política de negociación de idioma actual + /// [`LangNegotiation`](crate::global::LangNegotiation) indica que debe propagarse el idioma + /// para esta petición, se añade o actualiza el parámetro de *query* `lang=...` con el + /// identificador de idioma efectivo del contexto. /// /// Esto garantiza que los enlaces generados desde el contexto preservan la preferencia de - /// idioma del usuario durante la navegación. Si `path` **parece** una URL externa (ver - /// [`util::url_looks_external()`](crate::util::url_looks_external)), nunca se le añade `lang`. - /// - /// Este método asume que ya tienes `cx` a mano en el momento de construir la ruta (dentro de - /// `prepare()`, un *handler* HTTP, etc.). Si lo que estás definiendo es un campo de componente - /// que se construye una sola vez y se reutiliza en peticiones futuras (un menú, un botón, - /// etc.), usa [`Route`](crate::core::component::Route) en su lugar (su documentación explica el - /// criterio completo para elegir entre ambos). - pub fn route(&self, path: impl Into) -> RoutePath { - let mut route = path.into(); - if !route.is_external() && self.locale.needs_lang_query() { + /// idioma del usuario cuando procede. + pub fn route(&self, path: impl Into) -> RoutePath { + let mut route = RoutePath::new(path); + if self.locale.needs_lang_query() { route.alter_param("lang", self.locale.langid().to_string()); } route diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 10b0e325..ae2340c6 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -110,27 +110,15 @@ pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync { /// Genera el marcado HTML del componente cuando ningún tema lo sobrescribe. /// - /// Este es el cuarto paso del [ciclo de renderizado](ComponentRender) tras llamar al - /// [`setup()`](Self::setup) del componente y despachar la acción - /// [`BeforeRender`](crate::base::action::component::BeforeRender) que atiende los cambios de - /// otras extensiones antes de renderizar. Se invoca sólo si ningún tema en la cadena devuelve - /// `Some` en [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) para - /// este componente. - /// - /// Es `async`, a diferencia de [`setup()`](Self::setup), para permitir a los componentes - /// realizar aquí sus llamadas asíncronas, como consultas a base de datos, peticiones a - /// servicios externos, o cualquier operación de E/S, que necesiten para preparar su contenido. + /// Cuarto paso del [ciclo de renderizado](ComponentRender): se invoca tras + /// [`setup()`](Self::setup) y la acción + /// [`BeforeRender`](crate::base::action::component::BeforeRender), pero solamente si ningún + /// tema en la cadena devuelve `Some` en + /// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component). /// /// Se recomienda obtener los datos del componente a través de sus propios métodos para que los /// temas puedan implementar `handle_component()` sin depender de los detalles internos. /// - /// Los campos que representen contenido no deben almacenar [`Markup`] ya generado, sino que - /// guardarán el dato en bruto, por ejemplo [`L10n`](crate::locale::L10n) para textos - /// traducibles, o un componente anidado como [`Html`](crate::base::component::Html), o - /// cualquier otro `Component`, normalmente a través de [`Embed`](crate::core::component::Embed) - /// o [`Child`](crate::core::component::Child); y será este método quien lo convierta en HTML en - /// el momento del renderizado, no quien construye la instancia. - /// /// Por defecto, devuelve un [`Markup`] vacío (`Ok(html! {})`). En caso de error, devuelve un /// [`ComponentError`] que puede incluir un marcado alternativo (*fallback*). #[allow(unused_variables)] diff --git a/src/core/component/error.rs b/src/core/component/error.rs index afe69d29..5b548c62 100644 --- a/src/core/component/error.rs +++ b/src/core/component/error.rs @@ -1,8 +1,6 @@ use crate::html::{Markup, html}; use crate::{AutoDefault, Getters}; -use thiserror::Error; - /// Error producido durante el renderizado de un componente. /// /// Se usa en [`Component::prepare()`](super::Component::prepare) para devolver @@ -24,8 +22,7 @@ use thiserror::Error; /// } /// # } /// ``` -#[derive(AutoDefault, Debug, Error, Getters)] -#[error("{message}")] +#[derive(AutoDefault, Debug, Getters)] pub struct ComponentError { /// Mensaje descriptivo del error. message: String, @@ -54,9 +51,18 @@ impl ComponentError { // **< ComponentError GETTERS >***************************************************************** - // Consume el error y devuelve su marcado alternativo. Se invoca internamente desde - // `ComponentRender::render()`, en el mismo módulo `core::component`. - pub(super) fn into_fallback(self) -> Markup { + /// Consume el error y devuelve su marcado alternativo. + /// + /// Se invoca internamente en [`ComponentRender`](crate::core::component::ComponentRender). + pub(crate) fn into_fallback(self) -> Markup { self.fallback } } + +impl std::fmt::Display for ComponentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ComponentError {} diff --git a/src/core/component/route.rs b/src/core/component/route.rs deleted file mode 100644 index 8e563329..00000000 --- a/src/core/component/route.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::core::component::Context; -use crate::html::RoutePath; -use crate::util; - -use std::fmt; -use std::sync::Arc; - -/// Encapsula una función que **resuelve una ruta URL** según el contexto de renderizado. -/// -/// `Route` envuelve un closure (`Arc`) que le permite capturar valores de su entorno, -/// normalmente un identificador u otro dato dinámico, para construir la ruta. También puede -/// clonarse a bajo coste (sólo el `Arc` interno) y compartirse entre hilos. -/// -/// Los componentes que acepten una `Route` la invocan con [`resolve()`](Self::resolve) durante el -/// renderizado para obtener la [`RoutePath`] final, que se asigna al atributo HTML correspondiente -/// (normalmente `href` o `action`). -/// -/// # ¿Cuál usar, `RoutePath`, `Route` o `Context::route()`? -/// -/// [`RoutePath`] es el valor común. Incluye un *path* más una lista de parámetros de consulta, sin -/// ninguna noción de [`Context`] ni de idioma. Es lo que devuelven [`Context::route()`] y -/// [`resolve()`](Self::resolve), y lo que reciben directamente funciones que ya se llaman con `cx` -/// a mano y consumen el resultado de inmediato, como -/// [`Waypoint::append_to()`](crate::response::Waypoint::append_to) / -/// [`Waypoint::or()`](crate::response::Waypoint::or), [`Redirect::*`](crate::response::Redirect) o -/// [`SortLink`](crate::base::component::table::SortLink). Al no depender de `Context`, también -/// sirve fuera del ciclo de renderizado, por ejemplo, para construir la URL de una API externa. -/// -/// Entre `Route` y `Context::route()`, cuál usar depende de una sola pregunta: ¿el valor es una -/// propiedad de un componente, que se resolverá más tarde en su propio -/// [`prepare()`](crate::core::component::Component::prepare), o se va a resolver desde una función -/// que ya tiene `cx: &Context`? -/// -/// - Los componentes suelen usar `Route` en sus propiedades. Su contenido llega en bruto y se -/// resuelve más tarde, normalmente en la ejecución de su `prepare()`, cuando ya existe un -/// `Context` concreto para esa petición. -/// -/// Un literal (`"/path"`) o un `String` se enrutan automáticamente mediante conversión implícita -/// a `Route`, así que no hace falta invocarlo a mano salvo que quieras añadir parámetros -/// adicionales. -/// -/// - Si tienes `cx: &Context` (o `&mut Context`) a tu disposición, ya sea en un handler HTTP, -/// dentro de un [`Html::with(|cx| ...)`](crate::base::component::Html::with) o cualquier otra -/// función que reciba `cx`, puedes usar `Context::route()` para obtener el `RoutePath` de -/// inmediato, seguir componiéndolo con `.with_param()`/`.with_flag()`, o insertarlo directamente -/// en el marcado. -/// -/// A diferencia de `Route`, un literal pasado directamente a las funciones anteriores **no** -/// reconoce `Context` ni idioma, por lo que habría que llamar a `cx.route(...)` a mano antes de -/// invocarlas (ver el ejemplo con `Waypoint::append_to()` más abajo). -/// -/// # Detección automática de URLs externas -/// -/// La conversión implícita desde `&str`/`String` reconoce si el texto **parece** una URL externa -/// (ver [`util::url_looks_external()`](crate::util::url_looks_external)) y la trata como tal. En -/// ese caso se comporta como [`Route::external()`], sin pasar por `Context::route()`. Una URL -/// externa nunca debe llevar `lang`, porque no pertenece al espacio de rutas de la aplicación. -/// -/// Esta protección no depende sólo de `Route`: [`Context::route()`] también comprueba si el -/// resultado parece externo antes de añadir `lang`, así que llamarlo directamente (por ejemplo, con -/// una URL dinámica que no ha pasado por `Route`) tampoco añade el parámetro. -/// -/// Esta detección es una comodidad, no una garantía: se basa en el prefijo del texto. Usa -/// [`Route::external()`] explícitamente cuando quieras dejar clara la intención sin ambigüedad, o -/// para esquemas que la heurística aplicada no reconozca. -/// -/// # Ejemplos -/// -/// El caso más común es una ruta relativa dependiente del contexto, normalmente usando -/// [`Context::route()`] para que el enlace preserve el parámetro `lang` cuando corresponda: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let route = Route::with(|cx| cx.route("/path/to/page")); -/// ``` -/// -/// Un literal o un `String` se convierten directamente, y también pasan por [`Context::route()`]: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let route: Route = "/path/to/page".into(); -/// ``` -/// -/// Una ruta dinámica puede capturar valores del entorno, algo que un simple puntero a función no -/// permite: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let user_id = 42; -/// let route = Route::with(move |cx| cx.route(format!("/users/{user_id}"))); -/// ``` -/// -/// Si ya tienes un [`RoutePath`] resuelto -- por ejemplo, combinando -/// [`Context::route()`] con [`Waypoint::append_to()`](crate::response::Waypoint::append_to) -- -/// se convierte directamente, sin volver a procesarlo: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// # let cx = Context::new(None); -/// # let waypoint = Waypoint::default(); -/// let route: Route = waypoint.append_to(cx.route("/items")).into(); -/// ``` -/// -/// Una URL externa fija, que no debe llevar `lang`, se detecta automáticamente por su prefijo al -/// convertir un literal, y se comporta como [`Route::external()`]: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let route: Route = "https://www.example.com".into(); -/// ``` -/// -/// [`Route::external()`] sigue siendo útil para dejar la intención explícita, o para esquemas que -/// la heurística no reconozca (por ejemplo una URL construida dinámicamente que no empieza -/// literalmente por uno de los prefijos detectados): -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// let route = Route::external("ftp://files.example.com"); -/// ``` -#[derive(Clone)] -pub struct Route(Arc RoutePath + Send + Sync>); - -impl Route { - /// Crea una `Route` a partir de un closure que resuelve la ruta según el contexto. - pub fn with(f: F) -> Self - where - F: Fn(&Context) -> RoutePath + Send + Sync + 'static, - { - Route(Arc::new(f)) - } - - /// Crea una `Route` para una URL externa fija. - /// - /// Una URL externa nunca debe llevar el parámetro `lang` ya que no pertenece al espacio de - /// rutas de la aplicación. Un literal o un `String` con pinta de URL externa (ver "Detección - /// automática de URLs externas" más arriba) ya se comportan así al convertirse a `Route`; usa - /// `external()` explícitamente para dejar la intención clara sin ambigüedad, o cuando el - /// heurístico no reconozca el esquema. - pub fn external(url: impl Into) -> Self { - url.into().into() - } - - /// Invoca el closure interno para obtener la [`RoutePath`] final. - pub fn resolve(&self, cx: &Context) -> RoutePath { - (self.0)(cx) - } -} - -impl fmt::Debug for Route { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Route") - .field(&"Fn(&Context) -> RoutePath") - .finish() - } -} - -impl Default for Route { - fn default() -> Self { - Route::with(|_| RoutePath::default()) - } -} - -/// Envuelve un [`RoutePath`] que ya tienes construido, sin volver a procesarlo. -/// -/// Es el caso de un `RoutePath` obtenido con [`Context::route()`], solo o combinado con -/// [`Waypoint::append_to()`](crate::response::Waypoint::append_to) o -/// [`Waypoint::or()`](crate::response::Waypoint::or): el idioma (`lang`) ya se aplicó ahí, y esta -/// conversión evita volver a aplicarlo. -/// -/// Ojo: esta conversión asume que el `RoutePath` ya pasó por `Context::route()`. Si construyes uno -/// a mano (por ejemplo con `RoutePath::new(...)` directamente, sin pasar por `cx.route()`), esta -/// conversión no añadirá `lang` por ti -- simplemente envuelve el valor tal cual, igual que -/// [`Route::external()`]. -impl From for Route { - fn from(path: RoutePath) -> Self { - Route::with(move |_| path.clone()) - } -} - -impl From<&'static str> for Route { - fn from(path: &'static str) -> Self { - if util::url_looks_external(path) { - Route::external(path) - } else { - Route::with(move |cx| cx.route(path)) - } - } -} - -impl From for Route { - fn from(path: String) -> Self { - if util::url_looks_external(&path) { - Route::external(path) - } else { - Route::with(move |cx| cx.route(path.clone())) - } - } -} diff --git a/src/core/theme.rs b/src/core/theme.rs index 2dd31eef..6a5bf6a7 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -5,7 +5,7 @@ //! del documento a partir de regiones ([`Region`]). Cada región es un contenedor lógico //! identificado por un nombre para agrupar y renderizar componentes. //! -//! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa +//! Una página ([`Page`](crate::response::page::Page)) es un documento HTML completo. Implementa //! [`Contextual`](crate::core::component::Contextual) para gestionar su propio [`Context`], donde //! mantiene el tema activo, la plantilla seleccionada y los componentes asociados a cada región a //! renderizar. @@ -104,7 +104,7 @@ use crate::{AutoDefault, util}; /// manera distinta. /// /// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla -/// ([`Template`]) al renderizar la página ([`Page`](crate::response::Page)). +/// ([`Template`]) al renderizar la página ([`Page`](crate::response::page::Page)). #[async_trait] pub trait Region: Send + Sync { /// Devuelve el nombre de la región. @@ -209,7 +209,7 @@ impl Region for DefaultRegions { /// Interfaz común para definir plantillas de contenido. /// /// Una `Template` puede proporcionar una o más variantes para decidir la composición del `` -/// de una página ([`Page`](crate::response::Page)). El tema utiliza esta información para +/// de una página ([`Page`](crate::response::page::Page)). El tema utiliza esta información para /// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden. #[async_trait] pub trait Template: Send + Sync { diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index b0e05ee2..318e8c39 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -6,7 +6,7 @@ use crate::core::theme::{DefaultRegions, DefaultTemplates, TemplateRef}; use crate::global; use crate::html::{Markup, html}; use crate::locale::L10n; -use crate::response::Page; +use crate::response::page::Page; use crate::web::http::StatusCode; /// Interfaz común que debe implementar cualquier tema de PageTop. @@ -63,7 +63,7 @@ pub trait Theme: Extension + Send + Sync { /// propone como predeterminada. /// /// Se utiliza al inicializar un [`Context`](crate::core::component::Context) o una página - /// ([`Page`](crate::response::Page)) por si no se elige ninguna otra plantilla con + /// ([`Page`](crate::response::page::Page)) por si no se elige ninguna otra plantilla con /// [`Contextual::with_template()`](crate::core::component::Contextual::with_template). /// /// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Standard`] con una @@ -299,12 +299,12 @@ pub trait Theme: Extension + Send + Sync { /// Permite al tema preparar y componer una página de **error fatal controlado**. /// /// Esta función decide explícitamente devolver - /// [`ErrorPage::BadRequest`](crate::response::ErrorPage::BadRequest), - /// [`ErrorPage::InternalError`](crate::response::ErrorPage::InternalError), - /// [`ErrorPage::ServiceUnavailable`](crate::response::ErrorPage::ServiceUnavailable) o - /// [`ErrorPage::GatewayTimeout`](crate::response::ErrorPage::GatewayTimeout) porque algo ha - /// fallado, pero el servidor sigue activo y el tema, el renderizado y el resto de componentes - /// funcionan con normalidad. + /// [`ErrorPage::BadRequest`](crate::response::page::ErrorPage::BadRequest), + /// [`ErrorPage::InternalError`](crate::response::page::ErrorPage::InternalError), + /// [`ErrorPage::ServiceUnavailable`](crate::response::page::ErrorPage::ServiceUnavailable) o + /// [`ErrorPage::GatewayTimeout`](crate::response::page::ErrorPage::GatewayTimeout) porque algo + /// ha fallado, pero el servidor sigue activo y el tema, el renderizado y el resto de + /// componentes funcionan con normalidad. /// /// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya /// activa en la página (normalmente [`DefaultTemplates::Standard`]) y muestra un componente diff --git a/src/datetime.rs b/src/datetime.rs index b06dc3c1..2e8b6229 100644 --- a/src/datetime.rs +++ b/src/datetime.rs @@ -2,8 +2,3 @@ //! (basado en [chrono](https://docs.rs/chrono)). pub use chrono::prelude::*; - -// `Duration` no forma parte de `chrono::prelude`, pero es de uso tan habitual junto al resto de -// tipos de este módulo (sumar/restar intervalos a un `NaiveDateTime`, calcular expiraciones...) que -// se reexporta igualmente. -pub use chrono::Duration; diff --git a/src/global.rs b/src/global.rs index 3d4dd8ef..17bbbe94 100644 --- a/src/global.rs +++ b/src/global.rs @@ -21,6 +21,7 @@ pub use log_format::LogFormat; include_config!(SETTINGS: Settings => [ // [app] "app.name" => "PageTop App", + "app.description" => "Developed with the amazing PageTop framework.", "app.theme" => "Basic", "app.lang_negotiation" => "Full", "app.startup_banner" => "Slant", @@ -57,6 +58,8 @@ pub struct Settings { pub struct App { /// Nombre de la aplicación. pub name: String, + /// Breve descripción de la aplicación. + pub description: String, /// Tema predeterminado. pub theme: String, /// Idioma predeterminado de la aplicación (p. ej., *"es-ES"* o *"en-US"*). diff --git a/src/html.rs b/src/html.rs index a368f2ad..32068cde 100644 --- a/src/html.rs +++ b/src/html.rs @@ -3,8 +3,8 @@ pub(crate) mod maud; pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html_private}; -mod route_path; -pub use route_path::RoutePath; +mod route; +pub use route::RoutePath; // **< HTML DOCUMENT ASSETS >*********************************************************************** diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index 26a9e028..397ee689 100644 --- a/src/html/assets/javascript.rs +++ b/src/html/assets/javascript.rs @@ -119,7 +119,7 @@ impl JavaScript { /// Equivale a ``. El parámetro `name` se usa como identificador interno del /// script. /// - /// Un closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. pub fn inline(name: impl Into, f: F) -> Self where F: Fn(&mut Context) -> String + Send + Sync + 'static, @@ -139,7 +139,7 @@ impl JavaScript { /// /// En condiciones normales, los scripts con `defer` se ejecutan antes de `DOMContentLoaded`. /// - /// Un closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. pub fn on_load(name: impl Into, f: F) -> Self where F: Fn(&mut Context) -> String + Send + Sync + 'static, @@ -153,10 +153,11 @@ impl JavaScript { /// Crea un **script embebido** con un **handler asíncrono**. /// /// El código se envuelve en un `addEventListener('DOMContentLoaded',async()=>{...})`, que - /// emplea una función `async` para que el cuerpo devuelto por un closure pueda usar `await`. - /// Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas iniciales. + /// emplea una función `async` para que el cuerpo devuelto por la función *closure* pueda usar + /// `await`. Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas + /// iniciales. /// - /// Un closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. pub fn on_load_async(name: impl Into, f: F) -> Self where F: Fn(&mut Context) -> String + Send + Sync + 'static, diff --git a/src/html/assets/preload.rs b/src/html/assets/preload.rs index d6e68358..6696c515 100644 --- a/src/html/assets/preload.rs +++ b/src/html/assets/preload.rs @@ -7,7 +7,7 @@ use crate::{AutoDefault, CowStr, Weight, util}; // Informa al navegador de la naturaleza del recurso para que pueda asignarle la prioridad correcta // y aplicar la política de caché adecuada. #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -enum AsType { +pub(crate) enum AsType { // Fuente web (`@font-face`). Implica `crossorigin` automático. #[default] Font, diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs index f0f6ca63..d106ae8e 100644 --- a/src/html/assets/stylesheet.rs +++ b/src/html/assets/stylesheet.rs @@ -100,7 +100,7 @@ impl StyleSheet { /// Equivale a ``. El parámetro `name` se usa como identificador interno del /// recurso. /// - /// Un closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado. pub fn inline(name: impl Into, f: F) -> Self where F: Fn(&mut Context) -> String + Send + Sync + 'static, diff --git a/src/html/props.rs b/src/html/props.rs index 2bc9104d..25cb10f0 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -2,8 +2,6 @@ use crate::core::TypeInfo; use crate::html::maud::{Escaper, Render}; use crate::{AutoDefault, CowStr, builder_fn, trace, util}; -use thiserror::Error; - use std::any::Any; use std::collections::HashMap; use std::fmt::{self, Write}; @@ -39,15 +37,16 @@ impl fmt::Debug for PropsExtra { // **< PropsError >********************************************************************************* /// Errores de acceso a valores extra de [`Props`]. -#[derive(Debug, PartialEq, Eq, Error)] +/// +/// - [`PropsError::ExtraNotFound`]: la clave no existe. Incluye la clave (`key`). +/// - [`PropsError::ExtraTypeMismatch`]: la clave existe pero el tipo solicitado no coincide con el +/// almacenado. Incluye la clave (`key`), tipo esperado (`expected`) y tipo realmente encontrado +/// (`found`) para facilitar el diagnóstico. +#[derive(Debug, PartialEq, Eq)] pub enum PropsError { - /// La clave no existe. Incluye la clave (`key`). - #[error("extra \"{key}\" not found")] - ExtraNotFound { key: &'static str }, - /// La clave existe pero el tipo solicitado no coincide con el almacenado. Incluye la clave - /// (`key`), tipo esperado (`expected`) y tipo realmente encontrado (`found`) para facilitar el - /// diagnóstico. - #[error("type mismatch for extra \"{key}\": expected \"{expected}\", found \"{found}\"")] + ExtraNotFound { + key: &'static str, + }, ExtraTypeMismatch { key: &'static str, expected: &'static str, @@ -55,6 +54,24 @@ pub enum PropsError { }, } +impl fmt::Display for PropsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PropsError::ExtraNotFound { key } => write!(f, "extra \"{key}\" not found"), + PropsError::ExtraTypeMismatch { + key, + expected, + found, + } => write!( + f, + "type mismatch for extra \"{key}\": expected \"{expected}\", found \"{found}\"" + ), + } + } +} + +impl std::error::Error for PropsError {} + // **< PropsOp >************************************************************************************ /// Operaciones sobre el identificador, clases CSS, atributos HTML y valores extra en [`Props`]. diff --git a/src/html/route_path.rs b/src/html/route.rs similarity index 51% rename from src/html/route_path.rs rename to src/html/route.rs index 95aab33a..ae694857 100644 --- a/src/html/route_path.rs +++ b/src/html/route.rs @@ -1,6 +1,6 @@ use crate::{AutoDefault, CowStr, builder_fn}; -use std::fmt::{self, Write as _}; +use std::fmt; /// Representa una ruta como un *path* inicial más una lista opcional de parámetros. /// @@ -8,22 +8,13 @@ use std::fmt::{self, Write as _}; /// pensadas para usarse en atributos HTML como `href`, `action` o `src`. /// /// `RoutePath` no valida ni interpreta la estructura del *path*; simplemente concatena los -/// parámetros de consulta sobre el valor proporcionado. El *path* tampoco se codifica: se asume -/// que ya es válido (rutas propias de la aplicación, normalmente literales o formadas a partir de -/// identificadores conocidos). -/// -/// # Codificación de los valores -/// -/// El método [`with_param()`](Self::with_param) codifica el **valor** (no la clave) según RFC 3986 -/// antes de insertarlo. Así, cualquier valor (como una búsqueda de usuario, un destino con su -/// propia *query string*, etc.) puede pasarse tal cual, sin que quien llama tenga que codificarlo -/// primero. +/// parámetros de consulta sobre el valor proporcionado. /// /// # Ejemplos /// /// ```rust /// # use pagetop::prelude::*; -/// // Ruta relativa con parámetros y un *flag* sin valor. +/// // Ruta relativa con parámetros y una *flag* sin valor. /// let route = RoutePath::new("/search") /// .with_param("q", "rust") /// .with_param("page", "2") @@ -33,12 +24,8 @@ use std::fmt::{self, Write as _}; /// // Ruta absoluta a un recurso externo. /// let external = RoutePath::new("https://example.com/export").with_param("format", "csv"); /// assert_eq!(external.to_string(), "https://example.com/export?format=csv"); -/// -/// // Un valor con espacios o símbolos se codifica automáticamente. -/// let search = RoutePath::new("/search").with_param("q", "rust & htmx"); -/// assert_eq!(search.to_string(), "/search?q=rust%20%26%20htmx"); /// ``` -#[derive(AutoDefault, Clone, Debug)] +#[derive(AutoDefault)] pub struct RoutePath { /// *Path* inicial sobre el que se añadirán los parámetros. /// @@ -65,17 +52,9 @@ impl RoutePath { } /// Añade o sustituye un parámetro `key=value`. Si la clave ya existe, el valor se sobrescribe. - /// - /// El valor se codifica según RFC 3986, los caracteres no reservados (alfanuméricos ASCII, `-`, - /// `_`, `.`, `~`) quedan intactos, el resto se sustituye por su secuencia `%XX`. La clave se - /// inserta tal cual, sin codificar. - /// - /// Un `value` vacío no se distingue de [`with_flag()`](Self::with_flag): ambos se renderizan - /// como `?key`, sin `=`. #[builder_fn] pub fn with_param(mut self, key: impl Into, value: impl Into) -> Self { - self.query - .insert(key.into(), Self::encode_query_value(&value.into())); + self.query.insert(key.into(), value.into()); self } @@ -90,29 +69,6 @@ impl RoutePath { pub fn path(&self) -> &str { &self.path } - - /// Indica si el *path* **parece** una URL externa por su prefijo (ver - /// [`util::url_looks_external()`](crate::util::url_looks_external)). - pub fn is_external(&self) -> bool { - crate::util::url_looks_external(&self.path) - } - - // **< RoutePath HELPERS >********************************************************************** - - // Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los - // caracteres no reservados quedan intactos y el resto se codifica como `%XX`. - fn encode_query_value(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for byte in value.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char); - } - _ => write!(out, "%{byte:02X}").unwrap(), - } - } - out - } } impl fmt::Display for RoutePath { @@ -135,13 +91,9 @@ impl fmt::Display for RoutePath { } } -// Cualquier `&str`, sea cual sea su vida, se acepta copiándolo a un `String` propio: así, por -// ejemplo, una función hipotética que devuelva un `&str` con una vida atada a una petición, no -// `'static` (del estilo `fn resolve_target<'a>(next: &'a str, fallback: &'a str) -> &'a str`), -// sigue pudiendo construir un `RoutePath` sin que quien llama tenga que convertir el valor a mano. -impl From<&str> for RoutePath { - fn from(path: &str) -> Self { - RoutePath::new(path.to_owned()) +impl From<&'static str> for RoutePath { + fn from(path: &'static str) -> Self { + RoutePath::new(path) } } diff --git a/src/locale/languages.rs b/src/locale/languages.rs index 13b5e913..cda4483d 100644 --- a/src/locale/languages.rs +++ b/src/locale/languages.rs @@ -5,19 +5,17 @@ use super::{LanguageIdentifier, langid}; use std::collections::HashMap; use std::sync::LazyLock; -// Tabla de idiomas soportados por PageTop. -// -// Cada entrada asocia un código de idioma en minúsculas (por ejemplo, "en" o "es-es") con: -// -// - Su `LanguageIdentifier` canónico. -// - La clave de traducción definida en `src/locale/{lang}/languages.ftl` para mostrar su nombre en -// el idioma activo. -// -// Esto permite admitir alias de idioma como "en" o "es" y, al mismo tiempo, mantener un -// identificador de idioma canónico (por ejemplo, `langid!("en-US")` o `langid!("es-ES")`). -// -// Sólo lo usa `locale::definition`, en el mismo módulo `locale`. -pub(super) static LANGUAGES: LazyLock> = +/// Tabla de idiomas soportados por PageTop. +/// +/// Cada entrada asocia un código de idioma en minúsculas (por ejemplo, `"en"` o `"es-es"`) con: +/// +/// - Su [`LanguageIdentifier`] canónico. +/// - La clave de traducción definida en `src/locale/{lang}/languages.ftl` para mostrar su nombre en +/// el idioma activo. +/// +/// Esto permite admitir alias de idioma como `"en"` o `"es"` y, al mismo tiempo, mantener un +/// identificador de idioma canónico (por ejemplo, `langid!("en-US")` o `langid!("es-ES")`). +pub(crate) static LANGUAGES: LazyLock> = LazyLock::new(|| { util::kv![ "en" => ( langid!("en-US"), "english" ), diff --git a/src/prelude.rs b/src/prelude.rs index c00e1eb9..f87d25b5 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -47,7 +47,7 @@ pub use crate::core::theme::*; pub use crate::auth::*; -pub use crate::response::*; +pub use crate::response::{json::*, page::*, redirect::*}; pub use crate::base::action; pub use crate::base::component::*; diff --git a/src/response.rs b/src/response.rs index a427a1f7..55150b71 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,13 +1,7 @@ //! Respuestas a las peticiones web en sus diferentes formatos. -mod page; -pub use page::*; +pub mod page; -mod json; -pub use json::*; +pub mod json; -mod redirect; -pub use redirect::*; - -mod waypoint; -pub use waypoint::*; +pub mod redirect; diff --git a/src/response/redirect.rs b/src/response/redirect.rs index 000890ef..ebe470f8 100644 --- a/src/response/redirect.rs +++ b/src/response/redirect.rs @@ -18,7 +18,6 @@ //! //! - **Respuestas especiales**. -use crate::html::RoutePath; use crate::web::{IntoResponse, Response, http}; /// Funciones predefinidas para generar respuestas HTTP de redirección. @@ -35,10 +34,10 @@ impl Redirect { /// Emplear cuando un recurso se ha movido de forma definitiva y la URL antigua debe dejar de /// usarse. #[must_use] - pub fn moved(redirect_to_url: impl Into) -> Response { + pub fn moved(redirect_to_url: &str) -> Response { ( http::StatusCode::MOVED_PERMANENTLY, - [(http::header::LOCATION, redirect_to_url.into().to_string())], + [(http::header::LOCATION, redirect_to_url.to_owned())], ) .into_response() } @@ -48,10 +47,10 @@ impl Redirect { /// Indicada para reorganizaciones de un sitio o aplicación web en las que también existen /// métodos distintos de GET (POST, PUT, ...) que no deben degradarse a GET. #[must_use] - pub fn permanent(redirect_to_url: impl Into) -> Response { + pub fn permanent(redirect_to_url: &str) -> Response { ( http::StatusCode::PERMANENT_REDIRECT, - [(http::header::LOCATION, redirect_to_url.into().to_string())], + [(http::header::LOCATION, redirect_to_url.to_owned())], ) .into_response() } @@ -62,10 +61,10 @@ impl Redirect { /// Útil cuando un recurso está fuera de servicio de forma imprevista (mantenimiento breve, /// sobrecarga, ...). #[must_use] - pub fn found(redirect_to_url: impl Into) -> Response { + pub fn found(redirect_to_url: &str) -> Response { ( http::StatusCode::FOUND, - [(http::header::LOCATION, redirect_to_url.into().to_string())], + [(http::header::LOCATION, redirect_to_url.to_owned())], ) .into_response() } @@ -76,10 +75,10 @@ impl Redirect { /// Se usa típicamente tras un POST o PUT para aplicar el patrón *Post/Redirect/Get*, permite /// recargar la página de resultados sin volver a ejecutar la operación. #[must_use] - pub fn see_other(redirect_to_url: impl Into) -> Response { + pub fn see_other(redirect_to_url: &str) -> Response { ( http::StatusCode::SEE_OTHER, - [(http::header::LOCATION, redirect_to_url.into().to_string())], + [(http::header::LOCATION, redirect_to_url.to_owned())], ) .into_response() } @@ -89,10 +88,10 @@ impl Redirect { /// Preferible a [`found`](Self::found) cuando el sitio expone operaciones diferentes de GET que /// deben respetarse durante la redirección. #[must_use] - pub fn temporary(redirect_to_url: impl Into) -> Response { + pub fn temporary(redirect_to_url: &str) -> Response { ( http::StatusCode::TEMPORARY_REDIRECT, - [(http::header::LOCATION, redirect_to_url.into().to_string())], + [(http::header::LOCATION, redirect_to_url.to_owned())], ) .into_response() } diff --git a/src/response/waypoint.rs b/src/response/waypoint.rs deleted file mode 100644 index b42c57c7..00000000 --- a/src/response/waypoint.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Parámetro `waypoint`, URL de destino transportada entre pantallas. - -use serde::Deserialize; - -use crate::AutoDefault; -use crate::html::RoutePath; - -/// URL de destino transportada por el parámetro `waypoint`. -/// -/// Cualquier pantalla alcanzada desde un listado (alta, edición, confirmación, o una cadena de -/// varias de ellas) puede recibir en su *query string* un parámetro `waypoint` con la URL de la -/// página a la que ir para continuar. -/// -/// `Waypoint` transporta ese valor a través de la cadena de pantallas. Se extrae de la petición -/// entrante con [`web::Query`](crate::web::Query), igual que cualquier otro parámetro, y se repone -/// en cada enlace o acción de formulario intermedia con [`append_to()`](Self::append_to) para que -/// sobreviva a la siguiente petición. Se resuelve con [`or()`](Self::or) al decidir el destino de -/// un enlace de vuelta o de una redirección. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// -/// const ITEMS_PATH: &str = "/items"; -/// -/// /// GET /items/{id}/edit - Formulario de edición, alcanzado desde el listado. -/// async fn edit_get( -/// web::Path(id): web::Path, -/// web::Query(waypoint): web::Query, -/// ) -> Markup { -/// // El listado de origen (si lo hay) viaja en la acción del formulario, para volver después. -/// let action = waypoint.append_to(format!("{ITEMS_PATH}/{id}/edit")); -/// html! { form action=(action) method="post" { /* ... */ } } -/// } -/// -/// /// POST /items/{id}/edit - Guarda los cambios y vuelve al listado de origen, o a `ITEMS_PATH`. -/// async fn edit_post( -/// web::Path(id): web::Path, -/// web::Query(waypoint): web::Query, -/// ) -> Response { -/// Redirect::see_other(waypoint.or(ITEMS_PATH)) -/// } -/// ``` -#[derive(AutoDefault, Clone, Debug, Deserialize)] -#[serde(from = "RawWaypoint")] -pub struct Waypoint { - waypoint: Option, -} - -impl Waypoint { - /// Crea un valor explícito. - /// - /// Sólo acepta una ruta local que empiece por `/`, pero no por `//` ni `/\` que el navegador - /// interpretaría como una URL *protocol-relative*. Una cadena vacía, una URL absoluta - /// (`https://...`), un esquema arbitrario (`javascript:...`) o cualquier otro valor que no - /// cumpla esa forma se trata igual que `None`. - /// - /// Este filtro protege contra *open redirect*; el `waypoint` viaja en la *query string* de la - /// petición, así que un cliente malicioso lo controla por completo. Por eso se aplica siempre, - /// también al deserializar con [`web::Query`](crate::web::Query), el punto de entrada habitual - /// donde el valor procede de fuera de la aplicación. - pub fn new(waypoint: impl Into>) -> Self { - Self { - waypoint: waypoint.into().filter(|d| { - // Sólo acepta `/algo`. Rechaza cadenas vacías, esquemas (`https:`, `javascript:`) y - // las URLs *protocol-relative* (`//evil.example`, `/\evil.example`). - let mut chars = d.chars(); - match chars.next() { - Some('/') => !matches!(chars.next(), Some('/') | Some('\\')), - _ => false, - } - }), - } - } - - /// Devuelve la URL de destino, si se proporcionó una y es una ruta local válida. - pub fn as_str(&self) -> Option<&str> { - self.waypoint.as_deref() - } - - /// Añade `?waypoint=` a `route`, si hay una URL de destino propia. Si no, - /// devuelve `route` sin modificar. - /// - /// Es la operación habitual para que un enlace o la acción de un formulario transporten el - /// waypoint a la siguiente pantalla. Igual que [`or()`](Self::or), devuelve un [`RoutePath`] - /// convertible directamente en [`Route`](crate::core::component::Route) sin volver a - /// procesarse. A diferencia de [`or()`](Self::or), nunca sustituye `route`; úsalo para propagar - /// el waypoint a un enlace intermedio, no para resolver un destino final. - /// - /// Aparece en dos momentos típicos: el `action` de un formulario, para que un envío no pierda - /// el waypoint (ver el ejemplo del módulo), y el `href` de los enlaces que un listado genera - /// hacia otras pantallas (ver el ejemplo). - /// - /// Preserva cualquier *query string* ya presente en `route` sin romperla (como `lang=...` si - /// `route` se construyó con [`Context::route()`](crate::core::component::Context::route)). - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// use pagetop::prelude::*; - /// - /// const ITEMS_PATH: &str = "/items"; - /// - /// fn edit_href(cx: &Context, waypoint: &Waypoint, item_id: i32) -> RoutePath { - /// waypoint.append_to(cx.route(format!("{ITEMS_PATH}/{item_id}/edit"))) - /// } - /// ``` - pub fn append_to(&self, route: impl Into) -> RoutePath { - let mut route = route.into(); - if let Some(d) = self.as_str() { - route.alter_param("waypoint", d); - } - route - } - - /// Devuelve la URL de destino, o `fallback` si no se proporcionó ninguna. - /// - /// Es la operación habitual al decidir el destino de un enlace de vuelta o de una redirección: - /// el waypoint transportado si lo hay, o la URL del listado por defecto si se llegó a esta - /// pantalla sin pasar por ninguno. A diferencia de [`append_to()`](Self::append_to), sustituye - /// por completo el destino. Úsalo cuando necesites un único `RoutePath` final, no para propagar - /// el waypoint a otro enlace intermedio. - /// - /// Aparece en dos momentos típicos de una misma pantalla: el `href` del enlace "volver" al - /// renderizarla (ver el ejemplo) y el destino de `Redirect::see_other(...)` tras guardar con - /// éxito en un POST, como en el ejemplo del módulo. - /// - /// Devuelve un [`RoutePath`], no una cadena ya renderizada, para que el resultado pueda - /// convertirse directamente en una [`Route`](crate::core::component::Route) (por ejemplo al - /// pasarlo a [`Form::with_action()`](crate::base::component::Form::with_action)) sin volver a - /// procesarse. `fallback` acepta cualquier tipo convertible a `RoutePath` (un literal, un - /// `String`, o un `RoutePath` ya construido); preservando los parámetros que correspondan. - /// - /// # Ejemplo - /// - /// ```rust,no_run - /// use pagetop::prelude::*; - /// - /// const ITEMS_PATH: &str = "/items"; - /// - /// fn back_href(cx: &Context, waypoint: &Waypoint) -> RoutePath { - /// waypoint.or(cx.route(ITEMS_PATH)) - /// } - /// ``` - pub fn or(&self, fallback: impl Into) -> RoutePath { - match self.as_str() { - Some(d) => RoutePath::new(d.to_owned()), - None => fallback.into(), - } - } -} - -impl From> for Waypoint { - fn from(waypoint: Option) -> Self { - Self::new(waypoint) - } -} - -impl From for Waypoint { - fn from(waypoint: String) -> Self { - Self::new(Some(waypoint)) - } -} - -// Valor tal y como llega en la deserialización (p. ej. desde `web::Query`), antes de ser filtrado. -#[derive(Deserialize)] -struct RawWaypoint { - #[serde(default)] - waypoint: Option, -} - -impl From for Waypoint { - fn from(raw: RawWaypoint) -> Self { - Self::new(raw.waypoint) - } -} diff --git a/src/util.rs b/src/util.rs index 4a0df3b5..bedee08e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -168,45 +168,6 @@ pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Opt } } -/// Indica si una URL **parece** externa por su prefijo. -/// -/// No es una validación de la URL; sólo mira el inicio del texto: `//` (relativa al protocolo), -/// `http://`, `https://`, `mailto:` o `tel:`. La comparación ignora mayúsculas/minúsculas ASCII en -/// los prefijos (`HTTPS://...` o `MAILTO:...` se detectan igual), porque el esquema de una URI es -/// *case-insensitive* según RFC 3986. -/// -/// La usan [`RoutePath::is_external()`](crate::html::RoutePath::is_external) y las conversiones -/// `From<&str>`/`From` de [`Route`](crate::core::component::Route) para decidir si una -/// ruta debe evitar [`Context::route()`](crate::core::component::Context::route). -/// -/// Cualquier otro código que necesite el mismo criterio (por ejemplo, para decidir si un enlace -/// debe llevar `target="_blank"`) puede usarla en lugar de reimplementar su propia versión. -/// -/// # Ejemplo -/// -/// ```rust -/// # use pagetop::util; -/// assert!(util::url_looks_external("https://example.com")); -/// assert!(util::url_looks_external("mailto:info@example.com")); -/// assert!(util::url_looks_external("HTTPS://EXAMPLE.COM")); -/// assert!(!util::url_looks_external("/admin/users")); -/// ``` -pub fn url_looks_external(url: &str) -> bool { - starts_with_ignore_ascii_case(url, "//") - || starts_with_ignore_ascii_case(url, "http://") - || starts_with_ignore_ascii_case(url, "https://") - || starts_with_ignore_ascii_case(url, "mailto:") - || starts_with_ignore_ascii_case(url, "tel:") -} - -// Compara si `text` empieza por `prefix` ignorando mayúsculas/minúsculas en ASCII (los esquemas de -// URI son case-insensitive según RFC 3986). No reserva memoria: sólo compara el primer tramo de -// bytes de `text` con `prefix`. -fn starts_with_ignore_ascii_case(text: &str, prefix: &str) -> bool { - text.get(..prefix.len()) - .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) -} - /// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta. /// /// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de diff --git a/src/web.rs b/src/web.rs index 4dd79b77..da3eaf46 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,13 +1,12 @@ //! Servidor web y rutas de la aplicación (basado en [Axum](https://docs.rs/axum)). //! -//! Define rutas y handlers: el [`Router`], las operaciones HTTP ([`get`], [`post`], [`put`], -//! [`delete`], [`patch`]), los extractores ([`Path`], [`Query`], [`Form`], [`RawForm`], -//! [`Request`]) e [`IntoResponse`], el módulo [`middleware`] para *middlewares* de función, y -//! re-exporta el módulo `http` para tipos de bajo nivel como `StatusCode`, `HeaderName` o `Method`. -//! También ofrece utilidades para servir archivos estáticos, [`ServeDir`] y [`ServeEmbedded`]. +//! Define rutas y *handlers*: el [`Router`], las operaciones HTTP ([`get`], [`post`], [`put`], +//! [`delete`], [`patch`]), los extractores ([`Path`], [`Query`]) e [`IntoResponse`], y re-exporta +//! el módulo `http` para tipos de bajo nivel como `StatusCode`, `HeaderName` o `Method`. También +//! ofrece utilidades para servir archivos estáticos, [`ServeDir`] y [`ServeEmbedded`]. //! -//! Los handlers son las funciones asíncronas que el servidor invoca al recibir una petición HTTP en -//! una ruta concreta. +//! Los *handlers* son las funciones asíncronas que el servidor invoca al recibir una petición +//! HTTP en una ruta concreta. use crate::StaticFile; @@ -18,7 +17,7 @@ pub use axum::http; pub use axum::Router; // Extractores de petición. -pub use axum::extract::{Form, Path, Query, RawForm, Request}; +pub use axum::extract::{Path, Query}; // Para implementar respuestas. pub use axum::response::{IntoResponse, Response}; @@ -26,9 +25,6 @@ pub use axum::response::{IntoResponse, Response}; // Operaciones HTTP para registrar rutas. pub use axum::routing::{delete, get, patch, post, put}; -// Middlewares de función (`from_fn`) y tipos asociados (`Next`). -pub use axum::middleware; - use axum::body::Body; use axum::extract::FromRequestParts; @@ -47,7 +43,7 @@ use std::task::{Context, Poll}; /// /// 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::ErrorPage): +/// [`ErrorPage`](crate::response::page::ErrorPage): /// /// ```rust,no_run /// # use pagetop::prelude::*; @@ -66,12 +62,25 @@ use std::task::{Context, Poll}; /// } /// ``` /// -/// `HttpRequest` no consume las extensiones de la petición, por lo que el resto de extractores del -/// handler que también las necesiten (como `Path`, `Extension`...) las siguen viendo -/// intactas, sea cual sea la posición en la que se declare `HttpRequest`. Por convención, se -/// declara como primer parámetro del handler. El único orden que sigue siendo obligatorio es el que -/// impone Axum: un extractor que consuma el cuerpo de la petición (`Form`, `RawForm`...) debe ir -/// siempre el último. +/// # Orden de parámetros en el handler +/// +/// `HttpRequest` toma las extensiones de la petición al extraerse. Los extractores que leen de ahí +/// (como `Path` o `Extension`) deben aparecer **antes** en la lista de parámetros del +/// handler: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// // Correcto: Path se declara antes que HttpRequest. +/// async fn view_post( +/// web::Path(id): web::Path, +/// request: HttpRequest, +/// ) -> Result { +/// # todo!() +/// } +/// ``` +/// +/// Los extractores que no dependen de las extensiones, como `Query` o `Method`, no están sujetos +/// a esta restricción. #[derive(Clone, Debug)] pub struct HttpRequest { uri: http::Uri, @@ -119,18 +128,20 @@ impl HttpRequest { impl FromRequestParts for HttpRequest { type Rejection = Infallible; - // Clona (no toma) las extensiones inyectadas por middleware, para que otros extractores del - // handler (`Path`, `Extension`...) las sigan viendo intactas sin importar en qué posición - // se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que `HttpRequest` - // sea `Clone` a coste mínimo en el resto de su ciclo de vida. + // Extrae la petición y toma las extensiones que han sido inyectadas por middleware. Las + // extensiones se mueven a un `Arc` compartido para que `HttpRequest` sea `Clone`. + // + // Nota: tras este extractor `parts.extensions` queda vacío; otros extractores que dependan de + // `Extension` deben registrarse antes en la cadena del handler. async fn from_request_parts( parts: &mut http::request::Parts, _state: &S, ) -> Result { + let extensions = std::mem::take(&mut parts.extensions); Ok(HttpRequest { uri: parts.uri.clone(), headers: parts.headers.clone(), - extensions: Arc::new(parts.extensions.clone()), + extensions: Arc::new(extensions), }) } } diff --git a/tests/route.rs b/tests/route.rs deleted file mode 100644 index f1e64bb1..00000000 --- a/tests/route.rs +++ /dev/null @@ -1,124 +0,0 @@ -use pagetop::prelude::*; - -// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in -// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes. -fn cx_with_lang(lang: &str) -> Context { - Context::new(None).with_langid(&Locale::resolve(lang)) -} - -// **< Route - automatic detection of external URLs >*********************************************** - -#[pagetop::test] -async fn route_from_str_detects_external_urls_by_prefix() { - let cx = cx_with_lang("es-ES"); - - for url in [ - "http://example.com", - "https://example.com", - "//example.com", - "mailto:user@example.com", - "tel:+123456789", - ] { - let route: Route = url.into(); - assert_eq!( - route.resolve(&cx).to_string(), - url, - "expected {url:?} to be detected as external and left unmodified" - ); - } -} - -#[pagetop::test] -async fn route_from_string_behaves_like_from_str() { - let cx = cx_with_lang("es-ES"); - - let external: Route = String::from("https://example.com").into(); - assert_eq!(external.resolve(&cx).to_string(), "https://example.com"); - - let local: Route = String::from("/local/path").into(); - assert_eq!(local.resolve(&cx).to_string(), "/local/path?lang=es-ES"); -} - -#[pagetop::test] -async fn route_local_path_goes_through_context_route_and_preserves_lang() { - let cx = cx_with_lang("es-ES"); - - let route: Route = "/local/path".into(); - assert_eq!(route.resolve(&cx).to_string(), "/local/path?lang=es-ES"); -} - -#[pagetop::test] -async fn route_unrecognized_scheme_is_not_detected_as_external() { - // `ftp://` is not among the prefixes recognized by the heuristic, so the implicit conversion - // treats it as a local path and goes through `Context::route()`; for a URL like this, - // `Route::external()` is still necessary (see the test below). - let cx = cx_with_lang("es-ES"); - - let route: Route = "ftp://files.example.com".into(); - assert_eq!( - route.resolve(&cx).to_string(), - "ftp://files.example.com?lang=es-ES" - ); -} - -// **< Route::external() / From (share the same fixed resolution) >********************** - -#[pagetop::test] -async fn route_external_ignores_the_rendering_context() { - let route = Route::external("/fixed/path"); - - let cx_es = cx_with_lang("es-ES"); - let cx_en = cx_with_lang("en-US"); - - assert_eq!(route.resolve(&cx_es).to_string(), "/fixed/path"); - assert_eq!(route.resolve(&cx_en).to_string(), "/fixed/path"); -} - -#[pagetop::test] -async fn route_from_route_path_ignores_the_rendering_context() { - let route: Route = RoutePath::new("/fixed/path").with_param("q", "rust").into(); - - let cx_es = cx_with_lang("es-ES"); - let cx_en = cx_with_lang("en-US"); - - assert_eq!(route.resolve(&cx_es).to_string(), "/fixed/path?q=rust"); - assert_eq!(route.resolve(&cx_en).to_string(), "/fixed/path?q=rust"); -} - -#[pagetop::test] -async fn route_external_never_adds_lang_even_for_unrecognized_schemes() { - let cx = cx_with_lang("es-ES"); - - let route = Route::external("ftp://files.example.com"); - assert_eq!(route.resolve(&cx).to_string(), "ftp://files.example.com"); -} - -// **< Route::with() >****************************************************************************** - -#[pagetop::test] -async fn route_with_captures_dynamic_values_from_the_environment() { - let cx = cx_with_lang("es-ES"); - - let user_id = 42; - let route = Route::with(move |cx| cx.route(format!("/users/{user_id}"))); - - assert_eq!(route.resolve(&cx).to_string(), "/users/42?lang=es-ES"); -} - -#[pagetop::test] -async fn route_default_resolves_to_an_empty_path() { - let cx = Context::new(None); - assert_eq!(Route::default().resolve(&cx).to_string(), ""); -} - -// **< Context::route() - direct protection against external URLs >********************************* - -#[pagetop::test] -async fn context_route_never_adds_lang_to_an_external_looking_url() { - let cx = cx_with_lang("es-ES"); - - assert_eq!( - cx.route("https://example.com/help").to_string(), - "https://example.com/help" - ); -} diff --git a/tests/waypoint.rs b/tests/waypoint.rs deleted file mode 100644 index 76422e4e..00000000 --- a/tests/waypoint.rs +++ /dev/null @@ -1,63 +0,0 @@ -use pagetop::prelude::*; - -// **< Waypoint - only accepts local paths >******************************************************** - -#[pagetop::test] -async fn waypoint_accepts_local_paths() { - for path in ["/", "/admin/users", "/admin/users?page=2"] { - let w = Waypoint::from(path.to_owned()); - assert_eq!(w.as_str(), Some(path)); - } -} - -#[pagetop::test] -async fn waypoint_rejects_open_redirect_targets() { - // Targets that a malicious client could slip into `?waypoint=...` to redirect off-site: they - // must be discarded just as if no waypoint had been provided. - for target in [ - "", - "https://evil.example", - "//evil.example", - "/\\evil.example", - "javascript:alert(1)", - ] { - let w = Waypoint::from(target.to_owned()); - assert_eq!(w.as_str(), None, "expected {target:?} to be rejected"); - } -} - -#[pagetop::test] -async fn waypoint_deserialize_rejects_open_redirect_targets() { - // Reproduces the real entry point (`web::Query`): the value arrives via - // deserialization, not through `Waypoint::from(String)` as in the previous test. - let w: Waypoint = serde_json::from_str(r#"{"waypoint":"https://evil.example"}"#).unwrap(); - assert_eq!(w.as_str(), None); -} - -// **< Waypoint::or() >***************************************************************************** - -#[pagetop::test] -async fn waypoint_or_returns_transported_local_path() { - let w = Waypoint::from("/admin/users?page=2".to_owned()); - assert_eq!(w.or("/fallback").to_string(), "/admin/users?page=2"); -} - -#[pagetop::test] -async fn waypoint_or_falls_back_when_target_is_not_local() { - let w = Waypoint::from("https://evil.example".to_owned()); - assert_eq!(w.or("/admin/users").to_string(), "/admin/users"); -} - -// **< Waypoint::append_to() >********************************************************************** - -#[pagetop::test] -async fn waypoint_append_to_adds_param_only_when_present() { - let w = Waypoint::from("/admin/users".to_owned()); - assert_eq!( - w.append_to("/items").to_string(), - "/items?waypoint=%2Fadmin%2Fusers" - ); - - let empty = Waypoint::default(); - assert_eq!(empty.append_to("/items").to_string(), "/items"); -}