feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-05 04:19:22 -05:00
parent b580a63540
commit faee6fdb25
145 changed files with 4050 additions and 12 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "el-aop"
version = "0.1.0"
edition = "2021"
description = "el-ui aspect-oriented programming — cross-cutting concerns as first-class features"
license = "MIT"
[lib]
name = "el_aop"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
+22
View File
@@ -0,0 +1,22 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Cross-cutting concerns as first-class language features. Decorators applied
// to components and methods. @authenticate is the default; @public is the
// explicit opt-out.
vessel "el-aop" {
version "0.1.0"
description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..."
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-auth "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+490
View File
@@ -0,0 +1,490 @@
//! Built-in aspects for el-ui.
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Mutex,
},
time::{Duration, Instant},
};
use crate::{AopError, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
// ── @authenticate ─────────────────────────────────────────────────────────────
/// `@authenticate` — Requires a valid session before the method executes.
///
/// Checks `ctx.metadata["session_token"]` or `ctx.metadata["user_id"]`.
/// If absent, rejects with `AopError::Unauthenticated`.
pub struct AuthenticateAspect;
impl Aspect for AuthenticateAspect {
fn name(&self) -> &'static str {
"authenticate"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
// Look for a session token or user ID in metadata.
// In production, the auth middleware populates these from the JWT/session.
let has_user = ctx.metadata.contains_key("user_id")
|| ctx.metadata.contains_key("session_token");
if !has_user {
return Err(AopError::Unauthenticated);
}
Ok(())
}
}
// ── @authorize ────────────────────────────────────────────────────────────────
/// `@authorize(role: "admin")` — Requires the caller to have a specific role.
pub struct AuthorizeAspect {
pub required_role: String,
}
impl AuthorizeAspect {
pub fn new(role: impl Into<String>) -> Self {
Self { required_role: role.into() }
}
}
impl Aspect for AuthorizeAspect {
fn name(&self) -> &'static str {
"authorize"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let user_roles = ctx
.metadata
.get("roles")
.map(|s| s.as_str())
.unwrap_or("");
// Roles are stored as comma-separated string: "admin,user"
let has_role = user_roles
.split(',')
.any(|r| r.trim() == self.required_role.as_str());
if !has_role {
return Err(AopError::Forbidden {
role: self.required_role.clone(),
});
}
Ok(())
}
}
// ── @cache ────────────────────────────────────────────────────────────────────
struct CacheEntry {
value: InvocationResult,
inserted_at: Instant,
ttl: Duration,
}
impl CacheEntry {
fn is_expired(&self) -> bool {
self.inserted_at.elapsed() > self.ttl
}
}
/// `@cache(ttl: 300)` — Cache method responses for `ttl` seconds.
///
/// Cache key is `"target::method::{args_sorted_json}"`.
pub struct CacheAspect {
pub ttl: Duration,
cache: Mutex<HashMap<String, CacheEntry>>,
}
impl CacheAspect {
pub fn new(ttl_seconds: u64) -> Self {
Self {
ttl: Duration::from_secs(ttl_seconds),
cache: Mutex::new(HashMap::new()),
}
}
fn cache_key(ctx: &InvocationContext) -> String {
let mut pairs: Vec<(&String, &String)> = ctx.args.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
let args = pairs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",");
format!("{}::{}::{}", ctx.target, ctx.method, args)
}
}
impl Aspect for CacheAspect {
fn name(&self) -> &'static str {
"cache"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
let key = Self::cache_key(&ctx);
// Check cache
{
let cache = self.cache.lock().expect("cache lock poisoned");
if let Some(entry) = cache.get(&key) {
if !entry.is_expired() {
return Ok(entry.value.clone());
}
}
}
// Cache miss — proceed and store result
let result = proceed(ctx)?;
{
let mut cache = self.cache.lock().expect("cache lock poisoned");
// Evict expired entries while we're here
cache.retain(|_, v| !v.is_expired());
cache.insert(
key,
CacheEntry {
value: result.clone(),
inserted_at: Instant::now(),
ttl: self.ttl,
},
);
}
Ok(result)
}
}
// ── @rate_limit ───────────────────────────────────────────────────────────────
struct RateWindow {
count: u32,
window_start: Instant,
window_duration: Duration,
}
/// `@rate_limit(requests: 100, per: 60)` — Allow at most `requests` calls per `per` seconds.
pub struct RateLimitAspect {
pub max_requests: u32,
pub window: Duration,
state: Mutex<HashMap<String, RateWindow>>,
}
impl RateLimitAspect {
pub fn new(max_requests: u32, per_seconds: u64) -> Self {
Self {
max_requests,
window: Duration::from_secs(per_seconds),
state: Mutex::new(HashMap::new()),
}
}
/// The rate-limit key for a caller. Uses `user_id` or "anonymous".
fn caller_key(ctx: &InvocationContext) -> String {
ctx.metadata
.get("user_id")
.cloned()
.unwrap_or_else(|| "anonymous".to_string())
}
}
impl Aspect for RateLimitAspect {
fn name(&self) -> &'static str {
"rate_limit"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let key = Self::caller_key(ctx);
let mut state = self.state.lock().expect("rate limit lock poisoned");
let now = Instant::now();
let window = state.entry(key).or_insert(RateWindow {
count: 0,
window_start: now,
window_duration: self.window,
});
// Reset window if expired
if now.duration_since(window.window_start) >= window.window_duration {
window.count = 0;
window.window_start = now;
}
if window.count >= self.max_requests {
return Err(AopError::RateLimited {
requests: self.max_requests,
per: self.window.as_secs(),
});
}
window.count += 1;
Ok(())
}
}
// ── @log ──────────────────────────────────────────────────────────────────────
/// `@log(level: "info")` — Structured logging for every method call.
pub struct LogAspect {
pub level: String,
}
impl LogAspect {
pub fn new(level: impl Into<String>) -> Self {
Self { level: level.into() }
}
}
impl Aspect for LogAspect {
fn name(&self) -> &'static str {
"log"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
// In production: use the `tracing` crate with the appropriate level macro.
let _log_entry = format!(
"[{}] {}.{}({:?})",
self.level.to_uppercase(),
ctx.target,
ctx.method,
ctx.args
);
let result = proceed(ctx.clone());
let _log_result = match &result {
Ok(r) => format!("[{}] {}.{} → ok: {}", self.level.to_uppercase(), ctx.target, ctx.method, r.value),
Err(e) => format!("[ERROR] {}.{} → err: {}", ctx.target, ctx.method, e),
};
result
}
}
// ── @validate ─────────────────────────────────────────────────────────────────
/// `@validate` — Run input validation before the method executes.
///
/// Validation rules are registered per method. If no rules are registered,
/// the aspect passes through (fail-open for ease of adoption).
pub struct ValidateAspect {
/// `"target::method"` → list of validation rules (field, rule_name)
rules: Mutex<HashMap<String, Vec<(String, String)>>>,
}
impl ValidateAspect {
pub fn new() -> Self {
Self {
rules: Mutex::new(HashMap::new()),
}
}
/// Add a validation rule. `rule` is one of: "required", "email", "min:N", "max:N".
pub fn add_rule(
&self,
target: &str,
method: &str,
field: impl Into<String>,
rule: impl Into<String>,
) {
let key = format!("{}::{}", target, method);
self.rules
.lock()
.expect("validate lock poisoned")
.entry(key)
.or_default()
.push((field.into(), rule.into()));
}
fn validate_field(value: &str, rule: &str) -> AopResult<()> {
if rule == "required" && value.trim().is_empty() {
return Err(AopError::ValidationFailed("field is required".into()));
}
if rule == "email" && !value.contains('@') {
return Err(AopError::ValidationFailed(format!(
"'{}' is not a valid email",
value
)));
}
if let Some(min_str) = rule.strip_prefix("min:") {
let min: usize = min_str.parse().unwrap_or(0);
if value.len() < min {
return Err(AopError::ValidationFailed(format!(
"minimum length is {}",
min
)));
}
}
if let Some(max_str) = rule.strip_prefix("max:") {
let max: usize = max_str.parse().unwrap_or(usize::MAX);
if value.len() > max {
return Err(AopError::ValidationFailed(format!(
"maximum length is {}",
max
)));
}
}
Ok(())
}
}
impl Default for ValidateAspect {
fn default() -> Self {
Self::new()
}
}
impl Aspect for ValidateAspect {
fn name(&self) -> &'static str {
"validate"
}
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let key = format!("{}::{}", ctx.target, ctx.method);
let rules = self.rules.lock().expect("validate lock poisoned");
if let Some(field_rules) = rules.get(&key) {
for (field, rule) in field_rules {
let value = ctx.args.get(field).map(|s| s.as_str()).unwrap_or("");
Self::validate_field(value, rule)?;
}
}
Ok(())
}
}
// ── @retry ────────────────────────────────────────────────────────────────────
/// `@retry(attempts: 3, backoff: "exponential")` — Retry on failure.
pub struct RetryAspect {
pub attempts: u32,
pub backoff: BackoffStrategy,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BackoffStrategy {
None,
Fixed(Duration),
Exponential { base: Duration },
}
impl RetryAspect {
pub fn new(attempts: u32) -> Self {
Self { attempts, backoff: BackoffStrategy::None }
}
pub fn with_exponential_backoff(mut self, base_ms: u64) -> Self {
self.backoff = BackoffStrategy::Exponential {
base: Duration::from_millis(base_ms),
};
self
}
pub fn with_fixed_backoff(mut self, ms: u64) -> Self {
self.backoff = BackoffStrategy::Fixed(Duration::from_millis(ms));
self
}
fn sleep_duration(&self, attempt: u32) -> Duration {
match &self.backoff {
BackoffStrategy::None => Duration::ZERO,
BackoffStrategy::Fixed(d) => *d,
BackoffStrategy::Exponential { base } => {
// base * 2^attempt, capped at 30s
let factor = 1u64 << attempt.min(10);
std::cmp::min(*base * factor as u32, Duration::from_secs(30))
}
}
}
}
impl Aspect for RetryAspect {
fn name(&self) -> &'static str {
"retry"
}
fn around(
&self,
ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
let mut last_error = String::new();
for attempt in 0..self.attempts {
match proceed(ctx.clone()) {
Ok(result) => return Ok(result),
Err(e) => {
last_error = e.to_string();
let sleep_for = self.sleep_duration(attempt);
if sleep_for > Duration::ZERO && attempt + 1 < self.attempts {
std::thread::sleep(sleep_for);
}
}
}
}
Err(AopError::RetriesExhausted {
attempts: self.attempts,
last_error,
})
}
}
// ── @trace ────────────────────────────────────────────────────────────────────
static TRACE_COUNTER: AtomicU64 = AtomicU64::new(1);
/// `@trace` — Add a distributed tracing span to every method call.
///
/// Injects a `trace_id` and `span_id` into context metadata.
/// In production, emit the span to an OpenTelemetry collector.
pub struct TraceAspect {
pub service_name: String,
}
impl TraceAspect {
pub fn new(service_name: impl Into<String>) -> Self {
Self { service_name: service_name.into() }
}
fn new_span_id() -> String {
let id = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("span-{:016x}", id)
}
}
impl Aspect for TraceAspect {
fn name(&self) -> &'static str {
"trace"
}
fn around(
&self,
mut ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
// Create or inherit trace ID
let trace_id = ctx
.metadata
.get("trace_id")
.cloned()
.unwrap_or_else(|| format!("trace-{:016x}", TRACE_COUNTER.load(Ordering::Relaxed)));
let span_id = Self::new_span_id();
ctx.metadata.insert("trace_id".into(), trace_id.clone());
ctx.metadata.insert("span_id".into(), span_id.clone());
let start = Instant::now();
let result = proceed(ctx.clone());
let duration_us = start.elapsed().as_micros();
// In production: emit span to OpenTelemetry:
// tracer.start_with_context("method_call", parent_cx)
// .set_attribute(KeyValue::new("service", self.service_name.clone()))
// .set_attribute(KeyValue::new("method", ctx.method.clone()))
// .set_attribute(KeyValue::new("duration_us", duration_us as i64))
// .end();
let _ = duration_us;
result.map(|mut r| {
r.metadata.insert("trace_id".into(), trace_id);
r.metadata.insert("span_id".into(), span_id);
r
})
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Aspect chain — ordered execution of aspects around a method call.
//!
//! Aspects execute in order: each one wraps the next, forming a chain.
//! The innermost item is the actual method invocation.
//!
//! ```text
//! @authenticate → @authorize → @cache → @log → [method body]
//! before before check log
//! hit? ──→ return cached
//! miss? → [method body] → store → after-log
//! ```
//!
//! ## Security-by-default
//!
//! `AspectChain::with_default_auth()` prepends `AuthenticateAspect` to every
//! chain. Call this when building chains for non-`@public` functions.
use crate::{aspects::AuthenticateAspect, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
use std::sync::Arc;
/// An ordered chain of aspects applied to a single method.
pub struct AspectChain {
aspects: Vec<Arc<dyn Aspect>>,
}
impl AspectChain {
pub fn new() -> Self {
Self { aspects: Vec::new() }
}
/// Add an aspect to the end of the chain.
pub fn add(mut self, aspect: Arc<dyn Aspect>) -> Self {
self.aspects.push(aspect);
self
}
/// Number of aspects in this chain.
pub fn len(&self) -> usize {
self.aspects.len()
}
pub fn is_empty(&self) -> bool {
self.aspects.is_empty()
}
/// Execute the chain around the given proceed function.
///
/// Aspects run in order (left to right in the decorator list).
/// Each aspect's `around` method receives the next aspect's `around`
/// as the `proceed` function, forming a true onion model.
pub fn execute(
&self,
ctx: InvocationContext,
proceed: ProceedFn,
) -> AopResult<InvocationResult> {
if self.aspects.is_empty() {
return proceed(ctx);
}
self.run_aspect(0, ctx, proceed)
}
fn run_aspect(
&self,
index: usize,
ctx: InvocationContext,
final_proceed: ProceedFn,
) -> AopResult<InvocationResult> {
if index >= self.aspects.len() {
return final_proceed(ctx);
}
let aspect = self.aspects[index].clone();
let remaining_aspects = self.aspects[index + 1..].to_vec();
let final_proceed = Arc::new(final_proceed);
let next: ProceedFn = Box::new(move |ctx: InvocationContext| {
if remaining_aspects.is_empty() {
return final_proceed(ctx);
}
// Build remaining chain recursively
let sub_chain = AspectChain {
aspects: remaining_aspects.clone(),
};
sub_chain.execute(ctx, {
let fp = final_proceed.clone();
Box::new(move |ctx| fp(ctx))
})
});
aspect.around(ctx, &next)
}
/// Return the names of all aspects in this chain (in order).
pub fn aspect_names(&self) -> Vec<&str> {
self.aspects.iter().map(|a| a.name()).collect()
}
/// Prepend `AuthenticateAspect` to this chain.
///
/// This is the mechanism for security-by-default: the framework calls
/// `with_default_auth()` on every chain that does NOT have `@public`.
///
/// Equivalent to `.add(Arc::new(AuthenticateAspect))` at position 0, but
/// semantically explicit about what it means.
pub fn with_default_auth(self) -> Self {
let mut aspects = vec![Arc::new(AuthenticateAspect) as Arc<dyn Aspect>];
aspects.extend(self.aspects);
Self { aspects }
}
/// Returns `true` if the chain contains an `AuthenticateAspect`.
pub fn has_auth(&self) -> bool {
self.aspects.iter().any(|a| a.name() == "authenticate")
}
}
impl Default for AspectChain {
fn default() -> Self {
Self::new()
}
}
+157
View File
@@ -0,0 +1,157 @@
//! el-aop — Aspect-Oriented Programming for el-ui.
//!
//! Cross-cutting concerns as first-class language features. Not a library you
//! import. Built into the framework. Applied as decorators:
//!
//! ```text
//! @authenticate ← applied by DEFAULT to every function
//! @authorize(role: "admin")
//! @cache(ttl: 300)
//! @rate_limit(requests: 100, per: 60)
//! component AdminDashboard { ... }
//!
//! @public ← explicit opt-out of authentication
//! component LandingPage { ... }
//! ```
//!
//! ## Security-by-default
//!
//! `@authenticate` is the default. Functions without `@public` are protected.
//! This makes it as hard as possible to accidentally ship an unprotected endpoint.
pub mod aspects;
pub mod chain;
pub mod registry;
pub use aspects::{
AuthenticateAspect, AuthorizeAspect, CacheAspect, LogAspect, RateLimitAspect, RetryAspect,
TraceAspect, ValidateAspect,
};
pub use chain::AspectChain;
pub use public::PublicMarker;
pub use registry::AspectRegistry;
pub mod public;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AopError {
#[error("authentication required")]
Unauthenticated,
#[error("forbidden: requires role '{role}'")]
Forbidden { role: String },
#[error("rate limit exceeded: {requests} requests per {per}s")]
RateLimited { requests: u32, per: u64 },
#[error("validation failed: {0}")]
ValidationFailed(String),
#[error("aspect error: {0}")]
Aspect(String),
#[error("all {attempts} retry attempts failed: {last_error}")]
RetriesExhausted { attempts: u32, last_error: String },
}
pub type AopResult<T> = Result<T, AopError>;
/// Context passed through the aspect chain.
///
/// Contains the incoming arguments and metadata about the call.
/// Aspects can read and mutate this context as they execute.
#[derive(Debug, Clone)]
pub struct InvocationContext {
/// The component or service being called.
pub target: String,
/// The method being called.
pub method: String,
/// Arguments passed to the method.
pub args: HashMap<String, String>,
/// Metadata added by aspects (e.g., the authenticated user, trace ID).
pub metadata: HashMap<String, String>,
}
impl InvocationContext {
pub fn new(target: impl Into<String>, method: impl Into<String>) -> Self {
Self {
target: target.into(),
method: method.into(),
args: HashMap::new(),
metadata: HashMap::new(),
}
}
pub fn with_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.args.insert(key.into(), value.into());
self
}
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn get_meta(&self, key: &str) -> Option<&str> {
self.metadata.get(key).map(|s| s.as_str())
}
}
/// The result of invoking a method through an aspect chain.
#[derive(Debug, Clone)]
pub struct InvocationResult {
pub value: String,
pub metadata: HashMap<String, String>,
}
impl InvocationResult {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
metadata: HashMap::new(),
}
}
}
/// A handler that performs the actual method invocation.
/// Aspects wrap around this.
pub type ProceedFn = Box<dyn Fn(InvocationContext) -> AopResult<InvocationResult> + Send + Sync>;
/// The core Aspect trait.
///
/// Each aspect implements `before`, `after`, or `around` advice.
/// The default implementations are no-ops — only override what you need.
pub trait Aspect: Send + Sync {
/// The aspect's name (used for debugging and registry lookup).
fn name(&self) -> &'static str;
/// Before advice — runs before the method. Can reject the call.
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
let _ = ctx;
Ok(())
}
/// After advice — runs after the method. Receives the result.
/// Can modify the result or perform cleanup.
fn after(
&self,
ctx: &InvocationContext,
result: AopResult<InvocationResult>,
) -> AopResult<InvocationResult> {
let _ = ctx;
result
}
/// Around advice — wraps the entire invocation.
/// The default implementation calls `before`, then `proceed`, then `after`.
fn around(
&self,
mut ctx: InvocationContext,
proceed: &ProceedFn,
) -> AopResult<InvocationResult> {
self.before(&mut ctx)?;
let result = proceed(ctx.clone());
self.after(&ctx, result)
}
}
+223
View File
@@ -0,0 +1,223 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Each aspect has three advice points: before, after, around. The aspect
// chain is composed at compile time by the el-ui-compiler from decorators.
//
// The El runtime model uses tagged JSON for InvocationContext so an aspect
// chain composes purely by passing the context map through each advice fn.
//
// Built-in aspects:
// @authenticate defaults on every component (security-by-default)
// @public opt-out marker
// @authorize role/permission gate
// @cache TTL-keyed memoization
// @rate_limit per-principal token bucket
// @retry retry on error with backoff
// @log structured log around invocation
// @trace emit OpenTelemetry-shaped spans
// @validate JSON schema check on args
// Errors
let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated"
let AOP_ERR_FORBIDDEN: String = "aop.forbidden"
let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited"
let AOP_ERR_VALIDATION: String = "aop.validation_failed"
let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted"
// Invocation context
//
// Stored as JSON: { target, method, args:{}, metadata:{} }
// All advice mutates by returning a new ctx (functional style).
fn ctx_new(target: String, method: String) -> String {
"{\"target\":\"" + target + "\",\"method\":\"" + method
+ "\",\"args\":{},\"metadata\":{}}"
}
fn ctx_with_arg(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "args." + key, "\"" + value + "\"")
}
fn ctx_with_meta(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "metadata." + key, "\"" + value + "\"")
}
fn ctx_get_meta(ctx: String, key: String) -> String {
json_get_path(ctx, "metadata." + key)
}
// Aspect dispatch table
//
// Each aspect is a triple of fn names: (before, around, after). The registry
// is a map from aspect name -> { before, around, after } JSON.
fn registry_new() -> String {
"{}"
}
fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String {
let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn
+ "\",\"after\":\"" + after_fn + "\"}"
json_set(reg, name, entry)
}
// @authenticate applied by default
fn aspect_authenticate_before(ctx: String) -> String {
let token: String = ctx_get_meta(ctx, "authorization")
if str_eq(token, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let secret: String = env("JWT_SECRET")
let auth_ctx: String = auth_middleware(token, secret)
let user_id: String = json_get(auth_ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
ctx_with_meta(ctx, "user_id", user_id)
}
// @public explicit opt-out marker
fn aspect_public_before(ctx: String) -> String {
ctx_with_meta(ctx, "public", "true")
}
// @authorize(role)
fn aspect_authorize_before(ctx: String, required_role: String) -> String {
let user_id: String = ctx_get_meta(ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let roles_json: String = engram_edge_traverse(user_id, "has_role")
if !str_contains(roles_json, "\"" + required_role + "\"") {
return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN)
}
ctx
}
// @cache(ttl_seconds)
fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if str_eq(key, "") {
let target: String = json_get(ctx, "target")
let method: String = json_get(ctx, "method")
let args: String = json_get(ctx, "args")
let key = sha256_hex(target + ":" + method + ":" + args)
}
let cached: String = cache_get(key)
if !str_eq(cached, "") {
return ctx_with_meta(ctx, "result", cached)
}
// Caller invokes proceed(ctx) externally; we record the key for `after` to use.
ctx_with_meta(ctx, "cache_key", key)
}
fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if !str_eq(key, "") { cache_put(key, result, ttl) }
result
}
// @rate_limit(requests, per_seconds)
fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String {
let principal: String = ctx_get_meta(ctx, "user_id")
if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") }
let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal
let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds)
if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) }
ctx
}
// @retry(attempts, backoff_ms)
//
// retry is necessarily an `around` aspect it must own the loop.
fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String {
let i: Int = 0
let result: String = ""
while i < attempts {
let result = call_dynamic(proceed_fn_name, ctx)
let err: String = json_get(result, "error")
if str_eq(err, "") { return result }
sleep_ms(backoff_ms * (i + 1))
let i = i + 1
}
ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED)
}
// @log / @trace
fn aspect_log_before(ctx: String) -> String {
println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
ctx
}
fn aspect_log_after(ctx: String, result: String) -> String {
println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
result
}
fn aspect_trace_before(ctx: String) -> String {
let span_id: String = uuid_v4()
ctx_with_meta(ctx, "span_id", span_id)
}
// @validate(schema)
fn aspect_validate_before(ctx: String, schema_json: String) -> String {
let args: String = json_get(ctx, "args")
let valid: Bool = json_schema_check(args, schema_json)
if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) }
ctx
}
// Aspect chain composition
//
// The compiler emits a call sequence like:
// ctx = ctx_new(...)
// ctx = aspect_authenticate_before(ctx)
// ctx = aspect_log_before(ctx)
// result = proceed(ctx)
// result = aspect_log_after(ctx, result)
// At runtime an explicit `chain_run` exists for dynamic composition.
fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String {
let cur_ctx: String = ctx
// before chain
let i: Int = 0
let befs: String = before_fns
while !str_eq(befs, "") {
let comma: Int = str_index_of(befs, ",")
let fn_name: String = befs
if comma > 0 { let fn_name = str_slice(befs, 0, comma) }
let cur_ctx = call_dynamic(fn_name, cur_ctx)
let err: String = ctx_get_meta(cur_ctx, "error")
if !str_eq(err, "") { return cur_ctx }
if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) }
if comma < 0 { let befs = "" }
}
// around / proceed
let result: String = call_dynamic(proceed_fn, cur_ctx)
// after chain (right-to-left composition; simplified left-to-right here)
let afts: String = after_fns
while !str_eq(afts, "") {
let comma: Int = str_index_of(afts, ",")
let fn_name: String = afts
if comma > 0 { let fn_name = str_slice(afts, 0, comma) }
let result = call_dynamic2(fn_name, cur_ctx, result)
if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) }
if comma < 0 { let afts = "" }
}
result
}
// Entry smoke test
let ctx: String = ctx_new("ProfilePage", "render")
let ctx = ctx_with_arg(ctx, "user_id", "u-001")
println("[el-aop] ctx = " + ctx)
+57
View File
@@ -0,0 +1,57 @@
//! `@public` marker — the explicit opt-out of authentication.
//!
//! The security-by-default model: `@authenticate` is applied to EVERY function
//! by default. `@public` is the rare annotation that says "this endpoint
//! intentionally has no auth".
//!
//! `PublicMarker` is a zero-cost marker. When the compiler sees `@public` on a
//! function, it strips `AuthenticateAspect` from the chain for that function.
//! The chain-builder checks `is_public` before prepending default auth.
/// Zero-cost marker indicating that a function is intentionally public.
///
/// When `@public` is present, the default `AuthenticateAspect` is NOT added
/// to the function's aspect chain.
///
/// Usage in the el-ui compiler:
/// ```text
/// @public
/// fn health_check() -> Status { ... }
/// ```
///
/// In the AOP chain builder:
/// ```rust
/// use el_aop::{AspectChain, PublicMarker, AuthenticateAspect};
/// use std::sync::Arc;
///
/// fn build_chain(is_public: bool) -> AspectChain {
/// if is_public || PublicMarker::is_bypassing() {
/// AspectChain::new()
/// } else {
/// AspectChain::new().with_default_auth()
/// }
/// }
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PublicMarker;
impl PublicMarker {
/// Returns `true` — always. Exists for use in match arms / conditional logic.
///
/// The presence of a `PublicMarker` in the decorator list is the signal;
/// this method is a convenience for procedural logic over decorator lists.
pub const fn is_bypassing() -> bool {
true
}
/// The decorator name this marker corresponds to.
pub const fn decorator_name() -> &'static str {
"public"
}
}
impl std::fmt::Display for PublicMarker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "@public")
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Aspect registry — register built-in and custom aspects by name.
//!
//! The compiler's AOP codegen uses the registry to look up aspect implementations
//! by their decorator name (e.g., `"authenticate"` → `AuthenticateAspect`).
//!
//! ## Security-by-default
//!
//! `"public"` is a special bypass marker — NOT an aspect. When the compiler sees
//! `@public` it calls `registry.is_public_bypass(name)` and skips default auth.
//!
//! `set_default_auth_guard()` installs the global default `AuthenticateAspect`
//! that is prepended to every non-`@public` chain.
use crate::Aspect;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type AspectFactory = Box<dyn Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync>;
/// Registry of aspect factories, indexed by decorator name.
pub struct AspectRegistry {
factories: RwLock<HashMap<String, AspectFactory>>,
/// When `true`, the registry is configured to prepend AuthenticateAspect
/// to every non-`@public` chain via `AspectChain::with_default_auth()`.
default_auth_enabled: RwLock<bool>,
/// Names that are bypass markers (not real aspects). Currently just "public".
bypass_markers: RwLock<std::collections::HashSet<String>>,
}
impl AspectRegistry {
pub fn new() -> Self {
let mut markers = std::collections::HashSet::new();
markers.insert("public".to_string());
Self {
factories: RwLock::new(HashMap::new()),
default_auth_enabled: RwLock::new(false),
bypass_markers: RwLock::new(markers),
}
}
/// Create a registry with all built-in aspects registered.
pub fn with_builtins() -> Self {
let registry = Self::new();
registry.register_builtins();
registry
}
/// Enable security-by-default: every non-`@public` chain will have
/// `AuthenticateAspect` prepended automatically.
///
/// Call at application startup. After this, use `AspectChain::with_default_auth()`
/// when building chains for protected functions.
pub fn set_default_auth_enabled(&self, enabled: bool) {
*self.default_auth_enabled.write().expect("registry lock poisoned") = enabled;
}
/// Returns `true` if security-by-default auth is active.
pub fn is_default_auth_enabled(&self) -> bool {
*self.default_auth_enabled.read().expect("registry lock poisoned")
}
/// Returns `true` if `name` is a public bypass marker (e.g., `"public"`).
///
/// Bypass markers are NOT aspects — they signal that default auth should
/// be skipped for the decorated function.
pub fn is_public_bypass(&self, name: &str) -> bool {
self.bypass_markers
.read()
.expect("registry lock poisoned")
.contains(name)
}
/// Register a custom bypass marker name.
///
/// By default only `"public"` is registered. Use this to add custom
/// bypass annotations (e.g., `"internal_only"` that uses a different guard).
pub fn register_bypass_marker(&self, name: &str) {
self.bypass_markers
.write()
.expect("registry lock poisoned")
.insert(name.to_string());
}
/// Build an `AspectChain` for a function with the given decorators.
///
/// This is the primary chain-building entry point used by the AOP codegen.
///
/// - If any decorator is a bypass marker (`@public`), returns a plain chain
/// with no default auth.
/// - Otherwise, if `default_auth_enabled`, prepends `AuthenticateAspect`.
/// - Unknown decorator names are silently skipped (forward-compatible).
pub fn build_chain(&self, decorator_names: &[(&str, HashMap<String, String>)]) -> crate::AspectChain {
let is_public = decorator_names.iter().any(|(name, _)| self.is_public_bypass(name));
let mut chain = crate::AspectChain::new();
for (name, params) in decorator_names {
if self.is_public_bypass(name) {
continue; // bypass markers are not aspects
}
if let Some(aspect) = self.create(name, params) {
chain = chain.add(aspect);
}
}
if !is_public && self.is_default_auth_enabled() {
chain = chain.with_default_auth();
}
chain
}
/// Register all built-in aspects.
pub fn register_builtins(&self) {
use crate::aspects::*;
self.register("authenticate", |_params| {
Arc::new(AuthenticateAspect)
});
self.register("authorize", |params| {
let role = params
.get("role")
.cloned()
.unwrap_or_else(|| "user".to_string());
Arc::new(AuthorizeAspect::new(role))
});
self.register("cache", |params| {
let ttl: u64 = params
.get("ttl")
.and_then(|s| s.parse().ok())
.unwrap_or(300);
Arc::new(CacheAspect::new(ttl))
});
self.register("rate_limit", |params| {
let requests: u32 = params
.get("requests")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let per: u64 = params
.get("per")
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Arc::new(RateLimitAspect::new(requests, per))
});
self.register("log", |params| {
let level = params
.get("level")
.cloned()
.unwrap_or_else(|| "info".to_string());
Arc::new(LogAspect::new(level))
});
self.register("validate", |_params| Arc::new(ValidateAspect::new()));
self.register("retry", |params| {
let attempts: u32 = params
.get("attempts")
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let backoff = params
.get("backoff")
.map(|s| s.as_str())
.unwrap_or("none");
let aspect = RetryAspect::new(attempts);
let aspect = match backoff {
"exponential" => aspect.with_exponential_backoff(100),
"fixed" => aspect.with_fixed_backoff(500),
_ => aspect,
};
Arc::new(aspect)
});
self.register("trace", |params| {
let service = params
.get("service")
.cloned()
.unwrap_or_else(|| "el-ui".to_string());
Arc::new(TraceAspect::new(service))
});
}
/// Register a custom aspect factory.
pub fn register(
&self,
name: &str,
factory: impl Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync + 'static,
) {
self.factories
.write()
.expect("registry lock poisoned")
.insert(name.to_string(), Box::new(factory));
}
/// Instantiate an aspect by decorator name with the given params.
pub fn create(
&self,
name: &str,
params: &HashMap<String, String>,
) -> Option<Arc<dyn Aspect>> {
let factories = self.factories.read().expect("registry lock poisoned");
factories.get(name).map(|f| f(params))
}
/// List all registered aspect names.
pub fn aspect_names(&self) -> Vec<String> {
self.factories
.read()
.expect("registry lock poisoned")
.keys()
.cloned()
.collect()
}
/// Check if an aspect name is registered.
pub fn contains(&self, name: &str) -> bool {
self.factories
.read()
.expect("registry lock poisoned")
.contains_key(name)
}
}
impl Default for AspectRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod registry_default_auth_tests {
use super::*;
#[test]
fn test_default_auth_disabled_by_default() {
let reg = AspectRegistry::new();
assert!(!reg.is_default_auth_enabled());
}
#[test]
fn test_set_default_auth_enabled() {
let reg = AspectRegistry::new();
reg.set_default_auth_enabled(true);
assert!(reg.is_default_auth_enabled());
}
#[test]
fn test_public_is_bypass_marker() {
let reg = AspectRegistry::new();
assert!(reg.is_public_bypass("public"));
assert!(!reg.is_public_bypass("authenticate"));
}
#[test]
fn test_build_chain_public_skips_default_auth() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("public", HashMap::new())];
let chain = reg.build_chain(&decorators);
assert!(!chain.has_auth(), "public chain should not have default auth");
}
#[test]
fn test_build_chain_non_public_gets_default_auth() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("log", HashMap::new())];
let chain = reg.build_chain(&decorators);
assert!(chain.has_auth(), "non-public chain should have default auth prepended");
}
#[test]
fn test_build_chain_auth_is_first_aspect() {
let reg = AspectRegistry::with_builtins();
reg.set_default_auth_enabled(true);
let decorators = vec![("log", HashMap::new())];
let chain = reg.build_chain(&decorators);
let names = chain.aspect_names();
assert_eq!(names[0], "authenticate", "authenticate must be first in the chain");
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Tests for el-aop.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use crate::{
aspects::*,
chain::AspectChain,
registry::AspectRegistry,
AopError, Aspect, InvocationContext, InvocationResult,
};
fn succeed_proceed(value: impl Into<String> + Clone) -> crate::ProceedFn {
let v = value.into();
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
}
fn fail_proceed(msg: impl Into<String> + Clone) -> crate::ProceedFn {
let m = msg.into();
Box::new(move |_ctx| Err(AopError::Aspect(m.clone())))
}
fn ctx(target: &str, method: &str) -> InvocationContext {
InvocationContext::new(target, method)
}
fn authed_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method).with_meta("user_id", "user-123")
}
fn admin_ctx(target: &str, method: &str) -> InvocationContext {
ctx(target, method)
.with_meta("user_id", "admin-1")
.with_meta("roles", "admin,user")
}
// ── Test 1: AuthenticateAspect rejects unauthenticated calls ─────────────
#[test]
fn test_authenticate_rejects_unauthenticated() {
let aspect = AuthenticateAspect;
let mut ctx = ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_err());
assert!(matches!(result, Err(AopError::Unauthenticated)));
}
// ── Test 2: AuthenticateAspect allows authenticated calls ─────────────────
#[test]
fn test_authenticate_allows_authenticated() {
let aspect = AuthenticateAspect;
let mut ctx = authed_ctx("AdminDashboard", "load");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 3: AuthorizeAspect rejects wrong role ────────────────────────────
#[test]
fn test_authorize_rejects_wrong_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::Forbidden { .. })));
}
// ── Test 4: AuthorizeAspect allows correct role ───────────────────────────
#[test]
fn test_authorize_allows_correct_role() {
let aspect = AuthorizeAspect::new("admin");
let mut ctx = admin_ctx("Dashboard", "delete");
let result = aspect.before(&mut ctx);
assert!(result.is_ok());
}
// ── Test 5: CacheAspect returns cached result on second call ──────────────
#[test]
fn test_cache_returns_cached_result() {
let aspect = CacheAspect::new(300);
let ctx = authed_ctx("OrderService", "get_orders");
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let cc = call_count.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
Ok(InvocationResult::new(format!("result-{}", n)))
});
// First call — executes proceed
let r1 = aspect.around(ctx.clone(), &proceed).unwrap();
// Second call — should return cached (proceed not called again)
let proceed2: crate::ProceedFn = Box::new(|_ctx| {
panic!("proceed should not be called on cache hit");
});
let r2 = aspect.around(ctx.clone(), &proceed2).unwrap();
assert_eq!(r1.value, r2.value, "cached value should be returned");
}
// ── Test 6: RateLimitAspect blocks after limit exceeded ───────────────────
#[test]
fn test_rate_limit_blocks_after_limit() {
let aspect = RateLimitAspect::new(2, 60);
let mut ctx = authed_ctx("OrderService", "create_order");
// First two calls succeed
assert!(aspect.before(&mut ctx).is_ok());
assert!(aspect.before(&mut ctx).is_ok());
// Third call should be blocked
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::RateLimited { .. })));
}
// ── Test 7: LogAspect passes through to proceed ───────────────────────────
#[test]
fn test_log_aspect_passthrough() {
let aspect = LogAspect::new("info");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap();
assert_eq!(result.value, "user-data");
}
// ── Test 8: ValidateAspect rejects required field missing ────────────────
#[test]
fn test_validate_required_field() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "name", "required");
let mut ctx = authed_ctx("UserService", "create_user");
// No "name" arg
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 9: ValidateAspect passes when field is present ──────────────────
#[test]
fn test_validate_required_field_present() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "alice@example.com");
assert!(aspect.before(&mut ctx).is_ok());
}
// ── Test 10: ValidateAspect rejects invalid email ─────────────────────────
#[test]
fn test_validate_email_rule() {
let aspect = ValidateAspect::new();
aspect.add_rule("UserService", "create_user", "email", "email");
let mut ctx = authed_ctx("UserService", "create_user")
.with_arg("email", "not-an-email");
let result = aspect.before(&mut ctx);
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
}
// ── Test 11: RetryAspect retries on failure ───────────────────────────────
#[test]
fn test_retry_succeeds_on_third_attempt() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let attempt_clone = attempt.clone();
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n < 2 {
Err(AopError::Aspect("transient error".into()))
} else {
Ok(InvocationResult::new("success"))
}
});
let result = aspect.around(ctx, &proceed).unwrap();
assert_eq!(result.value, "success");
}
// ── Test 12: RetryAspect exhausts all attempts ───────────────────────────
#[test]
fn test_retry_exhausted() {
let aspect = RetryAspect::new(3);
let ctx = authed_ctx("OrderService", "place_order");
let result = aspect.around(ctx, &fail_proceed("always fails"));
assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. })));
}
// ── Test 13: TraceAspect injects trace/span IDs ───────────────────────────
#[test]
fn test_trace_aspect_injects_ids() {
let aspect = TraceAspect::new("el-ui");
let ctx = authed_ctx("UserService", "get_user");
let result = aspect.around(ctx, &succeed_proceed("data")).unwrap();
assert!(
result.metadata.contains_key("trace_id"),
"should inject trace_id"
);
assert!(
result.metadata.contains_key("span_id"),
"should inject span_id"
);
}
// ── Test 14: AspectChain executes aspects in order ────────────────────────
#[test]
fn test_aspect_chain_ordering() {
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
struct OrderTracker {
name: &'static str,
order: Arc<std::sync::Mutex<Vec<&'static str>>>,
}
impl Aspect for OrderTracker {
fn name(&self) -> &'static str { self.name }
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
self.order.lock().unwrap().push(self.name);
proceed(ctx)
}
}
let chain = AspectChain::new()
.add(Arc::new(OrderTracker { name: "first", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "second", order: order.clone() }))
.add(Arc::new(OrderTracker { name: "third", order: order.clone() }));
let ctx = authed_ctx("MyService", "my_method");
chain.execute(ctx, succeed_proceed("ok")).unwrap();
let recorded = order.lock().unwrap();
assert_eq!(*recorded, vec!["first", "second", "third"]);
}
// ── Test 15: AspectChain with auth + authorize rejects unauthenticated ────
#[test]
fn test_aspect_chain_auth_flow() {
let chain = AspectChain::new()
.add(Arc::new(AuthenticateAspect))
.add(Arc::new(AuthorizeAspect::new("admin")));
// Unauthenticated — should fail at authenticate
let ctx = ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Unauthenticated)));
// Authenticated but wrong role — should fail at authorize
let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(matches!(result, Err(AopError::Forbidden { .. })));
// Admin — should succeed
let ctx = admin_ctx("AdminDashboard", "load");
let result = chain.execute(ctx, succeed_proceed("ok"));
assert!(result.is_ok());
}
// ── Test 16: AspectRegistry registers all builtins ────────────────────────
#[test]
fn test_registry_has_builtins() {
let registry = AspectRegistry::with_builtins();
for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] {
assert!(registry.contains(name), "should have built-in: {}", name);
}
}
// ── Test 17: AspectRegistry creates aspects from params ───────────────────
#[test]
fn test_registry_creates_aspect() {
let registry = AspectRegistry::with_builtins();
let mut params = HashMap::new();
params.insert("role".into(), "admin".into());
let aspect = registry.create("authorize", &params);
assert!(aspect.is_some(), "should create authorize aspect");
assert_eq!(aspect.unwrap().name(), "authorize");
}
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
#[test]
fn test_registry_unknown_aspect() {
let registry = AspectRegistry::with_builtins();
let result = registry.create("unknown_aspect", &HashMap::new());
assert!(result.is_none());
}
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
#[test]
fn test_cache_zero_ttl() {
let aspect = CacheAspect::new(0); // Immediate expiry
let ctx = authed_ctx("Service", "method");
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
let nc = n.clone();
// Both calls should hit proceed since ttl=0 means instant expiry
let p1: crate::ProceedFn = Box::new(move |_ctx| {
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(InvocationResult::new(v.to_string()))
});
// Zero TTL will expire immediately; we just verify it doesn't panic
let _ = aspect.around(ctx.clone(), &p1);
// Second call should also execute proceed
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
let r = aspect.around(ctx, &p2).unwrap();
assert_eq!(r.value, "fresh");
}
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
#[test]
fn test_empty_chain_calls_proceed() {
let chain = AspectChain::new();
assert!(chain.is_empty());
let ctx = authed_ctx("Service", "method");
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
assert_eq!(result.value, "direct");
}
}