(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.
//!
//! Define el tipo mínimo [`CurrentUser`] que PageTop inyecta en el [`Context`] de cada petición.
//! Incluye también la acción [`CheckPermission`] para que las extensiones puedan implementar sus
//! propios modelos de permisos, y la función auxiliar [`has_permission()`].
//! Define el tipo [`CurrentUser`] que PageTop inyecta en el [`Context`] con la información mínima
//! sobre el usuario que ejecuta la petición actual ([`HttpRequest`]).
//!
//! 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
//! (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
//! decide si existe y, si es así, lo aplica dentro de su propio handler [`CheckPermission`].
//! como "administrador" que tiene todos los permisos no es responsabilidad de PageTop: cada
//! 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::component::Context;
use crate::{UniqueId, Weight};
use crate::locale::L10n;
use crate::response::ErrorPage;
use crate::web::HttpRequest;
use crate::{CowStr, UniqueId, Weight};
// **< CurrentUser >********************************************************************************
/// 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
/// [`Context::new()`](crate::core::component::Context::new)). La identidad se extrae de las
/// extensiones de la petición, que una extensión de autenticación inyecta mediante su middleware.
/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se
/// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su
/// 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
/// 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)]
pub enum CurrentUser {
/// 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 >****************************************************************************
/// Tipo de función para comprobar si el usuario actual tiene un permiso concreto.
///
/// Se invoca con:
///
/// - `cx`: el contexto de renderizado desde el que se puede acceder a la petición HTTP y a
/// cualquier dato inyectado por el middleware de autenticación.
/// - `key`: clave del permiso a comprobar (p. ej. `"myapp.edit_posts"`).
/// - `request`: petición HTTP desde la que se accede a los datos inyectados por el middleware de
/// autenticación.
/// - `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.
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.
///
/// Las extensiones de autenticación registran handlers de esta acción para implementar su modelo de
/// permisos. Los handlers son aditivos: si cualquiera de ellos asigna `granted = true`, el permiso
/// se concede.
/// Las extensiones de autenticación pueden registrar su handler sobre esta acción para implementar
/// su modelo de permisos. Los handlers son aditivos de tal forma que si cualquiera de ellos asigna
/// `granted = true`, el permiso se concede.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// fn check_my_permissions(cx: &Context, key: &str, granted: &mut bool) {
/// // Leer datos extendidos de autenticación desde las extensiones de la petición.
/// // Si el usuario tiene el permiso, asignar `*granted = true`.
/// fn check_my_permissions(request: &HttpRequest, perm: PermissionRef, granted: &mut bool) {
/// // Leer los datos extendidos de autenticación inyectados en la petición.
/// // Comparar `perm.key()` contra el modelo propio.
/// // Si concede el permiso, asignar `*granted = true`.
/// }
///
/// pub struct MyAuth;
@ -104,7 +179,7 @@ pub type FnCheckPermission = fn(cx: &Context, key: &str, granted: &mut bool);
/// }
/// ```
pub struct CheckPermission {
f: FnCheckPermission,
f: FnActionCheckPerm,
weight: Weight,
}
@ -116,7 +191,7 @@ impl ActionDispatcher for CheckPermission {
impl CheckPermission {
/// 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 }
}
@ -128,12 +203,12 @@ impl CheckPermission {
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
#[inline]
pub(crate) fn check(cx: &Context, key: &str) -> bool {
pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool {
let mut granted = false;
try_dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| {
(action.f)(cx, key, &mut granted);
(action.f)(request, perm, &mut granted);
if granted {
std::ops::ControlFlow::Break(())
} else {
@ -160,14 +235,62 @@ impl CheckPermission {
///
/// ```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> {
/// let mut page = Page::new(request.clone());
/// if !has_permission(page.context(), "myapp.edit") {
/// return Err(ErrorPage::NotFound(request));
/// if !has_permission(&request, &MyPermission::Edit) {
/// return Err(ErrorPage::NotFound(Some(request)));
/// }
/// page.render().await
/// Page::new(request).render().await
/// }
/// ```
pub fn has_permission(cx: &Context, key: &str) -> bool {
CheckPermission::check(cx, key)
pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
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:
///
/// - 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
/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`].
/// - Leer y mantener **parámetros dinámicos tipados** de contexto.
@ -77,8 +77,8 @@ pub enum ContextError {
/// # use pagetop_aliner::Aliner;
/// fn prepare_context<C: Contextual>(cx: C) -> C {
/// cx.with_langid(&Locale::resolve("es-ES"))
/// .with_theme(&Aliner)
/// .with_template(&CoreTemplate::Standard)
/// .with_theme(&Aliner)
/// .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")))
@ -93,17 +93,23 @@ pub trait Contextual: LangId {
fn with_langid(self, language: &impl LangId) -> Self;
/// 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]
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.
#[builder_fn]
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.
///
/// 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
/// # use pagetop::prelude::*;
/// let cx = Context::new(None)
/// let cx = Context::default()
/// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string())
/// .with_param("flags", vec!["a", "b"]);
@ -165,12 +171,12 @@ pub trait Contextual: LangId {
/// ```
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.
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.
///
/// Devuelve:
@ -183,7 +189,7 @@ pub trait Contextual: LangId {
///
/// ```rust
/// # use pagetop::prelude::*;
/// let cx = Context::new(None)
/// let cx = Context::default()
/// .with_param("user_id", 42_i32)
/// .with_param("title", "Hello".to_string());
///
@ -230,7 +236,7 @@ pub trait Contextual: LangId {
///
/// ```rust
/// # 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")); // ya no existe
/// ```
@ -243,7 +249,7 @@ pub trait Contextual: LangId {
/// [`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
/// ([`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
/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param).
@ -267,21 +273,21 @@ pub trait Contextual: LangId {
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_aliner::Aliner;
/// fn new_context(request: HttpRequest) -> Context {
/// Context::new(Some(request))
/// // Establece el idioma del documento a español.
/// .with_langid(&Locale::resolve("es-ES"))
/// // Establece el tema para renderizar.
/// .with_theme(&Aliner)
/// // Asigna un favicon.
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// // Añade una hoja de estilo externa.
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css")))
/// // Añade un script JavaScript.
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js")))
/// // Añade un parámetro dinámico al contexto.
/// .with_param("user_id", 42)
/// }
/// # fn new_context(request: HttpRequest) -> Context {
/// let cx = Context::new(request)
/// // Establece el idioma del documento a español.
/// .with_langid(&Locale::resolve("es-ES"))
/// // Establece el tema para renderizar.
/// .with_theme(&Aliner)
/// // Asigna un favicon.
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// // Añade una hoja de estilo externa.
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/style.css")))
/// // Añade un script JavaScript.
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js")))
/// // Añade un parámetro dinámico al contexto.
/// .with_param("user_id", 42);
/// # cx }
/// ```
///
/// Y hace operaciones con un contexto dado:
@ -309,8 +315,8 @@ pub struct Context {
request : Option<HttpRequest>, // 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.
theme : ThemeRef, // Referencia al tema usado para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
preloads : Assets<Preload>, // Recursos para precarga.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
@ -324,25 +330,25 @@ pub struct Context {
impl Default for Context {
fn default() -> Self {
Context::new(None)
Self::base(None, &CoreTemplate::Standard)
}
}
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.
// 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
// `Default::default()`, o viceversa). Recibe la plantilla para que `admin()` no tenga que
// construir con la plantilla estándar y sobrescribirla después.
#[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 current_user = Self::resolve_current_user(request.as_ref());
Context {
request,
locale,
current_user,
template,
theme : *DEFAULT_THEME,
template : &CoreTemplate::Standard,
favicon : None,
preloads : Assets::<Preload>::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
// `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa.
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
@ -483,7 +508,7 @@ impl Context {
///
/// ```rust,no_run
/// # 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"));
/// ```
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
/// 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
/// permite que el [`Context`] se use como fuente de idioma coherente en
/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using).
/// permite que el [`Context`] se use como fuente de idioma coherente en [`L10n::lookup()`] o
/// [`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 {
#[inline]
fn langid(&self) -> &'static LanguageIdentifier {
@ -536,14 +566,14 @@ impl Contextual for Context {
}
#[builder_fn]
fn with_theme(mut self, theme: ThemeRef) -> Self {
self.theme = theme;
fn with_template(mut self, template: TemplateRef) -> Self {
self.template = template;
self
}
#[builder_fn]
fn with_template(mut self, template: TemplateRef) -> Self {
self.template = template;
fn with_theme(mut self, theme: ThemeRef) -> Self {
self.theme = theme;
self
}
@ -619,14 +649,14 @@ impl Contextual for Context {
&self.current_user
}
fn theme(&self) -> ThemeRef {
self.theme
}
fn template(&self) -> TemplateRef {
self.template
}
fn theme(&self) -> ThemeRef {
self.theme
}
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
any.downcast_ref::<T>()

View file

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

View file

@ -59,7 +59,7 @@ enum Source {
/// "#.to_string());
///
/// // 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| {
/// // 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::core::component::{AssetsOp, ChildOp, ComponentRender};
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::{Attr, Props, PropsOp};
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.
pub fn new(request: HttpRequest) -> Self {
Page {
context: Context::new(Some(request)),
context: Context::new(request),
..Default::default()
}
}
@ -114,9 +114,11 @@ impl Page {
/// Cada tema puede maquetarla de forma distinta capturando
/// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la
/// plantilla en sí es la misma constante para cualquier tema.
///
/// [`CoreTemplate::Admin`]: crate::core::theme::CoreTemplate::Admin
pub fn admin(request: HttpRequest) -> Self {
Page {
context: Context::new(Some(request)).with_template(&CoreTemplate::Admin),
context: Context::admin(request),
..Default::default()
}
}
@ -154,22 +156,22 @@ impl Page {
// **< Page GETTERS >***************************************************************************
/// 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)
}
/// 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)
}
/// 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
}
/// 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
}
@ -281,14 +283,14 @@ impl Contextual for Page {
}
#[builder_fn]
fn with_theme(mut self, theme: ThemeRef) -> Self {
self.context.alter_theme(theme);
fn with_template(mut self, template: TemplateRef) -> Self {
self.context.alter_template(template);
self
}
#[builder_fn]
fn with_template(mut self, template: TemplateRef) -> Self {
self.context.alter_template(template);
fn with_theme(mut self, theme: ThemeRef) -> Self {
self.context.alter_theme(theme);
self
}
@ -332,14 +334,14 @@ impl Contextual for Page {
self.context.current_user()
}
fn theme(&self) -> ThemeRef {
self.context.theme()
}
fn template(&self) -> TemplateRef {
self.context.template()
}
fn theme(&self) -> ThemeRef {
self.context.theme()
}
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
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.
///
/// 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
/// de estado concreto.
/// Cada variante encapsula la petición original si está disponible ([`HttpRequest`]), y se asocia a
/// un código 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 (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)).
/// plantilla y el contenido del mensaje con los métodos específicos del tema, como
/// [`Theme::error_403()`], [`Theme::error_404()`] o [`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)]
pub enum ErrorPage {
BadRequest(HttpRequest),
AccessDenied(HttpRequest),
NotFound(HttpRequest),
InternalError(HttpRequest),
ServiceUnavailable(HttpRequest),
GatewayTimeout(HttpRequest),
/// Petición incorrecta (400). El servidor no puede procesar la petición tal y como está
/// formulada (datos malformados, parámetros inválidos, etc.).
BadRequest(Option<HttpRequest>),
/// Acceso denegado (403). El usuario actual no tiene permiso para acceder al recurso.
///
/// 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 {
@ -53,12 +72,12 @@ impl ErrorPage {
let status = self.status_code();
let mut page = match self {
Self::AccessDenied(request) => {
let mut page = Page::new(request);
let mut page = Page::default().with_request(request);
page.theme().error_403(&mut page);
page
}
Self::NotFound(request) => {
let mut page = Page::new(request);
let mut page = Page::default().with_request(request);
page.theme().error_404(&mut page);
page
}
@ -66,7 +85,7 @@ impl ErrorPage {
| Self::InternalError(request)
| Self::ServiceUnavailable(request)
| Self::GatewayTimeout(request) => {
let mut page = Page::new(request);
let mut page = Page::default().with_request(request);
page.theme().error_fatal(
&mut page,
status,
@ -84,6 +103,9 @@ impl ErrorPage {
rendered.into_string(),
)
.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(),
}
}
@ -104,7 +126,7 @@ impl IntoResponse for ErrorPage {
//
// Se registra como `.fallback()` del router principal desde [`Application`](crate::Application).
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.

View file

@ -39,7 +39,7 @@ async fn current_user_propagates_from_request_extensions() {
display_name: "Bob".to_owned(),
})
.to_http_request();
let cx = Context::new(Some(req));
let cx = Context::new(req);
let user = cx.current_user();
assert!(user.is_authenticated());
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()
.uri("/hello/world")
.to_http_request();
let mut cx = Context::new(Some(req));
let mut cx = Context::new(req);
let mut component = Html::with(|cx| {
let path = cx

View file

@ -46,10 +46,10 @@ impl Theme for MarkerTheme {
#[pagetop::test]
async fn default_template_identity_is_independent_of_theme() {
let cx = Context::new(None);
let cx = Context::default();
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");
}
@ -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() {
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called
// afterwards.
let cx = Context::new(None)
let cx = Context::default()
.with_template(&CoreTemplate::Admin)
.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
// 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))
Context::default().with_langid(&Locale::resolve(lang))
}
// **< Route - automatic detection of external URLs >***********************************************
@ -107,7 +107,7 @@ async fn route_with_captures_dynamic_values_from_the_environment() {
#[pagetop::test]
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(), "");
}