224 lines
9.8 KiB
EmacsLisp
224 lines
9.8 KiB
EmacsLisp
// 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)
|