Add output converter support at binding boundaries

This commit is contained in:
Sipke Schoorstra 2026-07-31 04:10:48 +02:00
parent 1000d29feb
commit d698e6b005
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
78 changed files with 4202 additions and 42 deletions

View file

@ -1,3 +1,3 @@
{
"feature_directory": "specs/008-weaver-ai-copilot"
"feature_directory": "specs/012-output-converters"
}

45
CONTEXT.md Normal file
View file

@ -0,0 +1,45 @@
# Elsa Workflow Runtime
The workflow runtime executes activities and moves their results into destinations that workflows can consume.
## Language
**Activity Output**:
The native value produced by an activity. Its meaning and type belong to the activity's contract and are not changed by a consumer's binding choices. Activity-output registers, journals, APIs, and diagnostics expose this native value.
_Avoid_: Converted output, bound output
**Output Binding**:
The association that delivers an Activity Output to a variable or workflow output. It may apply at most one explicitly configured Output Converter before delivery. Its optional persisted converter configuration contains only a Converter ID and JSON Converter Settings. Without converter configuration, it follows the existing assignment path unchanged.
_Avoid_: Activity output, output definition
**Output Converter**:
An optional deterministic, side-effect-free transformation selected strictly at an Output Binding. It changes the value delivered by that binding without changing the underlying Activity Output. It does not convert activity inputs or general expression results. Environmental choices such as locale are explicit Converter Settings.
_Avoid_: Activity converter, implicit coercion
**Converter ID**:
The stable semantic identifier by which an Output Binding explicitly selects a registered Output Converter. Matching is ordinal and case-sensitive, while registrations that differ only by case are rejected. Breaking changes to conversion behavior, settings, or result semantics use a new Converter ID.
_Avoid_: Converter type name, converter class
**Converter Settings**:
Optional workflow-specific parameters that refine how the selected Output Converter transforms one Output Binding. They are immutable during conversion.
_Avoid_: Global converter options, converter service configuration
**Converter Descriptor**:
The server-owned, API-discoverable identity and compatibility declaration of an Output Converter, including its supported source type, declared result type, localizable display metadata, and optional JSON Schema for Converter Settings. Source compatibility follows base-class and interface assignability; the result must be assignable to the Destination Type. Display metadata is not persisted with the workflow.
_Avoid_: Converter instance, activity descriptor
**Conversion Context**:
The narrow, immutable input supplied to an Output Converter: the native value, declared source and destination types, and Converter Settings. It does not expose mutable workflow execution state or a service locator.
_Avoid_: Activity execution context, workflow context
**Destination Type**:
The resolvable declared type of the variable or workflow output receiving a Bound Value. `object` is a valid Destination Type; an unknown or untyped destination is not.
_Avoid_: Runtime value type, inferred target
**Output Conversion Error**:
The dedicated activity fault raised when converter resolution, settings validation, compatibility checking, invocation, or result validation fails. It carries structured converter, activity, output, destination, and failure-stage metadata without exposing native values or raw settings by default.
_Avoid_: Assignment error, converter log message
**Bound Value**:
The value delivered only to the destination of an Output Binding after any configured Output Converter has run. It may be null only when the destination permits null.
_Avoid_: Activity output

View file

@ -129,6 +129,7 @@
<PackageVersion Include="IronCompress" Version="1.7.0"/>
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4"/>
<PackageVersion Include="Jint" Version="4.4.2"/>
<PackageVersion Include="JsonSchema.Net" Version="9.4.0"/>
<PackageVersion Include="LinqKit.Core" Version="1.2.11"/>
<PackageVersion Include="MailKit" Version="4.14.1"/>
<PackageVersion Include="MassTransit" Version="8.5.7"/>

View file

@ -0,0 +1,5 @@
# Output conversion occurs synchronously at the binding boundary
An Activity Output remains the native value defined by its activity and is the value exposed by output registers, journals, APIs, and diagnostics. An explicitly configured Output Converter synchronously transforms only the Bound Value delivered to a variable or workflow output; null inputs bypass conversion, and a converter-produced null is valid only for a nullable destination. Conversion and result validation complete before the destination is written, so a failure leaves it unchanged while retaining the native Activity Output for diagnostics.
Async conversion, activity-input conversion, expression coercion, converter chains, and converter configuration without a destination are outside this boundary. A binding without converter configuration follows the existing assignment path without converter-related lookup, validation, processing, or allocation.

View file

@ -0,0 +1,7 @@
# Output converters use explicit stable identities
Each Output Binding selects zero or one registered Output Converter by an ordinal, case-sensitive Converter ID; registrations that duplicate an ID or differ only by case are rejected. Converter IDs have immutable semantics, so breaking behavior, settings, or result changes require a new ID, and Core never infers a converter from a source/destination type pair.
Converters are synchronous, deterministic, and side-effect-free. They receive a narrow immutable Conversion Context containing the native value, declared types, and JSON-only settings; dependencies use constructor injection, instances resolve from the active workflow scope, and cached descriptors never retain scoped services. Source compatibility uses normal base-class and interface assignability, the declared result type must be assignable to a resolvable Destination Type, and open-generic matching is deferred.
Core validates registration, compatibility, and settings when accepting or materializing a definition and repeats safety checks at runtime. Resolution, validation, invocation, and result failures enter normal activity fault handling through a privacy-safe Output Conversion Error that preserves an originating exception but omits native values and raw settings by default.

View file

@ -0,0 +1,5 @@
# Output converter discovery is server-owned
Core owns the Converter Descriptor registry, and Elsa's API exposes descriptors filterable by declared source and Destination Type. Descriptors include a stable ID, compatible types, localizable display metadata, and an optional JSON Schema for settings; Studio and other clients consume this catalog instead of hard-coding converter implementations.
Workflow JSON persists only an optional `converter` object containing `id` and JSON `settings`; it never persists CLR types, descriptors, instances, or display metadata. Core ships the infrastructure and a reference converter in tests or a sample, while production modules register converters whose semantics they own instead of Core providing a broad coercion catalog.

View file

@ -10,3 +10,6 @@
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
* [9. Asterisk Sentinel Value for Tenant-Agnostic Entities](0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md)
* [10. Default Admin User Bootstrap for Initial Identity Access](0010-default-admin-user-bootstrap-for-initial-identity-access.md)
* [11. Output Conversion Occurs Synchronously at the Binding Boundary](0011-output-conversion-at-binding-is-synchronous.md)
* [12. Output Converters Use Explicit Stable Identities](0012-output-converters-use-explicit-stable-identities.md)
* [13. Output Converter Discovery Is Server-Owned](0013-output-converter-discovery-is-server-owned.md)

View file

@ -43,6 +43,7 @@ flowchart LR
| [Workflow Runtime](workflow-runtime.md) | Dispatch, triggers, bookmarks, queues, background activity scheduling, graceful shutdown, and recovery. |
| [Workflow API](workflow-api.md) | FastEndpoints, route prefixing, API categories, SignalR, and client-facing contracts. |
| [Activities And Authoring](activities-and-authoring.md) | How workflows are authored in C#, JSON, ElsaScript, and host methods. |
| [Output Converters](output-converters.md) | How to register, configure, validate, discover, and operate bound-value converters. |
| [Expressions And Scripting](expressions-and-scripting.md) | Expression evaluators and language feature packages. |
| [HTTP, Scheduling, And Resilience](http-scheduling-resilience.md) | Inbound HTTP workflows, outbound HTTP, scheduled triggers, and resilience strategies. |
| [Persistence](persistence.md) | In-memory stores, EF Core stores, provider packages, migrations, and multi-provider rules. |

View file

@ -0,0 +1,110 @@
# Output Converters
Output converters let an extension transform an activity's native output for one bound variable or workflow output. Conversion is explicit and opt-in: activity-output registers and diagnostics keep the native value, while only the binding destination receives the converted value.
## Implement And Register A Converter
Converters are synchronous, deterministic, and side-effect free. Their context contains only the native value, declared source and destination types, and immutable JSON settings.
```csharp
using System.Globalization;
using System.Text.Json;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Models;
public sealed class NumberToTextConverter : IOutputConverter
{
public object Convert(OutputConversionContext context)
{
var format = context.Settings?.TryGetProperty("format", out var value) == true
? value.GetString()
: null;
return ((decimal)context.Value).ToString(format, CultureInfo.InvariantCulture);
}
}
using var schemaDocument = JsonDocument.Parse(
"""{"type":"object","properties":{"format":{"type":"string"}}}""");
services.AddOutputConverter<NumberToTextConverter>(
new OutputConverterDescriptor(
"sample.number-to-text.v1",
typeof(decimal),
typeof(string),
"Number to text",
"Formats a decimal using an explicit invariant format.",
schemaDocument.RootElement));
```
The converter ID is a persisted public contract. Keep lookup case-sensitive, do not reuse an ID for breaking behavior or settings changes, and register a new versioned ID instead.
The default service lifetime is scoped. Elsa resolves the implementation from the active workflow execution scope; the registry caches descriptors and registrations, not converter instances.
## Configure A Binding
Converter configuration belongs to an `Output<T>` binding:
```csharp
activity.Result = new Output<decimal>(targetVariable)
{
Converter = new OutputConverterConfiguration(
"sample.number-to-text.v1",
JsonDocument.Parse("""{"format":"0.00"}""").RootElement)
};
```
The serialized binding adds one optional object:
```json
{
"typeName": "Decimal",
"memoryReference": {
"id": "formatted-total"
},
"converter": {
"id": "sample.number-to-text.v1",
"settings": {
"format": "0.00"
}
}
}
```
Bindings without a converter omit `converter` and retain the existing assignment path.
## Validation And Failure Behavior
At definition validation and again at runtime, Elsa checks:
- the binding has a resolvable variable or workflow-output destination;
- the converter ID is registered;
- the declared output type is compatible with the converter source type;
- the converter result type is assignable to the destination;
- JSON Schema and converter-owned settings validation pass.
Null native values bypass converter invocation and are delivered only to destinations that permit null. Conversion and result validation finish before Elsa writes the destination, so a failure leaves it unchanged.
Runtime failures fault through Elsa's normal activity fault pipeline as `OutputConversionException`. Persisted exception state includes safe structured identities and the failure stage; it excludes native values, raw settings, and converter exception details.
## Discovery API And Studio
Studio queries:
```http
GET /descriptors/output-converters?sourceType=Decimal&destinationType=String
```
The endpoint requires `read:*` or `read:output-converters`. It returns compatible IDs, declared type names, display metadata, and optional settings schemas. It never exposes converter instances, implementation types, or service lifetimes.
Studio offers schema-driven fields for simple object schemas and a raw JSON-object editor otherwise. Unknown persisted converter IDs remain visible, and older servers that do not expose discovery do not cause Studio to delete existing configuration.
## Operational Guidance
- Make locale, timezone, rounding, and similar environmental choices explicit settings.
- Do not perform I/O or mutate workflow state from a converter.
- Treat converter removal as deployment drift: previously published workflows that reference it will fault at assignment.
- Use activity inputs or an explicit activity when transformation is asynchronous or has side effects.
The design decisions are recorded in [ADR 0011](../adr/0011-output-conversion-at-binding-is-synchronous.md), [ADR 0012](../adr/0012-output-converters-use-explicit-stable-identities.md), and [ADR 0013](../adr/0013-output-converter-discovery-is-server-owned.md).

View file

@ -0,0 +1,78 @@
# Output Converter Completion Audit
This checklist maps the approved requirements to implementation and automated evidence. A checked item means the requirement is represented in production code and covered by the cited test or review evidence.
## Functional requirements
| Status | Requirement | Evidence |
| --- | --- | --- |
| [x] | FR-001: zero or one explicit converter | `Output.Converter`; output JSON and Studio round-trip tests |
| [x] | FR-002: stable ID plus optional JSON settings | `OutputConverterConfiguration`; serialization tests |
| [x] | FR-003: unchanged omitted shape and assignment | `OutputJsonConverterTests`; no-converter runtime tests |
| [x] | FR-004: no implementation details persisted | serialization and descriptor-redaction tests |
| [x] | FR-005: native output, converted bound value | activity-execution-context and component tests |
| [x] | FR-006: native observation surfaces | native output-register assertions and unchanged journal path |
| [x] | FR-007: synchronous boundary conversion | `IOutputConverter.Convert`; `ActivityExecutionContext.Set` |
| [x] | FR-008: native null bypasses invocation | invoker/runtime null-bypass tests |
| [x] | FR-009: converter null obeys destination nullability | destination and invoker nullability tests |
| [x] | FR-010: validate before destination write | invoker atomicity tests |
| [x] | FR-011: failure preserves destination and native output | unit atomicity and missing-registration component scenario |
| [x] | FR-012: stable ordinal case-sensitive IDs | registry tests |
| [x] | FR-013: reject duplicate and case-only IDs | registry tests |
| [x] | FR-014: behavioral versions use new IDs | public extension documentation and ADR 0012 |
| [x] | FR-015: no inferred selection | explicit configuration path and registry tests |
| [x] | FR-016: declared source and result types | `OutputConverterDescriptor` |
| [x] | FR-017: source assignability | registry and invoker compatibility tests |
| [x] | FR-018: declared/runtime result assignability | destination resolver and invoker tests |
| [x] | FR-019: reject unknown destination; allow `object` | destination resolver and management validation tests |
| [x] | FR-020: no open generics or chains | registration rejection tests; single configuration model |
| [x] | FR-021: narrow immutable invocation context | `OutputConversionContext` |
| [x] | FR-022: no mutable workflow state or service locator | public converter contract review |
| [x] | FR-023: active-scope dependency resolution | keyed registration and multi-scope tests |
| [x] | FR-024: descriptors do not retain scoped instances | registry design and lifetime tests |
| [x] | FR-025: deterministic, side-effect-free contract | XML/public documentation and reference converter tests |
| [x] | FR-026: definition-time validation | `ValidateOutputConvertersTests` |
| [x] | FR-027: runtime drift validation | missing-registration component scenario |
| [x] | FR-028: schema plus custom settings validation | settings-validator tests |
| [x] | FR-029: dedicated normal-pipeline fault, no custom retry | `OutputConversionException`; fault component scenario |
| [x] | FR-030: structured contextual metadata | exception-state tests and component incident assertions |
| [x] | FR-031: originating exception retained at runtime | invoker exception-cause tests |
| [x] | FR-032: values/settings excluded from default errors | privacy and exception-state tests |
| [x] | FR-033: server registry/query capability | `OutputConverterRegistry`; descriptor endpoint |
| [x] | FR-034: API filtering by declared types | endpoint tests |
| [x] | FR-035: safe descriptor response | endpoint redaction test |
| [x] | FR-036: Studio uses API discovery | `RemoteOutputConverterService` and tests |
| [x] | FR-037: Studio filters by declared binding types | Outputs-tab tests, including arrays |
| [x] | FR-038: Studio select/configure/clear/reopen/validate | Outputs-tab and settings-editor tests |
| [x] | FR-039: schema-driven editor with raw JSON fallback | settings-editor tests |
| [x] | FR-040: no Core production catalog | repository review; reference converter exists only in tests |
| [x] | FR-041: inputs/expression coercion unchanged | change-scope review |
| [x] | FR-042: cross-cutting automated coverage | Core, Management, API, client, component, and Studio suites listed below |
## Success criteria
| Status | Criterion | Evidence |
| --- | --- | --- |
| [x] | SC-001: existing no-converter serialization/assignment behavior | omitted-shape and no-converter-path tests |
| [x] | SC-002: no lookup/allocation and no regression over 2% | zero-infrastructure-call test; fixed-count benchmark measured 1.275 µs versus 1.294 µs with overlapping confidence intervals |
| [x] | SC-003: static invalid configurations rejected | 3 management validation tests |
| [x] | SC-004: all failure stages yield safe dedicated faults | invoker, privacy, and exception-state tests |
| [x] | SC-005: complete Studio authoring flow | automated component flow; the two-minute threshold is a manual UX acceptance measure |
| [x] | SC-006: configuration survives every round trip | Core serialization, API client, and Studio tests |
| [x] | SC-007: native and converted values independently correct | variable and workflow-output scenarios |
| [x] | SC-008: extension-only converter registration/discovery | registration-usage and API discovery tests |
## Verification log
- Core output-converter and serialization tests: 45 passed on `net10.0`.
- Management definition-validation tests: 3 passed on `net10.0`.
- API endpoint tests: 6 passed on `net10.0`.
- API-client component tests: 2 passed on `net10.0`.
- Runtime component tests: 3 passed on `net10.0` (workflow-output scenario plus the corrected variable and missing-registration scenarios).
- Studio output-converter tests: 8 passed on `net10.0` against this Core worktree.
- Core project build: passed on `net10.0` with zero warnings and errors.
- Core, Management, API, and API-client projects built successfully for `net8.0`, `net9.0`, and `net10.0` during the solution build.
- The complete solution build could not finish with `--no-restore` because unrelated projects had no `project.assets.json` in this worktree (`Elsa.Workflows.IntegrationTests`, `Elsa.Hosting.Management`, and `Elsa.Workflows.Runtime.UnitTests`).
- The final fixed-count BenchmarkDotNet run measured the no-converter path at 1.275 µs versus 1.294 µs for the legacy-equivalent path, with strongly overlapping confidence intervals and no statistically significant regression. Code review confirms that the added null property check does not allocate, while unit tests confirm zero converter-registry, resolver, validator, or invoker calls.
All functional requirements and measurable success criteria have implementation evidence. SC-005's two-minute threshold remains a release-level manual UX confirmation in addition to its automated Studio flow coverage.

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Extensible Activity Output Converters
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-30
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details leak into product behavior requirements
- [x] Focused on workflow-author, extension-developer, and operator value
- [x] Written so requirements and outcomes are understandable outside the implementation
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are outcome-focused
- [x] Acceptance scenarios cover each prioritized user story
- [x] Edge cases are identified
- [x] Scope and explicit exclusions are clear
- [x] Dependencies and assumptions are identified
## Feature Readiness
- [x] Functional requirements have clear acceptance evidence
- [x] User scenarios cover runtime, extension, validation, and Studio flows
- [x] Measurable outcomes define completion
- [x] The specification is ready for technical planning
## Notes
- All significant product and semantic choices were resolved during the preceding structured design interview and recorded in the related ADRs.

View file

@ -0,0 +1,63 @@
# REST API Contract: Output Converter Descriptors
## List compatible descriptors
```http
GET /descriptors/output-converters?sourceType={typeName}&destinationType={typeName}
```
Authorization:
```text
read:* OR read:output-converters
```
Both query parameters are required registered type aliases or resolvable safe type names.
### Successful response
```json
{
"items": [
{
"id": "sample.to-text",
"sourceTypeName": "Sample.Source",
"resultTypeName": "String",
"displayName": "Convert to text",
"description": "Formats the source as text.",
"settingsSchema": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["compact", "indented"]
}
}
}
}
]
}
```
### Validation responses
- `400`: either type parameter is missing or cannot be resolved.
- `403`: caller lacks descriptor permission when API security is enabled.
### Safety
The response never contains converter implementation types, service keys beyond the public ID, instances, factories, lifetimes, or settings values from workflow definitions.
## API client
```csharp
public interface IOutputConvertersApi
{
[Get("/descriptors/output-converters")]
Task<ListOutputConvertersResponse> ListAsync(
[Query] ListOutputConvertersRequest request,
CancellationToken cancellationToken = default);
}
```
`ListOutputConvertersRequest` contains `SourceType` and `DestinationType`. The response model mirrors the safe descriptor shape using strings and `JsonElement?`.

View file

@ -0,0 +1,59 @@
# Runtime Extension Contract
The public API will expose equivalent C# contracts with XML documentation.
```csharp
public sealed record OutputConverterConfiguration(
string Id,
JsonElement? Settings = null);
public sealed record OutputConverterDescriptor(
string Id,
Type SourceType,
Type ResultType,
string DisplayName,
string? Description = null,
JsonElement? SettingsSchema = null);
public sealed record OutputConversionContext(
object Value,
Type SourceType,
Type DestinationType,
JsonElement? Settings);
public interface IOutputConverter
{
object? Convert(OutputConversionContext context);
IEnumerable<string> ValidateSettings(JsonElement? settings);
}
public interface IOutputConverterRegistry
{
IEnumerable<OutputConverterDescriptor> ListAll();
OutputConverterDescriptor? Find(string id);
IEnumerable<OutputConverterDescriptor> FindCompatible(
Type sourceType,
Type destinationType);
}
```
Registration supports an explicit lifetime:
```csharp
services.AddOutputConverter<TConverter>(
descriptor,
ServiceLifetime.Scoped);
```
Required semantics:
- Converter ID lookup is ordinal and case-sensitive.
- Exact and case-only duplicate registrations fail.
- A descriptor is compatible when its source type is assignable from the declared output type and its result type is assignable to the destination type.
- The converter implementation resolves from the active scope using its ID.
- Settings and schema values are cloned before storage/exposure.
- Null native values bypass `Convert`.
- Converter implementations are deterministic and side-effect-free.

