Portal Community

Contents

  1. The Plugin Model
  2. Step 1 — Create the Project
  3. Step 2 — Implement the Directive
  4. Step 3 — Register via DI Extension
  5. Step 4 — Register in Host
  6. Step 5 — Add Metadata (optional)
  7. Security Checklist
  8. Testing Your Directive
  9. Complete Example — $weather Directive

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.

Everything is a plugin If you can register an implementation of IExpressionDirectiveService with the DI container, your directive is a first-class citizen with full access to the evaluation context.

Directive naming rules:

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;
    }
}
Transient lifetime All directives must be registered as Transient. 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

ConcernRequired Action
External HTTP callsDeclare IsolationLevel.Trusted requirement; check at start of EvaluateAsync
Secrets in logsSet IsSensitive = true on any option that outputs credentials
Tenant isolationAlways scope queries by Context.TenantId — never return data for other tenants
Input validationValidate parsed path segments before using them in queries or API calls
TimeoutPass CancellationToken to all async calls; respect cancellation
Error leakageCatch exceptions and return EvaluationResponse.Failure — never let exceptions propagate
Depth guardCall 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);
        }
    }
}