diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 6c288062..dda79e45 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -105,7 +105,7 @@ impl Extension for SuperMenu { )), )); - InRegion::Global(&DefaultRegion::Header).add( + InRegion::Global(&DefaultRegions::Header).add( bs::Container::new() .with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0))) .with_child(navbar_menu), diff --git a/src/app.rs b/src/app.rs index 55f256b1..87d81b6c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,20 +3,31 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; -use crate::html::Markup; use crate::locale::Locale; -use crate::response::page::{ErrorPage, render_error_pages}; -use crate::web::{HttpRequest, Router}; +use crate::response::page::{render_error_pages, response_for_panic, route_not_found}; +use crate::web::Router; use crate::{PAGETOP_VERSION, global, trace}; +use tower_http::catch_panic::CatchPanicLayer; + use std::io::Error; use std::sync::LazyLock; /// Punto de entrada de una aplicación PageTop. /// -/// Orquesta el arranque de la aplicación. Primero se instancia con [`new()`](Application::new) o -/// [`prepare()`](Application::prepare), y después se ejecuta usando [`run()`](Application::run) (o -/// usando [`test()`](Application::test) si se está preparando un entorno de pruebas). +/// Orquesta el arranque de la aplicación. Primero se instancia con [`Application::new()`] o +/// [`Application::prepare()`], y después se ejecuta usando [`run()`](Application::run). Si se está +/// 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 +/// [`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)). +/// +/// La última capa del router captura cualquier **fallo catastrófico** (`panic!`) de la aplicación +/// en lugar de abortar la conexión. Devuelve una respuesta mínima HTTP 500 que es independiente del +/// tema y del ciclo de renderizado de componentes. pub struct Application; impl Application { @@ -34,7 +45,7 @@ impl Application { /// que no dependen de ninguna otra, luego las que dependen de extensiones ya habilitadas, y así /// hasta habilitar la extensión raíz. /// - /// Es *async* porque cada extensión puede realizar operaciones asíncronas en su + /// Es `async` porque cada extensión puede realizar operaciones asíncronas en su /// [`initialize()`](crate::core::extension::Extension::initialize) (conexión a base de datos, /// migraciones, semillas de datos...). pub async fn prepare(root_extension: ExtensionRef) -> Self { @@ -106,12 +117,16 @@ impl Application { } // Construye el router con las rutas y el middleware de todas las extensiones habilitadas. + // + // Con `CatchPanicLayer` en la última capa se capturan incluso los `panic!` que se produzcan + // dentro del propio renderizado de una página de error. fn build_router() -> Router { let router = extension::all::configure_routes(Router::new()); let router = extension::all::configure_middleware(router); router .fallback(route_not_found) .layer(axum::middleware::from_fn(render_error_pages)) + .layer(CatchPanicLayer::custom(response_for_panic)) } /// Arranca el servidor web de la aplicación. @@ -155,7 +170,3 @@ impl Application { Self::build_router() } } - -async fn route_not_found(request: HttpRequest) -> Result { - Err(ErrorPage::NotFound(request)) -} diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 26090da3..71aebdd0 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -25,7 +25,7 @@ impl Theme for Basic { .with_weight(-99), )) .alter_child_in( - &DefaultRegion::Footer, + &DefaultRegions::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), ); } diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 351564da..4dd7a1dc 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -2,7 +2,7 @@ use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; -use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; +use crate::core::theme::{ChildrenInRegions, DefaultRegions, RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::L10n; @@ -96,7 +96,7 @@ impl std::error::Error for ContextError {} /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&Locale::resolve("es-ES")) /// .with_theme(&Aliner) -/// .with_template(&DefaultTemplate::Standard) +/// .with_template(&DefaultTemplates::Standard) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) @@ -306,13 +306,36 @@ 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. locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. theme : ThemeRef, // Referencia al tema usado para renderizar. - template : TemplateRef, // Plantilla usada para renderizar. + template : TemplateSource, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. @@ -344,7 +367,7 @@ impl Context { locale, current_user, theme : *DEFAULT_THEME, - template : DEFAULT_THEME.default_template(), + template : TemplateSource::Default, favicon : None, preloads : Assets::::new(), stylesheets: Assets::::new(), @@ -366,6 +389,13 @@ impl Context { .unwrap_or(CurrentUser::Anonymous) } + // Fuerza la plantilla de administración del tema activo (usada por `Page::admin()`). Se + // resuelve dinámicamente contra `self.theme`, igual que `TemplateSource::Default`, así que + // sigue reflejando cualquier cambio de tema posterior con `with_theme()`. + pub(crate) fn use_admin_template(&mut self) { + self.template = TemplateSource::Admin; + } + // **< Context RENDER >************************************************************************* /// Renderiza los recursos del contexto. @@ -534,7 +564,7 @@ impl Contextual for Context { #[builder_fn] fn with_template(mut self, template: TemplateRef) -> Self { - self.template = template; + self.template = TemplateSource::Explicit(template); self } @@ -591,7 +621,7 @@ impl Contextual for Context { #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { self.regions - .alter_child_in(&DefaultRegion::Content, op.into()); + .alter_child_in(&DefaultRegions::Content, op.into()); self } @@ -616,7 +646,7 @@ impl Contextual for Context { } fn template(&self) -> TemplateRef { - self.template + self.template.resolve(self.theme) } fn param(&self, key: &'static str) -> Result<&T, ContextError> { diff --git a/src/core/theme.rs b/src/core/theme.rs index 46b5d086..6a5bf6a7 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -1,31 +1,86 @@ //! API para añadir y gestionar nuevos temas. //! -//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension) y -//! también [`Theme`], de modo que [`Extension::theme()`](crate::core::extension::Extension::theme) -//! permita identificar y registrar los temas disponibles. -//! //! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos //! interactivos. Para ello utiliza plantillas ([`Template`]) que describen cómo maquetar el cuerpo -//! del documento a partir de varias regiones ([`Region`]). Cada región es un contenedor lógico -//! identificado por un nombre, cuyo contenido se obtiene del [`Context`] de la página. +//! 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)) representa 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. +//! 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. //! -//! De este modo, temas y extensiones colaboran sobre una estructura común: las aplicaciones -//! registran componentes en el [`Context`], las plantillas organizan las regiones y las páginas -//! generan el documento HTML resultante. +//! Además, PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un +//! tema hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos +//! selectivamente. Por ejemplo, puede redefinir el renderizado de un componente a través de +//! [`Theme::handle_component()`] sin cambiar el resto del comportamiento heredado. Un tema hijo +//! puede ser a su vez padre de otro, basta declararlo cada vez con [`Theme::parent()`]. //! -//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un tema -//! hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos selectivamente: -//! por ejemplo, puede redefinir el renderizado de un componente con [`Theme::handle_component()`] -//! sin modificar el resto del comportamiento heredado. Un tema hijo puede ser a su vez padre de -//! otro, basta declararlo cada vez con [`Theme::parent()`]. +//! # Cómo crear un tema nuevo //! -//! Los temas pueden definir sus propias implementaciones de [`Template`] y [`Region`] (por ejemplo, -//! mediante *enums* adicionales) para añadir nuevas plantillas o exponer regiones específicas. +//! Un tema mínimo es una extensión que implementa [`Extension`](crate::core::extension::Extension) +//! y también [`Theme`] para que [`Extension::theme()`](crate::core::extension::Extension::theme) +//! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los +//! métodos de [`Theme`] tienen implementación por defecto. +//! +//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por +//! defecto no basta: +//! +//! 1. **Definir regiones propias**. Por defecto PageTop define [`DefaultRegions`], con tres +//! regiones (`Header`, `Content` y `Footer`) que usan la implementación por defecto de +//! [`Region::render()`]. Un tema puede definir un *enum* propio que implemente [`Region`] para +//! exponer sus propias regiones (por ejemplo, una barra lateral) o para cambiar cómo se muestra +//! el contenido de una región ya existente (identificada por su nombre). +//! 2. **Definir plantillas propias**. Por defecto existe [`DefaultTemplates`], con dos plantillas +//! (`Standard` y `Admin`) que usan la implementación por defecto de [`Template::render()`] para +//! renderizar [`DefaultRegions::Header`], [`DefaultRegions::Content`] y +//! [`DefaultRegions::Footer`], en este orden. Un tema puede definir un *enum* propio que +//! implemente [`Template`] para crear nuevas plantillas, maquetar las regiones de otra forma, +//! cambiar su orden o envolverlas en contenedores adicionales. +//! 3. **Elegir las plantillas predeterminadas**. Por un lado, la plantilla por defecto vía +//! [`Theme::default_template()`] y, por otro, la plantilla para las páginas de administración, +//! [`Theme::admin_template()`]. De esta forma, las páginas creadas con `Page::new()` usarán +//! automáticamente la plantilla `default_template()` del tema activo, y las páginas creadas con +//! `Page::admin()` usarán la de `admin_template()`, sin tener que llamar manualmente a +//! [`with_template()`](crate::core::component::Contextual::with_template). Un tema que no +//! sobrescriba estos métodos sigue usando las plantillas por defecto de PageTop. +//! +//! Las páginas de error (403, 404, y otros errores fatales) no tienen una plantilla propia: se +//! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de +//! navegación del sitio. Los temas pueden personalizar su contenido sobrescribiendo +//! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una +//! plantilla distinta. +//! +//! El resto del comportamiento de un tema (renderizado del ``, o intervención en el +//! renderizado de componentes concretos con [`Theme::handle_component()`]) se sobrescribe de forma +//! independiente de estos tres pasos y no es necesario para tener un tema funcional. +//! +//! # Componentes que se procesan en todas las páginas +//! +//! Los componentes añadidos a una página con +//! [`with_child_in()`](crate::core::component::Contextual::with_child_in) sólo existen para esa +//! petición concreta: hay que volver a añadirlos cada vez que se construya la página. [`InRegion`] +//! resuelve el caso contrario: un componente que se debe procesar en todas las páginas, o en todas +//! las de un tema concreto, sin tener que registrarlo en el código de cada página. +//! +//! `InRegion` registra el componente una sola vez, normalmente al arrancar la aplicación o al +//! inicializar una extensión, y a partir de ahí se procesa automáticamente en todas las páginas que +//! correspondan: +//! +//! ```rust,no_run +//! # use pagetop::prelude::*; +//! InRegion::Global(&DefaultRegions::Footer).add(PoweredBy::new()); +//! ``` +//! +//! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del +//! renderizado, de modo que su `setup()` siempre parte de un estado inicial limpio y no acumula +//! mutaciones entre peticiones. +//! +//! Como cualquier otro componente, antes de renderizarse pasa por +//! [`is_renderable()`](crate::core::component::Component::is_renderable), el primer paso del +//! [ciclo de renderizado](crate::core::component::ComponentRender). Esto permite registrarlo una +//! sola vez y que decida por sí mismo cuándo mostrarse, por ejemplo según la ruta de la petición o +//! si el usuario actual está autenticado. use crate::async_trait; use crate::core::component::Context; @@ -44,7 +99,7 @@ use crate::{AutoDefault, util}; /// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas /// implementaciones de [`Region`] que devuelvan el mismo nombre compartirán el mismo conjunto de /// componentes registrados en el [`Context`], aunque cada región puede renderizar ese contenido de -/// forma diferente. Por ejemplo, [`DefaultRegion::Header`] y `BootsierRegion::Header` mostrarían +/// forma diferente. Por ejemplo, [`DefaultRegions::Header`] y `BootsierRegions::Header` mostrarían /// los mismos componentes si ambas devuelven el nombre `"header"`, pero podrían maquetarse de /// manera distinta. /// @@ -102,7 +157,7 @@ pub trait Region: Send + Sync { /// Referencia estática a una región. pub type RegionRef = &'static dyn Region; -// **< DefaultRegion >****************************************************************************** +// **< DefaultRegions >***************************************************************************** /// Regiones básicas que PageTop proporciona por defecto. /// @@ -110,7 +165,7 @@ pub type RegionRef = &'static dyn Region; /// equivalente definida por otros temas, por lo que comparten también el contenido registrado bajo /// esos nombres. #[derive(AutoDefault)] -pub enum DefaultRegion { +pub enum DefaultRegions { /// Región estándar para la **cabecera** del documento, de nombre `"header"`. /// /// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc. @@ -129,7 +184,7 @@ pub enum DefaultRegion { Footer, } -impl Region for DefaultRegion { +impl Region for DefaultRegions { #[inline] fn name(&self) -> &'static str { match self { @@ -160,8 +215,8 @@ impl Region for DefaultRegion { pub trait Template: Send + Sync { /// Renderiza el contenido de la plantilla. /// - /// Por defecto, renderiza las regiones básicas de [`DefaultRegion`] en este orden: - /// [`DefaultRegion::Header`], [`DefaultRegion::Content`] y [`DefaultRegion::Footer`]. + /// Por defecto, renderiza las regiones básicas de [`DefaultRegions`] en este orden: + /// [`DefaultRegions::Header`], [`DefaultRegions::Content`] y [`DefaultRegions::Footer`]. /// /// Se puede sobrescribir este método para: /// @@ -175,9 +230,9 @@ pub trait Template: Send + Sync { /// propia página ([`Contextual::template()`](crate::core::component::Contextual::template())). async fn render(&self, cx: &mut Context) -> Markup { html! { - (DefaultRegion::Header.render(cx).await) - (DefaultRegion::Content.render(cx).await) - (DefaultRegion::Footer.render(cx).await) + (DefaultRegions::Header.render(cx).await) + (DefaultRegions::Content.render(cx).await) + (DefaultRegions::Footer.render(cx).await) } } } @@ -185,11 +240,11 @@ pub trait Template: Send + Sync { /// Referencia estática a una plantilla. pub type TemplateRef = &'static dyn Template; -// **< DefaultTemplate >**************************************************************************** +// **< DefaultTemplates >*************************************************************************** /// Plantillas que PageTop proporciona por defecto. #[derive(AutoDefault)] -pub enum DefaultTemplate { +pub enum DefaultTemplates { /// Plantilla predeterminada. /// /// Utiliza la implementación por defecto de [`Template::render()`] y se emplea cuando no se @@ -197,15 +252,15 @@ pub enum DefaultTemplate { #[default] Standard, - /// Plantilla de error. + /// Plantilla para la **interfaz de administración**. /// - /// Se utiliza para páginas de error u otros estados excepcionales. Por defecto utiliza la misma + /// Se utiliza para páginas de administración o paneles de control. Por defecto utiliza la misma /// implementación de [`Template::render()`] que [`Self::Standard`]. - Error, + Admin, } #[async_trait] -impl Template for DefaultTemplate {} +impl Template for DefaultTemplates {} // **< render_component! >************************************************************************** diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 720d128c..318e8c39 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -2,7 +2,7 @@ use crate::async_trait; use crate::base::component::{Html, Intro, IntroOpening}; use crate::core::component::{ChildOp, Component, ComponentError, Context, Contextual}; use crate::core::extension::Extension; -use crate::core::theme::{DefaultRegion, DefaultTemplate, TemplateRef}; +use crate::core::theme::{DefaultRegions, DefaultTemplates, TemplateRef}; use crate::global; use crate::html::{Markup, html}; use crate::locale::L10n; @@ -66,14 +66,25 @@ pub trait Theme: Extension + Send + Sync { /// ([`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 estándar ([`DefaultTemplate::Standard`]) - /// con una estructura básica para la página. Los temas pueden sobrescribir este método para - /// seleccionar otra plantilla predeterminada o una plantilla propia. + /// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Standard`] con una + /// estructura básica para la página. Los temas pueden sobrescribir este método para seleccionar + /// otra plantilla predeterminada o una plantilla propia. #[inline] fn default_template(&self) -> TemplateRef { - self.parent().map_or(&DefaultTemplate::Standard, |parent| { - parent.default_template() - }) + self.parent() + .map_or(&DefaultTemplates::Standard, |p| p.default_template()) + } + + /// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el tema propone para + /// la interfaz de administración. + /// + /// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Admin`] con una + /// estructura básica para la interfaz de administración. Los temas pueden sobrescribir este + /// método para seleccionar otra plantilla predeterminada o una plantilla propia. + #[inline] + fn admin_template(&self) -> TemplateRef { + self.parent() + .map_or(&DefaultTemplates::Admin, |p| p.admin_template()) } /// Acciones específicas del tema antes de renderizar el `` de la página. @@ -101,9 +112,9 @@ pub trait Theme: Extension + Send + Sync { /// partir de las regiones. /// /// Con la configuración por defecto, la plantilla estándar utiliza las regiones - /// [`DefaultRegion::Header`](crate::core::theme::DefaultRegion::Header), - /// [`DefaultRegion::Content`](crate::core::theme::DefaultRegion::Content) y - /// [`DefaultRegion::Footer`](crate::core::theme::DefaultRegion::Footer) en ese orden. + /// [`DefaultRegions::Header`](crate::core::theme::DefaultRegions::Header), + /// [`DefaultRegions::Content`](crate::core::theme::DefaultRegions::Content) y + /// [`DefaultRegions::Footer`](crate::core::theme::DefaultRegions::Footer) en ese orden. /// /// Los temas pueden sobrescribir este método para: /// @@ -236,90 +247,101 @@ pub trait Theme: Extension + Send + Sync { /// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado). /// - /// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la - /// página de error. + /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser + /// [`DefaultTemplates::Standard`]), para que el usuario no pierda el contexto de navegación del + /// sitio. Los temas pueden sobrescribir este método para personalizar completamente el diseño y + /// el contenido de la página de error. fn error_403(&self, page: &mut Page) { if let Some(parent) = self.parent() { return parent.error_403(page); } - page.alter_title(L10n::l("error403_title")) - .alter_template(&DefaultTemplate::Error) - .alter_child_in( - &DefaultRegion::Content, - ChildOp::Prepend( - Html::with(move |cx| { - html! { - div { - h1 { (L10n::l("error403_alert").using(cx)) } - p { (L10n::l("error403_help").using(cx)) } - } + page.alter_title(L10n::l("error403_title")).alter_child_in( + &DefaultRegions::Content, + ChildOp::Prepend( + Html::with(move |cx| { + html! { + div { + h1 { (L10n::l("error403_alert").using(cx)) } + p { (L10n::l("error403_help").using(cx)) } } - }) - .into(), - ), - ); + } + }) + .into(), + ), + ); } /// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado). /// - /// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la - /// página de error. + /// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser + /// [`DefaultTemplates::Standard`]). Los temas pueden sobrescribir este método para personalizar + /// completamente el diseño y el contenido de la página de error. fn error_404(&self, page: &mut Page) { if let Some(parent) = self.parent() { return parent.error_404(page); } - page.alter_title(L10n::l("error404_title")) - .alter_template(&DefaultTemplate::Error) - .alter_child_in( - &DefaultRegion::Content, - ChildOp::Prepend( - Html::with(move |cx| { - html! { - div { - h1 { (L10n::l("error404_alert").using(cx)) } - p { (L10n::l("error404_help").using(cx)) } - } + page.alter_title(L10n::l("error404_title")).alter_child_in( + &DefaultRegions::Content, + ChildOp::Prepend( + Html::with(move |cx| { + html! { + div { + h1 { (L10n::l("error404_alert").using(cx)) } + p { (L10n::l("error404_help").using(cx)) } } - }) - .into(), - ), - ); + } + }) + .into(), + ), + ); } - /// Permite al tema preparar y componer una página de error fatal. + /// Permite al tema preparar y componer una página de **error fatal controlado**. /// - /// Por defecto, asigna el título al documento (`title`) y muestra un componente [`Intro`] con - /// el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y `help`) como - /// descripción del error. + /// 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. + /// + /// 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 + /// [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y + /// `help`) como descripción del error. /// /// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni /// [`Self::error_404()`], que definen su propio contenido específico. /// + /// Tampoco cubre los **fallos catastróficos** (un `panic!` en un handler, componente o + /// plantilla). Ese caso se intercepta en una última capa que responde con un HTML mínimo y + /// autónomo, sin pasar por el tema ni por el ciclo de renderizado de componentes, porque no es + /// seguro asumir que ese ciclo sigue funcionando tras un `panic!`. + /// /// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la /// página de error. fn error_fatal(&self, page: &mut Page, code: StatusCode, title: L10n, alert: L10n, help: L10n) { if let Some(parent) = self.parent() { return parent.error_fatal(page, code, title, alert, help); } - page.alter_title(title) - .alter_template(&DefaultTemplate::Error) - .alter_child_in( - &DefaultRegion::Content, - ChildOp::Prepend( - Intro::new() - .with_title(L10n::l("error_code").with_arg("code", code.to_string())) - .with_slogan(L10n::n(code.to_string())) - .with_button(None) - .with_opening(IntroOpening::Custom) - .with_child(Html::with(move |cx| { - html! { - h1 { (alert.using(cx)) } - p { (help.using(cx)) } - } - })) - .into(), - ), - ); + page.alter_title(title).alter_child_in( + &DefaultRegions::Content, + ChildOp::Prepend( + Intro::new() + .with_title(L10n::l("error_code").with_arg("code", code.to_string())) + .with_slogan(L10n::n(code.to_string())) + .with_button(None) + .with_opening(IntroOpening::Custom) + .with_child(Html::with(move |cx| { + html! { + h1 { (alert.using(cx)) } + p { (help.using(cx)) } + } + })) + .into(), + ), + ); } } diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 8ac9ce55..abf19bfb 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -1,5 +1,5 @@ use crate::core::component::{Child, ChildOp, Children, Component}; -use crate::core::theme::{DefaultRegion, RegionRef, ThemeRef}; +use crate::core::theme::{DefaultRegions, RegionRef, ThemeRef}; use crate::{AutoDefault, UniqueId, builder_fn}; use parking_lot::RwLock; @@ -118,15 +118,15 @@ impl ChildrenInRegions { /// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" })); /// /// // Texto en la cabecera, visible en todos los temas. -/// InRegion::Global(&DefaultRegion::Header).add(Html::with(|_| html! { "Publicidad" })); +/// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| html! { "Publicidad" })); /// ``` pub enum InRegion { /// Región principal de **contenido** por defecto. /// /// Añade el componente a la región lógica de contenido principal de la aplicación. Por - /// convención, esta región corresponde a [`DefaultRegion::Content`], cuyo nombre es + /// convención, esta región corresponde a [`DefaultRegions::Content`], cuyo nombre es /// `"content"`. Cualquier tema que renderice esa misma región de contenido, ya sea usando - /// directamente [`DefaultRegion::Content`] o cualquier otra implementación de + /// directamente [`DefaultRegions::Content`] o cualquier otra implementación de /// [`Region`](crate::core::theme::Region) que devuelva ese mismo nombre, mostrará los /// componentes registrados aquí, aunque lo harán según su propio método de renderizado /// ([`Region::render()`](crate::core::theme::Region::render)). @@ -163,19 +163,19 @@ impl InRegion { /// })); /// /// // Texto en la cabecera. - /// InRegion::Global(&DefaultRegion::Header).add(Html::with(|_| { + /// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| { /// html! { "Publicidad" } /// })); /// /// // Contenido sólo para la región del pie de página en un tema concreto. - /// InRegion::ForTheme(&theme::Basic, &DefaultRegion::Footer).add(Html::with(|_| { + /// InRegion::ForTheme(&theme::Basic, &DefaultRegions::Footer).add(Html::with(|_| { /// html! { "Aviso legal" } /// })); /// ``` pub fn add(&self, component: impl Component + Clone + 'static) -> &Self { let proto: Arc = Arc::new(component); match self { - InRegion::Content => Self::add_to_common(&DefaultRegion::Content, proto), + InRegion::Content => Self::add_to_common(&DefaultRegions::Content, proto), InRegion::Global(region_ref) => Self::add_to_common(*region_ref, proto), InRegion::ForTheme(theme_ref, region_ref) => { THEME_REGIONS diff --git a/src/response/page.rs b/src/response/page.rs index 61d5968e..aa59130c 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -15,12 +15,12 @@ mod error; pub use error::ErrorPage; -pub(crate) use error::render_error_pages; +pub(crate) use error::{render_error_pages, response_for_panic, route_not_found}; use crate::auth::CurrentUser; use crate::base::action; use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual}; -use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef}; +use crate::core::theme::{DefaultRegions, Region, RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{Attr, Props, PropsOp}; use crate::html::{DOCTYPE, Markup, html}; @@ -112,6 +112,13 @@ impl Page { } } + /// Crea una nueva instancia de página con la plantilla de administración del tema activo. + pub fn admin(request: HttpRequest) -> Self { + let mut page = Page::new(request); + page.context().use_admin_template(); + page + } + // **< Page BUILDER >*************************************************************************** /// Establece el título de la página como un valor traducible. @@ -304,7 +311,7 @@ impl Contextual for Page { #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { self.context - .alter_child_in(&DefaultRegion::Content, op.into()); + .alter_child_in(&DefaultRegions::Content, op.into()); self } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 62b3840c..69d4c764 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -2,12 +2,17 @@ use axum::extract::Request; use axum::middleware::Next; use crate::core::component::Contextual; +use crate::html::Markup; use crate::locale::L10n; -use crate::util; use crate::web::{HttpRequest, IntoResponse, Response, http}; +use crate::{trace, util}; use super::Page; +use std::any::Any; + +// **< Errores controlados >************************************************************************ + /// Página de error asociada a un código de estado HTTP. /// /// Este enumerado agrupa tipos esenciales de error que pueden devolverse como página HTML completa. @@ -15,7 +20,7 @@ use super::Page; /// de estado concreto. /// /// Para cada error se construye una [`Page`] usando el tema activo, lo que permite personalizar la -/// plantilla y el contenido del mensaje mediante los métodos específicos del tema (por ejemplo, +/// plantilla y el contenido del mensaje mediante los métodos específicos del tema (como /// [`Theme::error_403()`](crate::core::theme::Theme::error_403), /// [`Theme::error_404()`](crate::core::theme::Theme::error_404) o /// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)). @@ -95,14 +100,58 @@ impl IntoResponse for ErrorPage { } } -// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML completas -// usando el tema activo. +// Gestión de las rutas sin coincidencia. // -// Se registra globalmente sobre el router principal desde [`crate::app`]. -pub(crate) async fn render_error_pages(req: Request, next: Next) -> Response { - let mut response = next.run(req).await; +// Se registra como `.fallback()` del router principal desde [`Application`](crate::Application). +pub(crate) async fn route_not_found(request: HttpRequest) -> Result { + Err(ErrorPage::NotFound(request)) +} + +// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML. +// +// Se registra globalmente sobre el router principal desde [`Application`](crate::Application). +pub(crate) async fn render_error_pages(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; if let Some(error_page) = response.extensions_mut().remove::() { return error_page.render_html().await; } response } + +// **< Fallo catastrófico >************************************************************************* + +// HTML mínimo para un fallo catastrófico (`panic!`). No usa el tema, ni componentes, ni acceso a +// datos, porque el fallo podría estar precisamente ahí. Tampoco se traduce porque ni siquiera el +// sistema de localización es seguro; tampoco hay forma de elegir idioma vía `Accept-Language`. +const FATAL_ERROR_HTML: &str = concat!( + "", + "", + "", + "", + "", + "Unexpected error", + "", + "

