✨ (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:
parent
ea0dc021ce
commit
747e64ce34
10 changed files with 297 additions and 120 deletions
187
src/auth.rs
187
src/auth.rs
|
|
@ -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())))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
/// ```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(), "");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue