Plugin / Extension Guide
How a third-party vendor or internal team creates a new directive and registers it in the expression framework.
Contents
The Plugin Model
Every directive in BizFirst is a plugin. The framework provides no "magic" built-in directives — even $ctx, $var, and $input are independently registered via DI extension methods. A third-party vendor follows the exact same pattern.
IExpressionDirectiveService with the DI container, your directive is a first-class citizen with full access to the evaluation context.
Directive naming rules:
- Name must be lowercase, alphanumeric with optional hyphens
- The
$prefix is used in expressions but NOT in theDirectiveNameproperty or DI key - Name must be unique across all registered directives in a host
- Recommended format for third-party:
vendorname-concept(e.g.,acme-crm)
Step 1 — Create the Project
Create a .NET class library targeting net9.0. Add a NuGet reference to the BizFirst Expression Domain package:
<!-- Acme.CRM.ExpressionDirective.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BizFirst.Runtime.Expressions.Evaluation.Domain"
Version="1.0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="9.0.*" />
</ItemGroup>
</Project>
Step 2 — Implement the Directive
Extend BaseDirectiveEvaluator and implement EvaluateAsync:
using BizFirst.Runtime.Expressions.Evaluation.Domain;
public class AcmeCrmDirectiveService : BaseDirectiveEvaluator
{
public override string DirectiveName => "acme-crm";
// Used in expressions as: {@ $acme-crm.contacts.email }
private readonly IAcmeCrmClient _crmClient;
public AcmeCrmDirectiveService(IAcmeCrmClient crmClient)
{
_crmClient = crmClient;
}
protected override async Task<EvaluationResponse> EvaluateAsync(
EvaluationRequest request,
CancellationToken ct)
{
// 1. Enforce isolation level
if (Context.IsolationLevel < ExpressionIsolationLevel.Trusted)
return EvaluationResponse.Failure(
EvaluationErrorCode.IsolationViolation,
"$acme-crm requires Trusted isolation level");
// 2. Parse path: "contacts.email" → resource = "contacts", field = "email"
var segments = request.Path.Split('.', 2);
if (segments.Length < 2)
return EvaluationResponse.Failure(
EvaluationErrorCode.ParseError,
"$acme-crm path must be: resource.field (e.g., contacts.email)");
var resource = segments[0];
var field = segments[1];
// 3. Get the entity ID from memory or input
var entityId = Context.NodeExecutionData?.CurrentItem?.GetField("crmId")?.ToString();
if (entityId is null)
return EvaluationResponse.Failure(
EvaluationErrorCode.PathNotFound,
"No 'crmId' field found in current input item");
// 4. Call external CRM API
var data = await _crmClient.GetFieldAsync(resource, entityId, field, ct);
if (data is null)
return EvaluationResponse.Failure(
EvaluationErrorCode.PathNotFound,
$"CRM {resource}/{entityId}/{field} not found");
return EvaluationResponse.Success(data);
}
}
Step 3 — Register via DI Extension
Create a DI extension method following the framework convention:
public static class AcmeCrmDirectiveServiceCollectionExtensions
{
public static IServiceCollection AddAcmeCrmDirective(
this IServiceCollection services,
Action<AcmeCrmOptions>? configure = null)
{
if (configure is not null)
services.Configure(configure);
services.AddHttpClient<IAcmeCrmClient, AcmeCrmClient>();
services.AddTransient<IExpressionDirectiveService, AcmeCrmDirectiveService>();
return services;
}
}
BaseDirectiveEvaluator stores the active request on _activeRequest — sharing an instance across concurrent calls would cause race conditions.
Step 4 — Register in Host
The consuming application adds the vendor directive to the DI chain:
builder.Services
.AddExpressionEvaluationCore(builder.Configuration)
.AddContextDirective()
.AddMemoryVariableDirective()
// ... other built-in directives ...
.AddAcmeCrmDirective(opts => // ← vendor directive
{
opts.BaseUrl = "https://api.acme-crm.com/v2";
opts.ApiKey = builder.Configuration["AcmeCRM:ApiKey"]!;
});
Step 5 — Add Metadata (optional, for Discovery API)
Implement IDirectiveMetadataProvider to expose your directive's paths in the expression builder UI:
public class AcmeCrmDirectiveMetadata : IDirectiveMetadataProvider
{
public string DirectiveName => "acme-crm";
public string DisplayName => "Acme CRM";
public string Description => "Read customer and contact data from Acme CRM";
public string IconUrl => "https://cdn.acme.com/logo-32.png";
public string DocsUrl => "https://docs.acme-crm.com/bizfirst";
public IReadOnlyList<DirectivePath> GetTopLevelPaths() =>
[
new DirectivePath("contacts", "Contact records", hasChildren: true),
new DirectivePath("accounts", "Account (company) records", hasChildren: true),
new DirectivePath("deals", "Deal/opportunity records", hasChildren: true),
];
public async Task<IReadOnlyList<DirectivePath>> GetChildPathsAsync(
string parentPath,
CancellationToken ct) =>
parentPath switch
{
"contacts" => [
new("contacts.email", "Email address", ReturnType.String),
new("contacts.firstName", "First name", ReturnType.String),
new("contacts.lastName", "Last name", ReturnType.String),
new("contacts.phone", "Phone number", ReturnType.String),
],
"accounts" => [ /* ... */ ],
_ => []
};
}
Security Checklist
| Concern | Required Action |
|---|---|
| External HTTP calls | Declare IsolationLevel.Trusted requirement; check at start of EvaluateAsync |
| Secrets in logs | Set IsSensitive = true on any option that outputs credentials |
| Tenant isolation | Always scope queries by Context.TenantId — never return data for other tenants |
| Input validation | Validate parsed path segments before using them in queries or API calls |
| Timeout | Pass CancellationToken to all async calls; respect cancellation |
| Error leakage | Catch exceptions and return EvaluationResponse.Failure — never let exceptions propagate |
| Depth guard | Call GuardChainDepth() before any chaining call |
Testing Your Directive
[Fact]
public async Task ReturnsContactEmail_WhenCrmIdPresentInInput()
{
// Arrange
var crmClient = Substitute.For<IAcmeCrmClient>();
crmClient.GetFieldAsync("contacts", "42", "email", Arg.Any<CancellationToken>())
.Returns("alice@acme.com");
var directive = new AcmeCrmDirectiveService(crmClient);
var context = new EvaluationContext
{
TenantId = "5",
ExecutionId = Guid.NewGuid().ToString(),
ThreadId = "t1",
NodeKey = "TestNode",
IsolationLevel = ExpressionIsolationLevel.Trusted,
Cache = NoOpExpressionCache.Instance,
Memory = NoOpExecutionMemory.Instance,
NodeExecutionData = FakeNodeData("crmId", "42"),
};
var request = new EvaluationRequest
{
Directives = [new DirectiveToken("acme-crm")],
Path = "contacts.email",
RawExpression = "{@ $acme-crm.contacts.email }",
Context = context,
Depth = 0,
VisitedKeys = ImmutableHashSet<string>.Empty,
};
// Act
var response = await directive.InvokeAsync(request, CancellationToken.None);
// Assert
response.IsSuccess.Should().BeTrue();
response.Value.Should().Be("alice@acme.com");
}
Complete Example — $weather Directive
A simple read-only directive that retrieves weather data. No isolation level requirement since it's a safe read:
// Usage: {@ $weather.london.temperature } → 18
// {@ $weather.london.description } → "Partly cloudy"
public class WeatherDirectiveService : BaseDirectiveEvaluator
{
public override string DirectiveName => "weather";
private readonly IWeatherApiClient _weather;
public WeatherDirectiveService(IWeatherApiClient weather) => _weather = weather;
protected override async Task<EvaluationResponse> EvaluateAsync(
EvaluationRequest request, CancellationToken ct)
{
var parts = request.Path.Split('.', 2);
var city = parts[0];
var field = parts.Length > 1 ? parts[1] : "temperature";
try
{
var data = await _weather.GetAsync(city, ct);
var value = field switch
{
"temperature" => (object)data.Temperature,
"description" => data.Description,
"humidity" => data.Humidity,
"windSpeed" => data.WindSpeed,
_ => null
};
return value is null
? EvaluationResponse.Failure(EvaluationErrorCode.PathNotFound, $"Unknown field: {field}")
: EvaluationResponse.Success(value);
}
catch (Exception ex)
{
return EvaluationResponse.Failure(EvaluationErrorCode.ExternalApiError, ex.Message);
}
}
}