(auth): Añade CurrentUser y sistema de permisos

- Nuevo módulo `auth` que exporta `CurrentUser`, `CheckPermission` y
  `has_permission()`.
- `Context` y `Page` exponen `current_user()` vía `Contextual`.
- `HttpRequest` accede a extensiones de middleware con `extension<T>()`.
- `Extension` incluye nuevo método `configure_middleware`.
- `try_dispatch_actions` para despachar acciones con control del flujo.
This commit is contained in:
Manuel Cillero 2026-07-02 20:52:13 +02:00
parent 3120fd9a6f
commit 0e96bcce64
22 changed files with 544 additions and 84 deletions

View file

@ -110,9 +110,10 @@ impl Application {
}
}
// Construye el router con las rutas de todas las extensiones habilitadas.
// Construye el router con las rutas y el middleware de todas las extensiones habilitadas.
fn build_router() -> Router {
let router = extension::all::configure_routes(Router::new());
let router = extension::all::configure_middleware(router);
router.fallback(route_not_found)
}

170
src/auth.rs Normal file
View file

@ -0,0 +1,170 @@
//! 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()`].
//!
//! 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.
use crate::core::action::{ActionDispatcher, ActionKey, try_dispatch_actions};
use crate::core::component::Context;
use crate::{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 accede con [`Contextual::current_user()`](crate::core::component::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).
#[derive(Clone, Debug)]
pub enum CurrentUser {
/// Usuario no autenticado.
Anonymous,
/// Usuario autenticado con su identificador y nombre visible.
Authenticated {
/// Identificador único del usuario en el sistema.
id: i32,
/// Nombre visible del usuario.
display_name: String,
},
}
impl CurrentUser {
/// Devuelve `true` si el usuario no está autenticado.
pub fn is_anonymous(&self) -> bool {
matches!(self, CurrentUser::Anonymous)
}
/// Devuelve `true` si el usuario está autenticado.
pub fn is_authenticated(&self) -> bool {
matches!(self, CurrentUser::Authenticated { .. })
}
/// Devuelve el identificador del usuario, o `None` si es anónimo.
pub fn id(&self) -> Option<i32> {
match self {
CurrentUser::Anonymous => None,
CurrentUser::Authenticated { id, .. } => Some(*id),
}
}
/// Devuelve el nombre visible del usuario, o `None` si es anónimo.
pub fn display_name(&self) -> Option<&str> {
match self {
CurrentUser::Anonymous => None,
CurrentUser::Authenticated { display_name, .. } => Some(display_name),
}
}
}
// **< 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"`).
/// - `granted`: referencia mutable; el *handler* debe asignarla a `true` si concede el permiso.
pub type FnCheckPermission = fn(cx: &Context, key: &str, 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.
///
/// # 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`.
/// }
///
/// pub struct MyAuth;
///
/// impl Extension for MyAuth {
/// fn actions(&self) -> Vec<ActionBox> {
/// actions![CheckPermission::new(check_my_permissions)]
/// }
/// }
/// ```
pub struct CheckPermission {
f: FnCheckPermission,
weight: Weight,
}
impl ActionDispatcher for CheckPermission {
fn weight(&self) -> Weight {
self.weight
}
}
impl CheckPermission {
/// Registra una nueva acción para la comprobación de permisos.
pub fn new(f: FnCheckPermission) -> Self {
CheckPermission { f, weight: 0 }
}
/// Opcional. Acciones con pesos más bajos se aplican antes. Se pueden usar valores negativos.
pub fn with_weight(mut self, value: Weight) -> Self {
self.weight = value;
self
}
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
#[inline]
pub(crate) fn check(cx: &Context, key: &str) -> bool {
let mut granted = false;
try_dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| {
(action.f)(cx, key, &mut granted);
if granted {
std::ops::ControlFlow::Break(())
} else {
std::ops::ControlFlow::Continue(())
}
},
);
granted
}
}
// **< has_permission >*****************************************************************************
/// Comprueba si el usuario actual tiene el permiso indicado.
///
/// Despacha la acción [`CheckPermission`]: cualquier extensión registrada puede conceder el permiso
/// asignando `granted = true` en su *handler*. Si no hay extensiones de autenticación activas,
/// devuelve `false` para cualquier usuario, incluido el anónimo.
///
/// La decisión de conceder o denegar permisos al usuario anónimo también es responsabilidad de cada
/// extensión.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// 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));
/// }
/// page.render()
/// }
/// ```
pub fn has_permission(cx: &Context, key: &str) -> bool {
CheckPermission::check(cx, key)
}

View file

@ -10,19 +10,16 @@ pub struct AfterRender<C: Component> {
weight: Weight,
}
/// Filtro para despachar [`FnActionWithComponent`] después de renderizar un componente `C`.
// Filtro para despachar `FnActionWithComponent` después de renderizar un componente `C`.
impl<C: Component> ActionDispatcher for AfterRender<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
/// Devuelve el identificador del componente.
fn referer_id(&self) -> Option<String> {
self.referer_id.clone()
}
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}