An unexpected error has occurred

", + "

Sorry for the inconvenience. Please try again or contact your system administrator.

", + "", + "", +); + +// Captura el fallo catastrófico (`panic!`) con el motivo para diagnóstico y responde un HTTP 500. +// +// Se registra sobre el router principal desde [`Application`](crate::Application). +pub(crate) fn response_for_panic(err: Box) -> Response { + let reason = err + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| err.downcast_ref::().cloned()) + .unwrap_or_else(|| "panic with no message".to_string()); + trace::error!(panic = %reason, "Unhandled panic caught by CatchPanicLayer"); + + ( + http::StatusCode::INTERNAL_SERVER_ERROR, + [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + FATAL_ERROR_HTML, + ) + .into_response() +} diff --git a/src/web.rs b/src/web.rs index b8c930d8..da3eaf46 100644 --- a/src/web.rs +++ b/src/web.rs @@ -422,4 +422,12 @@ pub mod test { pub async fn send_request(router: &Router, req: http::Request) -> http::Response { router.clone().oneshot(req).await.unwrap() } + + /// Devuelve el cuerpo de una respuesta como texto UTF-8, para comprobaciones en tests. + pub async fn read_body_text(response: http::Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } } diff --git a/tests/error_handling.rs b/tests/error_handling.rs new file mode 100644 index 00000000..aa560c17 --- /dev/null +++ b/tests/error_handling.rs @@ -0,0 +1,54 @@ +use pagetop::prelude::*; + +// **< CatchPanicLayer >**************************************************************************** + +struct PanicExtension; + +#[async_trait] +impl Extension for PanicExtension { + fn configure_router(&self, router: Router) -> Router { + router.route("/boom", web::get(boom)) + } +} + +async fn boom() -> Result { + panic!("boom") +} + +#[pagetop::test] +async fn panic_in_handler_returns_minimal_500_page_instead_of_crashing() { + let app = web::test::init_router(Application::prepare(&PanicExtension).await.test()); + + let req = web::test::TestRequest::get().uri("/boom").to_request(); + let resp = web::test::send_request(&app, req).await; + + assert_eq!(resp.status(), web::http::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + resp.headers().get(web::http::header::CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + + let body = web::test::read_body_text(resp).await; + assert!(body.contains("An unexpected error has occurred")); +} + +// **< ErrorPage::NotFound >************************************************************************ + +// `EXTENSIONS` es un `OnceLock` global (`core/extension/all.rs`): se inicializa una sola vez por +// binario de test. Todos los tests de este fichero comparten la misma extensión raíz +// (`PanicExtension`) para que el orden de ejecución en paralelo no cambie qué rutas quedan +// registradas. +#[pagetop::test] +async fn unknown_route_returns_themed_404_page() { + let app = web::test::init_router(Application::prepare(&PanicExtension).await.test()); + + let req = web::test::TestRequest::get() + .uri("/does-not-exist") + .to_request(); + let resp = web::test::send_request(&app, req).await; + + assert_eq!(resp.status(), web::http::StatusCode::NOT_FOUND); + + let body = web::test::read_body_text(resp).await; + assert!(body.contains("****************************************************************** + +struct MarkerTemplate; + +#[async_trait] +impl Template for MarkerTemplate { + async fn render(&self, _cx: &mut Context) -> Markup { + html! { "marker-template-output" } + } +} + +struct MarkerTheme; + +#[async_trait] +impl Extension for MarkerTheme { + fn theme(&self) -> Option { + Some(&Self) + } +} + +#[async_trait] +impl Theme for MarkerTheme { + fn default_template(&self) -> TemplateRef { + &MarkerTemplate + } + + fn admin_template(&self) -> TemplateRef { + &MarkerTemplate + } +} + +async fn render_active_template(cx: &mut Context) -> String { + let template = cx.template(); + template.render(cx).await.into_string() +} + +// **< Context::template() sigue al tema activo >*************************************************** + +#[pagetop::test] +async fn with_theme_updates_the_effective_template() { + // Sin cambiar de tema, la plantilla activa no es la de `MarkerTheme`. + let mut cx = Context::new(None); + assert_ne!( + render_active_template(&mut cx).await, + "marker-template-output" + ); + + // Tras cambiar de tema con `with_theme()`, la plantilla activa pasa a ser la de ese tema, sin + // necesidad de llamar a `with_template()` explícitamente. + let mut cx = Context::new(None).with_theme(&MarkerTheme); + assert_eq!( + render_active_template(&mut cx).await, + "marker-template-output" + ); +} + +#[pagetop::test] +async fn explicit_template_is_not_overridden_by_a_later_with_theme() { + // Una plantilla fijada explícitamente con `with_template()` prevalece aunque `with_theme()` se + // llame después, en cualquier orden. + let mut cx = Context::new(None) + .with_template(&MarkerTemplate) + .with_theme(&pagetop::base::theme::Basic); + + assert_eq!( + render_active_template(&mut cx).await, + "marker-template-output" + ); +} + +// **< Page::admin() sigue al tema activo >********************************************************* + +#[pagetop::test] +async fn page_admin_template_follows_a_later_with_theme() { + let request = web::test::TestRequest::get().to_http_request(); + + let mut page = Page::admin(request).with_theme(&MarkerTheme); + let markup = page.context().template().render(page.context()).await; + + assert_eq!(markup.into_string(), "marker-template-output"); +}