View file

@ -0,0 +1,35 @@
# Studio Authoring Contract
## Binding editor
For each activity output:
1. The existing destination selector retains destination identity and declared type metadata.
2. When a typed destination is selected, Studio queries compatible descriptors using the output and destination type names.
3. Studio displays an optional Converter selector with a None choice.
4. Selecting a converter writes only its ID and settings into the activity output JSON.
5. Clearing the converter removes the optional converter object.
6. Changing the destination clears a converter only when it is no longer compatible.
7. Unrelated activity edits preserve converter JSON exactly.
## Settings editor
- With a supported object JSON Schema, render fields for string, number, integer, boolean, enum, required, title, description, and default.
- With no schema or unsupported schema constructs, render a raw JSON object editor.
- Malformed or non-object JSON is rejected locally.
- Server definition-validation messages remain authoritative and are surfaced to the author.
- Read-only workspaces cannot modify converter selection or settings.
## Loading and compatibility
- Cancel or ignore stale descriptor requests when output/destination selection changes.
- If descriptor discovery is unavailable on an older server, hide or disable new controls without deleting persisted converter configuration.
- If a persisted Converter ID is unknown, display the ID and validation state rather than silently clearing it.
- Studio-owned labels and errors are localized; server display text falls back to the Converter ID.
## Independent verification
- Query arguments use the selected declared source and destination types.
- Selecting, configuring, saving, reopening, and clearing round-trip correctly.
- Destination changes invalidate only incompatible converter selections.
- Read-only, unavailable-server, unknown-ID, and malformed-settings states preserve workflow data.

View file

@ -0,0 +1,108 @@
# Data Model: Extensible Activity Output Converters
## Output Binding
Existing `Output`/`Output<T>` binding model with one new optional relationship:
- `Converter`: zero or one Converter Configuration
- Existing memory reference remains the destination identity
- Native output type remains `T` or `object` for untyped outputs
Validation:
- Converter configuration requires a resolvable destination.
- No configuration preserves existing serialization and behavior.
## Converter Configuration
- `Id`: required non-empty Converter ID
- `Settings`: optional JSON object, cloned when persisted or invoked
Serialization:
```json
{
"typeName": "String",
"memoryReference": {
"id": "resultVariable"
},
"converter": {
"id": "sample.to-text",
"settings": {
"format": "compact"
}
}
}
```
## Output Converter Registration
- `Descriptor`: immutable Converter Descriptor
- `ServiceKey`: exact Converter ID used for keyed DI resolution
- `ServiceLifetime`: transient, scoped, or singleton registration lifetime
Uniqueness:
- Exact ordinal IDs are unique.
- IDs differing only by case are also prohibited.
## Converter Descriptor
- `Id`: stable semantic identity
- `SourceType`: supported source CLR type
- `ResultType`: declared result CLR type
- `DisplayName`: discoverable display text
- `Description`: optional discoverable description
- `SettingsSchema`: optional cloned JSON Schema
API projection replaces CLR types with registered aliases or safe type names and omits service registration data.
## Output Conversion Context
- `Value`: non-null native Activity Output
- `SourceType`: declared Activity Output type
- `DestinationType`: resolved declared destination type
- `Settings`: immutable/cloned JSON settings
The context has no workflow execution object or service provider.
## Destination
- `Id`: memory-reference or workflow-output identity
- `Type`: resolved CLR type
- `AllowsNull`: true for reference types and `Nullable<T>`, false for other value types
- `Kind`: variable or workflow output
Definition resolution walks the nearest variable-container scope and then workflow outputs. Runtime resolution uses declared memory-block metadata.
## Output Conversion Error
- `ConverterId`
- `Stage`: Resolution, SettingsValidation, SourceCompatibility, Invocation, or ResultValidation
- `ActivityId`
- `ActivityType`
- `OutputName`
- `DestinationId`
- `SourceTypeName`
- `DestinationTypeName`
- `InnerException`: optional originating converter exception
Safe fields are copied to persisted exception metadata. Native values and settings are never included.
## State transitions
```text
Unconfigured Binding
└─ assign native value using existing path
Configured Binding
├─ record native output
├─ native null → validate destination nullability → write null
└─ non-null
├─ resolve registration and destination
├─ validate source, destination, and settings
├─ invoke converter
├─ validate result and nullability
├─ success → write Bound Value
└─ failure → fault activity; destination unchanged
```

View file

@ -0,0 +1,121 @@
# Implementation Plan: Extensible Activity Output Converters
**Branch**: `012-output-converters` | **Date**: 2026-07-30 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/012-output-converters/spec.md`
## Summary
Add an explicit, synchronous conversion boundary between an activity's native output and its bound variable or workflow output. Elsa Core will own persisted converter configuration, a descriptor registry, scoped converter invocation, validation, and privacy-safe faults; Elsa's API and client will expose safe discovery contracts; Studio will filter, configure, validate, and round-trip converter settings. Unconfigured bindings retain the existing hot path.
## Technical Context
**Language/Version**: C# latest; .NET 8.0, 9.0, and 10.0 in Core; .NET 10.0 Blazor in Studio
**Primary Dependencies**: System.Text.Json, Microsoft.Extensions.DependencyInjection keyed services, JsonSchema.Net, FastEndpoints, Refit, MudBlazor
**Storage**: Existing workflow-definition JSON and persisted workflow exception state; no new database entity or migration
**Testing**: xUnit, Elsa shared workflow fixtures, component/integration tests, bUnit for Studio
**Target Platform**: Elsa server library and web API on supported .NET platforms; Elsa Studio web application
**Project Type**: Multi-project library, web API/client, and separate Blazor Studio repository
**Performance Goals**: Zero converter lookup or converter-related allocation for unconfigured bindings; no more than 2% representative assignment-throughput regression
**Constraints**: Synchronous deterministic conversion, no mutable workflow context in converters, no native values/settings in default faults, backward-compatible omitted JSON, multi-target Core support
**Scale/Scope**: Hundreds of registered descriptors, one optional converter per binding, small JSON settings documents
## Constitution Check
- **Modular Architecture — PASS**: Runtime contracts and behavior stay in Workflows Core, definition validation in Management, discovery endpoints in Workflows API, client contracts in Elsa.Api.Client, and authoring behavior in Studio.
- **Composition & Extensibility — PASS**: Public converter registration uses DI and explicit descriptors; modules own production converter semantics.
- **Convention-Driven Design — PASS**: Existing Output wrappers, FastEndpoints structure, Refit resources, nullable annotations, and American English are retained.
- **Async & Pipeline Execution — PASS**: Conversion is intentionally synchronous and in-memory; faults continue through the existing activity execution pipeline.
- **Testing Discipline — PASS**: Unit, integration/component, API, serialization, and bUnit coverage are required.
- **Trunk-Based Development — PASS**: One feature branch and one concern; public API documentation is included.
- **Simplicity, SRP, DRY & KISS — PASS**: One registry, one invoker, one destination resolver, and one settings validator divide responsibilities without introducing converter chains or generic coercion.
Post-design re-check: PASS. The new JSON Schema dependency is justified by a standards-compliant validation requirement; an ad hoc subset was rejected.
## Project Structure
### Documentation
```text
specs/012-output-converters/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── rest-api.md
│ ├── runtime-contract.md
│ └── studio-contract.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Elsa Core repository
```text
src/modules/Elsa.Workflows.Core/
├── Contracts/
├── Contexts/ActivityExecutionContext.cs
├── Enums/
├── Exceptions/
├── Extensions/
├── Models/
├── Serialization/
└── Services/
src/modules/Elsa.Workflows.Management/
└── Handlers/Notifications/
src/modules/Elsa.Workflows.Api/
└── Endpoints/OutputConverters/List/
src/clients/Elsa.Api.Client/
├── Resources/OutputConverters/
└── Shared/Models/ActivityOutput.cs
test/
├── unit/Elsa.Workflows.Core.UnitTests/
├── unit/Elsa.Workflows.Management.UnitTests/
├── unit/Elsa.Workflows.Api.UnitTests/
└── component/Elsa.Workflows.ComponentTests/
```
### Elsa Studio repository
```text
src/modules/Elsa.Studio.Workflows.Core/
├── Domain/Contracts/
├── Domain/Services/
└── Extensions/
src/modules/Elsa.Studio.Workflows/
└── Components/WorkflowDefinitionEditor/Components/ActivityProperties/Tabs/Outputs/
src/modules/Elsa.Studio.Workflows.Tests/
└── OutputConverters/
```
**Structure Decision**: Extend the existing module and resource boundaries. No new production project is needed; the feature is a Core extensibility point consumed by existing Management, API, client, and Studio modules.
## Implementation Strategy
1. Add persisted configuration and public runtime contracts with backward-compatible serialization.
2. Add strict registration, descriptor lookup, scoped invocation, binder-specific destination writes, and privacy-safe structured faults.
3. Add publication/import validation using the same descriptor, destination, settings, and compatibility rules as runtime safety checks.
4. Add descriptor discovery API/client contracts and authorization.
5. Add Studio remote discovery, compatibility-aware authoring, settings editing, and round-trip tests.
6. Harden version-skew, synthetic-output, replay, default-path performance, and public documentation.
## Complexity Tracking
No constitution violations require justification.

View file

@ -0,0 +1,40 @@
# Quickstart: Verify an Output Converter
## Register a converter
Follow the complete implementation and registration example in [Output Converters](../../doc/wiki/output-converters.md). Create a deterministic converter that turns a sample object into text, declare source/result types and a settings schema, and register it with a stable ID through the public service-registration extension.
## Author a workflow
1. Add a string variable or workflow output.
2. Bind an activity's sample-object output to that destination.
3. Select the registered converter.
4. Configure its formatting setting.
5. Save and reopen the workflow.
## Execute
Run the workflow and verify:
- The bound variable/workflow output contains the converted text.
- The activity output register and journal contain the original sample object.
- The converter resolves from the workflow scope.
- Replaying the same assignment produces the same Bound Value.
## Exercise failures
Verify definition rejection for an unknown ID, incompatible destination, and invalid settings. Then remove the registration after publishing and execute the definition:
- The activity faults through normal fault handling.
- The destination remains unchanged.
- The native Activity Output remains available.
- Persisted exception metadata identifies the converter, activity, output, destination, and resolution stage.
- The default message contains neither the native value nor raw settings.
## Verify backward compatibility
Execute and serialize a workflow with no converter configuration:
- JSON omits the converter object.
- Existing output assignment tests remain unchanged.
- Instrumentation observes no converter registry lookup.

View file

@ -0,0 +1,89 @@
# Research: Extensible Activity Output Converters
## Runtime seam and assignment ordering
**Decision**: Orchestrate configured conversion in `ActivityExecutionContext.Set`. Preserve the existing two-call branch for unconfigured outputs. For configured outputs, record the native value first, bypass invocation for null, convert and validate into a local value, then write the destination atomically.
**Rationale**: This is the only centralized boundary that has both the output binding and native activity result. Recording first preserves diagnostics when conversion fails.
**Alternatives considered**: Converting in the lower expression setter would affect non-activity assignment and cannot preserve the native result independently.
## Bound-value write path
**Decision**: Add a binder-specific destination write that writes an already validated Bound Value directly to the output memory reference and workflow-output dictionary.
**Rationale**: The existing lower setter calls `Output.ParseValue`, which converts through the native `Output<T>` type and would undo a type-changing conversion.
**Alternatives considered**: Changing `Output.ParseValue` globally would alter existing output/input coercion behavior.
## Registration and scoped resolution
**Decision**: Register converter implementations as keyed `IOutputConverter` services using their Converter ID. Store immutable registrations/descriptors separately in a singleton registry, reject ordinal duplicates and case-only variants during registration, and resolve the keyed converter from the active execution/validation scope for each invocation.
**Rationale**: Keyed DI supports multiple converter implementations and arbitrary lifetimes without caching scoped instances or exposing implementation types through persisted/API models.
**Alternatives considered**: A singleton dictionary of converter instances violates scoped lifetimes. Resolving an unkeyed implementation type is ambiguous when one implementation serves multiple IDs.
## Invocation contract
**Decision**: Use a non-generic synchronous `IOutputConverter` receiving an immutable `OutputConversionContext` containing the non-null native value, declared source and destination types, and cloned settings represented as `JsonElement`.
**Rationale**: Descriptor types provide compatibility metadata while the non-generic interface supports runtime discovery. `JsonElement` prevents mutation of persisted settings. Dependencies use constructor injection.
**Alternatives considered**: Passing `ActivityExecutionContext` or `IServiceProvider` creates hidden workflow mutations. Generic-only converter interfaces complicate heterogeneous registry invocation.
## Destination resolution and nulls
**Decision**: Centralize destination resolution for runtime memory metadata and definition graphs. At runtime, a destination permits null when its CLR type is a reference type or `Nullable<T>`; non-nullable value types reject null.
**Rationale**: Elsa currently has no nullable-reference metadata on variables or workflow outputs. CLR representability is deterministic and avoids expanding this feature into a repository-wide nullability model.
**Alternatives considered**: Adding nullability metadata to all variable/output definition and client models would be a separate breaking feature.
## Definition validation lifecycle
**Decision**: Add a dedicated `WorkflowDefinitionValidating` handler that visits the materialized workflow graph and validates configured bindings during publication/import acceptance. Repeat all safety checks during runtime assignment.
**Rationale**: Elsa's existing validator is notification-extensible and publication already invokes it. Runtime validation covers registration drift and programmatically executed definitions.
**Alternatives considered**: Validating every draft save changes current draft semantics. Validating only at runtime gives poor author feedback.
## Settings validation
**Decision**: Use centrally versioned JsonSchema.Net for synchronous standards-compliant schema evaluation, then invoke optional converter-owned custom settings validation. Parse/cache descriptor schemas, never settings-bound converter instances.
**Rationale**: Core has no existing JSON Schema validator and an ad hoc subset would not satisfy the advertised contract. JsonSchema.Net supports the repository's .NET targets and System.Text.Json model.
**Alternatives considered**: NJsonSchema centers Newtonsoft.Json and asynchronous schema parsing. Custom-only validation would make descriptors misleading to Studio.
## Structured conversion faults
**Decision**: Add `OutputConversionException`, a failure-stage enum, and a narrow safe-exception-metadata interface. Copy only that safe metadata into persisted `ExceptionState` and its API-client mirror; preserve converter exceptions as inner exceptions.
**Rationale**: Custom exception properties are otherwise lost after persistence. Serializing arbitrary `Exception.Data` could leak native values or settings.
**Alternatives considered**: Message-only diagnostics are not machine-readable. Persisting the full exception data bag violates privacy.
## Descriptor API
**Decision**: Expose `GET /descriptors/output-converters` with required source and destination type-name filters and `read:output-converters` permission. Map server descriptors to safe string type names, display metadata, and optional settings schema.
**Rationale**: This matches existing descriptor endpoints and prevents implementation types or instances from crossing the API boundary.
**Alternatives considered**: Returning every converter forces client-side type semantics. Reusing activity-descriptor permission couples unrelated capabilities.
## Studio authoring
**Decision**: Add an output-converter service using the generated API client, enrich binding targets with destination type metadata, and extend the Outputs tab with compatible converter selection. Provide a scoped schema editor for object properties and raw JSON fallback for absent/unsupported schemas.
**Rationale**: The current Outputs tab is the single binding seam. Studio has no reusable generic JSON Schema renderer, so a narrow settings component meets the feature without claiming a platform-wide renderer.
**Alternatives considered**: A hard-coded catalog violates server ownership. Raw JSON only does not satisfy schema-driven authoring.
## Version skew
**Decision**: Omit converter JSON when absent, preserve unknown/current converter configuration during unrelated Studio edits, and hide or disable discovery controls when connected to an older server without erasing persisted data.
**Rationale**: Core/API/Studio may be upgraded at different times, and old workflow definitions must remain valid.
**Alternatives considered**: Clearing unknown configuration silently changes workflow semantics.

View file