View file

@ -10,19 +10,16 @@ pub struct BeforeRender<C: Component> {
weight: Weight,
}
/// Filtro para despachar [`FnActionWithComponent`] antes de renderizar un componente `C`.
// Filtro para despachar `FnActionWithComponent` antes de renderizar un componente `C`.
impl<C: Component> ActionDispatcher for BeforeRender<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
/// Devuelve el identificador del componente.
fn referer_id(&self) -> Option<String> {
self.referer_id.clone()
}
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}

View file

@ -10,19 +10,16 @@ pub struct TransformMarkup<C: Component> {
weight: Weight,
}
/// Filtro para despachar [`FnActionTransformMarkup`] sobre el renderizado de un componente `C`.
// Filtro para despachar `FnActionTransformMarkup` sobre el renderizado de un componente `C`.
impl<C: Component> ActionDispatcher for TransformMarkup<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
/// Devuelve el identificador del componente.
fn referer_id(&self) -> Option<String> {
self.referer_id.clone()
}
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}

View file

@ -14,8 +14,8 @@ pub struct AfterRenderBody {
weight: Weight,
}
// Filtro para despachar `FnActionWithPage` después de renderizar el cuerpo de la página.
impl ActionDispatcher for AfterRenderBody {
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}
@ -35,8 +35,7 @@ impl AfterRenderBody {
}
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
#[inline]
pub(crate) fn dispatch(page: &mut Page) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None),

View file

@ -14,8 +14,8 @@ pub struct BeforeRenderBody {
weight: Weight,
}
// Filtro para despachar `FnActionWithPage` antes de renderizar el cuerpo de la página.
impl ActionDispatcher for BeforeRenderBody {
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}
@ -35,8 +35,7 @@ impl BeforeRenderBody {
}
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
#[inline]
pub(crate) fn dispatch(page: &mut Page) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None),

View file

@ -12,7 +12,7 @@ use list::ActionsList;
mod all;
pub(crate) use all::add_action;
pub use all::dispatch_actions;
pub use all::{dispatch_actions, try_dispatch_actions};
// **< actions! >***********************************************************************************

View file

