diff --git a/examples/form-controls.rs b/examples/form-controls.rs index b88bcfa4..f8be00c7 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, FnPathByContext)>) + .with_button(None::<(L10n, Route)>) // Bloque 1: casillas, interruptores y botones de opción. .with_child( Block::new() diff --git a/examples/intro-colors.rs b/examples/intro-colors.rs index cde6deed..0150b248 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, FnPathByContext)>) + .with_button(None::<(L10n, Route)>) .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 dda79e45..49b44d09 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -20,13 +20,10 @@ 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), - |cx| cx.route("/"), - )) + .with_item(bs::nav::Item::link(L10n::t("menus_item_link", &LOC), "/")) .with_item(bs::nav::Item::link_blank( L10n::t("menus_item_blank", &LOC), - |_| "https://docs.rs/pagetop".into(), + "https://docs.rs/pagetop", )) .with_item(bs::nav::Item::dropdown( bs::Dropdown::new() @@ -37,15 +34,15 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_dev_getting_started", &LOC), - |cx| cx.route("/dev/getting-started"), + "/dev/getting-started", )) .with_item(bs::dropdown::Item::link( L10n::t("menus_dev_guides", &LOC), - |cx| cx.route("/dev/guides"), + "/dev/guides", )) .with_item(bs::dropdown::Item::link_blank( L10n::t("menus_dev_forum", &LOC), - |_| "https://forum.example.dev".into(), + "https://forum.example.dev", )) .with_item(bs::dropdown::Item::divider()) .with_item(bs::dropdown::Item::header(L10n::t( @@ -54,15 +51,15 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_rust", &LOC), - |cx| cx.route("/dev/sdks/rust"), + "/dev/sdks/rust", )) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_js", &LOC), - |cx| cx.route("/dev/sdks/js"), + "/dev/sdks/js", )) .with_item(bs::dropdown::Item::link( L10n::t("menus_sdk_python", &LOC), - |cx| cx.route("/dev/sdks/python"), + "/dev/sdks/python", )) .with_item(bs::dropdown::Item::divider()) .with_item(bs::dropdown::Item::header(L10n::t( @@ -71,22 +68,22 @@ impl Extension for SuperMenu { ))) .with_item(bs::dropdown::Item::link( L10n::t("menus_plugin_auth", &LOC), - |cx| cx.route("/dev/sdks/rust/plugins/auth"), + "/dev/sdks/rust/plugins/auth", )) .with_item(bs::dropdown::Item::link( L10n::t("menus_plugin_cache", &LOC), - |cx| cx.route("/dev/sdks/rust/plugins/cache"), + "/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( @@ -97,11 +94,11 @@ impl Extension for SuperMenu { ))) .with_item(bs::nav::Item::link( L10n::t("menus_item_sign_up", &LOC), - |cx| cx.route("/auth/sign-up"), + "/auth/sign-up", )) .with_item(bs::nav::Item::link( L10n::t("menus_item_login", &LOC), - |cx| cx.route("/auth/login"), + "/auth/login", )), )); diff --git a/src/app.rs b/src/app.rs index 78b05388..9f661ed6 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::page::{render_error_pages, response_for_panic, route_not_found}; +use crate::response::{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::page::ErrorPage)) se renderizan usando el tema activo (ver +/// [`ErrorPage`](crate::response::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)). diff --git a/src/base/action/page.rs b/src/base/action/page.rs index b6dbe9ab..b455913b 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::Page; +use crate::response::Page; /// Tipo de función para manipular una página durante su construcción o renderizado. /// diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index f4fdb550..ab985f6d 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, FnPathByContext)>) +/// .with_button(None::<(L10n, Route)>) /// .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, FnPathByContext)>, + button: Option<(L10n, Route)>, /// 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)(cx)) + href=(lnk.resolve(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, closure_url))` para mostrarlo, donde [`FnPathByContext`] recibe el - /// [`Context`] y devuelve la ruta o URL final al pulsar el botó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 `None` para ocultarlo. /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; - /// // Define un botón con texto y una URL fija. - /// let intro = Intro::default().with_button(Some((L10n::n("Start"), |_| "/start".into()))); + /// // 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()))); /// // 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, FnPathByContext)>) -> Self { + pub fn with_button(mut self, button: Option<(L10n, Route)>) -> Self { self.button = button; self } diff --git a/src/core/component.rs b/src/core/component.rs index 740d7d26..07a00e30 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -1,6 +1,10 @@ //! API para construir nuevos componentes. - -use crate::html::RoutePath; +//! +//! 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. mod error; pub use error::ComponentError; @@ -18,6 +22,9 @@ 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 @@ -71,41 +78,3 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// } /// ``` 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/context.rs b/src/core/component/context.rs index 4edc8ea3..d9dc70a9 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::{CowStr, builder_fn, util}; +use crate::{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,14 +40,15 @@ pub enum AssetsOp { } /// Errores de acceso a parámetros dinámicos del contexto. -/// -/// - [`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)] +#[derive(Debug, Error)] 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, @@ -55,26 +56,6 @@ 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: @@ -86,7 +67,7 @@ impl std::error::Error for 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::Page). +/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page). /// /// # Ejemplo /// @@ -255,12 +236,51 @@ 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. /// -/// 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. +/// 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. /// /// # Ejemplos /// @@ -306,29 +326,6 @@ pub trait Contextual: LangId { /// 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. @@ -440,17 +437,24 @@ impl Context { /// Construye una ruta aplicada al contexto actual. /// - /// 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. + /// 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. /// /// Esto garantiza que los enlaces generados desde el contexto preservan la preferencia de - /// 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() { + /// 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() { route.alter_param("lang", self.locale.langid().to_string()); } route diff --git a/src/core/component/error.rs b/src/core/component/error.rs index c631619f..afe69d29 100644 --- a/src/core/component/error.rs +++ b/src/core/component/error.rs @@ -1,6 +1,8 @@ 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 @@ -22,7 +24,8 @@ use crate::{AutoDefault, Getters}; /// } /// # } /// ``` -#[derive(AutoDefault, Debug, Getters)] +#[derive(AutoDefault, Debug, Error, Getters)] +#[error("{message}")] pub struct ComponentError { /// Mensaje descriptivo del error. message: String, @@ -57,11 +60,3 @@ impl ComponentError { 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 new file mode 100644 index 00000000..8e563329 --- /dev/null +++ b/src/core/component/route.rs @@ -0,0 +1,198 @@ +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 6a5bf6a7..2dd31eef 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::Page)) es un documento HTML completo. Implementa +//! Una página ([`Page`](crate::response::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::Page)). +/// ([`Template`]) al renderizar la página ([`Page`](crate::response::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::Page)). El tema utiliza esta información para +/// de una página ([`Page`](crate::response::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 318e8c39..b0e05ee2 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::Page; +use crate::response::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::Page)) por si no se elige ninguna otra plantilla con + /// ([`Page`](crate::response::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::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. + /// [`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. /// /// 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/html.rs b/src/html.rs index 32068cde..a368f2ad 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; -pub use route::RoutePath; +mod route_path; +pub use route_path::RoutePath; // **< HTML DOCUMENT ASSETS >*********************************************************************** diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs index c078a532..26a9e028 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. /// - /// La función closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// Un 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`. /// - /// La función closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// Un 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,11 +153,10 @@ 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 la función 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 un closure pueda usar `await`. + /// Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas iniciales. /// - /// La función closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// Un 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/stylesheet.rs b/src/html/assets/stylesheet.rs index ff3aaf53..f0f6ca63 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. /// - /// La función closure recibirá el [`Context`] por si se necesita durante el renderizado. + /// Un 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 25cb10f0..2bc9104d 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -2,6 +2,8 @@ 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}; @@ -37,16 +39,15 @@ impl fmt::Debug for PropsExtra { // **< PropsError >********************************************************************************* /// Errores de acceso a valores extra de [`Props`]. -/// -/// - [`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)] +#[derive(Debug, PartialEq, Eq, Error)] pub enum PropsError { - ExtraNotFound { - key: &'static str, - }, + /// 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}\"")] ExtraTypeMismatch { key: &'static str, expected: &'static str, @@ -54,24 +55,6 @@ 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.rs b/src/html/route_path.rs similarity index 51% rename from src/html/route.rs rename to src/html/route_path.rs index ae694857..95aab33a 100644 --- a/src/html/route.rs +++ b/src/html/route_path.rs @@ -1,6 +1,6 @@ use crate::{AutoDefault, CowStr, builder_fn}; -use std::fmt; +use std::fmt::{self, Write as _}; /// Representa una ruta como un *path* inicial más una lista opcional de parámetros. /// @@ -8,13 +8,22 @@ use std::fmt; /// 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. +/// 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. /// /// # Ejemplos /// /// ```rust /// # use pagetop::prelude::*; -/// // Ruta relativa con parámetros y una *flag* sin valor. +/// // Ruta relativa con parámetros y un *flag* sin valor. /// let route = RoutePath::new("/search") /// .with_param("q", "rust") /// .with_param("page", "2") @@ -24,8 +33,12 @@ use std::fmt; /// // 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)] +#[derive(AutoDefault, Clone, Debug)] pub struct RoutePath { /// *Path* inicial sobre el que se añadirán los parámetros. /// @@ -52,9 +65,17 @@ 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(), value.into()); + self.query + .insert(key.into(), Self::encode_query_value(&value.into())); self } @@ -69,6 +90,29 @@ 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 { @@ -91,9 +135,13 @@ impl fmt::Display for RoutePath { } } -impl From<&'static str> for RoutePath { - fn from(path: &'static str) -> Self { - RoutePath::new(path) +// 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()) } } diff --git a/src/prelude.rs b/src/prelude.rs index f87d25b5..c00e1eb9 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::{json::*, page::*, redirect::*}; +pub use crate::response::*; pub use crate::base::action; pub use crate::base::component::*; diff --git a/src/response.rs b/src/response.rs index 55150b71..a427a1f7 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,7 +1,13 @@ //! Respuestas a las peticiones web en sus diferentes formatos. -pub mod page; +mod page; +pub use page::*; -pub mod json; +mod json; +pub use json::*; -pub mod redirect; +mod redirect; +pub use redirect::*; + +mod waypoint; +pub use waypoint::*; diff --git a/src/response/redirect.rs b/src/response/redirect.rs index ebe470f8..000890ef 100644 --- a/src/response/redirect.rs +++ b/src/response/redirect.rs @@ -18,6 +18,7 @@ //! //! - **Respuestas especiales**. +use crate::html::RoutePath; use crate::web::{IntoResponse, Response, http}; /// Funciones predefinidas para generar respuestas HTTP de redirección. @@ -34,10 +35,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: &str) -> Response { + pub fn moved(redirect_to_url: impl Into) -> Response { ( http::StatusCode::MOVED_PERMANENTLY, - [(http::header::LOCATION, redirect_to_url.to_owned())], + [(http::header::LOCATION, redirect_to_url.into().to_string())], ) .into_response() } @@ -47,10 +48,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: &str) -> Response { + pub fn permanent(redirect_to_url: impl Into) -> Response { ( http::StatusCode::PERMANENT_REDIRECT, - [(http::header::LOCATION, redirect_to_url.to_owned())], + [(http::header::LOCATION, redirect_to_url.into().to_string())], ) .into_response() } @@ -61,10 +62,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: &str) -> Response { + pub fn found(redirect_to_url: impl Into) -> Response { ( http::StatusCode::FOUND, - [(http::header::LOCATION, redirect_to_url.to_owned())], + [(http::header::LOCATION, redirect_to_url.into().to_string())], ) .into_response() } @@ -75,10 +76,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: &str) -> Response { + pub fn see_other(redirect_to_url: impl Into) -> Response { ( http::StatusCode::SEE_OTHER, - [(http::header::LOCATION, redirect_to_url.to_owned())], + [(http::header::LOCATION, redirect_to_url.into().to_string())], ) .into_response() } @@ -88,10 +89,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: &str) -> Response { + pub fn temporary(redirect_to_url: impl Into) -> Response { ( http::StatusCode::TEMPORARY_REDIRECT, - [(http::header::LOCATION, redirect_to_url.to_owned())], + [(http::header::LOCATION, redirect_to_url.into().to_string())], ) .into_response() } diff --git a/src/response/waypoint.rs b/src/response/waypoint.rs new file mode 100644 index 00000000..b42c57c7 --- /dev/null +++ b/src/response/waypoint.rs @@ -0,0 +1,177 @@ +//! 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 bedee08e..4a0df3b5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -168,6 +168,45 @@ 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 a0156e3f..4dd79b77 100644 --- a/src/web.rs +++ b/src/web.rs @@ -47,7 +47,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::page::ErrorPage): +/// [`ErrorPage`](crate::response::ErrorPage): /// /// ```rust,no_run /// # use pagetop::prelude::*; diff --git a/tests/route.rs b/tests/route.rs new file mode 100644 index 00000000..f1e64bb1 --- /dev/null +++ b/tests/route.rs @@ -0,0 +1,124 @@ +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 new file mode 100644 index 00000000..76422e4e --- /dev/null +++ b/tests/waypoint.rs @@ -0,0 +1,63 @@ +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"); +}