Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -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]
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms);
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user