@ -45,9 +45,9 @@ pub(crate) fn add_action(action: ActionBox) {
/// # Parámetros genéricos
///
/// - `A`: Tipo de acción que esperamos procesar. Debe implementar [`ActionDispatcher`].
/// - `F`: Función asociada a cada acción, devuelve un valor de tipo `B`.
/// - `F`: Función que se aplica para una acción dada.
///
/// # Ejemplo de uso
/// # Ejemplo
///
/// ```rust,ignore
/// pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
@ -61,12 +61,47 @@ pub(crate) fn add_action(action: ActionBox) {
/// );
/// }
/// ```
pub fn dispatch_actions<A, B, F>(key: &ActionKey, f: F)
pub fn dispatch_actions<A, F>(key: &ActionKey, f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> B,
F: FnMut(&A),
{
if let Some(list) = ACTIONS.read().get(key) {
list.iter_map(f);
list.for_each(f);
}
}
/// Despacha las funciones asociadas a una [`ActionKey`] con posible salida anticipada.
///
/// Funciona igual que [`dispatch_actions`], pero el *closure* puede devolver
/// [`std::ops::ControlFlow::Continue`] para continuar ejecutando la siguiente acción; o
/// [`std::ops::ControlFlow::Break`] para detener la iteración inmediatamente.
///
/// # Ejemplo
///
/// ```rust,ignore
/// pub(crate) fn check(cx: &Context, key: &str) -> bool {
/// let mut granted = false;
/// try_dispatch_actions(
/// &ActionKey::new(UniqueId::of::<Self>(), None, None),
/// |action: &Self| {
/// (action.f)(cx, key, &mut granted);
/// if granted {
/// std::ops::ControlFlow::Break(())
/// } else {
/// std::ops::ControlFlow::Continue(())
/// }
/// },
/// );
/// granted
/// }
/// ```
pub fn try_dispatch_actions<A, F>(key: &ActionKey, f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> std::ops::ControlFlow<()>,
{
if let Some(list) = ACTIONS.read().get(key) {
list.try_for_each(f);
}
}

View file

@ -45,17 +45,17 @@ impl ActionKey {
/// Las acciones tienen que sobrescribir los métodos para el filtro que apliquen. Por defecto
/// implementa un filtro nulo.
pub trait ActionDispatcher: AnyInfo + Send + Sync {
/// Identificador de tipo ([`UniqueId`]) del objeto referido. En este caso devuelve `None`.
/// Devuelve el identificador de tipo ([`UniqueId`]) del objeto referido.
fn referer_type_id(&self) -> Option<UniqueId> {
None
}
/// Identificador del objeto referido. En este caso devuelve `None`.
/// Devuelve el identificador del objeto referido.
fn referer_id(&self) -> Option<String> {
None
}
/// Funciones con pesos más bajos se aplican antes. En este caso siempre devuelve `0`.
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
0
}

View file

@ -19,24 +19,35 @@ impl ActionsList {
list.sort_by_key(|a| a.weight());
}
pub fn iter_map<A, B, F>(&self, mut f: F)
pub fn for_each<A, F>(&self, mut f: F)
where
Self: Sized,
A: ActionDispatcher,
F: FnMut(&A) -> B,
F: FnMut(&A),
{
let _: Vec<_> = self
.0
.read()
.iter()
.rev()
.map(|a| {
let list = self.0.read();
for a in list.iter().rev() {
if let Some(action) = (**a).downcast_ref::<A>() {
f(action);
} else {
trace::error!("Failed to downcast action of type {}", (**a).type_name());
}
})
.collect();
}
}
pub fn try_for_each<A, F>(&self, mut f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> std::ops::ControlFlow<()>,
{
let list = self.0.read();
for a in list.iter().rev() {
if let Some(action) = (**a).downcast_ref::<A>() {
if f(action).is_break() {
break;
}
} else {
trace::error!("Failed to downcast action of type {}", (**a).type_name());
}
}
}
}

View file

@ -1,3 +1,4 @@
use crate::auth::CurrentUser;
use crate::core::TypeInfo;
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
use crate::core::theme::all::DEFAULT_THEME;
@ -156,6 +157,26 @@ pub trait Contextual: LangId {
/// Devuelve una referencia a la petición HTTP asociada, si existe.
fn request(&self) -> Option<&HttpRequest>;
/// Devuelve la identidad del usuario actual.
///
/// Si ninguna extensión de autenticación ha inyectado un
/// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve
/// `&CurrentUser::Anonymous`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// async fn greet(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// let mut page = Page::new(request);
/// if page.current_user().is_authenticated() {
/// // Personalizar la página para el usuario autenticado.
/// }
/// page.render()
/// }
/// ```
fn current_user(&self) -> &CurrentUser;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
@ -286,6 +307,7 @@ pub trait Contextual: LangId {
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.
favicon : Option<Favicon>, // Favicon, si se ha definido.
@ -312,9 +334,11 @@ impl Context {
#[rustfmt::skip]
pub fn new(request: Option<HttpRequest>) -> Self {
let locale = RequestLocale::from_request(request.as_ref());
let current_user = Self::resolve_current_user(request.as_ref());
Context {
request,
locale,
current_user,
theme : *DEFAULT_THEME,
template : DEFAULT_THEME.default_template(),
favicon : None,
@ -328,6 +352,15 @@ impl Context {
}
}
// 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 {
request
.and_then(|r| r.extension::<CurrentUser>())
.cloned()
.unwrap_or(CurrentUser::Anonymous)
}
// **< Context RENDER >*************************************************************************
/// Renderiza los recursos del contexto.
@ -421,9 +454,9 @@ impl Context {
}
}
/// Acumula un [`StatusMessage`] en el contexto para notificar al visitante.
/// Acumula un [`StatusMessage`] en el contexto para notificar al usuario.
///
/// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado,
/// Pueden generarse en cualquier punto del ciclo de una petición web (*handlers*, renderizado,
/// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en
/// la página final devuelta al usuario.
///
@ -470,8 +503,10 @@ impl Contextual for Context {
#[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
// Recalcula el locale según la nueva petición y la política de negociación configurada.
// Recalcula el *locale* y el usuario actual según la nueva petición y la política de
// negociación configurada.
self.locale = RequestLocale::from_request(self.request.as_ref());
self.current_user = Self::resolve_current_user(self.request.as_ref());
self
}
@ -555,6 +590,10 @@ impl Contextual for Context {
self.request.as_ref()
}
fn current_user(&self) -> &CurrentUser {
&self.current_user
}
fn theme(&self) -> ThemeRef {
self.theme
}

View file

@ -4,7 +4,7 @@ use crate::core::theme::ThemeRef;
use crate::core::{AnyInfo, TypeInfo};
use crate::html::{Markup, html};
/// Permite clonar un componente.
/// Habilita el clonado de componentes.
///
/// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. El método
/// [`clone_box`](Self::clone_box) devuelve una copia en la *pila* del componente original, lo que

View file

@ -17,7 +17,7 @@ pub enum MessageLevel {
///
/// Representa un mensaje con carácter informativo, una advertencia o un error. A diferencia de
/// [`ComponentError`](super::ComponentError), no está ligado a un fallo interno de renderizado,
/// puede generarse en cualquier punto del procesamiento de una petición web (manejadores,
/// puede generarse en cualquier punto del procesamiento de una petición web (*handlers*,
/// renderizado, lógica de negocio, etc.).
///
/// El texto se almacena como [`L10n`] para resolverse con el idioma del contexto en el momento de

View file

@ -99,3 +99,17 @@ pub fn configure_routes(router: Router) -> Router {
router
}
// **< CONFIGURA EL MIDDLEWARE GLOBAL >*************************************************************
/// Aplica las capas de *middleware* globales de todas las extensiones sobre el router ya enrutado.
///
/// Se llama después de [`configure_routes`] para garantizar que las capas envuelven todas las
/// rutas de la aplicación, independientemente del orden de las extensiones.
pub fn configure_middleware(router: Router) -> Router {
EXTENSIONS
.get()
.into_iter()
.flatten()
.fold(router, |r, e| e.configure_middleware(r))
}

View file

@ -100,7 +100,6 @@ pub trait Extension: AnyInfo + Send + Sync {
/// | Ruta HTTP | `.route("/path", web::get(handler))` |
/// | Rutas bajo prefijo común | `.nest("/prefix", sub_router)` |
/// | Archivos estáticos | `serve_static_files!(router, [...] => "/path")` |
/// | Capa de *middleware* | `.layer(some_layer)` |
/// | Estado compartido entre *handlers* | `.with_state(my_state)` |
///
/// # Ejemplos
@ -143,7 +142,16 @@ pub trait Extension: AnyInfo + Send + Sync {
/// }
/// ```
///
/// ## Rutas con capa de *middleware*
/// ## Rutas con *middleware* acotado a esta extensión
///
/// Cada `Router` mantiene su propia pila de *middleware* independiente. Cuando se crea un
/// `Router::new()` separado y se llama a `.layer()` sobre él, esa capa sólo se aplica a las
/// rutas de ese *router* concreto. Al fusionarlo con `.merge()` en el `router` principal, cada
/// ruta se añade con su *middleware* asociado, sin tocar las demás.
///
/// Otra cosa es llamar a `.layer()` directamente sobre el `router` principal que se recibe como
/// parámetro, porque ese objeto ya contiene todas las rutas acumuladas por extensiones
/// anteriores, y la capa se aplica a todas las rutas, no sólo a las propias.
///
/// ```rust,ignore
/// # use pagetop::prelude::*;
@ -151,13 +159,17 @@ pub trait Extension: AnyInfo + Send + Sync {
///
/// impl Extension for Api {
/// fn configure_router(&self, router: Router) -> Router {
/// router
/// let api = Router::new()
/// .route("/api/data", web::get(get_data))
/// .layer(auth_layer())
/// .layer(auth_layer());
/// router.merge(api)
/// }
/// }
/// ```
///
/// Para *middleware* que deba cubrir **todas** las rutas, usar
/// [`configure_middleware`](Self::configure_middleware).
///
/// ## Archivos estáticos
///
/// La macro [`serve_static_files!`](crate::serve_static_files) sombrea `router` internamente,
@ -177,6 +189,36 @@ pub trait Extension: AnyInfo + Send + Sync {
fn configure_router(&self, router: Router) -> Router {
router
}
/// Añade capas de *middleware* globales al *router* ya completamente preparado.
///
/// Se invoca **después** de que todas las extensiones hayan registrado sus rutas con
/// [`configure_router`](Self::configure_router), de modo que las capas añadidas aquí se aplican
/// a **todas** las rutas de la aplicación, independientemente del orden de las extensiones.
///
/// Usar este método cuando el *middleware* deba interceptar cualquier petición entrante (p. ej.
/// resolución de sesión, autenticación, cabeceras de seguridad, ...).
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use axum::middleware;
/// # async fn session_middleware(
/// # req: axum::extract::Request,
/// # next: middleware::Next,
/// # ) -> axum::response::Response { next.run(req).await }
/// pub struct MyAuth;
///
/// impl Extension for MyAuth {
/// fn configure_middleware(&self, router: Router) -> Router {
/// router.layer(middleware::from_fn(session_middleware))
/// }
/// }
/// ```
fn configure_middleware(&self, router: Router) -> Router {
router
}
}
/// Representa una referencia a una extensión.

View file

@ -15,7 +15,7 @@ use crate::{AutoDefault, CowStr, Weight, util};
/// ejecuta en cuanto esté listo, **sin garantizar** el orden relativo respecto a otros scripts.
/// - [`Inline`] - Inserta el código directamente en la etiqueta `<script>`.
/// - [`OnLoad`] - Inserta el código JavaScript y lo ejecuta tras el evento `DOMContentLoaded`.
/// - [`OnLoadAsync`] - Igual que [`OnLoad`], pero con manejador asíncrono (`async`), útil si dentro
/// - [`OnLoadAsync`] - Igual que [`OnLoad`], pero con *handler* asíncrono (`async`), útil si dentro
/// del código JavaScript se utiliza `await`.
#[derive(AutoDefault)]
enum Source {
@ -27,7 +27,7 @@ enum Source {
Inline(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
/// `name`, `closure(&mut Context) -> String` (se ejecuta tras `DOMContentLoaded`).
OnLoad(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
/// `name`, `closure(&mut Context) -> String` (manejador `async` tras `DOMContentLoaded`).
/// `name`, `closure(&mut Context) -> String` (*handler* `async` tras `DOMContentLoaded`).
OnLoadAsync(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
}
@ -58,7 +58,7 @@ enum Source {
/// }
/// "#.to_string());
///
/// // Script embebido con manejador 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 js = JavaScript::on_load_async("hydrate", |cx| {
@ -150,7 +150,7 @@ impl JavaScript {
}
}
/// Crea un **script embebido** con un **manejador asíncrono**.
/// Crea un **script embebido** con un ***handler* asíncrono**.
///
/// El código se envuelve en un `addEventListener('DOMContentLoaded',async()=>{...})`, que
/// emplea una función `async` para que el cuerpo devuelto por la función *closure* pueda usar

View file

@ -196,6 +196,8 @@ pub mod locale;
pub mod datetime;
// Tipos y funciones esenciales para crear acciones, componentes, extensiones y temas.
pub mod core;
// Identidad del usuario y sistema de autorización extensible.
pub mod auth;
// Respuestas a peticiones web en sus diferentes formatos.
pub mod response;
// Gestión del servidor y rutas web.

View file

@ -45,6 +45,8 @@ pub use crate::core::component::*;
pub use crate::core::extension::*;
pub use crate::core::theme::*;
pub use crate::auth::*;
pub use crate::response::{json::*, page::*, redirect::*};
pub use crate::base::action;

View file

@ -16,6 +16,7 @@
mod error;
pub use error::ErrorPage;
use crate::auth::CurrentUser;
use crate::base::action;
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef};
@ -95,8 +96,10 @@ pub struct Page {
impl Page {
/// Crea una nueva instancia de página.
///
/// La petición HTTP se guardará en el contexto de renderizado de la página para poder ser
/// recuperada por los componentes si es necesario.
/// La petición HTTP se guarda en el contexto de renderizado, que extrae automáticamente el
/// [`CurrentUser`] inyectado por *middleware* en sus extensiones (ver
/// [`Context::new`](crate::core::component::Context::new)). Cualquier *handler* tiene acceso al
/// usuario actual desde el momento en que se crea la página, sin llamadas adicionales.
#[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self {
Page {
@ -316,6 +319,10 @@ impl Contextual for Page {
self.context.request()
}
fn current_user(&self) -> &CurrentUser {
self.context.current_user()
}
fn theme(&self) -> ThemeRef {
self.context.theme()
}

View file

@ -1,22 +1,20 @@
//! Servidor web y rutas de la aplicación (basado en [Axum](https://docs.rs/axum)).
//!
//! Define rutas y manejadores: el [`Router`], las operaciones HTTP ([`get`], [`post`], [`put`],
//! Define rutas y *handlers*: el [`Router`], las operaciones HTTP ([`get`], [`post`], [`put`],
//! [`delete`], [`patch`]), los extractores ([`Path`], [`Query`]) e [`IntoResponse`], y re-exporta
//! el módulo `http` para tipos de bajo nivel como `StatusCode`, `HeaderName` o `Method`. También
//! ofrece utilidades para servir archivos estáticos, [`ServeDir`] y [`ServeEmbedded`].
//!
//! Los *handlers* son las funciones asíncronas que el servidor invoca al recibir una petición
//! HTTP en una ruta concreta.
use crate::StaticFile;
use std::collections::HashMap;
use std::convert::Infallible;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::FromRequestParts;
#[doc(inline)]
pub use axum::http;
// Infraestructura del router.
pub use axum::Router;
pub use axum::http;
// Extractores de petición.
pub use axum::extract::{Path, Query};
@ -27,24 +25,47 @@ pub use axum::response::{IntoResponse, Response};
// Operaciones HTTP para registrar rutas.
pub use axum::routing::{delete, get, patch, post, put};
use axum::body::Body;
use axum::extract::FromRequestParts;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use std::task::{Context, Poll};
// **< HttpRequest >********************************************************************************
/// Representa una petición HTTP.
///
/// Almacena los datos necesarios para negociar el idioma y renderizar las páginas de error,
/// incluyendo la URI completa y las cabeceras de la petición original.
/// incluyendo la URI completa, las cabeceras de la petición original y las extensiones de tipo que
/// los *middlewares* pueden inyectar antes de que el *handler* se ejecute.
///
/// Puede declararse directamente como parámetro en un *handler* para pasarlo al
/// [`Context`](crate::core::component::Context) de renderizado y a las variantes de
/// [`ErrorPage`](crate::response::page::ErrorPage):
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// async fn my_handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// // ...
/// # todo!()
/// }
/// ```
///
/// Las extensiones inyectadas por *middleware* son accesibles vía [`extension`](Self::extension).
/// Por ejemplo, desde un *handler*, tras añadir `MyData` en un *middleware*:
///
/// ```rust,ignore
/// async fn my_handler(request: HttpRequest) -> Result<Markup, ErrorPage> { ... }
/// if let Some(data) = request.extension::<MyData>() {
/// // ...
/// }
/// ```
#[derive(Clone, Debug)]
pub struct HttpRequest {
uri: http::Uri,
headers: http::HeaderMap,
extensions: Arc<http::Extensions>,
}
impl HttpRequest {
@ -72,19 +93,35 @@ impl HttpRequest {
pub fn headers(&self) -> &http::HeaderMap {
&self.headers
}
/// Accede a un valor inyectado por *middleware* en las extensiones de la petición.
///
/// Devuelve una referencia al tipo `T` si algún *middleware* lo insertó antes de que el
/// *handler* recibiera la petición, o `None` en caso contrario.
///
/// El tipo debe implementar `Send + Sync + 'static` (requisito de [`http::Extensions`]).
pub fn extension<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.extensions.get::<T>()
}
}
impl<S: Send + Sync> FromRequestParts<S> for HttpRequest {
type Rejection = Infallible;
// Implementa el extractor de Axum para poder declarar `HttpRequest` como parámetro.
// Extrae la petición y toma las extensiones que han sido inyectadas por *middleware*. Las
// extensiones se mueven a un `Arc` compartido para que `HttpRequest` sea `Clone`.
//
// Nota: tras este extractor `parts.extensions` queda vacío; otros extractores que dependan de
// `Extension<T>` deben registrarse antes en la cadena del *handler*.
async fn from_request_parts(
parts: &mut http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let extensions = std::mem::take(&mut parts.extensions);
Ok(HttpRequest {
uri: parts.uri.clone(),
headers: parts.headers.clone(),
extensions: Arc::new(extensions),
})
}
}
@ -104,7 +141,7 @@ pub use tower_http::services::ServeDir;
/// devuelve el `index.html` raíz si existe; no busca por subdirectorio.
///
/// Implementa [`Clone`] para clonar el servicio por petición, pero internamente comparte el mapa de
/// recursos con un [`Arc`](std::sync::Arc) para evitar copias innecesarias.
/// recursos con un [`Arc`] para evitar copias innecesarias.
#[derive(Clone)]
pub struct ServeEmbedded {
files: std::sync::Arc<HashMap<&'static str, StaticFile>>,
@ -300,6 +337,7 @@ pub mod test {
pub struct TestRequest {
method: http::Method,
uri: String,
extensions: http::Extensions,
}
impl TestRequest {
@ -308,6 +346,7 @@ pub mod test {
Self {
method: http::Method::GET,
uri: "/".to_owned(),
extensions: http::Extensions::new(),
}
}
@ -316,6 +355,7 @@ pub mod test {
Self {
method: http::Method::POST,
uri: "/".to_owned(),
extensions: http::Extensions::new(),
}
}
@ -325,13 +365,24 @@ pub mod test {
self
}
/// Inserta un valor en las extensiones de la petición.
///
/// Útil para simular lo que un *middleware* haría en producción antes de que el *handler*
/// reciba la petición. El tipo debe implementar `Clone + Send + Sync + 'static`.
pub fn with_extension<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
self.extensions.insert(value);
self
}
/// Construye la petición HTTP de Axum (para enviar al router en tests de integración).
pub fn to_request(self) -> http::Request<Body> {
http::Request::builder()
let mut req = http::Request::builder()
.method(self.method)
.uri(self.uri)
.body(Body::empty())
.unwrap()
.unwrap();
*req.extensions_mut() = self.extensions;
req
}
/// Construye un [`HttpRequest`](super::HttpRequest) listo para pasarlo a
@ -341,6 +392,7 @@ pub mod test {
super::HttpRequest {
uri,
headers: axum::http::HeaderMap::new(),
extensions: std::sync::Arc::new(self.extensions),
}
}
}

96
tests/auth.rs Normal file
View file

@ -0,0 +1,96 @@
use pagetop::prelude::*;
// **< CurrentUser >********************************************************************************
#[pagetop::test]
async fn anonymous_reports_itself_correctly() {
let user = CurrentUser::Anonymous;
assert!(user.is_anonymous());
assert!(!user.is_authenticated());
assert_eq!(user.id(), None);
assert_eq!(user.display_name(), None);
}
#[pagetop::test]
async fn authenticated_reports_itself_correctly() {
let user = CurrentUser::Authenticated {
id: 42,
display_name: "Alice".to_owned(),
};
assert!(!user.is_anonymous());
assert!(user.is_authenticated());
assert_eq!(user.id(), Some(42));
assert_eq!(user.display_name(), Some("Alice"));
}
// **< Context::current_user() >********************************************************************
#[pagetop::test]
async fn current_user_defaults_to_anonymous() {
let cx = Context::default();
assert!(cx.current_user().is_anonymous());
}
#[pagetop::test]
async fn current_user_propagates_from_request_extensions() {
let req = web::test::TestRequest::get()
.with_extension(CurrentUser::Authenticated {
id: 7,
display_name: "Bob".to_owned(),
})
.to_http_request();
let cx = Context::new(Some(req));
let user = cx.current_user();
assert!(user.is_authenticated());
assert_eq!(user.id(), Some(7));
assert_eq!(user.display_name(), Some("Bob"));
}
// **< HttpRequest::extension() >*******************************************************************
#[pagetop::test]
async fn request_extension_returns_none_for_unknown_type() {
let req = web::test::TestRequest::get().to_http_request();
assert!(req.extension::<CurrentUser>().is_none());
}
#[pagetop::test]
async fn request_extension_returns_injected_value() {
let req = web::test::TestRequest::get()
.with_extension(CurrentUser::Authenticated {
id: 1,
display_name: "Carol".to_owned(),
})
.to_http_request();
let user = req
.extension::<CurrentUser>()
.expect("extension should exist");
assert!(user.is_authenticated());
assert_eq!(user.id(), Some(1));
assert_eq!(user.display_name(), Some("Carol"));
}
// **< Page::new() >********************************************************************************
#[pagetop::test]
async fn page_new_propagates_current_user_from_request_extensions() {
let req = web::test::TestRequest::get()
.with_extension(CurrentUser::Authenticated {
id: 5,
display_name: "Dave".to_owned(),
})
.to_http_request();
let page = Page::new(req);
let user = page.current_user();
assert!(user.is_authenticated());
assert_eq!(user.id(), Some(5));
assert_eq!(user.display_name(), Some("Dave"));
}
#[pagetop::test]
async fn page_new_uses_anonymous_when_no_extension() {
let req = web::test::TestRequest::get().to_http_request();
let page = Page::new(req);
assert!(page.current_user().is_anonymous());
}