Discovery API
Self-describing metadata that powers the expression builder UI — autocomplete, path browsing, and option selection.
Contents
Purpose
The expression builder UI needs to guide users through building valid expressions without requiring them to memorize directive names, path structures, or option tokens. The Discovery API provides a self-describing metadata layer that every directive can contribute to.
The UI interaction model is:
- User types
{@→ UI shows a list of all registered directives - User selects
$ctx→ UI shows top-level paths (env, tenant, user, app, platform, now, today) - User selects
user→ UI shows sub-paths (id, name, email, roles…) - User selects
name→ UI shows: type=string, example="Alice Smith", available options - User optionally picks
@uppercase→ expression assembled
What the UI Needs
| UI Feature | Data Required |
|---|---|
| Directive picker (first level) | All registered directive names, display names, icons, descriptions |
| Path autocomplete (first segment) | Top-level paths for selected directive with descriptions and type hints |
| Path autocomplete (drill-down) | Children of a selected path segment |
| Type annotation | Return type for a specific path (string, number, boolean, object, array) |
| Example value | A representative example value for the path |
| Option picker | Which options are compatible with this directive/path combination |
| Option description | What each option does |
| Canned expression browser | List of aliases for this tenant/app with descriptions and tags |
| Syntax selector | Which syntax flavors are supported |
Core Interfaces
/// Implemented by each directive to expose its paths and metadata.
public interface IDirectiveMetadataProvider
{
string DirectiveName { get; }
string DisplayName { get; }
string Description { get; }
string? IconUrl { get; }
string? DocsUrl { get; }
ExpressionIsolationLevel RequiredIsolationLevel { get; }
/// Returns top-level paths (no parent)
IReadOnlyList<DirectivePath> GetTopLevelPaths();
/// Returns child paths for a given parent path (lazy drill-down)
Task<IReadOnlyList<DirectivePath>> GetChildPathsAsync(
string parentPath,
CancellationToken ct);
}
/// Aggregates all directive metadata providers — the main discovery service.
public interface IExpressionDiscoveryService
{
/// Returns summary of all registered directives
IReadOnlyList<DirectiveSummary> GetAllDirectives();
/// Returns top-level paths for a directive
IReadOnlyList<DirectivePath> GetTopLevelPaths(string directiveName);
/// Returns children of a path (for drill-down)
Task<IReadOnlyList<DirectivePath>> GetChildPathsAsync(
string directiveName,
string parentPath,
CancellationToken ct);
/// Returns all available options
IReadOnlyList<OptionMetadata> GetAllOptions();
/// Returns options compatible with a specific directive and path
IReadOnlyList<OptionMetadata> GetCompatibleOptions(
string directiveName,
string? path = null);
}
Data Model
public record DirectiveSummary(
string Name, // "ctx" (no $)
string DisplayName, // "Context"
string Description, // "Environment, tenant, user, and time values"
string? IconUrl,
string? DocsUrl,
ExpressionIsolationLevel RequiredIsolationLevel
);
public record DirectivePath(
string Path, // "user.name"
string DisplayName, // "User Name"
string Description, // "Display name of the authenticated user"
ReturnType ReturnType, // String, Number, Boolean, Object, Array, DateTime
string? ExampleValue, // "Alice Smith"
bool HasChildren, // true if this path has sub-paths
bool IsLeaf // true if this is a final path (no further drill-down)
);
public record OptionMetadata(
string Token, // "uppercase"
string DisplayName, // "Uppercase"
string Description, // "Converts the resolved value to upper case"
bool HasParameter, // true for @default:value, @date:format
string? ParameterHint, // "Value to return when null" / "yyyy-MM-dd"
bool IsCache, // true for cache options
string[] CompatibleWith // directive names this option works with ("*" = all)
);
Lazy Loading Strategy
Some directives have dynamic paths (e.g., $output.NodeKey where NodeKey depends on the workflow), or very large path trees (e.g., a CRM with hundreds of fields). Discovery uses lazy loading to handle this:
- First call:
GetTopLevelPaths("ctx")→ returns:[env, tenant, user, app, platform, now, today] - Drill-down:
GetChildPathsAsync("ctx", "user")→ returns:[user.id, user.name, user.email, user.roles] - Leaf:
user.namehasHasChildren=false→ no further drill-down
$output.NodeKey.field, the discovery endpoint can accept the current workflow definition as context, parse its nodes, and return the list of upstream node keys as the first-level children. This makes the browser context-aware.
JSON Response Examples
GET /api/expressions/discover/directives
[
{
"name": "ctx",
"displayName": "Context",
"description": "Environment, tenant, user, and time values",
"iconUrl": null,
"requiredIsolationLevel": "Safe"
},
{
"name": "var",
"displayName": "Variable",
"description": "Workflow memory variables",
"requiredIsolationLevel": "Safe"
},
{
"name": "js",
"displayName": "JavaScript",
"description": "Inline JavaScript in a Jint sandbox",
"requiredIsolationLevel": "Sandboxed"
}
]
GET /api/expressions/discover/directives/ctx/paths
[
{ "path": "env", "displayName": "Environment", "returnType": "String", "hasChildren": true, "isLeaf": false },
{ "path": "tenant", "displayName": "Tenant", "returnType": "Object", "hasChildren": true, "isLeaf": false },
{ "path": "user", "displayName": "Current User", "returnType": "Object", "hasChildren": true, "isLeaf": false },
{ "path": "now", "displayName": "Current DateTime", "returnType": "DateTime", "exampleValue": "2024-03-15T10:22:01Z", "hasChildren": false, "isLeaf": true },
{ "path": "today", "displayName": "Current Date", "returnType": "String", "exampleValue": "2024-03-15", "hasChildren": false, "isLeaf": true }
]
GET /api/expressions/discover/directives/ctx/paths?parent=user
[
{ "path": "user.id", "displayName": "User ID", "returnType": "Number", "exampleValue": "42", "isLeaf": true },
{ "path": "user.name", "displayName": "Display Name", "returnType": "String", "exampleValue": "Alice Smith", "isLeaf": true },
{ "path": "user.email", "displayName": "Email", "returnType": "String", "exampleValue": "alice@acme.com", "isLeaf": true },
{ "path": "user.roles", "displayName": "Roles", "returnType": "Array", "exampleValue": "[\"Admin\"]", "isLeaf": true }
]
Implementing IDirectiveMetadataProvider
The concrete directive class can also implement IDirectiveMetadataProvider, or a separate companion class can be provided:
public class ContextDirectiveMetadata : IDirectiveMetadataProvider
{
public string DirectiveName => "ctx";
public string DisplayName => "Context";
public string Description => "Environment variables, tenant, user, app, platform, and time";
public string? IconUrl => null;
public string? DocsUrl => null;
public ExpressionIsolationLevel RequiredIsolationLevel
=> ExpressionIsolationLevel.Safe;
public IReadOnlyList<DirectivePath> GetTopLevelPaths() =>
[
new("env", "Environment", "Environment variable (requires name)", ReturnType.String, null, HasChildren: true, IsLeaf: false),
new("tenant", "Tenant", "Current tenant metadata", ReturnType.Object, null, HasChildren: true, IsLeaf: false),
new("user", "Current User", "Authenticated user properties", ReturnType.Object, null, HasChildren: true, IsLeaf: false),
new("app", "Application", "Current application metadata", ReturnType.Object, null, HasChildren: true, IsLeaf: false),
new("platform", "Platform", "BizFirst platform metadata", ReturnType.Object, null, HasChildren: true, IsLeaf: false),
new("now", "Current DateTime", "UTC datetime string (ISO 8601)", ReturnType.DateTime, "2024-03-15T10:22Z", HasChildren: false, IsLeaf: true),
new("today", "Current Date", "Current date (YYYY-MM-DD)", ReturnType.String, "2024-03-15", HasChildren: false, IsLeaf: true),
];
public async Task<IReadOnlyList<DirectivePath>> GetChildPathsAsync(
string parentPath, CancellationToken ct) =>
parentPath switch
{
"user" => UserPaths(),
"tenant" => TenantPaths(),
"env" => [], // environment variable names are runtime-dynamic
_ => []
};
private static DirectivePath[] UserPaths() =>
[
new("user.id", "ID", "User ID", ReturnType.Number, "42", false, true),
new("user.name", "Name", "Display name", ReturnType.String, "Alice Smith", false, true),
new("user.email", "Email", "Email address", ReturnType.String, "alice@acme.com", false, true),
new("user.roles", "Roles", "Role names (array)", ReturnType.Array, "[\"Admin\"]", false, true),
];
}
UI Integration Patterns
Pattern 1 — Autocomplete Trigger
// When user types "{@" in expression field:
await fetch('/api/expressions/discover/directives')
// → render dropdown of all directives with icons and descriptions
// When user selects "$ctx":
await fetch('/api/expressions/discover/directives/ctx/paths')
// → render nested path browser
// When user drills into "user":
await fetch('/api/expressions/discover/directives/ctx/paths?parent=user')
// → render sub-path list with type badges
Pattern 2 — Type-Aware Option Filtering
// User selected $ctx.user.name (ReturnType = String)
// Show only string-compatible options:
await fetch('/api/expressions/discover/options?directive=ctx&path=user.name')
// → [uppercase, lowercase, trim, required, not-empty, default:, cache, cache-thread, cache-process]
// NOT shown: json (only for object/array), date (only for DateTime)
Pattern 3 — Expression Validation
// Before saving a workflow node, validate all field expressions:
POST /api/expressions/validate
{
"expressions": [
"{@ $ctx.user.name @uppercase }",
"{@ $var.missingVar @required }"
],
"tenantId": 5,
"appId": 7
}
// Response:
{
"results": [
{ "expression": "{@ $ctx.user.name @uppercase }", "valid": true },
{ "expression": "{@ $var.missingVar @required }", "valid": true, "warnings": ["Variable 'missingVar' not found at design time — ensure it is set at runtime"] }
]
}
Discovering Canned Expressions
Canned expressions are surfaced through a separate discovery endpoint since they're tenant/app-scoped:
// GET /api/expressions/canned?tenantId=5&appId=7&tag=finance
[
{
"alias": "company.currency",
"expression": "{@ $ctx.tenant.currency }",
"description": "Tenant default currency code",
"tags": ["finance", "global"],
"scope": "tenant" // "app" if AppId-specific
},
{
"alias": "company.taxRate",
"expression": "0.20",
"description": "Standard VAT rate",
"tags": ["finance", "tax"],
"scope": "app" // overrides tenant-wide
}
]
The UI can show these as a searchable palette alongside the directive browser, allowing users to pick an alias as an alternative to building a directive expression from scratch.
POST /api/expressions/canned/{id}/preview with the current tenant/app context to show a live preview of what the alias resolves to.