Files
el/ui/vessels/el-aop/src/main.el
T

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)