@ -0,0 +1,187 @@
# Feature Specification: Extensible Activity Output Converters
**Feature Branch**: `012-output-converters`
**Created**: 2026-07-30
**Status**: Draft
**Input**: Implement extensible activity output converters end to end across Elsa Core, the API client, and Elsa Studio, based on [elsa-core issue #7770](https://github.com/elsa-workflows/elsa-core/issues/7770).
## User Scenarios & Testing
### User Story 1 - Deliver a converted bound value (Priority: P1)
As a workflow author, I can explicitly select a registered Output Converter for an activity output binding so the destination receives the representation it needs while the activity's native output remains unchanged.
**Why this priority**: This is the feature's core value and can be used without design-time discovery when a workflow definition is authored through code or JSON.
**Independent Test**: Register one converter, bind an activity output to a compatible destination with that Converter ID, execute the workflow, and verify that the destination receives the converted value while activity-output observation surfaces retain the native value.
**Acceptance Scenarios**:
1. **Given** a compatible converter is explicitly configured on an output bound to a variable, **When** the activity produces a non-null native value, **Then** the variable receives the converted value and the activity output register retains the native value.
2. **Given** a compatible converter is explicitly configured on an output bound to a workflow output, **When** the activity completes, **Then** the workflow output receives the converted value and activity journals and diagnostics continue to report the native value.
3. **Given** no converter is configured, **When** an activity assigns an output, **Then** assignment follows the existing behavior without converter discovery, validation, or invocation.
4. **Given** a configured output receives null, **When** assignment occurs, **Then** conversion is bypassed and null is delivered only when the destination permits null.
---
### User Story 2 - Register a reusable converter safely (Priority: P1)
As an extension developer, I can register a deterministic Output Converter with a stable identity, compatible types, settings rules, and discoverable metadata so workflows can select it without persisting implementation details.
**Why this priority**: Workflow authors cannot use the feature until extension modules can define and register reliable converters.
**Independent Test**: Register a converter with descriptor metadata and settings validation, resolve it within a workflow execution scope, and verify identity, lifecycle, compatibility, validation, and deterministic invocation behavior.
**Acceptance Scenarios**:
1. **Given** a unique Converter ID and valid descriptor, **When** the extension is registered, **Then** the converter can be selected by that exact ID.
2. **Given** duplicate IDs or IDs differing only by case, **When** registration is built, **Then** registration fails deterministically rather than depending on registration order.
3. **Given** a converter with per-binding settings, **When** a workflow is validated and executed, **Then** the converter receives immutable JSON settings and no mutable workflow execution context or service locator.
4. **Given** a scoped converter dependency, **When** separate workflow execution scopes invoke the converter, **Then** each invocation resolves through its active workflow scope without a cached scoped instance.
---
### User Story 3 - Reject invalid converter configurations (Priority: P2)
As a workflow author or operator, I receive early, contextual validation when converter configuration is invalid and a privacy-safe activity fault if a deployment changes or conversion fails at runtime.
**Why this priority**: Persisted workflows can outlive deployments and registrations, so validation and diagnostics are required for operational safety.
**Independent Test**: Exercise invalid IDs, settings, type combinations, converter failures, and invalid results at definition acceptance and runtime, then verify rejection/fault stages, atomic destination behavior, and privacy-safe diagnostics.
**Acceptance Scenarios**:
1. **Given** a workflow references an unknown Converter ID, incompatible declared types, invalid settings, an unknown destination type, or no destination, **When** the definition is accepted or materialized, **Then** validation rejects the definition with actionable context.
2. **Given** a previously valid workflow executes in a deployment where its converter is missing or incompatible, **When** output assignment occurs, **Then** the activity faults through normal Elsa fault handling with a dedicated Output Conversion Error.
3. **Given** a converter throws or returns an invalid result, **When** assignment occurs, **Then** the destination remains unchanged, the native Activity Output remains available, and an originating exception is preserved.
4. **Given** an output or settings payload contains sensitive data, **When** a conversion error is reported, **Then** default error messages exclude native values and raw settings.
---
### User Story 4 - Discover and configure converters in Studio (Priority: P2)
As a Studio workflow author, I can discover converters compatible with the selected output and destination, configure their settings, save the workflow, and reopen it without losing configuration.
**Why this priority**: Server-owned discovery prevents Studio from duplicating extension registrations and makes the feature usable for visual workflow authors.
**Independent Test**: Open an output binding in Studio against a server with registered converters, select a destination, choose a compatible converter, edit valid settings, save and reopen the definition, and verify that the selection and settings round-trip.
**Acceptance Scenarios**:
1. **Given** an output and destination with known declared types, **When** the binding editor requests converter choices, **Then** it shows compatible server-provided descriptors using localized display metadata and falls back to the ID when metadata is unavailable.
2. **Given** a descriptor with a settings schema, **When** the author selects it, **Then** Studio provides schema-driven settings editing and surfaces validation feedback.
3. **Given** a descriptor without a settings schema, **When** the author selects it, **Then** Studio provides a raw JSON settings editor.
4. **Given** a saved binding with converter configuration, **When** the workflow is reopened or an unrelated activity property is edited, **Then** the Converter ID and settings remain intact.
5. **Given** the converter is cleared, **When** the workflow is saved, **Then** the optional converter object is removed and existing unconverted assignment behavior is restored.
### Edge Cases
- A converter configuration is present on an output with no variable or workflow-output destination.
- The destination type is `object`, a nullable value type, a non-nullable value type, a reference type with explicit nullability, unknown, or untyped.
- The native value is null, is derived from the declared source type, or violates the declared source type.
- A non-null input converts to null.
- A converter's supported source is a base class or interface of the declared output type.
- A converter's result declaration is not assignable to the destination type.
- A converter returns a runtime value inconsistent with its result declaration.
- A converter is removed, replaced incompatibly, or registered differently between validation and execution deployments.
- Two registrations use identical IDs or IDs that differ only by case.
- Settings are absent, empty, malformed, schema-invalid, or rejected by custom validation.
- An older workflow definition omits converter configuration.
- An older client edits a workflow containing converter configuration.
- A user attempts to configure multiple converters or an open-generic converter.
- Studio cannot load descriptors or localized display metadata.
## Requirements
### Functional Requirements
- **FR-001**: The system MUST support zero or one explicitly selected Output Converter on an Output Binding to a variable or workflow output.
- **FR-002**: The system MUST persist converter configuration as an optional object containing a stable Converter ID and optional JSON settings.
- **FR-003**: Workflow definitions without converter configuration MUST retain existing serialized shape and assignment behavior.
- **FR-004**: Converter configuration MUST NOT persist implementation type names, instances, descriptors, or display metadata.
- **FR-005**: The system MUST keep the Activity Output native and apply conversion only to the Bound Value delivered to the configured destination.
- **FR-006**: Activity-output registers, journals, API responses, and diagnostics MUST expose the native Activity Output.
- **FR-007**: Conversion MUST be synchronous at the Output Binding boundary.
- **FR-008**: Null native values MUST bypass converter invocation.
- **FR-009**: A converter-produced null MUST be accepted only when the destination explicitly permits null.
- **FR-010**: Conversion and result validation MUST complete before the destination is written.
- **FR-011**: A failed conversion MUST leave the destination unchanged and retain the native Activity Output for diagnostics.
- **FR-012**: Each converter MUST have a stable semantic Converter ID matched ordinally and case-sensitively.
- **FR-013**: Registration MUST reject duplicate IDs and IDs differing only by case.
- **FR-014**: Breaking changes to conversion behavior, accepted settings, or result semantics MUST use a new Converter ID.
- **FR-015**: The system MUST NOT infer converter selection from source and destination types.
- **FR-016**: A converter MUST declare its supported source type and result type.
- **FR-017**: Source compatibility MUST allow normal base-class and interface assignability from the Activity Output's declared type.
- **FR-018**: The declared converter result type and runtime result MUST be assignable to the resolvable Destination Type.
- **FR-019**: Unknown or untyped destinations MUST be invalid for converter configuration; `object` MUST remain a valid declared destination.
- **FR-020**: Open-generic converter matching and converter chains MUST remain outside the initial feature.
- **FR-021**: Converter invocation MUST receive only the native value, declared source and destination types, and immutable JSON settings.
- **FR-022**: Converter invocation MUST NOT expose mutable workflow execution state or a service locator.
- **FR-023**: Converter dependencies MUST be supplied through normal dependency registration and resolved from the active workflow execution scope.
- **FR-024**: Converter descriptors MAY be cached, but cached descriptors MUST NOT retain scoped converter instances or dependencies.
- **FR-025**: Converters MUST be documented and tested as deterministic and side-effect-free; environmental choices such as locale MUST be explicit settings.
- **FR-026**: The system MUST validate converter presence, declared-type compatibility, destination availability, and settings when a workflow definition is accepted or materialized.
- **FR-027**: The system MUST repeat converter safety validation during execution to detect deployment registration drift.
- **FR-028**: Converter settings MUST support optional JSON Schema validation and converter-owned custom validation.
- **FR-029**: Runtime resolution, settings, compatibility, invocation, and result failures MUST produce a dedicated Output Conversion Error through Elsa's normal activity fault pipeline without a converter-specific retry mechanism.
- **FR-030**: The Output Conversion Error MUST expose structured Converter ID, activity identity/type, output name, destination identity/type, and failure-stage metadata.
- **FR-031**: An originating converter exception MUST remain available as the Output Conversion Error's cause.
- **FR-032**: Default error messages MUST NOT expose native values or raw settings.
- **FR-033**: Core MUST provide a server-owned descriptor registry and query capability.
- **FR-034**: Elsa's API MUST expose descriptors filterable by declared source and Destination Type.
- **FR-035**: Descriptor API responses MUST expose the Converter ID, supported types, localizable display metadata, and optional settings schema without converter instances or implementation type names.
- **FR-036**: Studio MUST consume the descriptor API rather than maintain a hard-coded converter catalog.
- **FR-037**: Studio MUST filter converter choices by the selected Activity Output and destination types.
- **FR-038**: Studio MUST support selecting, configuring, clearing, saving, reopening, and validating Output Converter configuration.
- **FR-039**: Studio MUST offer schema-driven settings editing when a schema exists and raw JSON editing otherwise.
- **FR-040**: Core MUST NOT ship a broad production converter catalog; the extension mechanism MUST be demonstrated with a reference converter in tests or a sample.
- **FR-041**: The feature MUST NOT change activity-input conversion or general expression-result coercion.
- **FR-042**: Automated tests MUST cover serialization, compatibility, nullability, lifecycle, privacy, replay/retry behavior, API discovery, Studio round-tripping, version skew, and the unchanged no-converter path.
### Key Entities
- **Activity Output**: The native value and declared type produced by an activity.
- **Output Binding**: The association between an Activity Output and a variable or workflow-output destination, optionally carrying one converter configuration.
- **Converter Configuration**: The persisted Converter ID and optional JSON settings for one Output Binding.
- **Output Converter**: A registered deterministic transformation that produces a Bound Value from an Activity Output.
- **Converter Descriptor**: Server-owned discovery metadata describing identity, compatible types, display text, and optional settings schema.
- **Conversion Context**: The immutable invocation data supplied to an Output Converter.
- **Destination**: A variable or workflow output with a resolvable declared type and nullability.
- **Output Conversion Error**: A structured activity fault describing the stage and identities involved in a failed conversion.
## Success Criteria
### Measurable Outcomes
- **SC-001**: All existing workflow serialization and output-assignment tests pass unchanged for bindings without converter configuration.
- **SC-002**: Unconfigured output bindings perform zero converter registry lookups or converter-related allocations, with no statistically significant throughput regression beyond 2% in a representative output-assignment benchmark.
- **SC-003**: Every static invalid configuration covered by FR-026 is rejected before execution in definition-validation tests.
- **SC-004**: Every runtime failure stage named in FR-029 produces a dedicated fault with the required structured metadata and no native value or raw settings in its default message.
- **SC-005**: A workflow author can select, configure, save, reopen, and clear a compatible converter in Studio in under two minutes without editing raw workflow JSON.
- **SC-006**: Converter configuration survives server serialization, API client round-tripping, Studio editing, and workflow reopening without loss or mutation.
- **SC-007**: Native Activity Outputs and converted Bound Values are independently observable and correct in all variable-binding and workflow-output integration scenarios.
- **SC-008**: Extension developers can register a converter and make it discoverable without changing Core or Studio source code.
## Assumptions
- Output conversion is opt-in and used for in-memory deterministic transformations.
- Converter IDs are treated as durable public semantic identifiers.
- Existing Elsa authorization applied to comparable descriptor endpoints also applies to converter discovery.
- Definition validation has access to declared activity-output and destination types.
- Studio and the Elsa API client can be released in coordination with the supporting Core API.
- JSON settings and schemas are small configuration documents rather than arbitrary payload storage.
- A raw JSON editor is an acceptable fallback when a descriptor has no schema or no specialized form control is available.
## Out of Scope
- Asynchronous converters.
- Converter chains.
- Automatic converter selection or fallback.
- Open-generic converter matching.
- Activity-input conversion.
- General expression-result coercion.
- A broad Core-owned catalog of production converters.
- Persisting converter implementation or dependency-injection details in workflows.

View file

@ -0,0 +1,204 @@
# Tasks: Extensible Activity Output Converters
**Input**: Design documents from `specs/012-output-converters/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, and `contracts/`
**Tests**: Required by FR-042 and the repository constitution. Test tasks precede their corresponding implementation.
**Organization**: Tasks are grouped by independently testable user story.
## Phase 1: Setup
**Purpose**: Add the one standards dependency and establish feature test locations.
- [x] T001 Add a centrally versioned JsonSchema.Net dependency in `Directory.Packages.props` and reference it from `src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj`
- [x] T002 [P] Add output-converter test folders and shared test fixtures under `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/`
- [x] T003 [P] Add Studio output-converter test folder under `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/`
---
## Phase 2: Foundational Contracts and Persistence
**Purpose**: Establish public models, registration, serialization, and safe metadata used by every story.
**Critical**: User-story work begins after these contracts and round-trip tests pass.
- [x] T004 [P] Add failing normal and omitted-configuration serialization tests in `test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/OutputJsonConverterTests.cs`
- [x] T005 [P] Add failing synthetic-output serialization tests in `test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Helpers/SyntheticPropertiesWriterTests.cs`
- [x] T006 [P] Add failing registry identity and compatibility tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputConverterRegistryTests.cs`
- [x] T007 Add Converter Configuration, Descriptor, Conversion Context, Registration, and safe error metadata models under `src/modules/Elsa.Workflows.Core/Models/`
- [x] T008 [P] Add `IOutputConverter`, `IOutputConverterRegistry`, `IOutputConverterInvoker`, destination resolver, and settings validator contracts under `src/modules/Elsa.Workflows.Core/Contracts/`
- [x] T009 [P] Add failure-stage enum and privacy-safe `OutputConversionException` under `src/modules/Elsa.Workflows.Core/Enums/` and `src/modules/Elsa.Workflows.Core/Exceptions/`
- [x] T010 Implement strict descriptor registry and keyed DI registration extensions under `src/modules/Elsa.Workflows.Core/Services/OutputConverterRegistry.cs` and `src/modules/Elsa.Workflows.Core/Extensions/OutputConverterServiceCollectionExtensions.cs`
- [x] T011 Register registry, resolver, validator, and invoker infrastructure in both `src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs` and `src/modules/Elsa.Workflows.Core/ShellFeatures/WorkflowsFeature.cs`
- [x] T012 Add optional converter configuration to `src/modules/Elsa.Workflows.Core/Models/Output.cs`
- [x] T013 Update normal and synthetic output JSON round-tripping in `src/modules/Elsa.Workflows.Core/Serialization/Converters/OutputJsonConverter.cs` and `src/modules/Elsa.Workflows.Core/Serialization/Helpers/SyntheticPropertiesWriter.cs`
- [x] T014 Update API-client activity output round-tripping in `src/clients/Elsa.Api.Client/Shared/Models/ActivityOutput.cs`
- [x] T015 Run the foundational Core serialization and registry tests in `test/unit/Elsa.Workflows.Core.UnitTests/Elsa.Workflows.Core.UnitTests.csproj`
**Checkpoint**: Converter configuration round-trips, omitted JSON is unchanged, and registrations are stable and discoverable.
---
## Phase 3: User Story 1 - Deliver a Converted Bound Value (Priority: P1)
**Goal**: Convert only the destination value while preserving native activity-output observation and the default hot path.
**Independent Test**: Execute variable and workflow-output bindings with and without a converter and compare Bound Values with native output records.
### Tests
- [x] T016 [P] [US1] Add failing destination resolution, source/result compatibility, and nullability tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputBindingDestinationResolverTests.cs`
- [x] T017 [P] [US1] Add failing scoped invocation, settings, null bypass, result validation, and atomicity tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputConverterInvokerTests.cs`
- [x] T018 [P] [US1] Add failing native-register versus Bound Value and no-converter lookup tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/ActivityExecutionContextOutputConversionTests.cs`
- [x] T019 [P] [US1] Add failing variable/workflow-output component scenarios in `test/component/Elsa.Workflows.ComponentTests/Scenarios/OutputConverters/OutputConverterTests.cs`
### Implementation
- [x] T020 [US1] Implement runtime/static destination resolution in `src/modules/Elsa.Workflows.Core/Services/OutputBindingDestinationResolver.cs`
- [x] T021 [US1] Implement standards and custom settings validation in `src/modules/Elsa.Workflows.Core/Services/OutputConverterSettingsValidator.cs`
- [x] T022 [US1] Implement scoped resolution, compatibility checks, invocation, result checks, and privacy-safe wrapping in `src/modules/Elsa.Workflows.Core/Services/OutputConverterInvoker.cs`
- [x] T023 [US1] Add a binder-specific already-converted destination write in `src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs`
- [x] T024 [US1] Orchestrate configured conversion while preserving the unchanged default branch in `src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs`
- [x] T025 [US1] Run User Story 1 Core unit and component tests
**Checkpoint**: Valid bindings receive converted values, observation surfaces retain native values, configured failures are atomic, and unconfigured bindings use the old path.
---
## Phase 4: User Story 2 - Register a Reusable Converter Safely (Priority: P1)
**Goal**: Give extension developers a documented, lifetime-safe registration and invocation contract.
**Independent Test**: Register converters with different lifetimes and descriptors, resolve them across execution scopes, and discover compatible descriptors without Core changes.
### Tests
- [x] T026 [P] [US2] Add failing service-lifetime and multi-scope tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputConverterRegistrationTests.cs`
- [x] T027 [P] [US2] Add a deterministic reference converter fixture in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/Fixtures/ReferenceOutputConverter.cs`
### Implementation
- [x] T028 [US2] Complete public XML documentation and overloads for converter registration and contracts in `src/modules/Elsa.Workflows.Core/Extensions/OutputConverterServiceCollectionExtensions.cs` and `src/modules/Elsa.Workflows.Core/Contracts/`
- [x] T029 [US2] Add reference registration and usage coverage in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputConverterRegistrationUsageTests.cs`
- [x] T030 [US2] Run User Story 2 registration, lifetime, and reference-converter tests
**Checkpoint**: An extension module can add a discoverable scoped converter without modifying Core or persisting its implementation type.
---
## Phase 5: User Story 3 - Reject Invalid Configurations (Priority: P2)
**Goal**: Reject static configuration errors early and preserve contextual, privacy-safe runtime failures through persistence.
**Independent Test**: Publish invalid definitions and execute registration-drift/failing-converter scenarios, then inspect faults and unchanged destinations.
### Tests
- [x] T031 [P] [US3] Add failing workflow-definition validation tests in `test/unit/Elsa.Workflows.Management.UnitTests/Handlers/Notifications/ValidateOutputConvertersTests.cs`
- [x] T032 [P] [US3] Add failing safe exception-state persistence tests in `test/unit/Elsa.Workflows.Core.UnitTests/OutputConverters/OutputConversionExceptionStateTests.cs`
- [x] T033 [P] [US3] Add failing runtime drift and incident component scenarios in `test/component/Elsa.Workflows.ComponentTests/Scenarios/OutputConverters/OutputConverterFaultTests.cs`
### Implementation
- [x] T034 [US3] Implement graph-based definition validation in `src/modules/Elsa.Workflows.Management/Handlers/Notifications/ValidateOutputConverters.cs`
- [x] T035 [US3] Register definition validation in both `src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs` and `src/modules/Elsa.Workflows.Management/ShellFeatures/WorkflowManagementFeature.cs`
- [x] T036 [US3] Persist safe structured conversion metadata in `src/modules/Elsa.Workflows.Core/State/ExceptionState.cs` and populate it from the existing exception-state mapper
- [x] T037 [US3] Mirror safe exception metadata in `src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ExceptionState.cs`
- [x] T038 [US3] Run User Story 3 Management, Core, and component tests
**Checkpoint**: Static invalid definitions are rejected, deployment drift faults normally, and structured safe metadata survives persistence/API mapping.
---
## Phase 6: User Story 4 - Discover and Configure Converters in Studio (Priority: P2)
**Goal**: Provide server-owned discovery and complete Studio authoring/round-tripping.
**Independent Test**: Select a destination and converter in Studio, edit settings, save/reopen/clear, and verify compatibility, validation, and version-skew states.
### API and client tests
- [x] T039 [P] [US4] Add failing descriptor endpoint behavior and redaction tests in `test/unit/Elsa.Workflows.Api.UnitTests/OutputConverters/OutputConverterEndpointTests.cs`
- [x] T040 [P] [US4] Add failing API-client registration/serialization coverage in `test/component/Elsa.Workflows.ComponentTests/Scenarios/OutputConverters/OutputConverterApiClientTests.cs`
### API and client implementation
- [x] T041 [P] [US4] Add output-converter request, response, model, and Refit contract files under `src/clients/Elsa.Api.Client/Resources/OutputConverters/`
- [x] T042 [US4] Register `IOutputConvertersApi` in `src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs`
- [x] T043 [US4] Implement authorized compatible descriptor listing under `src/modules/Elsa.Workflows.Api/Endpoints/OutputConverters/List/`
- [x] T044 [US4] Run Output Converter API and client tests
### Studio tests
- [x] T045 [P] [US4] Add failing remote-service forwarding tests in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/RemoteOutputConverterServiceTests.cs`
- [x] T046 [P] [US4] Add failing Outputs-tab discovery, selection, preservation, clear, read-only, and version-skew tests in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputsTabConverterTests.cs`
- [x] T047 [P] [US4] Add failing schema/raw settings editor tests in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Tests/OutputConverters/OutputConverterSettingsEditorTests.cs`
### Studio implementation
- [x] T048 [P] [US4] Add `IOutputConverterService` and `RemoteOutputConverterService` under `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Core/Domain/Contracts/` and `Domain/Services/`
- [x] T049 [US4] Register the remote service in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Core/Extensions/ServiceCollectionExtensions.cs`
- [x] T050 [P] [US4] Enrich binding target metadata in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/ActivityProperties/Tabs/Outputs/Models/`
- [x] T051 [P] [US4] Implement schema-driven and raw JSON settings editing in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/ActivityProperties/Tabs/Outputs/Components/OutputConverterSettingsEditor.razor`
- [x] T052 [US4] Extend Outputs tab discovery, filtering, preservation, clearing, cancellation, read-only, and validation behavior in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows/Components/WorkflowDefinitionEditor/Components/ActivityProperties/Tabs/Outputs/Components/OutputsTab.razor` and `.razor.cs`
- [x] T053 [US4] Add localized Studio labels and errors in the existing localization resource mechanism
- [x] T054 [US4] Run Elsa Studio Workflows tests in `/Users/sipke/Projects/Elsa/elsa-studio/src/modules/Elsa.Studio.Workflows.Tests/Elsa.Studio.Workflows.Tests.csproj`
**Checkpoint**: Studio authors can discover, select, configure, save, reopen, validate, and clear converters without losing data against current or older servers.
---
## Phase 7: Polish and Cross-Cutting Verification
**Purpose**: Prove backward compatibility, privacy, performance, documentation, and cross-repository integration.
- [x] T055 [P] Add no-converter assignment benchmark coverage in `test/performance/Elsa.Workflows.PerformanceTests/OutputAssignmentBenchmark.cs`
- [x] T056 [P] Add version-skew and synthetic/workflow-as-activity coverage in `test/component/Elsa.Workflows.ComponentTests/Scenarios/OutputConverters/`
- [x] T057 [P] Update public feature documentation and samples in `doc/` and `specs/012-output-converters/quickstart.md`
- [x] T058 Run targeted Core, Management, API, client, component, and Studio test projects
- [x] T059 Run `dotnet build Elsa.sln` and the affected Studio solution/project build
- [x] T060 Run broader `./build.sh Test` or document any pre-existing/unrelated failures with evidence
- [x] T061 Audit FR-001 through FR-042 and SC-001 through SC-008 against implementation and test evidence in `specs/012-output-converters/checklists/completion.md`
---
## Dependencies and Execution Order
### Phase dependencies
- Setup precedes foundational contracts.
- Foundational contracts block every user story.
- User Stories 1 and 2 can proceed in parallel after foundations, but User Story 1's end-to-end tests use the registration contract completed in User Story 2.
- User Story 3 depends on runtime invocation and registry behavior from User Stories 1 and 2.
- User Story 4 API work depends on the descriptor registry; Studio work depends on API-client models.
- Polish and completion audit depend on all four stories.
### Parallel opportunities
- Foundational model/interface/error tasks can proceed in parallel before registry integration.
- Runtime tests, destination-resolution tests, and component tests can be authored in parallel.
- API/client and Studio component test scaffolding can proceed in parallel after contracts stabilize.
- Core and Studio builds can run independently before the cross-repository completion audit.
## Implementation Strategy
### MVP
Complete Setup, Foundational, User Story 1, and the registration portion of User Story 2. Validate converted destination values, native output preservation, atomic failure, scoped resolution, and the unchanged no-converter path.
### Incremental delivery
1. Contracts and persistence.
2. Runtime conversion and extension registration.
3. Definition validation and durable faults.
4. Descriptor API/client and Studio authoring.
5. Cross-cutting hardening and completion audit.
## Notes
- Tests must fail before their implementation tasks are completed.
- Mark each completed task `[x]` immediately.
- Do not edit or discard unrelated changes in the Studio repository.
- Do not add converter behavior to activity inputs or general expression evaluation.

View file

@ -0,0 +1,85 @@
# Output Converters Recommended Delivery Sequence
**Status**: Feasibility assessment
**Issue**: [elsa-core #7770](https://github.com/elsa-workflows/elsa-core/issues/7770)
**Related decisions**:
[binding boundary](../doc/adr/0011-output-conversion-at-binding-is-synchronous.md),
[converter identity](../doc/adr/0012-output-converters-use-explicit-stable-identities.md), and
[server-owned discovery](../doc/adr/0013-output-converter-discovery-is-server-owned.md)
The feature should be delivered in independently verifiable phases. Each phase preserves existing output-binding behavior when no converter is configured.
## Phase 1: Contracts and persistence
- Define converter configuration, descriptor, invocation context, registry, and error contracts.
- Extend the Output Binding and API client models with the optional `converter` object.
- Round-trip the Converter ID and JSON settings through workflow serialization.
- Reject duplicate and case-only-variant Converter IDs during registration.
**Exit criteria**: Existing workflow JSON remains compatible, configured converters round-trip without CLR implementation details, and the no-converter path remains behaviorally unchanged.
## Phase 2: Runtime conversion
- Invoke the explicitly selected converter synchronously at the Output Binding boundary.
- Preserve the native Activity Output in registers, journals, APIs, and diagnostics.
- Resolve converter instances from the active workflow execution scope.
- Enforce null, source compatibility, destination compatibility, result validation, and atomic-write rules.
- Route privacy-safe Output Conversion Errors through normal activity fault handling.
**Exit criteria**: Unit and integration tests demonstrate successful conversion, native-output preservation, atomic failures, scoped resolution, retry behavior, and the unchanged default path.
## Phase 3: Definition and settings validation
- Validate Converter ID presence, compatible declared types, and a resolvable destination type when definitions are accepted or materialized.
- Support optional JSON Schema validation and converter-owned custom validation for settings.
- Repeat safety validation during execution to handle registration differences between deployments.
**Exit criteria**: Invalid static configurations are rejected before execution, while runtime drift produces the dedicated contextual fault.
## Phase 4: Descriptor discovery API
- Provide a server-owned descriptor registry and query service.
- Expose safe descriptors through Elsa's API, filterable by declared source and Destination Type.
- Include localizable display metadata and optional settings schema.
- Add matching API client contracts without exposing converter instances or CLR implementation types.
**Exit criteria**: Clients can discover and filter registered converters solely through supported API contracts.
## Phase 5: Studio authoring
- Consume the descriptor API rather than maintaining a client-side catalog.
- Show only converters compatible with the selected activity output and destination.
- Persist converter selection and settings without disturbing unrelated activity JSON.
- Provide schema-driven settings editing with a raw JSON fallback when no schema is available.
- Surface definition-validation feedback in the output-binding editor.
**Exit criteria**: A workflow author can select, configure, clear, save, reopen, and validate an Output Converter entirely through Studio.
## Phase 6: End-to-end hardening
- Add serialization, compatibility, nullability, privacy, replay, retry, multi-target, and version-skew coverage.
- Verify no converter-related work occurs on unconfigured bindings.
- Document the public extension API and provide a reference converter in tests or a sample.
- Run targeted tests followed by the affected solution-level build and test suites.
**Exit criteria**: Core, API client, and Studio scenarios pass together, public contracts are documented, and default-path behavior and performance remain unchanged.
## Dependencies and parallel work
- Phase 1 blocks the runtime, validation, and API implementations.
- Phases 2 and 4 can proceed in parallel after their shared contracts stabilize.
- Studio can prototype against fixed descriptor contracts, but Phase 4 must complete before end-to-end verification.
- Phase 3 should complete before Studio validation UX is finalized.
- Phase 6 is the release gate.
## Effort envelope
| Delivery target | Estimated effort | Complexity |
| --- | ---: | --- |
| Runtime MVP through Phase 2 | 7–10 engineer-days | Medium-high |
| Complete backend through Phase 4 | 12–18 engineer-days | High |
| End-to-end delivery through Phase 6 | 18–28 engineer-days | High |
The largest uncertainty is schema-driven Studio settings editing because the current Studio codebase does not appear to provide a reusable JSON Schema form editor.

View file

@ -8,6 +8,7 @@ using Elsa.Api.Client.Resources.Features.Contracts;
using Elsa.Api.Client.Resources.Identity.Contracts;
using Elsa.Api.Client.Resources.IncidentStrategies.Contracts;
using Elsa.Api.Client.Resources.LogPersistenceStrategies;
using Elsa.Api.Client.Resources.OutputConverters.Contracts;
using Elsa.Api.Client.Resources.Resilience.Contracts;
using Elsa.Api.Client.Resources.Shells.Contracts;
using Elsa.Api.Client.Resources.Scripting.Contracts;
@ -86,6 +87,7 @@ public static class DependencyInjectionExtensions
services.AddApi<IShellsApi>(builderOptions);
services.AddApi<IJavaScriptApi>(builderOptions);
services.AddApi<IExpressionDescriptorsApi>(builderOptions);
services.AddApi<IOutputConvertersApi>(builderOptions);
services.AddApi<IWorkflowContextProviderDescriptorsApi>(builderOptions);
services.AddApi<IAlterationsApi>(builderOptions);
services.AddApi<ITasksApi>(builderOptions);
@ -187,4 +189,4 @@ public static class DependencyInjectionExtensions
httpClient.BaseAddress = options.BaseAddress;
options.ConfigureHttpClient?.Invoke(serviceProvider, httpClient);
}
}
}

View file

@ -0,0 +1,20 @@
using Elsa.Api.Client.Resources.OutputConverters.Requests;
using Elsa.Api.Client.Resources.OutputConverters.Responses;
using Refit;
namespace Elsa.Api.Client.Resources.OutputConverters.Contracts;
/// <summary>
/// Represents a client for output converter descriptor discovery.
/// </summary>
public interface IOutputConvertersApi
{
/// <summary>
/// Lists output converters compatible with the specified declared source and destination types.
/// </summary>
/// <param name="request">The request containing the declared source and destination type names.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The compatible output converter descriptors.</returns>
[Get("/descriptors/output-converters")]
Task<ListOutputConvertersResponse> ListAsync([Query] ListOutputConvertersRequest request, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,39 @@
using System.Text.Json;
namespace Elsa.Api.Client.Resources.OutputConverters.Models;
/// <summary>
/// Describes an output converter that can be selected by a workflow author.
/// </summary>
public record OutputConverterDescriptor
{
/// <summary>
/// The stable public converter identifier.
/// </summary>
public string Id { get; init; } = null!;
/// <summary>
/// The declared source type name supported by the converter.
/// </summary>
public string SourceTypeName { get; init; } = null!;
/// <summary>
/// The declared result type name produced by the converter.
/// </summary>
public string ResultTypeName { get; init; } = null!;
/// <summary>
/// The display name for the converter.
/// </summary>
public string DisplayName { get; init; } = null!;
/// <summary>
/// The optional converter description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// The optional JSON schema for converter settings.
/// </summary>
public JsonElement? SettingsSchema { get; init; }
}

View file

@ -0,0 +1,21 @@
using Refit;
namespace Elsa.Api.Client.Resources.OutputConverters.Requests;
/// <summary>
/// Represents a request to list output converters compatible with declared types.
/// </summary>
public class ListOutputConvertersRequest
{
/// <summary>
/// The declared source type name of the activity output.
/// </summary>
[AliasAs("sourceType")]
public string SourceType { get; set; } = null!;
/// <summary>
/// The declared destination type name of the output binding.
/// </summary>
[AliasAs("destinationType")]
public string DestinationType { get; set; } = null!;
}

View file

@ -0,0 +1,9 @@
using Elsa.Api.Client.Resources.OutputConverters.Models;
namespace Elsa.Api.Client.Resources.OutputConverters.Responses;
/// <summary>
/// Represents a response containing compatible output converter descriptors.
/// </summary>
/// <param name="Items">The compatible output converter descriptors.</param>
public record ListOutputConvertersResponse(ICollection<OutputConverterDescriptor> Items);

View file

@ -5,14 +5,19 @@ namespace Elsa.Api.Client.Resources.WorkflowInstances.Models;
/// <summary>
/// A simplified, serializable model representing an exception.
/// </summary>
public record ExceptionState(string Type, string Message, string? StackTrace, ExceptionState? InnerException)
public record ExceptionState(
string Type,
string Message,
string? StackTrace,
ExceptionState? InnerException,
IReadOnlyDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionState"/> class.
/// </summary>
[JsonConstructor]
public ExceptionState() : this(null!, null!, null, null)
public ExceptionState() : this(null!, null!, null, null, null)
{
}
}
}

View file

@ -1,3 +1,5 @@
using System.Text.Json;
namespace Elsa.Api.Client.Shared.Models;
/// <summary>
@ -14,4 +16,18 @@ public class ActivityOutput
/// Gets or sets the memory reference of this output.
/// </summary>
public MemoryReference MemoryReference { get; set; } = default!;
}
/// <summary>
/// Gets or sets the optional converter configured for this output binding.
/// </summary>
public OutputConverterConfiguration? Converter { get; set; }
}
/// <summary>
/// Selects an output converter and provides its per-binding settings.
/// </summary>
public class OutputConverterConfiguration
{
public string Id { get; set; } = default!;
public JsonElement? Settings { get; set; }
}

View file

@ -0,0 +1,110 @@
using System.Text.Json;
using Elsa.Abstractions;
using Elsa.Common.Serialization;
using Elsa.Extensions;
using Elsa.Models;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace Elsa.Workflows.Api.Endpoints.OutputConverters.List;
/// <summary>
/// Lists output converters compatible with declared output and destination types.
/// </summary>
[PublicAPI]
internal class List(IOutputConverterRegistry registry, ISerializationTypeRegistry serializationTypeRegistry) : ElsaEndpointWithoutRequest<ListResponse<OutputConverterDescriptorModel>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/descriptors/output-converters");
ConfigurePermissions("read:*", "read:output-converters");
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var sourceTypeName = Query<string>("sourceType", false);
var destinationTypeName = Query<string>("destinationType", false);
if (!TryListCompatible(sourceTypeName, destinationTypeName, out var response, out var errors))
{
foreach (var error in errors)
AddError(error);
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return;
}
await Send.OkAsync(response, cancellationToken);
}
internal bool TryListCompatible(
string? sourceTypeName,
string? destinationTypeName,
out ListResponse<OutputConverterDescriptorModel> response,
out ICollection<string> errors)
{
errors = [];
var hasSourceType = TryResolveType("sourceType", sourceTypeName, out var sourceType, out var sourceTypeError);
var hasDestinationType = TryResolveType("destinationType", destinationTypeName, out var destinationType, out var destinationTypeError);
if (!hasSourceType)
errors.Add(sourceTypeError!);
if (!hasDestinationType)
errors.Add(destinationTypeError!);
if (errors.Count > 0)
{
response = new ListResponse<OutputConverterDescriptorModel>([]);
return false;
}
var descriptors = registry.FindCompatible(sourceType, destinationType).Select(Map).ToList();
response = new ListResponse<OutputConverterDescriptorModel>(descriptors);
return true;
}
private bool TryResolveType(string parameterName, string? typeName, out Type type, out string? error)
{
if (string.IsNullOrWhiteSpace(typeName))
{
type = null!;
error = $"The {parameterName} query parameter is required.";
return false;
}
if (SerializationTypeResolver.TryResolveType(serializationTypeRegistry, typeName, out type))
{
error = null;
return true;
}
type = null!;
error = $"The {parameterName} query parameter must be a registered type alias or resolvable safe type name.";
return false;
}
private OutputConverterDescriptorModel Map(OutputConverterDescriptor descriptor) => new(
descriptor.Id,
GetTypeName(descriptor.SourceType),
GetTypeName(descriptor.ResultType),
descriptor.DisplayName,
descriptor.Description,
descriptor.SettingsSchema?.Clone());
private string GetTypeName(Type type) => SerializationTypeResolver.TryGetAlias(serializationTypeRegistry, type, out var alias)
? alias
: type.GetSimpleAssemblyQualifiedName();
}
internal record OutputConverterDescriptorModel(
string Id,
string SourceTypeName,
string ResultTypeName,
string DisplayName,
string? Description,
JsonElement? SettingsSchema);

View file

@ -4,6 +4,7 @@ using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
@ -773,11 +774,73 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <param name="outputName">The name of the output.</param>
public void Set(Output? output, object? value, [CallerArgumentExpression("output")] string? outputName = null)
{
// Store the value in the expression execution memory block.
ExpressionExecutionContext.Set(output, value);
if (output?.Converter == null)
{
// Store the value in the expression execution memory block.
ExpressionExecutionContext.Set(output, value);
// Also store the value in the workflow execution transient activity output register.
// Also store the value in the workflow execution transient activity output register.
WorkflowExecutionContext.RecordActivityOutput(this, outputName, value);
return;
}
var descriptor = ActivityDescriptor.Outputs.FirstOrDefault(x => x.PropertyInfo?.Name == outputName);
descriptor ??= ActivityDescriptor.Outputs.FirstOrDefault(x => x.ValueGetter != null && ReferenceEquals(x.ValueGetter(Activity), output));
var resolvedOutputName = descriptor?.Name ?? outputName ?? ActivityOutputRegister.DefaultOutputName;
var sourceType = descriptor?.Type ?? GetOutputValueType(output.GetType()) ?? typeof(object);
// The activity output register always retains the activity's native value.
WorkflowExecutionContext.RecordActivityOutput(this, outputName, value);
var destinationResolver = GetRequiredService<IOutputBindingDestinationResolver>();
var destination = destinationResolver.Resolve(this, output);
if (destination == null)
{
throw new OutputConversionException(
output.Converter.Id,
OutputConversionFailureStage.Resolution,
Activity.Id,
Activity.Type,
resolvedOutputName,
output.MemoryBlockReference().Id,
sourceType,
null);
}
if (value == null)
{
if (!destination.AllowsNull)
{
throw new OutputConversionException(
output.Converter.Id,
OutputConversionFailureStage.ResultValidation,
Activity.Id,
Activity.Type,
resolvedOutputName,
destination.Id,
sourceType,
destination.Type);
}
ExpressionExecutionContext.SetBoundValue(output, null);
return;
}
var converterInvoker = GetRequiredService<IOutputConverterInvoker>();
var convertedValue = converterInvoker.Invoke(this, output, resolvedOutputName, sourceType, value, destination);
ExpressionExecutionContext.SetBoundValue(output, convertedValue);
}
private static Type? GetOutputValueType(Type outputType)
{
for (var currentType = outputType; currentType != null; currentType = currentType.BaseType)
{
if (currentType.IsGenericType && currentType.GetGenericTypeDefinition() == typeof(Output<>))
return currentType.GenericTypeArguments[0];
}
return null;
}
/// <summary>
@ -815,4 +878,3 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
{
}
}

View file

@ -0,0 +1,15 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <summary>
/// Resolves the declared destination of an output binding.
/// </summary>
public interface IOutputBindingDestinationResolver
{
/// <summary>Resolves a destination from active workflow memory.</summary>
OutputBindingDestination? Resolve(ActivityExecutionContext activityExecutionContext, Output output);
/// <summary>Resolves a destination from a materialized workflow graph.</summary>
OutputBindingDestination? Resolve(WorkflowGraph workflowGraph, ActivityNode activityNode, Output output);
}

View file

@ -0,0 +1,21 @@
using System.Text.Json;
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <summary>
/// Converts a native activity output into a value suitable for one output binding.
/// Implementations must be synchronous, deterministic and side-effect free.
/// </summary>
public interface IOutputConverter
{
/// <summary>
/// Converts a non-null native output value into a bound value.
/// </summary>
object? Convert(OutputConversionContext context);
/// <summary>
/// Validates optional per-binding settings. Returned messages must not include sensitive setting values.
/// </summary>
IEnumerable<string> ValidateSettings(JsonElement? settings) => [];
}

View file

@ -0,0 +1,20 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <summary>
/// Resolves, validates and invokes the converter selected by an output binding.
/// </summary>
public interface IOutputConverterInvoker
{
/// <summary>
/// Resolves and invokes the configured converter after validating its settings and declared types.
/// </summary>
object? Invoke(
ActivityExecutionContext activityExecutionContext,
Output output,
string outputName,
Type sourceType,
object value,
OutputBindingDestination destination);
}

View file

@ -0,0 +1,21 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <summary>
/// Provides registered output converter descriptors and registrations.
/// </summary>
public interface IOutputConverterRegistry
{
/// <summary>Lists every registered descriptor.</summary>
IEnumerable<OutputConverterDescriptor> ListAll();
/// <summary>Finds a descriptor by its ordinal, case-sensitive ID.</summary>
OutputConverterDescriptor? Find(string id);
/// <summary>Finds the keyed service registration for a converter ID.</summary>
OutputConverterRegistration? FindRegistration(string id);
/// <summary>Lists converters whose declared types are compatible with the supplied binding types.</summary>
IEnumerable<OutputConverterDescriptor> FindCompatible(Type sourceType, Type destinationType);
}

View file

@ -0,0 +1,18 @@
using System.Text.Json;
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <summary>
/// Validates converter settings against descriptor and converter-owned rules.
/// </summary>
public interface IOutputConverterSettingsValidator
{
/// <summary>
/// Validates settings against the descriptor schema and converter-owned validation rules.
/// </summary>
IReadOnlyCollection<string> Validate(
OutputConverterDescriptor descriptor,
IOutputConverter converter,
JsonElement? settings);
}

View file

@ -0,0 +1,9 @@
namespace Elsa.Workflows;
/// <summary>
/// Provides an explicitly allow-listed set of privacy-safe exception metadata.
/// </summary>
public interface ISafeExceptionMetadataProvider
{
IReadOnlyDictionary<string, string> GetSafeMetadata();
}

View file

@ -12,8 +12,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="JsonSchema.Net" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
@ -31,4 +32,4 @@
<ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -0,0 +1,10 @@
namespace Elsa.Workflows;
/// <summary>
/// Identifies the kind of destination targeted by an output binding.
/// </summary>
public enum OutputBindingDestinationKind
{
Variable,
WorkflowOutput
}

View file

@ -0,0 +1,13 @@
namespace Elsa.Workflows;
/// <summary>
/// Identifies the stage at which output conversion failed.
/// </summary>
public enum OutputConversionFailureStage
{
Resolution,
SettingsValidation,
SourceCompatibility,
Invocation,
ResultValidation
}

View file

@ -0,0 +1,81 @@
namespace Elsa.Workflows.Exceptions;
/// <summary>
/// Represents a privacy-safe failure while converting an activity output's bound value.
/// </summary>
public class OutputConversionException : Exception, ISafeExceptionMetadataProvider
{
/// <summary>
/// Initializes a new output conversion exception.
/// </summary>
public OutputConversionException(
string converterId,
OutputConversionFailureStage stage,
string activityId,
string activityType,
string outputName,
string? destinationId,
Type sourceType,
Type? destinationType,
Exception? innerException = null)
: base(CreateMessage(converterId, stage, activityId, outputName), innerException)
{
ConverterId = converterId;
Stage = stage;
ActivityId = activityId;
ActivityType = activityType;
OutputName = outputName;
DestinationId = destinationId;
SourceTypeName = sourceType.FullName ?? sourceType.Name;
DestinationTypeName = destinationType?.FullName;
}
/// <summary>The configured converter ID.</summary>
public string ConverterId { get; }
/// <summary>The stage at which conversion failed.</summary>
public OutputConversionFailureStage Stage { get; }
/// <summary>The definition ID of the activity producing the output.</summary>
public string ActivityId { get; }
/// <summary>The activity type name.</summary>
public string ActivityType { get; }
/// <summary>The declared output name.</summary>
public string OutputName { get; }
/// <summary>The optional destination ID.</summary>
public string? DestinationId { get; }
/// <summary>The declared source type name.</summary>
public string SourceTypeName { get; }
/// <summary>The optional declared destination type name.</summary>
public string? DestinationTypeName { get; }
/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetSafeMetadata()
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
[nameof(ConverterId)] = ConverterId,
[nameof(Stage)] = Stage.ToString(),
[nameof(ActivityId)] = ActivityId,
[nameof(ActivityType)] = ActivityType,
[nameof(OutputName)] = OutputName,
[nameof(SourceTypeName)] = SourceTypeName
};
if (DestinationId != null)
metadata[nameof(DestinationId)] = DestinationId;
if (DestinationTypeName != null)
metadata[nameof(DestinationTypeName)] = DestinationTypeName;
return metadata;
}
private static string CreateMessage(string converterId, OutputConversionFailureStage stage, string activityId, string outputName) =>
$"Output converter '{converterId}' failed during {stage} for output '{outputName}' of activity '{activityId}'.";
}

View file

@ -227,6 +227,21 @@ public static class ExpressionExecutionContextExtensions
}
}
/// <summary>
/// Sets an already converted value on the destination referenced by the specified output.
/// </summary>
public void SetBoundValue(Output output, object? value)
{
var outputMemoryBlockReference = output.MemoryBlockReference();
context.Set(outputMemoryBlockReference, value);
var workflowExecutionContext = context.GetWorkflowExecutionContext();
var workflowOutputDefinition = workflowExecutionContext.Workflow.Outputs.FirstOrDefault(x => x.Name == outputMemoryBlockReference.Id);
if (workflowOutputDefinition != null)
workflowExecutionContext.Output[workflowOutputDefinition.Name] = value!;
}
/// <summary>
/// Returns a dictionary of memory block keys and values across scopes.
/// </summary>

View file

@ -0,0 +1,53 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Elsa.Extensions;
/// <summary>
/// Adds output converter registrations to a service collection.
/// </summary>
public static class OutputConverterServiceCollectionExtensions
{
/// <summary>
/// Registers a discoverable output converter using its stable Converter ID as the keyed-service key.
/// </summary>
public static IServiceCollection AddOutputConverter<TConverter>(
this IServiceCollection services,
OutputConverterDescriptor descriptor,
ServiceLifetime serviceLifetime = ServiceLifetime.Scoped)
where TConverter : class, IOutputConverter
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(descriptor);
OutputConverterRegistry.ValidateDescriptor(descriptor);
ValidateUniqueId(services, descriptor.Id);
var registration = new OutputConverterRegistration(descriptor, descriptor.Id, serviceLifetime);
services.AddSingleton(registration);
services.Add(ServiceDescriptor.DescribeKeyed(
typeof(IOutputConverter),
descriptor.Id,
typeof(TConverter),
serviceLifetime));
services.TryAddSingleton<IOutputConverterRegistry, OutputConverterRegistry>();
return services;
}
private static void ValidateUniqueId(IServiceCollection services, string id)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Output converter IDs cannot be empty.", nameof(id));
var existingRegistration = services
.Where(x => x.ServiceType == typeof(OutputConverterRegistration))
.Select(x => x.ImplementationInstance)
.OfType<OutputConverterRegistration>()
.FirstOrDefault(x => string.Equals(x.Descriptor.Id, id, StringComparison.OrdinalIgnoreCase));
if (existingRegistration != null)
throw new InvalidOperationException($"Output converter ID '{id}' is already registered or differs from '{existingRegistration.Descriptor.Id}' only by case.");
}
}

View file

@ -31,6 +31,7 @@ using Elsa.Workflows.UIHints.Dropdown;
using Elsa.Workflows.UIHints.JsonEditor;
using Elsa.Workflows.UIHints.RadioList;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Newtonsoft.Json.Linq;
namespace Elsa.Workflows.Features;
@ -195,6 +196,8 @@ public class WorkflowsFeature : FeatureBase
options.AddTypeAlias<JArray>(nameof(JArray));
});
services.TryAddSingleton<IOutputConverterRegistry, OutputConverterRegistry>();
services
// Core.
@ -216,6 +219,9 @@ public class WorkflowsFeature : FeatureBase
.AddSingleton<IActivityDescriber, ActivityDescriber>()
.AddSingleton<IActivityRegistry, ActivityRegistry>()
.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>()
.AddScoped<IOutputBindingDestinationResolver, OutputBindingDestinationResolver>()
.AddSingleton<IOutputConverterSettingsValidator, OutputConverterSettingsValidator>()
.AddScoped<IOutputConverterInvoker, OutputConverterInvoker>()
.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>()
.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>()
.AddSingleton<IActivityFactory, ActivityFactory>()

View file

@ -206,6 +206,19 @@ public static class JsonActivityConstructorContextHelper
var output = Activator.CreateInstance(wrappedType, variable)!;
if (propertyElement.TryGetProperty("converter", out var converterElement) &&
converterElement.ValueKind == JsonValueKind.Object &&
converterElement.TryGetProperty("id", out var converterIdElement))
{
var settings = converterElement.TryGetProperty("settings", out var settingsElement)
? settingsElement
: (JsonElement?)null;
var converterId = converterIdElement.ValueKind == JsonValueKind.String
? converterIdElement.GetString()
: null;
((Output)output).Converter = new(converterId ?? string.Empty, settings);
}
activity.SyntheticProperties[outputName] = output!;
}
}
@ -237,4 +250,4 @@ public static class JsonActivityConstructorContextHelper
return null;
}
}
}

View file

@ -15,6 +15,11 @@ public class Output : Argument
public Output(Func<MemoryBlockReference> memoryBlockReference) : base(memoryBlockReference)
{
}
/// <summary>
/// Gets or sets the optional converter applied to the value delivered by this binding.
/// </summary>
public OutputConverterConfiguration? Converter { get; set; }
}
public class Output<T> : Output
@ -30,4 +35,4 @@ public class Output<T> : Output
public Output(Func<MemoryBlockReference> memoryBlockReference) : base(memoryBlockReference)
{
}
}
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Workflows.Models;
/// <summary>
/// Describes the declared destination of an output binding.
/// </summary>
public sealed record OutputBindingDestination(
string Id,
Type Type,
bool AllowsNull,
OutputBindingDestinationKind Kind);

View file

@ -0,0 +1,32 @@
using System.Text.Json;
namespace Elsa.Workflows.Models;
/// <summary>
/// Provides the immutable values available to an output converter.
/// </summary>
public sealed record OutputConversionContext
{
/// <summary>
/// Initializes a new instance of the <see cref="OutputConversionContext"/> class.
/// </summary>
public OutputConversionContext(object value, Type sourceType, Type destinationType, JsonElement? settings)
{
Value = value;
SourceType = sourceType;
DestinationType = destinationType;
Settings = settings?.Clone();
}
/// <summary>The non-null native activity output.</summary>
public object Value { get; }
/// <summary>The activity output's declared type.</summary>
public Type SourceType { get; }
/// <summary>The binding destination's declared type.</summary>
public Type DestinationType { get; }
/// <summary>A clone of the optional per-binding JSON settings.</summary>
public JsonElement? Settings { get; }
}

View file

@ -0,0 +1,28 @@
using System.Text.Json;
namespace Elsa.Workflows.Models;
/// <summary>
/// Selects an output converter and provides its per-binding settings.
/// </summary>
public sealed record OutputConverterConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="OutputConverterConfiguration"/> class.
/// </summary>
public OutputConverterConfiguration(string id, JsonElement? settings = null)
{
Id = id ?? string.Empty;
Settings = settings?.Clone();
}
/// <summary>
/// The stable converter ID.
/// </summary>
public string Id { get; }
/// <summary>
/// Optional immutable JSON settings.
/// </summary>
public JsonElement? Settings { get; }
}

View file

@ -0,0 +1,46 @@
using System.Text.Json;
namespace Elsa.Workflows.Models;
/// <summary>
/// Describes an output converter without exposing its implementation.
/// </summary>
public sealed record OutputConverterDescriptor
{
/// <summary>
/// Initializes a new instance of the <see cref="OutputConverterDescriptor"/> class.
/// </summary>
public OutputConverterDescriptor(
string id,
Type sourceType,
Type resultType,
string displayName,
string? description = null,
JsonElement? settingsSchema = null)
{
Id = id;
SourceType = sourceType;
ResultType = resultType;
DisplayName = displayName;
Description = description;
SettingsSchema = settingsSchema?.Clone();
}
/// <summary>The stable, ordinal case-sensitive converter ID.</summary>
public string Id { get; }
/// <summary>The declared source type accepted by the converter.</summary>
public Type SourceType { get; }
/// <summary>The declared result type produced by the converter.</summary>
public Type ResultType { get; }
/// <summary>The display name exposed through discovery.</summary>
public string DisplayName { get; }
/// <summary>The optional description exposed through discovery.</summary>
public string? Description { get; }
/// <summary>An immutable optional JSON Schema for per-binding settings.</summary>
public JsonElement? SettingsSchema { get; }
}

View file

@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Models;
/// <summary>
/// Associates a converter descriptor with its keyed dependency registration.
/// </summary>
public sealed record OutputConverterRegistration(
OutputConverterDescriptor Descriptor,
string ServiceKey,
ServiceLifetime ServiceLifetime);

View file

@ -42,8 +42,23 @@ public class OutputJsonConverter<T> : JsonConverter<Output<T>?>
Id = memoryReferenceIdElement.GetString()!
};
variable.Name = variable.Id;
return (Output<T>)Activator.CreateInstance(typeof(Output<T>), variable)!;
var output = (Output<T>)Activator.CreateInstance(typeof(Output<T>), variable)!;
if (doc.RootElement.TryGetProperty("converter", out var converterElement) &&
converterElement.ValueKind == JsonValueKind.Object &&
converterElement.TryGetProperty("id", out var converterIdElement))
{
var settings = converterElement.TryGetProperty("settings", out var settingsElement)
? settingsElement
: (JsonElement?)null;
var converterId = converterIdElement.ValueKind == JsonValueKind.String
? converterIdElement.GetString()
: null;
output.Converter = new(converterId ?? string.Empty, settings);
}
return output;
}
/// <inheritdoc />
@ -54,15 +69,34 @@ public class OutputJsonConverter<T> : JsonConverter<Output<T>?>
? alias
: valueType.GetSimpleAssemblyQualifiedName();
var model = new
{
TypeName = valueTypeAlias,
MemoryReference = value == null ? null : new
{
Id = value.MemoryBlockReference().Id
}
};
writer.WriteStartObject();
writer.WriteString("typeName", valueTypeAlias);
writer.WritePropertyName("memoryReference");
JsonSerializer.Serialize(writer, model, options);
if (value == null)
writer.WriteNullValue();
else
{
writer.WriteStartObject();
writer.WriteString("id", value.MemoryBlockReference().Id);
writer.WriteEndObject();
}
if (value?.Converter != null)
{
writer.WritePropertyName("converter");
writer.WriteStartObject();
writer.WriteString("id", value.Converter.Id);
if (value.Converter.Settings is { } settings)
{
writer.WritePropertyName("settings");
settings.WriteTo(writer);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
}

View file

@ -89,16 +89,30 @@ public class SyntheticPropertiesWriter(IExpressionDescriptorRegistry expressionD
var outputType = outputDescriptor.Type;
var memoryReferenceId = output.MemoryBlockReference().Id;
var outputModel = new
{
TypeName = outputType,
MemoryReference = new
{
Id = memoryReferenceId
}
};
writer.WriteStartObject();
writer.WritePropertyName("typeName");
JsonSerializer.Serialize(writer, outputType, options);
writer.WritePropertyName("memoryReference");
writer.WriteStartObject();
writer.WriteString("id", memoryReferenceId);
writer.WriteEndObject();
JsonSerializer.Serialize(writer, outputModel, outputModel.GetType(), options);
if (output.Converter != null)
{
writer.WritePropertyName("converter");
writer.WriteStartObject();
writer.WriteString("id", output.Converter.Id);
if (output.Converter.Settings is { } settings)
{
writer.WritePropertyName("settings");
settings.WriteTo(writer);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
}
}
}

View file

@ -0,0 +1,65 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <inheritdoc />
public class OutputBindingDestinationResolver : IOutputBindingDestinationResolver
{
/// <inheritdoc />
public OutputBindingDestination? Resolve(ActivityExecutionContext activityExecutionContext, Output output)
{
var reference = output.MemoryBlockReference();
if (reference.Id == null)
return null;
if (activityExecutionContext.ExpressionExecutionContext.TryGetBlock(reference, out var block) &&
block.Metadata is VariableBlockMetadata variableMetadata)
{
return CreateVariableDestination(variableMetadata.Variable);
}
return CreateWorkflowOutputDestination(activityExecutionContext.WorkflowExecutionContext.Workflow, reference.Id);
}
/// <inheritdoc />
public OutputBindingDestination? Resolve(WorkflowGraph workflowGraph, ActivityNode activityNode, Output output)
{
var referenceId = output.MemoryBlockReference().Id;
if (referenceId == null)
return null;
var variable = activityNode
.Ancestors()
.Select(x => x.Activity)
.OfType<IVariableContainer>()
.SelectMany(x => x.Variables)
.FirstOrDefault(x => x.Id == referenceId);
if (variable != null)
return CreateVariableDestination(variable);
return CreateWorkflowOutputDestination(workflowGraph.Workflow, referenceId);
}
private static OutputBindingDestination? CreateVariableDestination(Variable variable)
{
var variableType = variable.GetType().GenericTypeArguments.FirstOrDefault();
return variableType == null
? null
: new(variable.Id, variableType, AllowsNull(variableType), OutputBindingDestinationKind.Variable);
}
private static OutputBindingDestination? CreateWorkflowOutputDestination(Workflow workflow, string referenceId)
{
var output = workflow.Outputs.FirstOrDefault(x => x.Name == referenceId);
return output == null
? null
: new(output.Name, output.Type, AllowsNull(output.Type), OutputBindingDestinationKind.WorkflowOutput);
}
private static bool AllowsNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) != null;
}

View file

@ -0,0 +1,106 @@
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows;
/// <inheritdoc />
public class OutputConverterInvoker(
IOutputConverterRegistry registry,
IOutputConverterSettingsValidator settingsValidator) : IOutputConverterInvoker
{
/// <inheritdoc />
public object? Invoke(
ActivityExecutionContext activityExecutionContext,
Output output,
string outputName,
Type sourceType,
object value,
OutputBindingDestination destination)
{
var configuration = output.Converter ?? throw new InvalidOperationException("The output binding has no converter configuration.");
var descriptor = registry.Find(configuration.Id);
if (descriptor == null)
throw CreateException(OutputConversionFailureStage.Resolution);
if (!IsRuntimeValueCompatible(value, sourceType) || !descriptor.SourceType.IsAssignableFrom(sourceType))
throw CreateException(OutputConversionFailureStage.SourceCompatibility);
if (!OutputConverterRegistry.IsAssignableToDestination(descriptor.ResultType, destination.Type))
throw CreateException(OutputConversionFailureStage.ResultValidation);
IOutputConverter converter;
try
{
converter = activityExecutionContext.WorkflowExecutionContext.ServiceProvider
.GetRequiredKeyedService<IOutputConverter>(configuration.Id);
}
catch (Exception e)
{
throw CreateException(OutputConversionFailureStage.Resolution, e);
}
IReadOnlyCollection<string> settingsErrors;
try
{
settingsErrors = settingsValidator.Validate(descriptor, converter, configuration.Settings);
}
catch (Exception e)
{
throw CreateException(OutputConversionFailureStage.SettingsValidation, e);
}
if (settingsErrors.Count > 0)
throw CreateException(OutputConversionFailureStage.SettingsValidation);
object? convertedValue;
try
{
convertedValue = converter.Convert(new(value, sourceType, destination.Type, configuration.Settings));
}
catch (Exception e)
{
throw CreateException(OutputConversionFailureStage.Invocation, e);
}
if (convertedValue == null)
{
if (!destination.AllowsNull)
throw CreateException(OutputConversionFailureStage.ResultValidation);
return null;
}
if (!IsRuntimeValueCompatible(convertedValue, descriptor.ResultType) ||
!IsRuntimeValueCompatible(convertedValue, destination.Type))
{
throw CreateException(OutputConversionFailureStage.ResultValidation);
}
return convertedValue;
OutputConversionException CreateException(OutputConversionFailureStage stage, Exception? innerException = null) =>
new(
configuration.Id,
stage,
activityExecutionContext.Activity.Id,
activityExecutionContext.Activity.Type,
outputName,
destination.Id,
sourceType,
destination.Type,
innerException);
}
private static bool IsRuntimeValueCompatible(object value, Type declaredType)
{
if (declaredType.IsInstanceOfType(value))
return true;
return Nullable.GetUnderlyingType(declaredType)?.IsInstanceOfType(value) == true;
}
}

View file

@ -0,0 +1,72 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
/// <inheritdoc />
public class OutputConverterRegistry : IOutputConverterRegistry
{
private readonly IReadOnlyDictionary<string, OutputConverterRegistration> _registrations;
public OutputConverterRegistry(IEnumerable<OutputConverterRegistration> registrations)
{
var registrationList = registrations.ToList();
ValidateRegistrations(registrationList);
_registrations = registrationList.ToDictionary(x => x.Descriptor.Id, StringComparer.Ordinal);
}
/// <inheritdoc />
public IEnumerable<OutputConverterDescriptor> ListAll() => _registrations.Values.Select(x => x.Descriptor);
/// <inheritdoc />
public OutputConverterDescriptor? Find(string id) =>
_registrations.TryGetValue(id, out var registration) ? registration.Descriptor : null;
/// <summary>
/// Finds the internal keyed-service registration associated with the specified converter ID.
/// </summary>
public OutputConverterRegistration? FindRegistration(string id) =>
_registrations.TryGetValue(id, out var registration) ? registration : null;
/// <inheritdoc />
public IEnumerable<OutputConverterDescriptor> FindCompatible(Type sourceType, Type destinationType) =>
ListAll().Where(x =>
x.SourceType.IsAssignableFrom(sourceType) &&
IsAssignableToDestination(x.ResultType, destinationType));
internal static bool IsAssignableToDestination(Type valueType, Type destinationType)
{
if (destinationType.IsAssignableFrom(valueType))
return true;
var underlyingDestinationType = Nullable.GetUnderlyingType(destinationType);
return underlyingDestinationType?.IsAssignableFrom(valueType) == true;
}
private static void ValidateRegistrations(IReadOnlyCollection<OutputConverterRegistration> registrations)
{
foreach (var registration in registrations)
{
ValidateDescriptor(registration.Descriptor);
var id = registration.Descriptor.Id;
if (!string.Equals(id, registration.ServiceKey, StringComparison.Ordinal))
throw new InvalidOperationException($"Output converter registration '{id}' must use the Converter ID as its service key.");
}
var duplicate = registrations
.GroupBy(x => x.Descriptor.Id, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(x => x.Count() > 1);
if (duplicate != null)
throw new InvalidOperationException($"Output converter ID '{duplicate.Key}' is registered more than once or differs from another registration only by case.");
}
internal static void ValidateDescriptor(OutputConverterDescriptor descriptor)
{
if (string.IsNullOrWhiteSpace(descriptor.Id))
throw new InvalidOperationException("Output converter IDs cannot be empty.");
if (descriptor.SourceType.ContainsGenericParameters || descriptor.ResultType.ContainsGenericParameters)
throw new InvalidOperationException($"Output converter '{descriptor.Id}' cannot use open-generic source or result types.");
}
}

View file

@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Elsa.Workflows.Models;
using Json.Schema;
namespace Elsa.Workflows;
/// <inheritdoc />
public class OutputConverterSettingsValidator : IOutputConverterSettingsValidator
{
private static readonly JsonElement EmptySettings = JsonDocument.Parse("{}").RootElement.Clone();
private readonly ConcurrentDictionary<string, SchemaValidationPlan> _schemaValidationPlans = new(StringComparer.Ordinal);
/// <inheritdoc />
public IReadOnlyCollection<string> Validate(
OutputConverterDescriptor descriptor,
IOutputConverter converter,
JsonElement? settings)
{
var errors = new List<string>();
if (settings is { ValueKind: not JsonValueKind.Object })
errors.Add("Converter settings must be a JSON object.");
if (descriptor.SettingsSchema is { } schemaElement)
{
var plan = _schemaValidationPlans.GetOrAdd(descriptor.Id, _ => CreateSchemaValidationPlan(schemaElement));
if (plan.Schema == null)
errors.Add("The registered converter settings schema is invalid.");
else if (!plan.Schema.Evaluate(settings ?? EmptySettings).IsValid)
errors.Add("Converter settings do not satisfy the registered JSON Schema.");
}
try
{
errors.AddRange(converter.ValidateSettings(settings?.Clone()) ?? []);
}
catch
{
errors.Add("Converter-owned settings validation failed.");
}
return errors;
}
private static SchemaValidationPlan CreateSchemaValidationPlan(JsonElement schemaElement)
{
try
{
return new(JsonSchema.Build(schemaElement));
}
catch
{
return new(null);
}
}
private sealed record SchemaValidationPlan(JsonSchema? Schema);
}

View file

@ -31,6 +31,7 @@ using Elsa.Workflows.UIHints.Dropdown;
using Elsa.Workflows.UIHints.JsonEditor;
using Elsa.Workflows.UIHints.RadioList;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Newtonsoft.Json.Linq;
namespace Elsa.Workflows.ShellFeatures;
@ -114,6 +115,8 @@ public class WorkflowsFeature : IShellFeature
options.AddTypeAlias<JArray>(nameof(JArray));
});
services.TryAddSingleton<IOutputConverterRegistry, OutputConverterRegistry>();
services
// Core.
.AddScoped<IActivityInvoker, ActivityInvoker>()
@ -133,6 +136,9 @@ public class WorkflowsFeature : IShellFeature
.AddSingleton<IActivityDescriber, ActivityDescriber>()
.AddSingleton<IActivityRegistry, ActivityRegistry>()
.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>()
.AddScoped<IOutputBindingDestinationResolver, OutputBindingDestinationResolver>()
.AddSingleton<IOutputConverterSettingsValidator, OutputConverterSettingsValidator>()
.AddScoped<IOutputConverterInvoker, OutputConverterInvoker>()
.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>()
.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>()
.AddSingleton<IActivityFactory, ActivityFactory>()

View file

@ -7,13 +7,18 @@ namespace Elsa.Workflows.State;
/// <summary>
/// A simplified, serializable model representing an exception.
/// </summary>
public record ExceptionState(Type Type, string Message, string? StackTrace, ExceptionState? InnerException)
public record ExceptionState(
Type Type,
string Message,
string? StackTrace,
ExceptionState? InnerException,
IReadOnlyDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Constructor
/// </summary>
[JsonConstructor]
public ExceptionState() : this(default!, default!, default, default)
public ExceptionState() : this(default!, default!, default, default, default)
{
}
@ -23,6 +28,13 @@ public record ExceptionState(Type Type, string Message, string? StackTrace, Exce
/// </summary>
public static ExceptionState? FromException(Exception? ex)
{
return ex == null ? null : new ExceptionState(ex.GetType(), ex.Message, ex.StackTrace, FromException(ex.InnerException));
if (ex == null)
return null;
var metadataProvider = ex as ISafeExceptionMetadataProvider;
var metadata = metadataProvider?.GetSafeMetadata();
var innerException = metadataProvider == null ? FromException(ex.InnerException) : null;
return new ExceptionState(ex.GetType(), ex.Message, ex.StackTrace, innerException, metadata);
}
}
}

View file

@ -300,6 +300,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
.AddNotificationHandler<RefreshActivityRegistry>()
.AddNotificationHandler<UpdateConsumingWorkflows>()
.AddNotificationHandler<ValidateWorkflow>()
.AddNotificationHandler<ValidateOutputConverters>()
;
Services.Configure<ManagementOptions>(options =>

View file

@ -0,0 +1,115 @@
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Management.Handlers.Notifications;
/// <summary>
/// Validates output-converter bindings before a workflow definition is accepted.
/// </summary>
public class ValidateOutputConverters(
IWorkflowGraphBuilder workflowGraphBuilder,
IActivityRegistryLookupService activityRegistryLookupService,
IOutputConverterRegistry outputConverterRegistry,
IOutputBindingDestinationResolver destinationResolver,
IOutputConverterSettingsValidator settingsValidator,
IServiceProvider serviceProvider) : INotificationHandler<WorkflowDefinitionValidating>
{
/// <inheritdoc />
public async Task HandleAsync(WorkflowDefinitionValidating notification, CancellationToken cancellationToken)
{
var graph = await workflowGraphBuilder.BuildAsync(notification.Workflow, cancellationToken);
foreach (var node in graph.Nodes)
{
var activity = node.Activity;
var activityDescriptor = await activityRegistryLookupService.FindAsync(activity.Type, activity.Version);
if (activityDescriptor == null)
continue;
foreach (var outputDescriptor in activityDescriptor.Outputs)
{
if (outputDescriptor.ValueGetter(activity) is not Output { Converter: { } configuration } output)
continue;
ValidateBinding(
graph,
node,
output,
outputDescriptor,
configuration,
notification.ValidationErrors);
}
}
}
private void ValidateBinding(
WorkflowGraph graph,
ActivityNode node,
Output output,
OutputDescriptor outputDescriptor,
OutputConverterConfiguration configuration,
ICollection<WorkflowValidationError> validationErrors)
{
var activity = node.Activity;
void AddError(string message) =>
validationErrors.Add(new($"Output '{outputDescriptor.Name}' converter: {message}", activity.Id));
if (string.IsNullOrWhiteSpace(configuration.Id))
{
AddError("a converter ID is required.");
return;
}
var destination = destinationResolver.Resolve(graph, node, output);
if (destination == null)
{
AddError("a converter can only be configured when the output has a variable or workflow-output destination.");
return;
}
var registration = outputConverterRegistry.FindRegistration(configuration.Id);
if (registration == null)
{
AddError($"converter '{configuration.Id}' is not registered.");
return;
}
var converterDescriptor = registration.Descriptor;
if (!converterDescriptor.SourceType.IsAssignableFrom(outputDescriptor.Type))
AddError($"converter '{configuration.Id}' cannot accept declared source type '{outputDescriptor.Type.FullName}'.");
if (!CanAssign(converterDescriptor.ResultType, destination.Type))
AddError($"converter '{configuration.Id}' result type '{converterDescriptor.ResultType.FullName}' cannot be assigned to destination type '{destination.Type.FullName}'.");
IOutputConverter converter;
try
{
converter = serviceProvider.GetRequiredKeyedService<IOutputConverter>(registration.ServiceKey);
}
catch
{
AddError($"converter '{configuration.Id}' could not be resolved.");
return;
}
foreach (var settingsError in settingsValidator.Validate(converterDescriptor, converter, configuration.Settings))
AddError($"converter '{configuration.Id}' settings are invalid: {settingsError}");
}
private static bool CanAssign(Type sourceType, Type destinationType)
{
if (destinationType.IsAssignableFrom(sourceType))
return true;
return Nullable.GetUnderlyingType(destinationType)?.IsAssignableFrom(sourceType) == true;
}
}

View file

@ -124,7 +124,8 @@ public class WorkflowManagementFeature : IShellFeature
.AddNotificationHandler<DeleteWorkflowInstances>()
.AddNotificationHandler<RefreshActivityRegistry>()
.AddNotificationHandler<UpdateConsumingWorkflows>()
.AddNotificationHandler<ValidateWorkflow>();
.AddNotificationHandler<ValidateWorkflow>()
.AddNotificationHandler<ValidateOutputConverters>();
// Register built-in activities from the Workflows and WorkflowManagement assemblies.
services

View file

@ -14,6 +14,7 @@ using Elsa.Workflows.ComponentTests.Decorators;
using Elsa.Workflows.ComponentTests.Materializers;
using Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Mocks;
using Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities;
using Elsa.Workflows.ComponentTests.Scenarios.OutputConverters;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Distributed.Extensions;
@ -161,6 +162,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
});
services
.AddOutputConverter<TestOutputConverter>(TestOutputConverter.Descriptor)
.AddSingleton<SignalManager>()
.AddScoped<AsyncWorkflowRunner>()
.AddSingleton<WorkflowEvents>()

View file

@ -0,0 +1,73 @@
using System.Net;
using System.Text;
using Elsa.Api.Client;
using Elsa.Api.Client.Extensions;
using Elsa.Api.Client.Resources.OutputConverters.Contracts;
using Elsa.Api.Client.Resources.OutputConverters.Requests;
using Microsoft.Extensions.DependencyInjection;
using Refit;
namespace Elsa.Workflows.ComponentTests.Scenarios.OutputConverters;
public class OutputConverterApiClientTests
{
[Fact]
public void AddDefaultApiClients_RegistersOutputConverterDiscoveryClient()
{
var services = new ServiceCollection();
services.AddDefaultApiClients(options => options.BaseAddress = new Uri("https://example.test/elsa/api"));
using var serviceProvider = services.BuildServiceProvider();
Assert.IsAssignableFrom<IOutputConvertersApi>(serviceProvider.GetRequiredService<IOutputConvertersApi>());
}
[Fact]
public async Task ListAsync_SendsDeclaredTypesAndDeserializesTheSafeDescriptorShape()
{
var handler = new ResponseHandler("""
{
"items": [
{
"id": "sample.to-text",
"sourceTypeName": "String",
"resultTypeName": "String",
"displayName": "Convert to text",
"description": "Formats the source as text.",
"settingsSchema": { "type": "object" }
}
]
}
""");
using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/elsa/api") };
using var serviceProvider = new ServiceCollection().BuildServiceProvider();
var api = RestService.For<IOutputConvertersApi>(client, RefitSettingsHelper.CreateRefitSettings(serviceProvider));
var response = await api.ListAsync(new ListOutputConvertersRequest
{
SourceType = "String",
DestinationType = "String"
});
Assert.Equal("/elsa/api/descriptors/output-converters?sourceType=String&destinationType=String", handler.RequestUri!.PathAndQuery);
var descriptor = Assert.Single(response.Items);
Assert.Equal("sample.to-text", descriptor.Id);
Assert.Equal("String", descriptor.SourceTypeName);
Assert.Equal("String", descriptor.ResultTypeName);
Assert.Equal("object", descriptor.SettingsSchema!.Value.GetProperty("type").GetString());
}
private sealed class ResponseHandler(string response) : HttpMessageHandler
{
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(response, Encoding.UTF8, "application/json")
});
}
}
}

View file

@ -0,0 +1,111 @@
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.IncidentStrategies;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.ComponentTests.Scenarios.OutputConverters;
public class OutputConverterTests(App app) : AppComponentTest(app)
{
[Fact]
public async Task ConfiguredConverter_WritesConvertedValueToVariableAndRetainsNativeActivityOutput()
{
var builder = Scope.ServiceProvider.GetRequiredService<IWorkflowBuilderFactory>().CreateBuilder();
var destination = builder.WithVariable<string>("ConvertedValue", "unchanged").WithWorkflowStorage();
var observed = builder.WithOutput<string>("ObservedValue");
var activity = new NativeStringActivity
{
Result = new(destination)
{
Converter = new(TestOutputConverter.Descriptor.Id)
}
};
builder.Root = new Sequence
{
Activities =
[
activity,
new Inline<string>(
context => destination.Get(context.ExpressionExecutionContext)!,
new MemoryBlockReference(observed.Name))
]
};
var workflow = await builder.BuildWorkflowAsync();
var result = await Scope.ServiceProvider.GetRequiredService<IWorkflowInvoker>().InvokeAsync(workflow);
Assert.Equal("converted:native value", result.WorkflowExecutionContext.Output[observed.Name]);
Assert.Equal("native value", result.WorkflowExecutionContext.GetActivityOutputRegister().FindOutputByActivityId(activity.Id));
}
[Fact]
public async Task ConfiguredConverter_WritesConvertedValueToWorkflowOutputAndRetainsNativeActivityOutput()
{
var builder = Scope.ServiceProvider.GetRequiredService<IWorkflowBuilderFactory>().CreateBuilder();
var destination = builder.WithOutput<string>("ConvertedOutput");
var activity = new NativeStringActivity
{
Result = new(new MemoryBlockReference(destination.Name))
{
Converter = new(TestOutputConverter.Descriptor.Id)
}
};
builder.Root = activity;
var workflow = await builder.BuildWorkflowAsync();
var result = await Scope.ServiceProvider.GetRequiredService<IWorkflowInvoker>().InvokeAsync(workflow);
Assert.Equal("converted:native value", result.WorkflowExecutionContext.Output[destination.Name]);
Assert.Equal("native value", result.WorkflowExecutionContext.GetActivityOutputRegister().FindOutputByActivityId(activity.Id));
}
[Fact]
public async Task MissingConverter_FaultsWithSafeMetadataAndLeavesDestinationUnchanged()
{
var builder = Scope.ServiceProvider.GetRequiredService<IWorkflowBuilderFactory>().CreateBuilder();
builder.WorkflowOptions.IncidentStrategyType = typeof(FaultStrategy);
var destination = builder.WithVariable<string>("ConvertedValue", "unchanged").WithWorkflowStorage();
var activity = new NativeStringActivity
{
Result = new(destination)
{
Converter = new("tests.component.missing")
}
};
builder.Root = activity;
var workflow = await builder.BuildWorkflowAsync();
var result = await Scope.ServiceProvider.GetRequiredService<IWorkflowInvoker>().InvokeAsync(workflow);
Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowExecutionContext.SubStatus);
Assert.Equal("native value", result.WorkflowExecutionContext.GetActivityOutputRegister().FindOutputByActivityId(activity.Id));
var incident = Assert.Single(result.WorkflowExecutionContext.Incidents);
Assert.Equal(typeof(OutputConversionException), incident.Exception!.Type);
Assert.Equal("tests.component.missing", incident.Exception.Metadata![nameof(OutputConversionException.ConverterId)]);
Assert.Equal("Resolution", incident.Exception.Metadata[nameof(OutputConversionException.Stage)]);
Assert.DoesNotContain("native value", incident.Message);
}
private sealed class NativeStringActivity : CodeActivity<string>
{
protected override void Execute(ActivityExecutionContext context) => Result!.Set(context, "native value");
}
}
public sealed class TestOutputConverter : IOutputConverter
{
public static OutputConverterDescriptor Descriptor { get; } = new(
"tests.component.to-text",
typeof(string),
typeof(string),
"Component test converter");
public object? Convert(OutputConversionContext context) => $"converted:{context.Value}";
}

View file

@ -8,7 +8,8 @@ public class Config : ManualConfig
{
public Config()
{
BuildTimeout = TimeSpan.FromMinutes(5);
AddExporter(MarkdownExporter.GitHub);
AddDiagnoser(MemoryDiagnoser.Default);
}
}
}

View file

@ -0,0 +1,99 @@
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.PerformanceTests;
/// <summary>
/// Guards the allocation-sensitive output assignment path when no converter is configured.
/// </summary>
[Config(typeof(OutputAssignmentBenchmarkConfig))]
[MemoryDiagnoser]
public class OutputAssignmentBenchmark
{
private ActivityExecutionContext _activityExecutionContext = null!;
private Output<int> _output = null!;
private ServiceProvider _serviceProvider = null!;
[GlobalSetup]
public async Task GlobalSetup()
{
var services = new ServiceCollection();
services.AddElsa();
_serviceProvider = services.BuildServiceProvider();
var activity = new WriteLine("benchmark");
var registry = _serviceProvider.GetRequiredService<IActivityRegistry>();
await registry.RegisterAsync(activity.GetType());
var graphBuilder = _serviceProvider.GetRequiredService<IWorkflowGraphBuilder>();
var workflow = Workflow.FromActivity(activity);
var graph = await graphBuilder.BuildAsync(workflow);
var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(
_serviceProvider,
graph,
"output-assignment-benchmark",
CancellationToken.None);
_activityExecutionContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(activity);
_output = new(new MemoryBlockReference("benchmark-output"));
}
[Benchmark(Baseline = true)]
public void LegacyEquivalentAssignment() => LegacyEquivalentAssignmentCore(_output, 42, nameof(_output));
private void LegacyEquivalentAssignmentCore(Output? output, object? value, string? outputName)
{
_activityExecutionContext.ExpressionExecutionContext.Set(output, value);
RecordActivityOutput(
_activityExecutionContext.WorkflowExecutionContext,
_activityExecutionContext,
outputName,
value);
}
[Benchmark]
public void NoConverterAssignment() => _activityExecutionContext.Set(_output, 42);
[IterationSetup]
public void IterationSetup()
{
var register = _activityExecutionContext.WorkflowExecutionContext.GetActivityOutputRegister();
GetRecordsByActivityIdAndOutputName(register).Clear();
GetRecordsByActivityInstanceIdAndOutputName(register).Clear();
}
[GlobalCleanup]
public void GlobalCleanup() => _serviceProvider.Dispose();
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "RecordActivityOutput")]
private static extern void RecordActivityOutput(
WorkflowExecutionContext workflowExecutionContext,
ActivityExecutionContext activityExecutionContext,
string? outputName,
object? value);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_recordsByActivityIdAndOutputName")]
private static extern ref Dictionary<string, List<ActivityOutputRecord>> GetRecordsByActivityIdAndOutputName(ActivityOutputRegister register);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_recordsByActivityInstanceIdAndOutputName")]
private static extern ref Dictionary<string, ActivityOutputRecord> GetRecordsByActivityInstanceIdAndOutputName(ActivityOutputRegister register);
}
public sealed class OutputAssignmentBenchmarkConfig : Config
{
public OutputAssignmentBenchmarkConfig()
{
AddJob(Job.Default
.WithLaunchCount(1)
.WithWarmupCount(5)
.WithIterationCount(10)
.WithInvocationCount(65536)
.WithUnrollFactor(1));
}
}

View file

@ -0,0 +1,77 @@
using System.Text.Json;
using Elsa.Common.Serialization;
using Elsa.Models;
using Elsa.Workflows.Api.Endpoints.OutputConverters.List;
using Elsa.Workflows.Models;
using FastEndpoints;
using NSubstitute;
namespace Elsa.Workflows.Api.UnitTests.OutputConverters;
public class OutputConverterEndpointTests
{
[Fact]
public void Configure_ExposesTheAuthorizedDescriptorRoute()
{
var endpoint = new List(Substitute.For<IOutputConverterRegistry>(), SerializationTypeRegistry.CreateDefault());
var definition = new EndpointDefinition(typeof(List), typeof(EmptyRequest), typeof(ListResponse<OutputConverterDescriptorModel>));
typeof(List).GetProperty("Definition", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)!.SetValue(endpoint, definition);
endpoint.Configure();
Assert.Contains("/descriptors/output-converters", definition.Routes);
var permissions = (IEnumerable<string>)typeof(EndpointDefinition)
.GetProperty("AllowedPermissions", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)!
.GetValue(definition)!;
Assert.Contains("read:*", permissions);
Assert.Contains("read:output-converters", permissions);
}
[Fact]
public void ListCompatible_FiltersThroughTheRegistryAndExposesOnlySafeDescriptorMetadata()
{
using var document = JsonDocument.Parse("""{"type":"object","properties":{"format":{"type":"string"}}}""");
var descriptor = new OutputConverterDescriptor(
"sample.to-text",
typeof(string),
typeof(string),
"Convert to text",
"Formats the source as text.",
document.RootElement);
var registry = Substitute.For<IOutputConverterRegistry>();
registry.FindCompatible(typeof(string), typeof(int)).Returns([descriptor]);
var endpoint = new List(registry, SerializationTypeRegistry.CreateDefault());
var listed = endpoint.TryListCompatible("String", "Int32", out var response, out var errors);
var model = Assert.Single(response.Items);
Assert.True(listed);
Assert.Empty(errors);
registry.Received(1).FindCompatible(typeof(string), typeof(int));
Assert.Equal("sample.to-text", model.Id);
Assert.Equal("String", model.SourceTypeName);
Assert.Equal("String", model.ResultTypeName);
Assert.Equal("Convert to text", model.DisplayName);
Assert.Equal("Formats the source as text.", model.Description);
Assert.Equal("object", model.SettingsSchema!.Value.GetProperty("type").GetString());
Assert.DoesNotContain(typeof(OutputConverterDescriptorModel).GetProperties(), property => property.Name is "SourceType" or "ResultType" or "ServiceKey" or "ServiceLifetime");
}
[Theory]
[InlineData(null, "String", "The sourceType query parameter is required.")]
[InlineData("String", null, "The destinationType query parameter is required.")]
[InlineData("Unsafe.Type", "String", "The sourceType query parameter must be a registered type alias or resolvable safe type name.")]
[InlineData("String", "Unsafe.Type", "The destinationType query parameter must be a registered type alias or resolvable safe type name.")]
public void TryListCompatible_RejectsMissingOrUnsafeQueryTypes(string? sourceTypeName, string? destinationTypeName, string expectedError)
{
var registry = Substitute.For<IOutputConverterRegistry>();
var endpoint = new List(registry, SerializationTypeRegistry.CreateDefault());
var listed = endpoint.TryListCompatible(sourceTypeName, destinationTypeName, out var response, out var errors);
Assert.False(listed);
Assert.Empty(response.Items);
Assert.Contains(expectedError, errors);
registry.DidNotReceive().FindCompatible(Arg.Any<Type>(), Arg.Any<Type>());
}
}

View file

@ -0,0 +1,235 @@
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class ActivityExecutionContextOutputConversionTests
{
private const string ConverterId = "tests.to-text";
[Fact]
public async Task Set_WithConverter_WritesConvertedVariableAndRecordsNativeOutput()
{
var converter = new RecordingConverter(context => context.Value.ToString()!);
var variable = new Variable<string>("Destination", "unchanged");
var activity = new TestActivity
{
Result = new(variable)
{
Converter = new(ConverterId)
}
};
var context = await CreateContextAsync(
activity,
converter,
new(ConverterId, typeof(int), typeof(string), "To text"));
context.ExpressionExecutionContext.Memory.Declare(variable);
context.Set(activity.Result, 42, nameof(TestActivity.Result));
Assert.Equal("42", variable.Get(context.ExpressionExecutionContext));
Assert.Equal(
42,
context.WorkflowExecutionContext.GetActivityOutputRegister()
.FindOutputByActivityInstanceId(context.Id, nameof(TestActivity.Result)));
Assert.Equal(1, converter.ConvertCalls);
}
[Fact]
public async Task Set_WithConverter_WritesConvertedWorkflowOutputAndRecordsNativeOutput()
{
var converter = new RecordingConverter(context => context.Value.ToString()!);
var activity = new TestActivity
{
Result = new(new Elsa.Expressions.Models.MemoryBlockReference("workflowResult"))
{
Converter = new(ConverterId)
}
};
var context = await CreateContextAsync(
activity,
converter,
new(ConverterId, typeof(int), typeof(string), "To text"));
context.WorkflowExecutionContext.Workflow.Outputs.Add(new()
{
Name = "workflowResult",
Type = typeof(string)
});
context.Set(activity.Result, 42, nameof(TestActivity.Result));
Assert.Equal("42", context.WorkflowExecutionContext.Output["workflowResult"]);
Assert.Equal(
42,
context.WorkflowExecutionContext.GetActivityOutputRegister()
.FindOutputByActivityInstanceId(context.Id, nameof(TestActivity.Result)));
}
[Fact]
public async Task Set_WhenConverterThrows_LeavesDestinationUnchangedAndRecordsNativeOutput()
{
var converter = new RecordingConverter(_ => throw new InvalidOperationException("conversion failed"));
var variable = new Variable<string>("Destination", "unchanged");
var activity = new TestActivity
{
Result = new(variable)
{
Converter = new(ConverterId)
}
};
var context = await CreateContextAsync(
activity,
converter,
new(ConverterId, typeof(int), typeof(string), "To text"));
context.ExpressionExecutionContext.Memory.Declare(variable);
var exception = Assert.Throws<OutputConversionException>(
() => context.Set(activity.Result, 42, nameof(TestActivity.Result)));
Assert.Equal(OutputConversionFailureStage.Invocation, exception.Stage);
Assert.Equal("unchanged", variable.Get(context.ExpressionExecutionContext));
Assert.Equal(
42,
context.WorkflowExecutionContext.GetActivityOutputRegister()
.FindOutputByActivityInstanceId(context.Id, nameof(TestActivity.Result)));
}
[Fact]
public async Task Set_WithConfiguredNull_BypassesConverterForNullableDestination()
{
var converter = new RecordingConverter(_ => "should not be called");
var variable = new Variable<string?>("Destination", "unchanged");
var activity = new NullableTestActivity
{
Result = new(variable)
{
Converter = new(ConverterId)
}
};
var context = await CreateContextAsync(
activity,
converter,
new(ConverterId, typeof(int?), typeof(string), "To text"));
context.ExpressionExecutionContext.Memory.Declare(variable);
context.Set(activity.Result, null, nameof(NullableTestActivity.Result));
Assert.Null(variable.Get(context.ExpressionExecutionContext));
Assert.Equal(0, converter.ConvertCalls);
var record = Assert.Single(
context.WorkflowExecutionContext.GetActivityOutputRegister()
.FindMany(activity.Id, nameof(NullableTestActivity.Result)));
Assert.Null(record.Value);
}
[Fact]
public async Task Set_WithConfiguredNullForNonNullableDestination_LeavesDestinationUnchanged()
{
var converter = new RecordingConverter(_ => 0);
var variable = new Variable<int>("Destination", 7);
var activity = new NullableTestActivity
{
Result = new(variable)
{
Converter = new(ConverterId)
}
};
var context = await CreateContextAsync(
activity,
converter,
new(ConverterId, typeof(int?), typeof(int), "To integer"));
context.ExpressionExecutionContext.Memory.Declare(variable);
var exception = Assert.Throws<OutputConversionException>(
() => context.Set(activity.Result, null, nameof(NullableTestActivity.Result)));
Assert.Equal(OutputConversionFailureStage.ResultValidation, exception.Stage);
Assert.Equal(7, variable.Get(context.ExpressionExecutionContext));
Assert.Equal(0, converter.ConvertCalls);
}
[Fact]
public async Task Set_WithoutConverter_UsesExistingPathWithoutConverterInfrastructureCalls()
{
var registry = Substitute.For<IOutputConverterRegistry>();
var resolver = Substitute.For<IOutputBindingDestinationResolver>();
var validator = Substitute.For<IOutputConverterSettingsValidator>();
var invoker = Substitute.For<IOutputConverterInvoker>();
var variable = new Variable<int>("Destination", 0);
var activity = new TestActivity
{
Result = new(variable)
};
var fixture = new ActivityTestFixture(activity)
.ConfigureServices(services =>
{
services.AddSingleton(registry);
services.AddSingleton(resolver);
services.AddSingleton(validator);
services.AddSingleton(invoker);
});
var context = await fixture.BuildAsync();
context.ExpressionExecutionContext.Memory.Declare(variable);
context.Set(activity.Result, 42, nameof(TestActivity.Result));
Assert.Equal(42, variable.Get(context.ExpressionExecutionContext));
Assert.Empty(registry.ReceivedCalls());
Assert.Empty(resolver.ReceivedCalls());
Assert.Empty(validator.ReceivedCalls());
Assert.Empty(invoker.ReceivedCalls());
}
private static async Task<ActivityExecutionContext> CreateContextAsync(
IActivity activity,
IOutputConverter converter,
OutputConverterDescriptor descriptor)
{
var registry = new OutputConverterRegistry(
[new OutputConverterRegistration(descriptor, descriptor.Id, ServiceLifetime.Scoped)]);
var fixture = new ActivityTestFixture(activity)
.ConfigureServices(services =>
{
services.AddKeyedSingleton(descriptor.Id, converter);
services.AddSingleton<IOutputConverterRegistry>(registry);
services.AddSingleton<IOutputBindingDestinationResolver, OutputBindingDestinationResolver>();
services.AddSingleton<IOutputConverterSettingsValidator, OutputConverterSettingsValidator>();
services.AddSingleton<IOutputConverterInvoker, OutputConverterInvoker>();
});
return await fixture.BuildAsync();
}
private sealed class TestActivity : CodeActivity
{
public Output<int> Result { get; set; } = new();
protected override void Execute(ActivityExecutionContext context)
{
}
}
private sealed class NullableTestActivity : CodeActivity
{
public Output<int?> Result { get; set; } = new();
protected override void Execute(ActivityExecutionContext context)
{
}
}
private sealed class RecordingConverter(Func<OutputConversionContext, object?> convert) : IOutputConverter
{
public int ConvertCalls { get; private set; }
public object? Convert(OutputConversionContext context)
{
ConvertCalls++;
return convert(context);
}
}
}

View file

@ -0,0 +1,17 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters.Fixtures;
public sealed class ReferenceOutputConverter : IOutputConverter
{
public Guid InstanceId { get; } = Guid.NewGuid();
public object? Convert(OutputConversionContext context)
{
var prefix = context.Settings?.TryGetProperty("prefix", out var prefixElement) == true
? prefixElement.GetString()
: null;
return $"{prefix}{context.Value}";
}
}

View file

@ -0,0 +1,132 @@
using Elsa.Expressions.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputBindingDestinationResolverTests
{
private readonly OutputBindingDestinationResolver _resolver = new();
[Fact]
public async Task Resolve_RuntimeVariable_ReturnsDeclaredTypeAndKind()
{
var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync();
var variable = new Variable<string>("Destination", "unchanged");
context.ExpressionExecutionContext.Memory.Declare(variable);
var output = new Output<int>(variable);
var destination = _resolver.Resolve(context, output);
Assert.NotNull(destination);
Assert.Equal(variable.Id, destination.Id);
Assert.Equal(typeof(string), destination.Type);
Assert.True(destination.AllowsNull);
Assert.Equal(OutputBindingDestinationKind.Variable, destination.Kind);
}
[Fact]
public async Task Resolve_RuntimeWorkflowOutput_ReturnsWorkflowOutputDefinition()
{
var context = await new ActivityTestFixture(new WriteLine("test")).BuildAsync();
context.WorkflowExecutionContext.Workflow.Outputs.Add(new()
{
Name = "workflowResult",
Type = typeof(int)
});
var output = new Output<string>(new MemoryBlockReference("workflowResult"));
var destination = _resolver.Resolve(context, output);
Assert.NotNull(destination);
Assert.Equal("workflowResult", destination.Id);
Assert.Equal(typeof(int), destination.Type);
Assert.False(destination.AllowsNull);
Assert.Equal(OutputBindingDestinationKind.WorkflowOutput, destination.Kind);
}
[Fact]
public void Resolve_StaticVariable_UsesNearestVariableContainer()
{
var referenceId = "shared-destination";
var root = new Sequence
{
Id = "root",
Variables = [new Variable<int>("RootDestination", 0, referenceId)]
};
var nearest = new Sequence
{
Id = "nearest",
Variables = [new Variable<string>("NearestDestination", "", referenceId)]
};
var activity = new WriteLine("test") { Id = "activity" };
var rootNode = new ActivityNode(root, "Body");
var nearestNode = new ActivityNode(nearest, "Body");
var activityNode = new ActivityNode(activity, "Body");
Connect(rootNode, nearestNode);
Connect(nearestNode, activityNode);
var workflow = new Workflow { Root = root };
var graph = new WorkflowGraph(workflow, rootNode, [rootNode, nearestNode, activityNode]);
var output = new Output<int>(new MemoryBlockReference(referenceId));
var destination = _resolver.Resolve(graph, activityNode, output);
Assert.NotNull(destination);
Assert.Equal(referenceId, destination.Id);
Assert.Equal(typeof(string), destination.Type);
Assert.True(destination.AllowsNull);
Assert.Equal(OutputBindingDestinationKind.Variable, destination.Kind);
}
[Theory]
[InlineData(typeof(string), true)]
[InlineData(typeof(int?), true)]
[InlineData(typeof(int), false)]
public void Resolve_StaticWorkflowOutput_ReflectsClrNullability(Type type, bool expectedAllowsNull)
{
var activity = new WriteLine("test") { Id = "activity" };
var node = new ActivityNode(activity, "Body");
var workflow = new Workflow
{
Root = activity,
Outputs =
[
new OutputDefinition
{
Name = "workflowResult",
Type = type
}
]
};
var graph = new WorkflowGraph(workflow, node, [node]);
var output = new Output<object>(new MemoryBlockReference("workflowResult"));
var destination = _resolver.Resolve(graph, node, output);
Assert.NotNull(destination);
Assert.Equal(type, destination.Type);
Assert.Equal(expectedAllowsNull, destination.AllowsNull);
}
[Fact]
public void Resolve_WhenReferenceIsNeitherVariableNorWorkflowOutput_ReturnsNull()
{
var activity = new WriteLine("test") { Id = "activity" };
var node = new ActivityNode(activity, "Body");
var workflow = new Workflow { Root = activity };
var graph = new WorkflowGraph(workflow, node, [node]);
var output = new Output<object>(new MemoryBlockReference("missing"));
var destination = _resolver.Resolve(graph, node, output);
Assert.Null(destination);
}
private static void Connect(ActivityNode parent, ActivityNode child)
{
parent.AddChild(child);
child.AddParent(parent);
}
}

View file

@ -0,0 +1,46 @@
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.State;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputConversionExceptionStateTests
{
[Fact]
public void FromException_PersistsOnlyStructuredSafeMetadata()
{
var exception = new OutputConversionException(
"sample.to-text",
OutputConversionFailureStage.Invocation,
"activity-1",
"TestActivity",
"Result",
"workflow-result",
typeof(int),
typeof(string),
new InvalidOperationException("sensitive converter detail"));
var state = ExceptionState.FromException(exception)!;
var persistedText = string.Join(" ", state.Metadata!.Values.Append(state.Message));
Assert.Equal("sample.to-text", state.Metadata[nameof(OutputConversionException.ConverterId)]);
Assert.Equal("Invocation", state.Metadata[nameof(OutputConversionException.Stage)]);
Assert.Equal("activity-1", state.Metadata[nameof(OutputConversionException.ActivityId)]);
Assert.Equal("Result", state.Metadata[nameof(OutputConversionException.OutputName)]);
Assert.Null(state.InnerException);
Assert.DoesNotContain("sensitive converter detail", persistedText);
Assert.DoesNotContain("settings", persistedText, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("value", persistedText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void FromException_DoesNotPersistArbitraryExceptionData()
{
var exception = new InvalidOperationException("failure");
exception.Data["secret"] = "do not persist";
var state = ExceptionState.FromException(exception)!;
Assert.Null(state.Metadata);
Assert.DoesNotContain("do not persist", state.Message);
}
}

View file

@ -0,0 +1,255 @@
using System.Text.Json;
using Elsa.Testing.Shared;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputConverterInvokerTests
{
private const string ConverterId = "tests.output-converter";
[Fact]
public async Task Invoke_WhenDeclaredSourceDerivesFromSupportedSource_InvokesConverter()
{
var converter = new RecordingConverter(_ => "converted");
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(Animal), typeof(string), "Test"));
var result = invoker.Invoke(
context,
CreateOutput(),
"Result",
typeof(Dog),
new Dog(),
Destination(typeof(string)));
Assert.Equal("converted", result);
Assert.Equal(1, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenDeclaredSourceIsIncompatible_FailsBeforeConversion()
{
var converter = new RecordingConverter(_ => "converted");
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(Stream), typeof(string), "Test"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
CreateOutput(),
"Result",
typeof(Dog),
new Dog(),
Destination(typeof(string))));
Assert.Equal(OutputConversionFailureStage.SourceCompatibility, exception.Stage);
Assert.Equal(0, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenSettingsViolateJsonSchema_FailsBeforeConversion()
{
var converter = new RecordingConverter(_ => "converted");
var context = await CreateContextAsync(ConverterId, converter);
var schema = ParseJson(
"""
{
"type": "object",
"required": ["prefix"],
"properties": {
"prefix": { "type": "string" }
}
}
""");
var invoker = CreateInvoker(new(ConverterId, typeof(int), typeof(string), "Test", settingsSchema: schema));
var output = CreateOutput(ParseJson("{}"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
output,
"Result",
typeof(int),
42,
Destination(typeof(string))));
Assert.Equal(OutputConversionFailureStage.SettingsValidation, exception.Stage);
Assert.Equal(0, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenConverterRejectsSettings_FailsBeforeConversion()
{
var converter = new RecordingConverter(
_ => "converted",
_ => ["The configured format is unsupported."]);
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(int), typeof(string), "Test"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
CreateOutput(ParseJson("""{ "format": "unsupported" }""")),
"Result",
typeof(int),
42,
Destination(typeof(string))));
Assert.Equal(OutputConversionFailureStage.SettingsValidation, exception.Stage);
Assert.Equal(0, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenDeclaredResultCannotBeAssignedToDestination_FailsBeforeConversion()
{
var converter = new RecordingConverter(_ => 42);
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(int), typeof(int), "Test"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
CreateOutput(),
"Result",
typeof(int),
1,
Destination(typeof(string))));
Assert.Equal(OutputConversionFailureStage.ResultValidation, exception.Stage);
Assert.Equal(0, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenRuntimeResultViolatesDescriptor_FailsResultValidation()
{
var converter = new RecordingConverter(_ => 42);
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(int), typeof(string), "Test"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
CreateOutput(),
"Result",
typeof(int),
1,
Destination(typeof(object))));
Assert.Equal(OutputConversionFailureStage.ResultValidation, exception.Stage);
Assert.Equal(1, converter.ConvertCalls);
}
[Fact]
public async Task Invoke_WhenConverterReturnsNullForNonNullableDestination_FailsResultValidation()
{
var converter = new RecordingConverter(_ => null);
var context = await CreateContextAsync(ConverterId, converter);
var invoker = CreateInvoker(new(ConverterId, typeof(int), typeof(int?), "Test"));
var exception = Assert.Throws<OutputConversionException>(() => invoker.Invoke(
context,
CreateOutput(),
"Result",
typeof(int),
1,
Destination(typeof(int), allowsNull: false)));
Assert.Equal(OutputConversionFailureStage.ResultValidation, exception.Stage);
}
[Fact]
public async Task Invoke_ResolvesScopedKeyedConverterFromEachWorkflowProvider()
{
var descriptor = new OutputConverterDescriptor(ConverterId, typeof(int), typeof(Guid), "Test");
var invoker = CreateInvoker(descriptor);
var firstContext = await CreateScopedContextAsync();
var secondContext = await CreateScopedContextAsync();
var output = CreateOutput();
var destination = Destination(typeof(Guid));
var firstResult = invoker.Invoke(firstContext, output, "Result", typeof(int), 1, destination);
var repeatedFirstResult = invoker.Invoke(firstContext, output, "Result", typeof(int), 2, destination);
var secondResult = invoker.Invoke(secondContext, output, "Result", typeof(int), 3, destination);
Assert.Equal(firstResult, repeatedFirstResult);
Assert.NotEqual(firstResult, secondResult);
}
private static OutputConverterInvoker CreateInvoker(OutputConverterDescriptor descriptor)
{
var registration = new OutputConverterRegistration(descriptor, descriptor.Id, ServiceLifetime.Scoped);
var registry = new OutputConverterRegistry([registration]);
return new(registry, new OutputConverterSettingsValidator());
}
private static Output<int> CreateOutput(JsonElement? settings = null) =>
new()
{
Converter = new(ConverterId, settings)
};
private static OutputBindingDestination Destination(Type type, bool? allowsNull = null) =>
new(
"destination",
type,
allowsNull ?? (!type.IsValueType || Nullable.GetUnderlyingType(type) != null),
OutputBindingDestinationKind.Variable);
private static async Task<ActivityExecutionContext> CreateContextAsync(string id, IOutputConverter converter)
{
var fixture = new ActivityTestFixture(new TestActivity())
.ConfigureServices(services => services.AddKeyedSingleton(id, converter));
return await fixture.BuildAsync();
}
private static async Task<ActivityExecutionContext> CreateScopedContextAsync()
{
var fixture = new ActivityTestFixture(new TestActivity())
.ConfigureServices(services =>
{
services.AddScoped<ScopedToken>();
services.AddKeyedScoped<IOutputConverter, ScopeAwareConverter>(ConverterId);
});
return await fixture.BuildAsync();
}
private static JsonElement ParseJson(string json) => JsonDocument.Parse(json).RootElement.Clone();
private sealed class TestActivity : CodeActivity
{
protected override void Execute(ActivityExecutionContext context)
{
}
}
private class Animal
{
}
private sealed class Dog : Animal
{
}
private sealed class RecordingConverter(
Func<OutputConversionContext, object?> convert,
Func<JsonElement?, IEnumerable<string>>? validate = null) : IOutputConverter
{
public int ConvertCalls { get; private set; }
public object? Convert(OutputConversionContext context)
{
ConvertCalls++;
return convert(context);
}
public IEnumerable<string> ValidateSettings(JsonElement? settings) => validate?.Invoke(settings) ?? [];
}
private sealed class ScopedToken
{
public Guid Id { get; } = Guid.NewGuid();
}
private sealed class ScopeAwareConverter(ScopedToken token) : IOutputConverter
{
public object Convert(OutputConversionContext context) => token.Id;
}
}

View file

@ -0,0 +1,78 @@
using Elsa.Extensions;
using Elsa.Workflows.Core.UnitTests.OutputConverters.Fixtures;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputConverterRegistrationTests
{
[Fact]
public void ScopedRegistration_ResolvesOneConverterPerScope()
{
var services = CreateServices(ServiceLifetime.Scoped);
using var serviceProvider = services.BuildServiceProvider();
using var firstScope = serviceProvider.CreateScope();
using var secondScope = serviceProvider.CreateScope();
var first = firstScope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
var secondFromSameScope = firstScope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
var second = secondScope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
Assert.Same(first, secondFromSameScope);
Assert.NotSame(first, second);
}
[Fact]
public void SingletonRegistration_ResolvesTheSameConverterAcrossScopes()
{
var services = CreateServices(ServiceLifetime.Singleton);
using var serviceProvider = services.BuildServiceProvider();
using var firstScope = serviceProvider.CreateScope();
using var secondScope = serviceProvider.CreateScope();
var first = firstScope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
var second = secondScope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
Assert.Same(first, second);
}
[Fact]
public void TransientRegistration_ResolvesANewConverterForEachRequest()
{
var services = CreateServices(ServiceLifetime.Transient);
using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var first = scope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
var second = scope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(Descriptor.Id);
Assert.NotSame(first, second);
}
[Fact]
public void Registration_ExposesTheDescriptorThroughTheRegistryWithoutRetainingAConverterInstance()
{
var services = CreateServices(ServiceLifetime.Scoped);
using var serviceProvider = services.BuildServiceProvider();
var registry = serviceProvider.GetRequiredService<IOutputConverterRegistry>();
var descriptor = registry.Find(Descriptor.Id);
Assert.Same(Descriptor, descriptor);
Assert.Equal(Descriptor.Id, registry.FindRegistration(Descriptor.Id)!.ServiceKey);
}
private static ServiceCollection CreateServices(ServiceLifetime lifetime)
{
var services = new ServiceCollection();
services.AddOutputConverter<ReferenceOutputConverter>(Descriptor, lifetime);
return services;
}
internal static OutputConverterDescriptor Descriptor { get; } = new(
"tests.reference-output",
typeof(string),
typeof(string),
"Reference output converter");
}

View file

@ -0,0 +1,33 @@
using System.Text.Json;
using Elsa.Extensions;
using Elsa.Workflows.Core.UnitTests.OutputConverters.Fixtures;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputConverterRegistrationUsageTests
{
[Fact]
public void RegisteredReferenceConverter_IsDiscoverableAndConvertsWithBindingSettings()
{
var services = new ServiceCollection();
services.AddOutputConverter<ReferenceOutputConverter>(OutputConverterRegistrationTests.Descriptor, ServiceLifetime.Scoped);
using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
using var settingsDocument = JsonDocument.Parse("""{"prefix":"converted:"}""");
var descriptor = scope.ServiceProvider.GetRequiredService<IOutputConverterRegistry>()
.FindCompatible(typeof(string), typeof(string))
.Single();
var converter = scope.ServiceProvider.GetRequiredKeyedService<IOutputConverter>(descriptor.Id);
var result = converter.Convert(new OutputConversionContext(
"native value",
typeof(string),
typeof(string),
settingsDocument.RootElement));
Assert.Equal(OutputConverterRegistrationTests.Descriptor, descriptor);
Assert.Equal("converted:native value", result);
}
}

View file

@ -0,0 +1,194 @@
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Core.UnitTests.OutputConverters;
public class OutputConverterRegistryTests
{
[Fact]
public void Find_ReturnsDescriptorAndRegistrationForExactId()
{
// Arrange
var registration = CreateRegistration("sample.to-text", typeof(object), typeof(string));
var sut = new OutputConverterRegistry([registration]);
// Act
var descriptor = sut.Find("sample.to-text");
var resolvedRegistration = sut.FindRegistration("sample.to-text");
// Assert
Assert.Same(registration.Descriptor, descriptor);
Assert.Same(registration, resolvedRegistration);
}
[Fact]
public void Find_UsesOrdinalCaseSensitiveIdentity()
{
// Arrange
var registration = CreateRegistration("sample.to-text", typeof(object), typeof(string));
var sut = new OutputConverterRegistry([registration]);
// Act
var descriptor = sut.Find("Sample.To-Text");
var resolvedRegistration = sut.FindRegistration("Sample.To-Text");
// Assert
Assert.Null(descriptor);
Assert.Null(resolvedRegistration);
}
[Theory]
[InlineData("sample.to-text")]
[InlineData("SAMPLE.TO-TEXT")]
public void Constructor_RejectsExactAndCaseOnlyDuplicateIds(string duplicateId)
{
// Arrange
var registrations = new[]
{
CreateRegistration("sample.to-text", typeof(object), typeof(string)),
CreateRegistration(duplicateId, typeof(object), typeof(string))
};
// Act
var act = () => new OutputConverterRegistry(registrations);
// Assert
Assert.Throws<InvalidOperationException>(act);
}
[Theory]
[InlineData("different.converter")]
[InlineData("SAMPLE.TO-TEXT")]
public void Constructor_RejectsServiceKeyThatDoesNotExactlyMatchDescriptorId(string serviceKey)
{
// Arrange
var descriptor = CreateDescriptor("sample.to-text", typeof(object), typeof(string));
var registration = new OutputConverterRegistration(descriptor, serviceKey, ServiceLifetime.Scoped);
// Act
var act = () => new OutputConverterRegistry([registration]);
// Assert
Assert.Throws<InvalidOperationException>(act);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Constructor_RejectsOpenGenericSourceOrResultTypes(bool useOpenGenericSourceType)
{
// Arrange
var sourceType = useOpenGenericSourceType ? typeof(IEnumerable<>) : typeof(object);
var resultType = useOpenGenericSourceType ? typeof(string) : typeof(IEnumerable<>);
var registration = CreateRegistration("sample.open-generic", sourceType, resultType);
// Act
var act = () => new OutputConverterRegistry([registration]);
// Assert
Assert.Throws<InvalidOperationException>(act);
}
[Fact]
public void ListAll_ReturnsEveryRegisteredDescriptor()
{
// Arrange
var registrations = new[]
{
CreateRegistration("sample.first", typeof(object), typeof(string)),
CreateRegistration("sample.second", typeof(string), typeof(int))
};
var sut = new OutputConverterRegistry(registrations);
// Act
var ids = sut.ListAll().Select(x => x.Id).Order().ToArray();
// Assert
Assert.Equal(["sample.first", "sample.second"], ids);
}
[Fact]
public void FindCompatible_AllowsExactBaseAndInterfaceSourceTypesAndAssignableResultTypes()
{
// Arrange
var registrations = new[]
{
CreateRegistration("source.exact", typeof(DerivedSource), typeof(ConcreteResult)),
CreateRegistration("source.base", typeof(SourceBase), typeof(ConcreteResult)),
CreateRegistration("source.interface", typeof(ISourceContract), typeof(ConcreteResult)),
CreateRegistration("source.incompatible", typeof(string), typeof(ConcreteResult)),
CreateRegistration("result.incompatible", typeof(DerivedSource), typeof(object))
};
var sut = new OutputConverterRegistry(registrations);
// Act
var ids = sut.FindCompatible(typeof(DerivedSource), typeof(IResultContract))
.Select(x => x.Id)
.Order()
.ToArray();
// Assert
Assert.Equal(["source.base", "source.exact", "source.interface"], ids);
}
[Fact]
public void FindCompatible_AcceptsObjectAsADeclaredDestination()
{
// Arrange
var registration = CreateRegistration("sample.to-text", typeof(SourceBase), typeof(string));
var sut = new OutputConverterRegistry([registration]);
// Act
var descriptors = sut.FindCompatible(typeof(DerivedSource), typeof(object));
// Assert
Assert.Collection(descriptors, descriptor => Assert.Same(registration.Descriptor, descriptor));
}
[Fact]
public void FindCompatible_DoesNotReverseSourceOrResultAssignability()
{
// Arrange
var narrowedSource = CreateRegistration("source.narrowed", typeof(DerivedSource), typeof(ConcreteResult));
var widenedResult = CreateRegistration("result.widened", typeof(SourceBase), typeof(IResultContract));
// Act
var sourceMatches = new OutputConverterRegistry([narrowedSource])
.FindCompatible(typeof(SourceBase), typeof(IResultContract));
var resultMatches = new OutputConverterRegistry([widenedResult])
.FindCompatible(typeof(DerivedSource), typeof(ConcreteResult));
// Assert
Assert.Empty(sourceMatches);
Assert.Empty(resultMatches);
}
private static OutputConverterRegistration CreateRegistration(string id, Type sourceType, Type resultType)
{
var descriptor = CreateDescriptor(id, sourceType, resultType);
return new(descriptor, id, ServiceLifetime.Scoped);
}
private static OutputConverterDescriptor CreateDescriptor(string id, Type sourceType, Type resultType) =>
new(id, sourceType, resultType, id);
private interface ISourceContract
{
}
private class SourceBase
{
}
private sealed class DerivedSource : SourceBase, ISourceContract
{
}
private interface IResultContract
{
}
private sealed class ConcreteResult : IResultContract
{
}
}

View file

@ -0,0 +1,75 @@
using System.Text.Json;
using Elsa.Common.Serialization;
using Elsa.Extensions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Serialization.Converters;
namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters;
public sealed class OutputJsonConverterTests
{
[Fact]
public void When_SerializeAndDeserializeConfiguredOutput_Then_ConverterConfigurationRoundTrips()
{
// Arrange
var options = CreateOptions();
var output = new Output<string>(new Variable("Result"))
{
Converter = new OutputConverterConfiguration("sample.to-text", CreateSettings())
};
// Act
var json = JsonSerializer.Serialize(output, options);
var result = JsonSerializer.Deserialize<Output<string>>(json, options);
// Assert
Assert.NotNull(result);
Assert.Equal("resultVariable", result.MemoryBlockReference().Id);
var configuration = Assert.IsType<OutputConverterConfiguration>(result.Converter);
Assert.Equal("sample.to-text", configuration.Id);
Assert.True(configuration.Settings.HasValue);
Assert.Equal("compact", configuration.Settings.Value.GetProperty("format").GetString());
using var document = JsonDocument.Parse(json);
var converter = document.RootElement.GetProperty("converter");
Assert.Equal(new[] { "id", "settings" }, converter.EnumerateObject().Select(x => x.Name));
}
[Fact]
public void When_SerializeAndDeserializeUnconfiguredOutput_Then_ConverterPropertyIsOmitted()
{
// Arrange
var options = CreateOptions();
var output = new Output<string>(new Variable("Result"));
// Act
var json = JsonSerializer.Serialize(output, options);
var result = JsonSerializer.Deserialize<Output<string>>(json, options);
// Assert
Assert.NotNull(result);
Assert.Null(result.Converter);
using var document = JsonDocument.Parse(json);
Assert.Equal(new[] { "typeName", "memoryReference" }, document.RootElement.EnumerateObject().Select(x => x.Name));
Assert.Equal("String", document.RootElement.GetProperty("typeName").GetString());
Assert.Equal("resultVariable", document.RootElement.GetProperty("memoryReference").GetProperty("id").GetString());
}
private static JsonSerializerOptions CreateOptions() => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters =
{
new OutputJsonConverter<string>(SerializationTypeRegistry.CreateDefault())
}
};
private static JsonElement CreateSettings()
{
using var document = JsonDocument.Parse("""{"format":"compact"}""");
return document.RootElement.Clone();
}
}

View file

@ -0,0 +1,127 @@
using System.Text.Json;
using Elsa.Common.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Serialization.Converters;
using Elsa.Workflows.Serialization.Helpers;
using NSubstitute;
namespace Elsa.Workflows.Core.UnitTests.Serialization.Helpers;
public sealed class SyntheticPropertiesWriterTests
{
[Fact]
public void When_WriteConfiguredSyntheticOutput_Then_WritesConverterConfiguration()
{
// Arrange
var output = new Output<string>(new Variable("Result"))
{
Converter = new OutputConverterConfiguration("sample.to-text", CreateSettings())
};
// Act
using var document = WriteSyntheticOutput(output);
// Assert
var syntheticOutput = document.RootElement.GetProperty("result");
Assert.Equal("String", syntheticOutput.GetProperty("typeName").GetString());
Assert.Equal("resultVariable", syntheticOutput.GetProperty("memoryReference").GetProperty("id").GetString());
var converter = syntheticOutput.GetProperty("converter");
Assert.Equal("sample.to-text", converter.GetProperty("id").GetString());
Assert.Equal("compact", converter.GetProperty("settings").GetProperty("format").GetString());
Assert.Equal(new[] { "id", "settings" }, converter.EnumerateObject().Select(x => x.Name));
}
[Fact]
public void When_WriteUnconfiguredSyntheticOutput_Then_OmitsConverterProperty()
{
// Arrange
var output = new Output<string>(new Variable("Result"));
// Act
using var document = WriteSyntheticOutput(output);
// Assert
var syntheticOutput = document.RootElement.GetProperty("result");
Assert.Equal(new[] { "typeName", "memoryReference" }, syntheticOutput.EnumerateObject().Select(x => x.Name));
Assert.Equal("String", syntheticOutput.GetProperty("typeName").GetString());
Assert.Equal("resultVariable", syntheticOutput.GetProperty("memoryReference").GetProperty("id").GetString());
}
[Fact]
public void When_RoundTripConfiguredSyntheticOutput_Then_PreservesConverterConfiguration()
{
// Arrange
var output = new Output<string>(new Variable("Result"))
{
Converter = new OutputConverterConfiguration("sample.to-text", CreateSettings())
};
using var document = WriteSyntheticOutput(output);
// Act
var result = JsonActivityConstructorContextHelper.CreateActivity<TestActivity>(
CreateActivityDescriptor(),
document.RootElement,
CreateOptions());
// Assert
Assert.False(result.HasExceptions);
var roundTrippedOutput = Assert.IsType<Output<string>>(result.Activity.SyntheticProperties["Result"]);
var configuration = Assert.IsType<OutputConverterConfiguration>(roundTrippedOutput.Converter);
Assert.Equal("sample.to-text", configuration.Id);
Assert.True(configuration.Settings.HasValue);
Assert.Equal("compact", configuration.Settings.Value.GetProperty("format").GetString());
}
private static JsonDocument WriteSyntheticOutput(Output<string> output)
{
var activity = new TestActivity();
activity.SyntheticProperties["Result"] = output;
using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
var sut = new SyntheticPropertiesWriter(Substitute.For<IExpressionDescriptorRegistry>());
sut.WriteSyntheticProperties(writer, activity, CreateActivityDescriptor(), CreateOptions());
writer.WriteEndObject();
}
return JsonDocument.Parse(buffer.ToArray());
}
private static ActivityDescriptor CreateActivityDescriptor() => new()
{
Outputs =
{
new OutputDescriptor
{
Name = "Result",
Type = typeof(string),
IsSynthetic = true
}
}
};
private static JsonSerializerOptions CreateOptions() => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new TypeJsonConverter(SerializationTypeRegistry.CreateDefault())
}
};
private static JsonElement CreateSettings()
{
using var document = JsonDocument.Parse("""{"format":"compact"}""");
return document.RootElement.Clone();
}
private sealed class TestActivity : Activity
{
}
}

View file

@ -0,0 +1,125 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management.Handlers.Notifications;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Workflows.Management.UnitTests.Handlers.Notifications;
public class ValidateOutputConvertersTests
{
[Fact]
public async Task HandleAsync_WhenConverterHasNoDestination_AddsValidationError()
{
var fixture = new Fixture();
fixture.DestinationResolver.Resolve(fixture.Graph, fixture.Node, fixture.Activity.Result).Returns((OutputBindingDestination?)null);
var errors = await fixture.ValidateAsync();
var error = Assert.Single(errors);
Assert.Equal(fixture.Activity.Id, error.ActivityId);
Assert.Contains("only be configured", error.Message);
}
[Fact]
public async Task HandleAsync_WhenConverterIsUnknown_AddsValidationError()
{
var fixture = new Fixture();
var errors = await fixture.ValidateAsync();
Assert.Contains(errors, x => x.Message.Contains("is not registered"));
}
[Fact]
public async Task HandleAsync_WhenBindingAndSettingsAreValid_AddsNoValidationErrors()
{
var fixture = new Fixture();
var descriptor = new OutputConverterDescriptor("test", typeof(int), typeof(string), "Test");
fixture.Registry.FindRegistration("test").Returns(new OutputConverterRegistration(descriptor, "test", ServiceLifetime.Singleton));
fixture.SettingsValidator.Validate(descriptor, Arg.Any<IOutputConverter>(), null).Returns([]);
var errors = await fixture.ValidateAsync();
Assert.Empty(errors);
}
private sealed class Fixture
{
public Fixture()
{
Activity = new TestActivity
{
Id = "activity-1",
Result = new()
{
Converter = new("test")
}
};
Node = new(Activity, "Body");
var workflow = new Workflow();
Graph = new(workflow, Node, [Node]);
GraphBuilder.BuildAsync(workflow, Arg.Any<CancellationToken>()).Returns(Graph);
ActivityRegistry.FindAsync(Activity.Type, Activity.Version).Returns(new ActivityDescriptor
{
Outputs =
[
new(
nameof(TestActivity.Result),
"Result",
typeof(int),
activity => ((TestActivity)activity).Result,
(activity, value) => ((TestActivity)activity).Result = (Output<int>)value!)
]
});
DestinationResolver.Resolve(Graph, Node, Activity.Result).Returns(
new OutputBindingDestination("workflow-result", typeof(string), true, OutputBindingDestinationKind.WorkflowOutput));
var services = new ServiceCollection();
services.AddKeyedSingleton<IOutputConverter, TestConverter>("test");
ServiceProvider = services.BuildServiceProvider();
}
public TestActivity Activity { get; }
public ActivityNode Node { get; }
public WorkflowGraph Graph { get; }
public IWorkflowGraphBuilder GraphBuilder { get; } = Substitute.For<IWorkflowGraphBuilder>();
public IActivityRegistryLookupService ActivityRegistry { get; } = Substitute.For<IActivityRegistryLookupService>();
public IOutputConverterRegistry Registry { get; } = Substitute.For<IOutputConverterRegistry>();
public IOutputBindingDestinationResolver DestinationResolver { get; } = Substitute.For<IOutputBindingDestinationResolver>();
public IOutputConverterSettingsValidator SettingsValidator { get; } = Substitute.For<IOutputConverterSettingsValidator>();
public ServiceProvider ServiceProvider { get; }
public async Task<ICollection<WorkflowValidationError>> ValidateAsync()
{
var errors = new List<WorkflowValidationError>();
var notification = new WorkflowDefinitionValidating(Graph.Workflow, errors);
var handler = new ValidateOutputConverters(
GraphBuilder,
ActivityRegistry,
Registry,
DestinationResolver,
SettingsValidator,
ServiceProvider);
await handler.HandleAsync(notification, CancellationToken.None);
return errors;
}
}
private sealed class TestActivity : CodeActivity
{
public Output<int> Result { get; set; } = new();
protected override void Execute(ActivityExecutionContext context)
{
}
}
private sealed class TestConverter : IOutputConverter
{
public object Convert(OutputConversionContext context) => context.Value.ToString()!;
}
}