(auth): Añade permisos tipados

Sustituye las claves de permiso `&str` sueltas por el trait `Permission`
(clave, etiqueta y grupo), y añade `require_permission()` para cortar un
handler con `ErrorPage::AccessDenied` antes de construir nada.

Para que `require_permission()` pueda fallar sin un `Context` previo,
`ErrorPage` pasa a envolver `Option<HttpRequest>`, `Context::new()`
recibe ahora `HttpRequest` sin `Option`, y se añade `Context::admin()`
para la plantilla de administración.
This commit is contained in:
Manuel Cillero 2026-07-27 22:46:41 +02:00
parent ea0dc021ce
commit 747e64ce34
10 changed files with 297 additions and 120 deletions

View file

@ -1,31 +1,44 @@
//! Identidad del usuario y sistema de autorización extensible. //! Identidad del usuario y sistema de autorización extensible.
//! //!
//! Define el tipo mínimo [`CurrentUser`] que PageTop inyecta en el [`Context`] de cada petición. //! Define el tipo [`CurrentUser`] que PageTop inyecta en el [`Context`] con la información mínima
//! Incluye también la acción [`CheckPermission`] para que las extensiones puedan implementar sus //! sobre el usuario que ejecuta la petición actual ([`HttpRequest`]).
//! propios modelos de permisos, y la función auxiliar [`has_permission()`]. //!
//! Incluye la acción [`CheckPermission`] para que las extensiones puedan implementar sus propios
//! modelos de permisos. Y también las funciones auxiliares [`has_permission()`] y
//! [`require_permission()`] para validar en el comienzo de cada handler, antes de construir ni
//! ejecutar nada, si la petición está autorizada.
//! //!
//! La resolución concreta del usuario (sesión en BD, LDAP, OAuth, ...) y la lógica de permisos //! La resolución concreta del usuario (sesión en BD, LDAP, OAuth, ...) y la lógica de permisos
//! (RBAC, grupos LDAP, ...) son responsabilidad de las extensiones de autenticación. Un concepto //! (RBAC, grupos LDAP, ...) son responsabilidad de las extensiones de autenticación. Un concepto
//! como "administrador" que tiene todos los permisos no es responsabilidad del core: cada extensión //! como "administrador" que tiene todos los permisos no es responsabilidad de PageTop: cada
//! decide si existe y, si es así, lo aplica dentro de su propio handler [`CheckPermission`]. //! extensión decide si existe y, si es así, lo aplica dentro de su propio handler
//! [`CheckPermission`].
//!
//! [`Context`]: crate::core::component::Context
use crate::core::action::{ActionDispatcher, ActionKey, try_dispatch_actions}; use crate::core::action::{ActionDispatcher, ActionKey, try_dispatch_actions};
use crate::core::component::Context; use crate::locale::L10n;
use crate::{UniqueId, Weight}; use crate::response::ErrorPage;
use crate::web::HttpRequest;
use crate::{CowStr, UniqueId, Weight};
// **< CurrentUser >******************************************************************************** // **< CurrentUser >********************************************************************************
/// Identidad mínima del usuario que ejecuta la petición actual. /// Identidad mínima del usuario que ejecuta la petición actual.
/// ///
/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP (ver /// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se
/// [`Context::new()`](crate::core::component::Context::new)). La identidad se extrae de las /// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su
/// extensiones de la petición, que una extensión de autenticación inyecta mediante su middleware. /// middleware.
/// ///
/// Se accede con [`Contextual::current_user()`](crate::core::component::Contextual::current_user). /// Se accede usando [`Contextual::current_user()`].
/// ///
/// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son /// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son
/// responsabilidad de la extensión de autenticación y se obtienen a través de /// responsabilidad de la extensión de autenticación y se obtienen a través de
/// [`HttpRequest::extension`](crate::web::HttpRequest::extension). /// [`HttpRequest::extension`].
///
/// [`Context`]: crate::core::component::Context
/// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user
/// [`HttpRequest::extension`]: crate::web::HttpRequest::extension
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum CurrentUser { pub enum CurrentUser {
/// Usuario no autenticado. /// Usuario no autenticado.
@ -67,31 +80,93 @@ impl CurrentUser {
} }
} }
// **< Permission >*********************************************************************************
/// Clave tipada de un permiso de acceso.
///
/// Cada extensión que lo requiera puede definir su propio enum de permisos e implementar este trait
/// para obtener la clave textual que finalmente se compara contra su modelo de permisos (RBAC en
/// base de datos, grupos LDAP, ...).
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::auth::Permission;
/// # use pagetop::CowStr;
/// #[derive(Clone, Copy, Debug)]
/// pub enum MyPermission {
/// EditPosts,
/// DeletePosts,
/// }
///
/// impl Permission for MyPermission {
/// fn key(&self) -> CowStr {
/// match self {
/// Self::EditPosts => "my_extension.edit_posts".into(),
/// Self::DeletePosts => "my_extension.delete_posts".into(),
/// }
/// }
/// }
/// ```
pub trait Permission: Send + Sync {
/// Clave única del permiso (p. ej. `"my_extension.edit_posts"`).
fn key(&self) -> CowStr;
/// Descripción breve para humanos (p. ej. en una pantalla de asignación de permisos a roles).
///
/// Por defecto devuelve la propia clave; una extensión que registre sus permisos en un catálogo
/// visible debería sobrescribirlo con un texto traducible.
fn label(&self) -> L10n {
L10n::n(self.key())
}
/// Identificador estable de la categoría del permiso, usado para agrupar en un catálogo (p.
/// ej. `"administration"`). Por defecto no pertenece a ningún grupo.
fn group(&self) -> &'static str {
""
}
/// Título traducible de [`group()`](Self::group), mostrado en la UI de administración.
///
/// Por defecto reutiliza el propio identificador del grupo como texto fijo.
fn group_label(&self) -> L10n {
L10n::n(self.group())
}
}
/// Referencia estática a un permiso de acceso.
///
/// Es el tipo que recorre toda la API de autorización ([`has_permission()`],
/// [`require_permission()`] o [`CheckPermission`]).
pub type PermissionRef = &'static dyn Permission;
// **< CheckPermission >**************************************************************************** // **< CheckPermission >****************************************************************************
/// Tipo de función para comprobar si el usuario actual tiene un permiso concreto. /// Tipo de función para comprobar si el usuario actual tiene un permiso concreto.
/// ///
/// Se invoca con: /// Se invoca con:
/// ///
/// - `cx`: el contexto de renderizado desde el que se puede acceder a la petición HTTP y a /// - `request`: petición HTTP desde la que se accede a los datos inyectados por el middleware de
/// cualquier dato inyectado por el middleware de autenticación. /// autenticación.
/// - `key`: clave del permiso a comprobar (p. ej. `"myapp.edit_posts"`). /// - `perm`: permiso a comprobar; el handler usará [`Permission::key()`] para identificarlo contra
/// su propio modelo de permisos.
/// - `granted`: referencia mutable; el handler debe asignarla a `true` si concede el permiso. /// - `granted`: referencia mutable; el handler debe asignarla a `true` si concede el permiso.
pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool); pub type FnActionCheckPerm = fn(request: &HttpRequest, perm: PermissionRef, granted: &mut bool);
/// Acción para comprobar si el usuario actual tiene un permiso concreto. /// Acción para comprobar si el usuario actual tiene un permiso concreto.
/// ///
/// Las extensiones de autenticación registran handlers de esta acción para implementar su modelo de /// Las extensiones de autenticación pueden registrar su handler sobre esta acción para implementar
/// permisos. Los handlers son aditivos: si cualquiera de ellos asigna `granted = true`, el permiso /// su modelo de permisos. Los handlers son aditivos de tal forma que si cualquiera de ellos asigna
/// se concede. /// `granted = true`, el permiso se concede.
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// fn check_my_permissions(cx: &Context, key: &str, granted: &mut bool) { /// fn check_my_permissions(request: &HttpRequest, perm: PermissionRef, granted: &mut bool) {
/// // Leer datos extendidos de autenticación desde las extensiones de la petición. /// // Leer los datos extendidos de autenticación inyectados en la petición.
/// // Si el usuario tiene el permiso, asignar `*granted = true`. /// // Comparar `perm.key()` contra el modelo propio.
/// // Si concede el permiso, asignar `*granted = true`.
/// } /// }
/// ///
/// pub struct MyAuth; /// pub struct MyAuth;
@ -104,7 +179,7 @@ pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool);
/// } /// }
/// ``` /// ```
pub struct CheckPermission { pub struct CheckPermission {
f: FnCheckPermission, f: FnActionCheckPerm,
weight: Weight, weight: Weight,
} }
@ -116,7 +191,7 @@ impl ActionDispatcher for CheckPermission {
impl CheckPermission { impl CheckPermission {
/// Registra una nueva acción para la comprobación de permisos. /// Registra una nueva acción para la comprobación de permisos.
pub fn new(f: FnCheckPermission) -> Self { pub fn new(f: FnActionCheckPerm) -> Self {
CheckPermission { f, weight: 0 } CheckPermission { f, weight: 0 }
} }
@ -128,12 +203,12 @@ impl CheckPermission {
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso. // Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
#[inline] #[inline]
pub(crate) fn check(cx: &Context, key: &str) -> bool { pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool {
let mut granted = false; let mut granted = false;
try_dispatch_actions( try_dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None), &ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| { |action: &Self| {
(action.f)(cx, key, &mut granted); (action.f)(request, perm, &mut granted);
if granted { if granted {
std::ops::ControlFlow::Break(()) std::ops::ControlFlow::Break(())
} else { } else {
@ -160,14 +235,62 @@ impl CheckPermission {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # #[derive(Clone, Copy, Debug)]
/// # enum MyPermission { Edit }
/// # impl Permission for MyPermission {
/// # fn key(&self) -> CowStr { "myapp.edit".into() }
/// # }
/// async fn my_handler(request: HttpRequest) -> Result<Markup, ErrorPage> { /// async fn my_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// let mut page = Page::new(request.clone()); /// if !has_permission(&request, &MyPermission::Edit) {
/// if !has_permission(page.context(), "myapp.edit") { /// return Err(ErrorPage::NotFound(Some(request)));
/// return Err(ErrorPage::NotFound(request));
/// } /// }
/// page.render().await /// Page::new(request).render().await
/// } /// }
/// ``` /// ```
pub fn has_permission(cx: &Context, key: &str) -> bool { pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
CheckPermission::check(cx, key) CheckPermission::check(request, perm)
}
// **< require_permission >*************************************************************************
/// Comprueba un permiso y devuelve `Err(ErrorPage::AccessDenied)` si se deniega.
///
/// Ejecuta [`has_permission()`] para el caso más habitual: detener un handler con una respuesta 403
/// en cuanto falta el permiso, sin repetir el `if`/`return` en cada punto de comprobación. Se hace
/// directamente sobre la petición, antes de construir ni ejecutar nada (`Context`, `Page`,
/// consultas a datos, etc.), para no hacer ningún trabajo si la petición no está autorizada.
///
/// Si la aplicación necesita ocultar la existencia del recurso a quien no tiene permiso (devolver
/// un 404 en vez de un 403), no se puede reutilizar esta función: hay que llamar a
/// `has_permission()` directamente, como en su propio ejemplo.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # #[derive(Clone, Copy, Debug)]
/// # enum MyPermission { Edit }
/// # impl Permission for MyPermission {
/// # fn key(&self) -> CowStr { "myapp.edit".into() }
/// # }
/// async fn my_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// // Comprueba si la petición está autorizada.
/// require_permission(&request, &MyPermission::Edit)?;
///
/// // Ejecuta las instrucciones propias de la petición.
/// Page::new(request)
/// .with_child(Html::with(|_| html! { p { "You have permission!" } }))
/// .render()
/// .await
/// }
/// ```
// `ErrorPage` incluye `Option<HttpRequest>` en cada variante y es el tipo de error ya establecido
// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa.
#[allow(clippy::result_large_err)]
pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> {
if has_permission(request, perm) {
Ok(())
} else {
Err(ErrorPage::AccessDenied(Some(request.clone())))
}
} }

View file

@ -62,7 +62,7 @@ pub enum ContextError {
/// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para:
/// ///
/// - Almacenar la **petición HTTP** de origen. /// - Almacenar la **petición HTTP** de origen.
/// - Seleccionar el **tema** y la **plantilla** de renderizado. /// - Seleccionar la **plantilla** y el **tema** de renderizado.
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`].
/// - Leer y mantener **parámetros dinámicos tipados** de contexto. /// - Leer y mantener **parámetros dinámicos tipados** de contexto.
@ -77,8 +77,8 @@ pub enum ContextError {
/// # use pagetop_aliner::Aliner; /// # use pagetop_aliner::Aliner;
/// fn prepare_context<C: Contextual>(cx: C) -> C { /// fn prepare_context<C: Contextual>(cx: C) -> C {
/// cx.with_langid(&Locale::resolve("es-ES")) /// cx.with_langid(&Locale::resolve("es-ES"))
/// .with_theme(&Aliner)
/// .with_template(&CoreTemplate::Standard) /// .with_template(&CoreTemplate::Standard)
/// .with_theme(&Aliner)
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
@ -93,17 +93,23 @@ pub trait Contextual: LangId {
fn with_langid(self, language: &impl LangId) -> Self; fn with_langid(self, language: &impl LangId) -> Self;
/// Almacena la petición HTTP de origen en el contexto. /// Almacena la petición HTTP de origen en el contexto.
///
/// También recalcula el idioma ([`RequestLocale::from_request()`]) y
/// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando
/// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya
/// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en
/// la cadena de construcción, nunca después.
#[builder_fn] #[builder_fn]
fn with_request(self, request: Option<HttpRequest>) -> Self; fn with_request(self, request: Option<HttpRequest>) -> Self;
/// Especifica el tema para renderizar el documento.
#[builder_fn]
fn with_theme(self, theme: ThemeRef) -> Self;
/// Especifica la plantilla para renderizar el documento. /// Especifica la plantilla para renderizar el documento.
#[builder_fn] #[builder_fn]
fn with_template(self, template: TemplateRef) -> Self; fn with_template(self, template: TemplateRef) -> Self;
/// Especifica el tema para renderizar el documento.
#[builder_fn]
fn with_theme(self, theme: ThemeRef) -> Self;
/// Añade o modifica un parámetro dinámico del contexto. /// Añade o modifica un parámetro dinámico del contexto.
/// ///
/// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de
@ -114,7 +120,7 @@ pub trait Contextual: LangId {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// let cx = Context::new(None) /// let cx = Context::default()
/// .with_param("user_id", 42_i32) /// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string()) /// .with_param("title", "Hello".to_string())
/// .with_param("flags", vec!["a", "b"]); /// .with_param("flags", vec!["a", "b"]);
@ -165,12 +171,12 @@ pub trait Contextual: LangId {
/// ``` /// ```
fn current_user(&self) -> &CurrentUser; fn current_user(&self) -> &CurrentUser;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
/// Devuelve la plantilla configurada para renderizar el documento. /// Devuelve la plantilla configurada para renderizar el documento.
fn template(&self) -> TemplateRef; fn template(&self) -> TemplateRef;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
/// Recupera una *referencia tipada* al parámetro solicitado. /// Recupera una *referencia tipada* al parámetro solicitado.
/// ///
/// Devuelve: /// Devuelve:
@ -183,7 +189,7 @@ pub trait Contextual: LangId {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// let cx = Context::new(None) /// let cx = Context::default()
/// .with_param("user_id", 42_i32) /// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string()); /// .with_param("title", "Hello".to_string());
/// ///
@ -230,7 +236,7 @@ pub trait Contextual: LangId {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// let mut cx = Context::new(None).with_param("temp", 1u8); /// let mut cx = Context::default().with_param("temp", 1u8);
/// assert!(cx.remove_param("temp")); /// assert!(cx.remove_param("temp"));
/// assert!(!cx.remove_param("temp")); // ya no existe /// assert!(!cx.remove_param("temp")); // ya no existe
/// ``` /// ```
@ -243,7 +249,7 @@ pub trait Contextual: LangId {
/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)), /// [`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 /// 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 /// HTTP de origen, el idioma negociado, el usuario autenticado
/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos /// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos
/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los /// (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 /// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param). /// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param).
@ -267,21 +273,21 @@ pub trait Contextual: LangId {
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # use pagetop_aliner::Aliner; /// # use pagetop_aliner::Aliner;
/// fn new_context(request: HttpRequest) -> Context { /// # fn new_context(request: HttpRequest) -> Context {
/// Context::new(Some(request)) /// let cx = Context::new(request)
/// // Establece el idioma del documento a español. /// // Establece el idioma del documento a español.
/// .with_langid(&Locale::resolve("es-ES")) /// .with_langid(&Locale::resolve("es-ES"))
/// // Establece el tema para renderizar. /// // Establece el tema para renderizar.
/// .with_theme(&Aliner) /// .with_theme(&Aliner)
/// // Asigna un favicon. /// // Asigna un favicon.
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// // Añade una hoja de estilo externa. /// // Añade una hoja de estilo externa.
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css"))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css")))
/// // Añade un script JavaScript. /// // Añade un script JavaScript.
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js")))
/// // Añade un parámetro dinámico al contexto. /// // Añade un parámetro dinámico al contexto.
/// .with_param("user_id", 42) /// .with_param("user_id", 42);
/// } /// # cx }
/// ``` /// ```
/// ///
/// Y hace operaciones con un contexto dado: /// Y hace operaciones con un contexto dado:
@ -309,8 +315,8 @@ pub struct Context {
request : Option<HttpRequest>, // Petición HTTP de origen. request : Option<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición. locale : RequestLocale, // Idioma asociado a la petición.
current_user: CurrentUser, // Identidad del usuario actual. current_user: CurrentUser, // Identidad del usuario actual.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar. template : TemplateRef, // Plantilla usada para renderizar.
theme : ThemeRef, // Referencia al tema usado para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido. favicon : Option<Favicon>, // Favicon, si se ha definido.
preloads : Assets<Preload>, // Recursos para precarga. preloads : Assets<Preload>, // Recursos para precarga.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS. stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
@ -324,25 +330,25 @@ pub struct Context {
impl Default for Context { impl Default for Context {
fn default() -> Self { fn default() -> Self {
Context::new(None) Self::base(None, &CoreTemplate::Standard)
} }
} }
impl Context { impl Context {
/// Crea un nuevo contexto asociado a una petición HTTP. // Construye el `Context` compartido por `new()`, `admin()` y `Default::default()`, evitando
/// // duplicar la lista de campos entre ambos (y la recursión que tendría `new()` llamando a
/// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros // `Default::default()`, o viceversa). Recibe la plantilla para que `admin()` no tenga que
/// recursos cargados. // construir con la plantilla estándar y sobrescribirla después.
#[rustfmt::skip] #[rustfmt::skip]
pub fn new(request: Option<HttpRequest>) -> Self { fn base(request: Option<HttpRequest>, template: TemplateRef) -> Self {
let locale = RequestLocale::from_request(request.as_ref()); let locale = RequestLocale::from_request(request.as_ref());
let current_user = Self::resolve_current_user(request.as_ref()); let current_user = Self::resolve_current_user(request.as_ref());
Context { Context {
request, request,
locale, locale,
current_user, current_user,
template,
theme : *DEFAULT_THEME, theme : *DEFAULT_THEME,
template : &CoreTemplate::Standard,
favicon : None, favicon : None,
preloads : Assets::<Preload>::new(), preloads : Assets::<Preload>::new(),
stylesheets: Assets::<StyleSheet>::new(), stylesheets: Assets::<StyleSheet>::new(),
@ -355,6 +361,25 @@ impl Context {
} }
} }
/// Crea un nuevo contexto asociado a una petición HTTP.
///
/// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros
/// recursos cargados.
///
/// Para un contexto sin petición (renderizar un componente de forma aislada, en tests o fuera
/// del ciclo de una petición web), usa [`Context::default()`].
pub fn new(request: HttpRequest) -> Self {
Self::base(Some(request), &CoreTemplate::Standard)
}
/// Crea un nuevo contexto asociado a una petición HTTP, con la plantilla de administración.
///
/// El contexto inicializa el idioma, el tema y la plantilla [`CoreTemplate::Admin`], sin
/// favicon ni otros recursos cargados.
pub fn admin(request: HttpRequest) -> Self {
Self::base(Some(request), &CoreTemplate::Admin)
}
// Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o // Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o
// `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa. // `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa.
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser { fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
@ -483,7 +508,7 @@ impl Context {
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let mut cx = Context::new(None); /// # let mut cx = Context::default();
/// cx.push_message(MessageLevel::Warning, L10n::n("Session is not valid")); /// cx.push_message(MessageLevel::Warning, L10n::n("Session is not valid"));
/// ``` /// ```
pub fn push_message(&mut self, level: MessageLevel, text: L10n) { pub fn push_message(&mut self, level: MessageLevel, text: L10n) {
@ -501,14 +526,19 @@ impl Context {
} }
} }
/// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma. /// Permite a [`Context`] actuar como proveedor de idioma.
/// ///
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración /// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración
/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo. /// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo.
/// ///
/// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto /// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto
/// permite que el [`Context`] se use como fuente de idioma coherente en /// permite que el [`Context`] se use como fuente de idioma coherente en [`L10n::lookup()`] o
/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using). /// [`L10n::using()`].
///
/// [`Context`]: crate::core::component::Context
/// [`global::SETTINGS.app.lang_negotiation`]: crate::global::App::lang_negotiation
/// [`L10n::lookup()`]: crate::locale::L10n::lookup
/// [`L10n::using()`]: crate::locale::L10n::using
impl LangId for Context { impl LangId for Context {
#[inline] #[inline]
fn langid(&self) -> &'static LanguageIdentifier { fn langid(&self) -> &'static LanguageIdentifier {
@ -536,14 +566,14 @@ impl Contextual for Context {
} }
#[builder_fn] #[builder_fn]
fn with_theme(mut self, theme: ThemeRef) -> Self { fn with_template(mut self, template: TemplateRef) -> Self {
self.theme = theme; self.template = template;
self self
} }
#[builder_fn] #[builder_fn]
fn with_template(mut self, template: TemplateRef) -> Self { fn with_theme(mut self, theme: ThemeRef) -> Self {
self.template = template; self.theme = theme;
self self
} }
@ -619,14 +649,14 @@ impl Contextual for Context {
&self.current_user &self.current_user
} }
fn theme(&self) -> ThemeRef {
self.theme
}
fn template(&self) -> TemplateRef { fn template(&self) -> TemplateRef {
self.template self.template
} }
fn theme(&self) -> ThemeRef {
self.theme
}
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> { fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
any.downcast_ref::<T>() any.downcast_ref::<T>()

View file

@ -96,7 +96,7 @@ use std::sync::Arc;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::new(None); /// # let cx = Context::default();
/// # let waypoint = Waypoint::default(); /// # let waypoint = Waypoint::default();
/// let route: Route = waypoint.append_to(cx.route("/items")).into(); /// let route: Route = waypoint.append_to(cx.route("/items")).into();
/// ``` /// ```

View file

@ -59,7 +59,7 @@ enum Source {
/// "#.to_string()); /// "#.to_string());
/// ///
/// // Script embebido con handler asíncrono (`async`) que puede usar `await`. /// // Script embebido con handler asíncrono (`async`) que puede usar `await`.
/// let mut cx = Context::new(None).with_param("user_id", 7u32); /// let mut cx = Context::default().with_param("user_id", 7u32);
/// ///
/// let js = JavaScript::on_load_async("hydrate", |cx| { /// let js = JavaScript::on_load_async("hydrate", |cx| {
/// // Ejemplo: lectura de un parámetro del contexto para inyectarlo en el código. /// // Ejemplo: lectura de un parámetro del contexto para inyectarlo en el código.

View file

@ -23,7 +23,7 @@ use crate::base::action;
use crate::base::component::layout; use crate::base::component::layout;
use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; use crate::core::component::{AssetsOp, ChildOp, ComponentRender};
use crate::core::component::{Context, ContextError, Contextual}; use crate::core::component::{Context, ContextError, Contextual};
use crate::core::theme::{CoreRegion, CoreTemplate, RegionName, RegionRef, TemplateRef, ThemeRef}; use crate::core::theme::{CoreRegion, RegionName, RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{Attr, Props, PropsOp}; use crate::html::{Attr, Props, PropsOp};
use crate::html::{DOCTYPE, Markup, html}; use crate::html::{DOCTYPE, Markup, html};
@ -104,7 +104,7 @@ impl Page {
/// usuario actual desde el momento en que se crea la página, sin llamadas adicionales. /// usuario actual desde el momento en que se crea la página, sin llamadas adicionales.
pub fn new(request: HttpRequest) -> Self { pub fn new(request: HttpRequest) -> Self {
Page { Page {
context: Context::new(Some(request)), context: Context::new(request),
..Default::default() ..Default::default()
} }
} }
@ -114,9 +114,11 @@ impl Page {
/// Cada tema puede maquetarla de forma distinta capturando /// Cada tema puede maquetarla de forma distinta capturando
/// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la /// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la
/// plantilla en sí es la misma constante para cualquier tema. /// plantilla en sí es la misma constante para cualquier tema.
///
/// [`CoreTemplate::Admin`]: crate::core::theme::CoreTemplate::Admin
pub fn admin(request: HttpRequest) -> Self { pub fn admin(request: HttpRequest) -> Self {
Page { Page {
context: Context::new(Some(request)).with_template(&CoreTemplate::Admin), context: Context::admin(request),
..Default::default() ..Default::default()
} }
} }
@ -154,22 +156,22 @@ impl Page {
// **< Page GETTERS >*************************************************************************** // **< Page GETTERS >***************************************************************************
/// Devuelve el título traducido para el idioma de la página, si existe. /// Devuelve el título traducido para el idioma de la página, si existe.
pub fn title(&mut self) -> Option<String> { pub fn title(&self) -> Option<String> {
self.title.lookup(&self.context) self.title.lookup(&self.context)
} }
/// Devuelve la descripción traducida para el idioma de la página, si existe. /// Devuelve la descripción traducida para el idioma de la página, si existe.
pub fn description(&mut self) -> Option<String> { pub fn description(&self) -> Option<String> {
self.description.lookup(&self.context) self.description.lookup(&self.context)
} }
/// Devuelve la lista de metadatos `<meta name=...>`. /// Devuelve la lista de metadatos `<meta name=...>`.
pub fn metadata(&self) -> &Vec<(&str, &str)> { pub fn metadata(&self) -> &Vec<(&'static str, &'static str)> {
&self.metadata &self.metadata
} }
/// Devuelve la lista de propiedades `<meta property=...>`. /// Devuelve la lista de propiedades `<meta property=...>`.
pub fn properties(&self) -> &Vec<(&str, &str)> { pub fn properties(&self) -> &Vec<(&'static str, &'static str)> {
&self.properties &self.properties
} }
@ -281,14 +283,14 @@ impl Contextual for Page {
} }
#[builder_fn] #[builder_fn]
fn with_theme(mut self, theme: ThemeRef) -> Self { fn with_template(mut self, template: TemplateRef) -> Self {
self.context.alter_theme(theme); self.context.alter_template(template);
self self
} }
#[builder_fn] #[builder_fn]
fn with_template(mut self, template: TemplateRef) -> Self { fn with_theme(mut self, theme: ThemeRef) -> Self {
self.context.alter_template(template); self.context.alter_theme(theme);
self self
} }
@ -332,14 +334,14 @@ impl Contextual for Page {
self.context.current_user() self.context.current_user()
} }
fn theme(&self) -> ThemeRef {
self.context.theme()
}
fn template(&self) -> TemplateRef { fn template(&self) -> TemplateRef {
self.context.template() self.context.template()
} }
fn theme(&self) -> ThemeRef {
self.context.theme()
}
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> { fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
self.context.param(key) self.context.param(key)
} }

View file

@ -16,22 +16,41 @@ use std::any::Any;
/// Página de error asociada a un código de estado HTTP. /// 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. /// Este enumerado agrupa tipos esenciales de error que pueden devolverse como página HTML completa.
/// Cada variante encapsula la solicitud original ([`HttpRequest`]) y se corresponde con un código /// Cada variante encapsula la petición original si está disponible ([`HttpRequest`]), y se asocia a
/// de estado concreto. /// un código de estado concreto.
/// ///
/// Para cada error se construye una [`Page`] usando el tema activo, lo que permite personalizar la /// 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 (como /// plantilla y el contenido del mensaje con los métodos específicos del tema, como
/// [`Theme::error_403()`](crate::core::theme::Theme::error_403), /// [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`].
/// [`Theme::error_404()`](crate::core::theme::Theme::error_404) o ///
/// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)). /// Sin `request` (`None`), la página se renderiza igualmente, pero sin el idioma negociado ni el
/// usuario actual, que dependen de la petición original.
///
/// [`Theme::error_403()`]: crate::core::theme::Theme::error_403
/// [`Theme::error_404()`]: crate::core::theme::Theme::error_404
/// [`Theme::error_fatal()`]: crate::core::theme::Theme::error_fatal
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum ErrorPage { pub enum ErrorPage {
BadRequest(HttpRequest), /// Petición incorrecta (400). El servidor no puede procesar la petición tal y como está
AccessDenied(HttpRequest), /// formulada (datos malformados, parámetros inválidos, etc.).
NotFound(HttpRequest), BadRequest(Option<HttpRequest>),
InternalError(HttpRequest), /// Acceso denegado (403). El usuario actual no tiene permiso para acceder al recurso.
ServiceUnavailable(HttpRequest), ///
GatewayTimeout(HttpRequest), /// Se renderiza con [`Theme::error_403()`](crate::core::theme::Theme::error_403).
AccessDenied(Option<HttpRequest>),
/// Recurso no encontrado (404). La ruta solicitada no existe o no coincide con ningún handler.
///
/// Se renderiza con [`Theme::error_404()`](crate::core::theme::Theme::error_404).
NotFound(Option<HttpRequest>),
/// Error interno del servidor (500). Un fallo controlado (no un `panic!`) impide completar la
/// petición.
InternalError(Option<HttpRequest>),
/// Servicio no disponible (503). El servidor no puede atender la petición temporalmente
/// (mantenimiento, sobrecarga, etc.).
ServiceUnavailable(Option<HttpRequest>),
/// Tiempo de espera agotado (504). Una dependencia externa (proxy, servicio remoto) no ha
/// respondido a tiempo.
GatewayTimeout(Option<HttpRequest>),
} }
impl ErrorPage { impl ErrorPage {
@ -53,12 +72,12 @@ impl ErrorPage {
let status = self.status_code(); let status = self.status_code();
let mut page = match self { let mut page = match self {
Self::AccessDenied(request) => { Self::AccessDenied(request) => {
let mut page = Page::new(request); let mut page = Page::default().with_request(request);
page.theme().error_403(&mut page); page.theme().error_403(&mut page);
page page
} }
Self::NotFound(request) => { Self::NotFound(request) => {
let mut page = Page::new(request); let mut page = Page::default().with_request(request);
page.theme().error_404(&mut page); page.theme().error_404(&mut page);
page page
} }
@ -66,7 +85,7 @@ impl ErrorPage {
| Self::InternalError(request) | Self::InternalError(request)
| Self::ServiceUnavailable(request) | Self::ServiceUnavailable(request)
| Self::GatewayTimeout(request) => { | Self::GatewayTimeout(request) => {
let mut page = Page::new(request); let mut page = Page::default().with_request(request);
page.theme().error_fatal( page.theme().error_fatal(
&mut page, &mut page,
status, status,
@ -84,6 +103,9 @@ impl ErrorPage {
rendered.into_string(), rendered.into_string(),
) )
.into_response(), .into_response(),
// Si renderizar la propia página de error falla, se descarta el `ErrorPage` resultante
// en vez de intentar renderizarlo de nuevo, para no arriesgarse a una recursión si el
// fallo persiste.
Err(_) => status.into_response(), Err(_) => status.into_response(),
} }
} }
@ -104,7 +126,7 @@ impl IntoResponse for ErrorPage {
// //
// Se registra como `.fallback()` del router principal desde [`Application`](crate::Application). // Se registra como `.fallback()` del router principal desde [`Application`](crate::Application).
pub(crate) async fn route_not_found(request: HttpRequest) -> Result<Markup, ErrorPage> { pub(crate) async fn route_not_found(request: HttpRequest) -> Result<Markup, ErrorPage> {
Err(ErrorPage::NotFound(request)) Err(ErrorPage::NotFound(Some(request)))
} }
// Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML. // Intercepta respuestas con un [`ErrorPage`] pendiente y las convierte en páginas HTML.

View file

@ -39,7 +39,7 @@ async fn current_user_propagates_from_request_extensions() {
display_name: "Bob".to_owned(), display_name: "Bob".to_owned(),
}) })
.to_http_request(); .to_http_request();
let cx = Context::new(Some(req)); let cx = Context::new(req);
let user = cx.current_user(); let user = cx.current_user();
assert!(user.is_authenticated()); assert!(user.is_authenticated());
assert_eq!(user.id(), Some(7)); assert_eq!(user.id(), Some(7));

View file

@ -50,7 +50,7 @@ async fn component_html_can_access_request_path() {
let req = web::test::TestRequest::get() let req = web::test::TestRequest::get()
.uri("/hello/world") .uri("/hello/world")
.to_http_request(); .to_http_request();
let mut cx = Context::new(Some(req)); let mut cx = Context::new(req);
let mut component = Html::with(|cx| { let mut component = Html::with(|cx| {
let path = cx let path = cx

View file

@ -46,10 +46,10 @@ impl Theme for MarkerTheme {
#[pagetop::test] #[pagetop::test]
async fn default_template_identity_is_independent_of_theme() { async fn default_template_identity_is_independent_of_theme() {
let cx = Context::new(None); let cx = Context::default();
assert_eq!(cx.template().name(), "standard"); assert_eq!(cx.template().name(), "standard");
let cx = Context::new(None).with_theme(&MarkerTheme); let cx = Context::default().with_theme(&MarkerTheme);
assert_eq!(cx.template().name(), "standard"); assert_eq!(cx.template().name(), "standard");
} }
@ -67,7 +67,7 @@ async fn admin_template_identity_is_independent_of_theme() {
async fn explicit_template_is_not_overridden_by_a_later_with_theme() { async fn explicit_template_is_not_overridden_by_a_later_with_theme() {
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called // A template explicitly set with `with_template()` prevails even if `with_theme()` is called
// afterwards. // afterwards.
let cx = Context::new(None) let cx = Context::default()
.with_template(&CoreTemplate::Admin) .with_template(&CoreTemplate::Admin)
.with_theme(&pagetop::base::theme::Basic); .with_theme(&pagetop::base::theme::Basic);

View file

@ -3,7 +3,7 @@ use pagetop::prelude::*;
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in // 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. // the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
fn cx_with_lang(lang: &str) -> Context { fn cx_with_lang(lang: &str) -> Context {
Context::new(None).with_langid(&Locale::resolve(lang)) Context::default().with_langid(&Locale::resolve(lang))
} }
// **< Route - automatic detection of external URLs >*********************************************** // **< Route - automatic detection of external URLs >***********************************************
@ -107,7 +107,7 @@ async fn route_with_captures_dynamic_values_from_the_environment() {
#[pagetop::test] #[pagetop::test]
async fn route_default_resolves_to_an_empty_path() { async fn route_default_resolves_to_an_empty_path() {
let cx = Context::new(None); let cx = Context::default();
assert_eq!(Route::default().resolve(&cx).to_string(), ""); assert_eq!(Route::default().resolve(&cx).to_string(), "");
} }