//! CEL conditions and computed template values. use std::fmt; use std::sync::{Arc, LazyLock}; use cel::{Context, Env, Program, Value}; use chrono::{DateTime, Utc}; use serde_json::Value as Json; static ENV: LazyLock> = LazyLock::new(|| Arc::new(Env::stdlib())); /// Per-request values every expression can see as `now` and `rkey`. pub struct EvalCtx { pub rkey: String, pub now: DateTime, } #[derive(Debug)] pub enum ExprError { Compile { expr: String, reason: String }, Eval { expr: String, reason: String }, NotAtprotoData { expr: String, kind: String }, } impl fmt::Display for ExprError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Compile { expr, reason } => write!(f, "cannot compile `{expr}`: {reason}"), Self::Eval { expr, reason } => write!(f, "cannot evaluate `{expr}`: {reason}"), Self::NotAtprotoData { expr, kind } => { write!( f, "`{expr}` produced a {kind}, which atproto data cannot hold" ) } } } } impl std::error::Error for ExprError {} fn compile(src: &str) -> Result { ENV.compile(src).map_err(|e| ExprError::Compile { expr: src.into(), reason: e.to_string(), }) } fn base_context(ctx: &EvalCtx) -> Context<'static, 'static> { let mut cel_ctx = Context::with_env(ENV.clone()); cel_ctx .add_variable("now", cel::Timestamp(ctx.now.fixed_offset())) .expect("timestamps convert to CEL values"); cel_ctx .add_variable("rkey", &ctx.rkey) .expect("strings convert to CEL values"); cel_ctx } /// A boolean CEL expression over `record`, `rkey` and `now`. pub struct Condition { program: Program, } impl Condition { pub fn compile(src: &str) -> Result { compile(src).map(|program| Self { program }) } /// Evaluation errors and non-boolean results count as `false`. pub fn holds(&self, record: &Json, ctx: &EvalCtx) -> bool { let mut cel_ctx = base_context(ctx); cel_ctx.add_variable("record", record).is_ok() && matches!(self.program.execute(&cel_ctx), Ok(Value::Bool(true))) } } enum Node { Literal(Json), Computed { src: String, program: Program }, List(Vec), Object(Vec<(String, Node)>), } /// JSON in which strings written as `{% expr %}` are replaced by their CEL result. pub struct Template(Node); impl Template { pub fn compile(value: &Json) -> Result { compile_node(value).map(Self) } pub fn render(&self, ctx: &EvalCtx) -> Result { render_node(&self.0, &base_context(ctx)) } } fn compile_node(value: &Json) -> Result { Ok(match value { Json::String(s) => match s .strip_prefix("{%") .and_then(|rest| rest.strip_suffix("%}")) { Some(inner) => { let src = inner.trim().to_string(); Node::Computed { program: compile(&src)?, src, } } None => Node::Literal(value.clone()), }, Json::Array(items) => Node::List(items.iter().map(compile_node).collect::>()?), Json::Object(map) => Node::Object( map.iter() .map(|(k, v)| Ok((k.clone(), compile_node(v)?))) .collect::>()?, ), _ => Node::Literal(value.clone()), }) } fn render_node(node: &Node, cel_ctx: &Context) -> Result { Ok(match node { Node::Literal(v) => v.clone(), Node::Computed { src, program } => { let result = program.execute(cel_ctx).map_err(|e| ExprError::Eval { expr: src.clone(), reason: e.to_string(), })?; to_atproto_json(&result).map_err(|kind| ExprError::NotAtprotoData { expr: src.clone(), kind, })? } Node::List(items) => Json::Array( items .iter() .map(|n| render_node(n, cel_ctx)) .collect::>()?, ), Node::Object(members) => Json::Object( members .iter() .map(|(k, n)| Ok((k.clone(), render_node(n, cel_ctx)?))) .collect::>()?, ), }) } /// Errs with the name of the first CEL type atproto data cannot hold. fn to_atproto_json(value: &Value) -> Result { Ok(match value { Value::Null => Json::Null, Value::Bool(b) => (*b).into(), Value::Int(i) => (*i).into(), Value::UInt(u) => i64::try_from(*u) .map_err(|_| value.type_of().to_string())? .into(), Value::String(s) => s.as_str().into(), Value::List(items) => Json::Array( items .iter() .map(to_atproto_json) .collect::>()?, ), Value::Map(map) => Json::Object( map.map .iter() .map(|(k, v)| Ok((k.to_string(), to_atproto_json(v)?))) .collect::>()?, ), other => return Err(other.type_of().to_string()), }) }