Directive Structure
The anatomy of a directive — interfaces, base class, path resolution, and chaining.
Contents
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);
}
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:
| Directive | First segment | Remaining path |
|---|---|---|
$ctx.env.KEY | env → routes to EnvironmentHandler | KEY → env var name |
$ctx.user.email | user → routes to UserHandler | email → property name |
$input.current.name | current → active item | name → field in item |
$output.NodeKey.field | NodeKey → previous node | field → output field |
$var.myVar | myVar → variable name in memory | further 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
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);
}
}