< Summary

Information
Class: Elsa.Bpmn.Interchange.Binding.BpmnActivityBindingFormat
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs
Line coverage
92%
Covered lines: 49
Uncovered lines: 4
Coverable lines: 53
Total lines: 277
Line coverage: 92.4%
Branch coverage
87%
Covered branches: 21
Total branches: 24
Branch coverage: 87.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
Find(...)100%22100%
Attach(...)100%22100%
Write(...)100%11100%
Read(...)87.5%161693.54%
Parse(...)50%3250%
Attribute(...)100%11100%
AttributeOf(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Nodes;
 3using Bpmn.Model;
 4using Elsa.Bpmn.Interchange.Exceptions;
 5using Elsa.Workflows;
 6using Elsa.Workflows.Activities;
 7using Elsa.Workflows.Models;
 8
 9namespace Elsa.Bpmn.Interchange.Binding;
 10
 11/// <summary>
 12/// The <c>elsa:</c> vendor extension that records which Elsa activity performs a BPMN task the document describes but
 13/// does not implement, and the one place the names making up that format are defined.
 14/// </summary>
 15/// <remarks>
 16/// <para>
 17/// <b>Where it lives.</b> Inside the BPMN document, as a vendor extension on the element it binds — not in a side
 18/// envelope. An exported <c>.bpmn</c> is therefore self-contained and re-importable by itself. <c>Bpmn.Interchange</c>
 19/// retains any extension element it does not own as typed foreign content and writes it back where it came from, which
 20/// is what makes this survive a read-modify-write cycle; the library never interprets it.
 21/// </para>
 22/// <para>
 23/// <b>The format.</b> Namespace URI <c>https://elsaworkflows.io/schemas/bpmn/v1</c>, conventional prefix <c>elsa</c>.
 24/// One <c>&lt;elsa:activityBinding&gt;</c> element inside the BPMN element's <c>&lt;bpmn:extensionElements&gt;</c>:
 25/// </para>
 26/// <list type="table">
 27///   <item>
 28///     <term><c>activityType</c> — attribute, required</term>
 29///     <description>
 30///       The Elsa activity type name as the activity registry keys it, e.g. <c>Elsa.WriteLine</c>. This is
 31///       <see cref="IActivity.Type"/>, not a CLR type name.
 32///     </description>
 33///   </item>
 34///   <item>
 35///     <term><c>&lt;elsa:input name="…"&gt;</c> — child element, zero or more, <c>name</c> unique within the binding</t
 36///     <description>
 37///       One per configured activity input — every property <see cref="IActivityDescriber.GetInputProperties"/> reports
 38///       for the activity's CLR type, which is both every <c>Input&lt;T&gt;</c>-typed property and every plain-typed
 39///       property carrying <c>[Input]</c> (e.g. <c>Switch.Cases</c>); that is the same enumeration
 40///       <c>ActivityDescriptor.Inputs</c> is built from, so this format writes exactly the inputs Elsa itself considers
 41///       the activity to have. <c>name</c> is the input's property name as it appears in the activity's own JSON
 42///       (camelCase). The element's text is that single input value serialized by Elsa's configured activity
 43///       serializer — the same serializer a stored workflow definition is written and read through — so its shape
 44///       depends on how the activity declares the input: an <c>Input&lt;T&gt;</c>-typed property carries the
 45///       <c>{"typeName":…,"expression":…}</c> wrapper, which is what makes every expression type Elsa knows about
 46///       round-trip unchanged, while a plain-typed property carrying <c>[Input]</c> (e.g. <c>Switch.Cases</c>) carries
 47///       that value's own JSON — a collection such as <c>Switch.Cases</c> exports as a JSON array, not the wrapper.
 48///       No second encoding of activity inputs has to be kept in step with Elsa's own; an implementer must not assume
 49///       a single wrapper shape, only that the text is whatever Elsa's activity serializer produced for that input and
 50///       that reading it back through the same serializer reconstructs it. A second <c>&lt;elsa:input&gt;</c> naming an
 51///       input already declared is refused rather than silently taking the later one, and a <c>name</c> the activity
 52///       type does not declare an input for is refused rather than importing an activity quietly missing that configura
 53///       deserializer ignores an unknown JSON member without complaint, so nothing else would ever say so. Both are
 54///       refused the same way an unregistered activity type or a call activity with no <c>calledElement</c> is refused
 55///       elsewhere in this binder.
 56///     </description>
 57///   </item>
 58/// </list>
 59/// <para>
 60/// <b>Escaping.</b> An input's text is JSON, carried as ordinary XML element text — not wrapped in
 61/// <c>&lt;![CDATA[…]]&gt;</c>. <c>&lt;</c>, <c>&gt;</c> and <c>&amp;</c> inside the JSON (for example, inside a string
 62/// literal) are therefore XML-escaped as <c>&amp;lt;</c>, <c>&amp;gt;</c> and <c>&amp;amp;</c> the way any XML text
 63/// node escapes them; <c>Bpmn.Interchange</c> reads and writes this content through <c>System.Xml.Linq</c>, whose
 64/// standard text-node escaping decodes it back to the original JSON automatically. Nothing else needs to escape or
 65/// unescape this text: <see cref="Write"/> hands the writer plain JSON, and <see cref="Read"/> reads
 66/// <see cref="BpmnExtensionElement.Value"/> already decoded.
 67/// </para>
 68/// <para>Example, an <c>Input&lt;T&gt;</c>-typed input wrapped as an expression:</para>
 69/// <code>
 70/// &lt;bpmn:serviceTask id="notify"&gt;
 71///   &lt;bpmn:extensionElements&gt;
 72///     &lt;elsa:activityBinding activityType="Elsa.WriteLine"&gt;
 73///       &lt;elsa:input name="text"&gt;{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}&
 74///     &lt;/elsa:activityBinding&gt;
 75///   &lt;/bpmn:extensionElements&gt;
 76/// &lt;/bpmn:serviceTask&gt;
 77/// </code>
 78/// <para>Example, an <c>[Input]</c>-attributed plain-typed input carrying its own JSON shape (here, an array):</para>
 79/// <code>
 80/// &lt;bpmn:exclusiveGateway id="route"&gt;
 81///   &lt;bpmn:extensionElements&gt;
 82///     &lt;elsa:activityBinding activityType="Elsa.Switch"&gt;
 83///       &lt;elsa:input name="cases"&gt;[{"label":"case one","condition":{"type":"Literal","value":true}}]&lt;/elsa:inp
 84///     &lt;/elsa:activityBinding&gt;
 85///   &lt;/bpmn:extensionElements&gt;
 86/// &lt;/bpmn:exclusiveGateway&gt;
 87/// </code>
 88/// <para>
 89/// <b>Position is the key.</b> The element it sits inside is the element it binds; nothing records a binding ref.
 90/// That is deliberate: a binding ref is derived by the reader from the element id and a configurable prefix
 91/// (<c>BpmnImportOptions.BindingRefPrefix</c>), so writing one into the document would make an exported file depend on
 92/// the import setting that happened to be in force when it was produced.
 93/// </para>
 94/// <para>
 95/// <b>This is a compatibility surface.</b> Changing <see cref="NamespaceUri"/>, <see cref="BindingElementName"/>,
 96/// <see cref="ActivityTypeAttributeName"/>, <see cref="InputElementName"/> or <see cref="InputNameAttributeName"/>
 97/// breaks every previously exported <c>.bpmn</c> file: the old element stops being recognised and silently becomes
 98/// unrelated foreign content, which reads back as a task nobody bound. Anything that reads or writes this shape —
 99/// Elsa Studio included — has to agree with these constants, so a change here is a versioning decision, not a rename.
 100/// </para>
 101/// <para>
 102/// <b>Disclosure.</b> An exported <c>.bpmn</c> carries the binding configuration verbatim, which includes input
 103/// expressions: literal values, JavaScript, C#, Liquid, connection or endpoint names — whatever the author put on the
 104/// activity. Exporting a process is therefore a disclosure of its implementation detail, and a <c>.bpmn</c> from a
 105/// production tenant should be handled with the same care as the workflow definition it was built from. Nothing is
 106/// redacted, on purpose: a quietly redacted export produces a file that still looks executable and is not, and the
 107/// failure only shows up as wrong behaviour after someone re-imports it.
 108/// </para>
 109/// </remarks>
 52110public sealed class BpmnActivityBindingFormat(IActivitySerializer activitySerializer, IActivityDescriber activityDescrib
 111{
 112    /// <summary>The namespace URI of the <c>elsa:</c> BPMN vendor extension. See the remarks on this type before changi
 113    public const string NamespaceUri = "https://elsaworkflows.io/schemas/bpmn/v1";
 114
 115    /// <summary>The conventional prefix for <see cref="NamespaceUri"/>. Cosmetic in XML, but used verbatim in diagnosti
 116    public const string NamespacePrefix = "elsa";
 117
 118    /// <summary>The local name of the binding element.</summary>
 119    public const string BindingElementName = "activityBinding";
 120
 121    /// <summary>The local name of the attribute naming the Elsa activity type.</summary>
 122    public const string ActivityTypeAttributeName = "activityType";
 123
 124    /// <summary>The local name of a single-input child element.</summary>
 125    public const string InputElementName = "input";
 126
 127    /// <summary>The local name of the attribute naming the input an <see cref="InputElementName"/> element configures.<
 128    public const string InputNameAttributeName = "name";
 129
 1130    private static readonly BpmnQName BindingQName = new(NamespaceUri, BindingElementName);
 1131    private static readonly BpmnQName InputQName = new(NamespaceUri, InputElementName);
 132
 133    /// <summary>
 134    /// The activity binding declared on the given retained content, or <c>null</c> when it declares none.
 135    /// </summary>
 136    public static BpmnExtensionElement? Find(BpmnExtensions? extensions) =>
 166137        extensions?.ExtensionElements.FirstOrDefault(element => element.Name == BindingQName);
 138
 139    /// <summary>
 140    /// The given retained content with <paramref name="binding"/> as its activity binding, replacing any it already
 141    /// carried and leaving every other retained element in place.
 142    /// </summary>
 143    /// <remarks>
 144    /// Adding rather than replacing would leave two <c>activityBinding</c> elements on one BPMN element, and a reader
 145    /// taking the first of them would silently apply the older one.
 146    /// </remarks>
 147    public static BpmnExtensions Attach(BpmnExtensions? extensions, BpmnExtensionElement binding)
 148    {
 14149        extensions ??= BpmnExtensions.Empty;
 150
 14151        return extensions with
 14152        {
 2153            ExtensionElements = extensions.ExtensionElements.Where(element => element.Name != BindingQName).Append(bindi
 14154        };
 155    }
 156
 157    /// <summary>
 158    /// The binding element declaring that <paramref name="activity"/> performs the work, with each of its configured
 159    /// inputs serialized.
 160    /// </summary>
 161    public BpmnExtensionElement Write(IActivity activity)
 162    {
 163        // The same set IActivityDescriber.DescribeActivityAsync builds an ActivityDescriptor.Inputs from — every
 164        // property carrying an Input<T>, and every plain-typed property carrying [Input] (e.g. Switch.Cases). Filtering
 165        // on "derives from Input" alone, as this used to, silently drops the latter kind's configuration from the
 166        // export.
 167        //
 168        // Ordered by name so that exporting the same activity twice produces the same bytes, which is what makes a
 169        // .bpmn file diffable and a round-trip test meaningful.
 19170        var inputs = activityDescriber.GetInputProperties(activity.GetType())
 20171            .Select(property => (Name: JsonNamingPolicy.CamelCase.ConvertName(property.Name), Value: property.GetValue(a
 20172            .Where(input => input.Value is not null)
 20173            .OrderBy(input => input.Name, StringComparer.Ordinal)
 20174            .Select(input => new BpmnExtensionElement(InputQName, [Attribute(InputNameAttributeName, input.Name)], null,
 19175            .ToList();
 176
 19177        return new(BindingQName, [Attribute(ActivityTypeAttributeName, activity.Type)], inputs);
 178    }
 179
 180    /// <summary>
 181    /// The activity a binding element declares, built through Elsa's own activity serializer so that it is
 182    /// indistinguishable from the same activity loaded out of a stored workflow definition.
 183    /// </summary>
 184    /// <exception cref="BpmnBindingException">
 185    /// The element is malformed, names an activity type nothing registered, or names an input the activity type does
 186    /// not declare.
 187    /// </exception>
 188    public IActivity Read(BpmnExtensionElement element)
 189    {
 32190        var activityType = AttributeOf(element, ActivityTypeAttributeName)
 32191                           ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{BindingElementName}> element decla
 192
 31193        var activityJson = new JsonObject
 31194        {
 31195            ["type"] = activityType
 31196        };
 197
 198        // Every name seen so far, in the order the document declares them, so a second <elsa:input> with the same
 199        // name is refused rather than silently overwriting activityJson[name] and leaving the earlier one's
 200        // configuration invisible.
 31201        var seenInputNames = new List<string>();
 31202        var seenInputNameSet = new HashSet<string>(StringComparer.Ordinal);
 203
 157204        foreach (var input in element.Children.Where(child => child.Name == InputQName))
 205        {
 32206            var name = AttributeOf(input, InputNameAttributeName)
 32207                       ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{InputElementName}> element of the '{ac
 208
 32209            if (!seenInputNameSet.Add(name))
 1210                throw new BpmnBindingException($"The '{activityType}' binding declares the input '{name}' more than once
 211
 31212            seenInputNames.Add(name);
 31213            activityJson[name] = Parse(input.Value, name, activityType);
 214        }
 215
 216        IActivity activity;
 217
 218        try
 219        {
 30220            activity = activitySerializer.Deserialize(activityJson.ToJsonString());
 30221        }
 0222        catch (Exception exception) when (exception is JsonException or NotSupportedException)
 223        {
 0224            throw new BpmnBindingException($"The binding to activity type '{activityType}' could not be deserialized: {e
 225        }
 226
 227        // Elsa's activity serializer answers an unregistered type with a NotFoundActivity rather than throwing, and
 228        // that placeholder only fails once it executes — by which time the workflow has already started and the
 229        // process is mid-flight. Refusing at bind time turns "this .bpmn needs a module you have not installed" into a
 230        // sentence naming the type, at the point where someone can still do something about it.
 30231        if (activity is NotFoundActivity)
 1232            throw new BpmnBindingException($"The binding names activity type '{activityType}', which is not registered i
 233
 234        // Elsa's own JSON deserialization ignores a member the target type does not declare, so a mistyped or
 235        // stale input name would otherwise import silently as an activity missing that configuration, with no
 236        // diagnostic anywhere. IActivityDescriber.GetInputProperties is the same enumeration Write reads from and
 237        // ActivityDescriptor.Inputs is built from, so a name is accepted here exactly when Write could have produced
 238        // it.
 29239        if (seenInputNames.Count > 0)
 240        {
 29241            var declaredInputNames = activityDescriber.GetInputProperties(activity.GetType())
 30242                .Select(property => JsonNamingPolicy.CamelCase.ConvertName(property.Name))
 29243                .ToHashSet(StringComparer.Ordinal);
 244
 59245            var undeclaredInputNames = seenInputNames.Where(name => !declaredInputNames.Contains(name)).ToList();
 246
 29247            if (undeclaredInputNames.Count > 0)
 248            {
 1249                var noun = undeclaredInputNames.Count == 1 ? "an input" : "inputs";
 2250                var names = string.Join(", ", undeclaredInputNames.Select(name => $"'{name}'"));
 251
 1252                throw new BpmnBindingException($"The '{activityType}' binding declares {noun} {names}, which '{activityT
 253            }
 254        }
 255
 28256        return activity;
 257    }
 258
 259    private static JsonNode? Parse(string? json, string inputName, string activityType)
 260    {
 261        try
 262        {
 31263            return JsonNode.Parse(json ?? "null");
 264        }
 0265        catch (JsonException exception)
 266        {
 0267            throw new BpmnBindingException($"Input '{inputName}' of the '{activityType}' binding does not hold valid JSO
 268        }
 31269    }
 270
 39271    private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value);
 272
 273    // An unprefixed XML attribute belongs to no namespace, which is what the reader records for these; comparing on
 274    // the local name alone would also match a same-named attribute some other vendor put in its own namespace.
 275    private static string? AttributeOf(BpmnExtensionElement element, string name) =>
 127276        element.Attributes.FirstOrDefault(attribute => string.IsNullOrEmpty(attribute.Name.Namespace) && attribute.Name.
 277}