Portal Community

Contents

  1. IExpressionDirectiveService
  2. BaseDirectiveEvaluator
  3. EvaluateAsync — The Core Method
  4. Accessing Context Inside a Directive
  5. Path Resolution
  6. Chaining to Other Directives
  7. Reading Options
  8. Complete Worked Example

IExpressionDirectiveService

Every directive implements this single interface:

public interface IExpressionDirectiveService
{
    /// The directive name (without $). Used as DI registration key.
    string DirectiveName { get; }

    /// Resolves an expression to a value.
    Task<EvaluationResponse> InvokeAsync(
        EvaluationRequest request,
        CancellationToken cancellationToken);
}
IDefaultExpressionDirectiveService A marker interface extending IExpressionDirectiveService. The directive registered with this interface is used as the fallback when an expression has no explicit directive name (e.g., bare path expressions).

BaseDirectiveEvaluator

Abstract base class that all built-in directives extend. It handles timing and delegates to the abstract EvaluateAsync:

public abstract class BaseDirectiveEvaluator : IExpressionDirectiveService
{
    public abstract string DirectiveName { get; }

    private EvaluationRequest? _activeRequest;

    public async Task<EvaluationResponse> InvokeAsync(
        EvaluationRequest request,
        CancellationToken ct)
    {
        _activeRequest = request;
        var sw = Stopwatch.StartNew();
        EvaluationResponse response = await EvaluateAsync(request, ct);
        return response.WithDuration(sw.ElapsedMilliseconds);
    }

    // ── Abstract: subclass implements this ────────────────────
    protected abstract Task<EvaluationResponse> EvaluateAsync(
        EvaluationRequest request,
        CancellationToken ct);

    // ── Protected helpers ──────────────────────────────────────
    protected EvaluationContext Context => _activeRequest!.Context;

    protected async Task<EvaluationResponse> ChainExpressionAsync(
        string resolvedExpression,
        CancellationToken ct);     // re-enters full parse pipeline

    protected async Task<EvaluationResponse> ChainToDirectiveAsync(
        string directiveName,
        CancellationToken ct);     // routes directly to a named directive

    protected void GuardChainDepth();  // throws if Depth >= 10
}

EvaluateAsync — The Core Method

This is the only method a directive must implement. It receives the full EvaluationRequest and returns an EvaluationResponse.

protected override async Task<EvaluationResponse> EvaluateAsync(
    EvaluationRequest request,
    CancellationToken ct)
{
    // request.Directives[0].Name  == this directive's name (e.g., "ctx")
    // request.Path                == dot-path string (e.g., "user.name")
    // request.Options             == option tokens (e.g., ["uppercase"])
    // request.Context             == the EvaluationContext
    // request.Depth               == current chain depth (0 = top level)

    var resolved = await ResolvePathAsync(request.Path, ct);

    if (resolved is null)
        return EvaluationResponse.Failure(
            EvaluationErrorCode.PathNotFound,
            $"Path '{request.Path}' not found");

    return EvaluationResponse.Success(resolved);
}

Accessing Context Inside a Directive

The Context property on BaseDirectiveEvaluator provides the EvaluationContext. Use it to access execution state, memory, user info, and feature flags:

// Tenant isolation
var tenantId = Context.TenantId;

// Current user
var userName = Context.CurrentUser?.Name;

// Workflow memory
var value = Context.Memory.Get(variableName, Context.ExecutionId);

// Node input/output data
var inputItem = Context.NodeExecutionData.CurrentItem;

// Isolation check
if (Context.IsolationLevel < ExpressionIsolationLevel.Sandboxed)
    return EvaluationResponse.Failure(
        EvaluationErrorCode.IsolationViolation,
        "$js requires Sandboxed isolation level");

Path Resolution

Paths in expressions are dot-separated strings that navigate into data structures. The JsonPathResolver utility handles this for all directives:

// Path: "customer.address.city"
// Data: { "customer": { "address": { "city": "Dublin" } } }

var result = JsonPathResolver.Resolve(data, "customer.address.city");
// → "Dublin"

// Array indexing: "items[0].amount"
var result = JsonPathResolver.Resolve(data, "items[0].amount");
// → 99.99

Each directive segment has a semantic meaning:

DirectiveFirst segmentRemaining path
$ctx.env.KEYenv → routes to EnvironmentHandlerKEY → env var name
$ctx.user.emailuser → routes to UserHandleremail → property name
$input.current.namecurrent → active itemname → field in item
$output.NodeKey.fieldNodeKey → previous nodefield → output field
$var.myVarmyVar → variable name in memoryfurther segments → nested JSON path

Chaining to Other Directives

A directive can chain to other directives in two ways:

ChainExpressionAsync — Re-parse a new expression string

// The canned expression directive resolves the alias to an expression string,
// then chains to re-evaluate that expression.
var storedExpression = await _store.GetAsync(alias, Context.TenantId, ct);
// storedExpression = "{@ $ctx.tenant.taxRate }"

GuardChainDepth(); // throws if depth >= 10
return await ChainExpressionAsync(storedExpression, ct);
// → full re-parse → routes to $ctx → returns tenant.taxRate value

ChainToDirectiveAsync — Route directly without re-parsing

// Template directive evaluates all embedded $-directives by routing directly.
// Use this when you already know the target directive name.
GuardChainDepth();
return await ChainToDirectiveAsync("var", ct);
// → skips parser → calls $var.InvokeAsync directly
Always call GuardChainDepth() before chaining Both chain methods increment request.Depth. Failing to guard allows runaway recursion. The orchestrator enforces a hard limit of depth = 10.

Reading Options Inside a Directive

Options in request.Options are string tokens parsed from the expression. Most option handling happens in the orchestrator, but a directive can inspect them:

// Check if a specific option was requested
bool wantsJson = request.Options.Contains("json");

// Options with parameters: "@cache-thread" or "@default:fallback"
var defaultOpt = request.Options.FirstOrDefault(o => o.StartsWith("default:"));
if (defaultOpt is not null)
{
    var fallback = defaultOpt["default:".Length..];
    // use fallback
}

Complete Worked Example — $ctx Directive

public class ContextDirectiveService : BaseDirectiveEvaluator
{
    public override string DirectiveName => "ctx";

    private readonly IReadOnlyDictionary<string, IContextHandler> _handlers;

    public ContextDirectiveService(IEnumerable<IContextHandler> handlers)
    {
        _handlers = handlers.ToDictionary(h => h.SubKey, StringComparer.OrdinalIgnoreCase);
    }

    protected override async Task<EvaluationResponse> EvaluateAsync(
        EvaluationRequest request,
        CancellationToken ct)
    {
        // Path = "user.name" → split → handler key = "user", sub-path = "name"
        var segments = request.Path.Split('.', 2);
        var handlerKey = segments[0];
        var subPath    = segments.Length > 1 ? segments[1] : "";

        if (!_handlers.TryGetValue(handlerKey, out var handler))
            return EvaluationResponse.Failure(
                EvaluationErrorCode.PathNotFound,
                $"Unknown $ctx sub-key: '{handlerKey}'");

        var value = await handler.ResolveAsync(subPath, Context, ct);
        return value is null
            ? EvaluationResponse.Failure(EvaluationErrorCode.PathNotFound,
                $"Path '{request.Path}' resolved to null")
            : EvaluationResponse.Success(value);
    }
}