| | | 1 | | using System.Text.Json; |
| | | 2 | | using System.Text.Json.Nodes; |
| | | 3 | | using Bpmn.Model; |
| | | 4 | | using Elsa.Bpmn.Interchange.Exceptions; |
| | | 5 | | using Elsa.Workflows; |
| | | 6 | | using Elsa.Workflows.Activities; |
| | | 7 | | using Elsa.Workflows.Models; |
| | | 8 | | |
| | | 9 | | namespace 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><elsa:activityBinding></c> element inside the BPMN element's <c><bpmn:extensionElements></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><elsa:input name="…"></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<T></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<T></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><elsa:input></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><![CDATA[…]]></c>. <c><</c>, <c>></c> and <c>&</c> inside the JSON (for example, inside a string |
| | | 62 | | /// literal) are therefore XML-escaped as <c>&lt;</c>, <c>&gt;</c> and <c>&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<T></c>-typed input wrapped as an expression:</para> |
| | | 69 | | /// <code> |
| | | 70 | | /// <bpmn:serviceTask id="notify"> |
| | | 71 | | /// <bpmn:extensionElements> |
| | | 72 | | /// <elsa:activityBinding activityType="Elsa.WriteLine"> |
| | | 73 | | /// <elsa:input name="text">{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}& |
| | | 74 | | /// </elsa:activityBinding> |
| | | 75 | | /// </bpmn:extensionElements> |
| | | 76 | | /// </bpmn:serviceTask> |
| | | 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 | | /// <bpmn:exclusiveGateway id="route"> |
| | | 81 | | /// <bpmn:extensionElements> |
| | | 82 | | /// <elsa:activityBinding activityType="Elsa.Switch"> |
| | | 83 | | /// <elsa:input name="cases">[{"label":"case one","condition":{"type":"Literal","value":true}}]</elsa:inp |
| | | 84 | | /// </elsa:activityBinding> |
| | | 85 | | /// </bpmn:extensionElements> |
| | | 86 | | /// </bpmn:exclusiveGateway> |
| | | 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> |
| | 52 | 110 | | public 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 | | |
| | 1 | 130 | | private static readonly BpmnQName BindingQName = new(NamespaceUri, BindingElementName); |
| | 1 | 131 | | 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) => |
| | 166 | 137 | | 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 | | { |
| | 14 | 149 | | extensions ??= BpmnExtensions.Empty; |
| | | 150 | | |
| | 14 | 151 | | return extensions with |
| | 14 | 152 | | { |
| | 2 | 153 | | ExtensionElements = extensions.ExtensionElements.Where(element => element.Name != BindingQName).Append(bindi |
| | 14 | 154 | | }; |
| | | 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. |
| | 19 | 170 | | var inputs = activityDescriber.GetInputProperties(activity.GetType()) |
| | 20 | 171 | | .Select(property => (Name: JsonNamingPolicy.CamelCase.ConvertName(property.Name), Value: property.GetValue(a |
| | 20 | 172 | | .Where(input => input.Value is not null) |
| | 20 | 173 | | .OrderBy(input => input.Name, StringComparer.Ordinal) |
| | 20 | 174 | | .Select(input => new BpmnExtensionElement(InputQName, [Attribute(InputNameAttributeName, input.Name)], null, |
| | 19 | 175 | | .ToList(); |
| | | 176 | | |
| | 19 | 177 | | 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 | | { |
| | 32 | 190 | | var activityType = AttributeOf(element, ActivityTypeAttributeName) |
| | 32 | 191 | | ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{BindingElementName}> element decla |
| | | 192 | | |
| | 31 | 193 | | var activityJson = new JsonObject |
| | 31 | 194 | | { |
| | 31 | 195 | | ["type"] = activityType |
| | 31 | 196 | | }; |
| | | 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. |
| | 31 | 201 | | var seenInputNames = new List<string>(); |
| | 31 | 202 | | var seenInputNameSet = new HashSet<string>(StringComparer.Ordinal); |
| | | 203 | | |
| | 157 | 204 | | foreach (var input in element.Children.Where(child => child.Name == InputQName)) |
| | | 205 | | { |
| | 32 | 206 | | var name = AttributeOf(input, InputNameAttributeName) |
| | 32 | 207 | | ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{InputElementName}> element of the '{ac |
| | | 208 | | |
| | 32 | 209 | | if (!seenInputNameSet.Add(name)) |
| | 1 | 210 | | throw new BpmnBindingException($"The '{activityType}' binding declares the input '{name}' more than once |
| | | 211 | | |
| | 31 | 212 | | seenInputNames.Add(name); |
| | 31 | 213 | | activityJson[name] = Parse(input.Value, name, activityType); |
| | | 214 | | } |
| | | 215 | | |
| | | 216 | | IActivity activity; |
| | | 217 | | |
| | | 218 | | try |
| | | 219 | | { |
| | 30 | 220 | | activity = activitySerializer.Deserialize(activityJson.ToJsonString()); |
| | 30 | 221 | | } |
| | 0 | 222 | | catch (Exception exception) when (exception is JsonException or NotSupportedException) |
| | | 223 | | { |
| | 0 | 224 | | 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. |
| | 30 | 231 | | if (activity is NotFoundActivity) |
| | 1 | 232 | | 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. |
| | 29 | 239 | | if (seenInputNames.Count > 0) |
| | | 240 | | { |
| | 29 | 241 | | var declaredInputNames = activityDescriber.GetInputProperties(activity.GetType()) |
| | 30 | 242 | | .Select(property => JsonNamingPolicy.CamelCase.ConvertName(property.Name)) |
| | 29 | 243 | | .ToHashSet(StringComparer.Ordinal); |
| | | 244 | | |
| | 59 | 245 | | var undeclaredInputNames = seenInputNames.Where(name => !declaredInputNames.Contains(name)).ToList(); |
| | | 246 | | |
| | 29 | 247 | | if (undeclaredInputNames.Count > 0) |
| | | 248 | | { |
| | 1 | 249 | | var noun = undeclaredInputNames.Count == 1 ? "an input" : "inputs"; |
| | 2 | 250 | | var names = string.Join(", ", undeclaredInputNames.Select(name => $"'{name}'")); |
| | | 251 | | |
| | 1 | 252 | | throw new BpmnBindingException($"The '{activityType}' binding declares {noun} {names}, which '{activityT |
| | | 253 | | } |
| | | 254 | | } |
| | | 255 | | |
| | 28 | 256 | | return activity; |
| | | 257 | | } |
| | | 258 | | |
| | | 259 | | private static JsonNode? Parse(string? json, string inputName, string activityType) |
| | | 260 | | { |
| | | 261 | | try |
| | | 262 | | { |
| | 31 | 263 | | return JsonNode.Parse(json ?? "null"); |
| | | 264 | | } |
| | 0 | 265 | | catch (JsonException exception) |
| | | 266 | | { |
| | 0 | 267 | | throw new BpmnBindingException($"Input '{inputName}' of the '{activityType}' binding does not hold valid JSO |
| | | 268 | | } |
| | 31 | 269 | | } |
| | | 270 | | |
| | 39 | 271 | | 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) => |
| | 127 | 276 | | element.Attributes.FirstOrDefault(attribute => string.IsNullOrEmpty(attribute.Name.Namespace) && attribute.Name. |
| | | 277 | | } |