Add event-driven quick action dispatch

This commit is contained in:
Drechsler, Meik
2026-07-22 11:57:32 +02:00
parent fb45f1c42b
commit 9bd3896ba0
20 changed files with 591 additions and 139 deletions

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using F4SD.ActionConnector.Enumerations;
using F4SD.ActionConnector.Events;
using F4SD.ActionConnector.Execution;
using F4SD.ActionConnector.Models;
using F4SD.ActionConnector.Payloads;
using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Bus
{
/// <summary>
/// Entry point for the ActionConnector.
/// Subscribes to <see cref="ActionConnectorEvents"/>, reads the configuration once,
/// and dispatches actions whenever a trigger event fires.
///
/// Usage from the host application:
/// <code>
/// var bus = new ActionBus(new ActionBusOptions
/// {
/// ConfigurationFilePath = @"C:\Config\F4SD-ExternalCommunication-Configuration.xml",
/// QuickActionExecutor = async request =>
/// {
/// await myJiraClient.CreateTicketAsync(request.QuickActionRef, request.Parameters);
/// return ActionResult.Ok(request.ActionId);
/// }
/// });
///
/// bus.Start();
/// // later:
/// bus.Stop();
/// </code>
/// </summary>
public sealed class ActionBus : IDisposable
{
private readonly ActionBusOptions _options;
private readonly ActionDispatcher _dispatcher;
private ExternalCommunicationConfiguration _configuration;
private bool _running;
public ActionBus(ActionBusOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_dispatcher = new ActionDispatcher(options.QuickActionExecutor);
}
/// <summary>
/// Loads the configuration and subscribes to all trigger events.
/// Call once during application startup.
/// </summary>
public void Start()
{
var CM = MethodBase.GetCurrentMethod();
LogMethodBegin(CM);
try
{
_configuration = new ExternalCommunicationConfiguration();
_configuration.Initiate(_options.ConfigRootElement, _options.Parser);
ActionConnectorEvents.CaseClosed += OnCaseClosed;
ActionConnectorEvents.CaseCreated += OnCaseCreated;
ActionConnectorEvents.ApplicationStartup += OnApplicationStartup;
_running = true;
LogEntry("ActionBus started.", C4IT.Logging.LogLevels.Info);
}
catch (Exception ex)
{
LogException(ex);
}
finally
{
LogMethodEnd(CM);
}
}
/// <summary>
/// Unsubscribes from all trigger events.
/// Call during application shutdown.
/// </summary>
public void Stop()
{
ActionConnectorEvents.CaseClosed -= OnCaseClosed;
ActionConnectorEvents.CaseCreated -= OnCaseCreated;
ActionConnectorEvents.ApplicationStartup -= OnApplicationStartup;
_running = false;
LogEntry("ActionBus stopped.", C4IT.Logging.LogLevels.Info);
}
public void Dispose() => Stop();
// ──────────────────────────────────────────────────────────────────────────
// Event handlers — each one creates a typed ActionContext and fires dispatch
// ──────────────────────────────────────────────────────────────────────────
private void OnCaseClosed(object sender, TriggerEventArgs<CaseClosedPayload> args)
=> DispatchTrigger(TriggerEvent.CaseClosed, args.Payload, sender);
private void OnCaseCreated(object sender, TriggerEventArgs<CaseCreatedPayload> args)
=> DispatchTrigger(TriggerEvent.CaseCreated, args.Payload, sender);
private void OnApplicationStartup(object sender, TriggerEventArgs<ApplicationStartupPayload> args)
=> DispatchTrigger(TriggerEvent.ApplicationStartup, args.Payload, sender);
// ──────────────────────────────────────────────────────────────────────────
// Core dispatch — fire-and-forget wrapper that surfaces results via events
// ──────────────────────────────────────────────────────────────────────────
private void DispatchTrigger(TriggerEvent triggerEvent, PayloadBase payload, object sender)
{
if (!_running) return;
if (!_configuration.Triggers.TryGetValue(triggerEvent, out Trigger trigger)) return;
// Run async dispatch on the thread pool; do not block the caller.
Task.Run(() => DispatchActionsAsync(trigger.Actions, triggerEvent, payload, sender));
}
private async Task DispatchActionsAsync(
IDictionary<string, ActionDefinitionBase> actions,
TriggerEvent triggerEvent,
PayloadBase payload,
object sender)
{
foreach (var pair in actions)
{
ActionDefinitionBase action = pair.Value;
var context = new ActionContext
{
TriggerEvent = triggerEvent,
AwaitResult = action.AwaitResult,
Payload = payload,
};
ActionResult result = await ExecuteSafeAsync(action, context);
ActionConnectorEvents.RaiseActionCompleted(
sender,
new ActionCompletedEventArgs(result, context));
}
}
private async Task<ActionResult> ExecuteSafeAsync(ActionDefinitionBase action, ActionContext context)
{
try
{
return await _dispatcher.DispatchAsync(action, context);
}
catch (Exception ex)
{
LogException(ex);
return ActionResult.Fail(action.Id, ex.Message);
}
}
}
}

View File

@@ -0,0 +1,23 @@
using System;
using System.Threading.Tasks;
using System.Xml;
using C4IT.XML;
using F4SD.ActionConnector.Execution;
using F4SD.ActionConnector.Models;
namespace F4SD.ActionConnector.Bus
{
public sealed class ActionBusOptions
{
public XmlElement ConfigRootElement { get; set; }
public cXmlParser Parser { get; set; }
/// <summary>
/// Called for every QuickAction that fires.
/// Template variables in <see cref="QuickActionRequest.Parameters"/> are already
/// resolved against the event payload before this delegate is invoked.
/// Must be set when any QuickAction is configured.
/// </summary>
public Func<QuickActionRequest, Task<ActionResult>> QuickActionExecutor { get; set; }
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Threading.Tasks;
using F4SD.ActionConnector.Enumerations;
using F4SD.ActionConnector.Models;
using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Execution
{
/// <summary>
/// Routes a single action definition to the correct executor based on its <see cref="ActionType"/>.
/// </summary>
internal sealed class ActionDispatcher
{
private readonly Func<QuickActionRequest, Task<ActionResult>> _quickActionExecutor;
internal ActionDispatcher(Func<QuickActionRequest, Task<ActionResult>> quickActionExecutor)
{
_quickActionExecutor = quickActionExecutor;
}
internal async Task<ActionResult> DispatchAsync(ActionDefinitionBase action, ActionContext context)
{
if (!action.IsEnabled)
return ActionResult.Skip(action.Id);
switch (action.Type)
{
case ActionType.QuickAction:
return await ExecuteQuickActionAsync((QuickActionDefinition)action, context);
case ActionType.HttpCall:
case ActionType.Webhook:
throw new NotImplementedException(
$"ActionType '{action.Type}' is not yet implemented.");
default:
LogEntry($"ActionDispatcher: unknown ActionType '{action.Type}' for action '{action.Id}'.",
C4IT.Logging.LogLevels.Warning);
return ActionResult.Skip(action.Id);
}
}
private async Task<ActionResult> ExecuteQuickActionAsync(QuickActionDefinition definition, ActionContext context)
{
if (_quickActionExecutor == null)
throw new InvalidOperationException(
$"A QuickAction ('{definition.Id}') was triggered but no {nameof(Bus.ActionBusOptions.QuickActionExecutor)} was registered.");
var resolvedParameters = PayloadVariableResolver.Resolve(definition.Parameters, context.Payload);
var request = new QuickActionRequest
{
ActionId = definition.Id,
QuickActionRef = definition.QuickActionRef,
Parameters = resolvedParameters,
Context = context,
};
return await _quickActionExecutor(request);
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using System.Text.RegularExpressions;
using F4SD.ActionConnector.Payloads;
namespace F4SD.ActionConnector.Execution
{
/// <summary>
/// Resolves template variables such as {{Case.Id}} or {{Case.ClosedAt:yyyy-MM-ddTHH:mm:ssZ}}
/// against a payload object.
///
/// Variable format: {{ObjectName.PropertyName[:FormatSpecifier]}}
/// </summary>
internal static class PayloadVariableResolver
{
private static readonly Regex VariablePattern =
new Regex(@"\{\{(?<obj>[^.}]+)\.(?<prop>[^:}]+)(?::(?<fmt>[^}]+))?\}\}",
RegexOptions.Compiled);
/// <summary>
/// Returns a new dictionary with every value in <paramref name="rawParameters"/> having
/// its template variables replaced by the corresponding payload property values.
/// Pure single-variable templates (e.g. <c>{{Case.ClosedAt}}</c>) yield the raw typed
/// object; mixed or formatted templates yield a resolved string.
/// </summary>
internal static IDictionary<string, object> Resolve(
IDictionary<string, string> rawParameters,
PayloadBase payload)
{
var resolved = new Dictionary<string, object>(rawParameters.Count);
foreach (var pair in rawParameters)
resolved[pair.Key] = ResolveValue(pair.Value, payload);
return resolved;
}
private static object ResolveValue(string template, PayloadBase payload)
{
var match = VariablePattern.Match(template);
// Pure single variable with no format specifier → return raw object
if (match.Success && match.Value == template && !match.Groups["fmt"].Success)
return ResolveRawProperty(match, payload);
// Mixed template, literal, or explicit format specifier → return resolved string
return ResolveString(template, payload);
}
private static object ResolveRawProperty(Match match, PayloadBase payload)
{
string propertyName = match.Groups["prop"].Value;
PropertyInfo property = payload.GetType().GetProperty(
propertyName,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
return match.Value;
return property.GetValue(payload);
}
private static string ResolveString(string template, PayloadBase payload)
{
return VariablePattern.Replace(template, match => ResolveMatch(match, payload));
}
private static string ResolveMatch(Match match, PayloadBase payload)
{
string propertyName = match.Groups["prop"].Value;
string formatSpecifier = match.Groups["fmt"].Success ? match.Groups["fmt"].Value : null;
PropertyInfo property = payload.GetType().GetProperty(
propertyName,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
return match.Value;
object value = property.GetValue(payload);
if (value == null)
return string.Empty;
if (formatSpecifier != null && value is IFormattable formattable)
return formattable.ToString(formatSpecifier, CultureInfo.InvariantCulture);
return value.ToString();
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using F4SD.ActionConnector.Models;
namespace F4SD.ActionConnector.Execution
{
public sealed class QuickActionRequest
{
/// <summary>
/// Matches <see cref="QuickActionDefinition.Id"/> from the configuration.
/// </summary>
public string ActionId { get; internal set; }
/// <summary>
/// The name of the Quick Action to invoke, e.g. "Document case in JIRA".
/// </summary>
public string QuickActionRef { get; internal set; }
/// <summary>
/// Template variables resolved against the event payload.
/// Values are the raw payload values; callers are responsible for casting.
/// </summary>
public IDictionary<string, object> Parameters { get; internal set; }
/// <summary>
/// Full context of the triggering event.
/// </summary>
public ActionContext Context { get; internal set; }
}
}

View File

@@ -5,8 +5,8 @@ namespace F4SD.ActionConnector.Models
{
public sealed class ActionContext
{
public TriggerEvent TriggerEvent { get; set; }
public bool AwaitResult { get; set; }
public PayloadBase Payload { get; set; }
public TriggerEvent TriggerEvent { get; internal set; }
public bool AwaitResult { get; internal set; }
public PayloadBase Payload { get; internal set; }
}
}

View File

@@ -8,31 +8,31 @@ namespace F4SD.ActionConnector.Models
{
public abstract class ActionDefinitionBase
{
public string Id { get; set; }
public string Id { get; internal set; }
public abstract ActionType Type { get; }
public bool AwaitResult { get; set; }
public bool IsEnabled { get; set; }
public string Description { get; set; }
public bool AwaitResult { get; internal set; }
public bool IsEnabled { get; internal set; }
public string Description { get; internal set; }
public bool IsValid { get; private protected set; }
protected ActionDefinitionBase(XmlElement xNode, cXmlParser parser)
{
try
{
Id = cXmlParser.GetStringFromXmlAttribute(xNode, "Id");
AwaitResult = cXmlParser.GetBoolFromXmlAttribute(xNode, "AwaitResult");
IsEnabled = cXmlParser.GetBoolFromXmlAttribute(xNode, "IsEnabled");
XmlNode descriptionNode = xNode.SelectSingleNode("Description");
if (descriptionNode != null)
Description = cXmlParser.GetInnerTextFromXmlElement(descriptionNode);
IsValid = true;
}
catch (Exception ex)
{
LogException(ex);
}
{
try
{
Id = cXmlParser.GetStringFromXmlAttribute(xNode, "Id");
AwaitResult = cXmlParser.GetBoolFromXmlAttribute(xNode, "AwaitResult");
IsEnabled = cXmlParser.GetBoolFromXmlAttribute(xNode, "IsEnabled");
XmlNode descriptionNode = xNode.SelectSingleNode("Description");
if (descriptionNode != null)
Description = cXmlParser.GetInnerTextFromXmlElement(descriptionNode);
IsValid = true;
}
catch (Exception ex)
{
LogException(ex);
}
}
}
}

View File

@@ -5,29 +5,29 @@ namespace F4SD.ActionConnector.Models
{
public sealed class ActionResult
{
public string ActionId { get; set; }
public ActionResultStatus Status { get; set; }
public string ActionId { get; private set; }
public ActionResultStatus Status { get; private set; }
/// <summary>
/// HTTP status code for HttpCall actions; null for other types.
/// </summary>
public int? HttpStatusCode { get; set; }
public int? HttpStatusCode { get; private set; }
/// <summary>
/// Raw response body (HttpCall) or serialized output (QuickAction).
/// </summary>
public string RawResponse { get; set; }
public string RawResponse { get; private set; }
/// <summary>
/// Named fields extracted from the response via jsonPath mappings.
/// Populated when DisplayInCockpit is true.
/// </summary>
public IDictionary<string, string> MappedFields { get; set; } = new Dictionary<string, string>();
public IDictionary<string, string> MappedFields { get; private set; } = new Dictionary<string, string>();
/// <summary>
/// Human-readable error message; set when Status is Failure.
/// </summary>
public string ErrorMessage { get; set; }
public string ErrorMessage { get; private set; }
public static ActionResult Ok(string actionId, string rawResponse = null)
=> new ActionResult { ActionId = actionId, Status = ActionResultStatus.Success, RawResponse = rawResponse };

View File

@@ -3,17 +3,16 @@ using F4SD.ActionConnector.Enumerations;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using System.Xml;
using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Models
{
internal class ExternalCommunicationConfiguration
public class ExternalCommunicationConfiguration
{
private const string FileNameConfig = "F4SD-ExternalCommunication-Configuration.xml";
private const string FileNameConfigSchema = "F4SD-ExternalCommunication-Configuration.xsd";
private const string ConfigRootElement = "F4SD-ExternalCommunication-Configuration";
public const string ConfigRootElement = "F4SD-ExternalCommunication-Configuration";
public IDictionary<TriggerEvent, Trigger> Triggers { get; } = new Dictionary<TriggerEvent, Trigger>();
@@ -43,7 +42,7 @@ namespace F4SD.ActionConnector.Models
{
Trigger trigger = new Trigger(triggerNodeElement, parser);
if (trigger is null || !Triggers.ContainsKey(trigger.Event))
if (trigger is null || !trigger.IsValid || Triggers.ContainsKey(trigger.Event))
continue;
Triggers.Add(trigger.Event, trigger);

View File

@@ -11,20 +11,20 @@ namespace F4SD.ActionConnector.Models
{
public override ActionType Type => ActionType.HttpCall;
public string Url { get; set; }
public HttpMethod Method { get; set; } = HttpMethod.Post;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>();
public string Body { get; set; }
public string Url { get; internal set; }
public HttpMethod Method { get; internal set; } = HttpMethod.Post;
public TimeSpan Timeout { get; internal set; } = TimeSpan.FromSeconds(30);
public IDictionary<string, string> Headers { get; internal set; } = new Dictionary<string, string>();
public string Body { get; internal set; }
/// <summary>
/// jsonPath expressions keyed by field name, used to extract values from the response.
/// </summary>
public IDictionary<string, string> MappedFieldJsonPaths { get; set; } = new Dictionary<string, string>();
public IDictionary<string, string> MappedFieldJsonPaths { get; internal set; } = new Dictionary<string, string>();
internal HttpCallDefinition(XmlElement xNode, cXmlParser parser) : base(xNode, parser)
{
{
}
}
}

View File

@@ -1,27 +1,27 @@
using C4IT.XML;
using F4SD.ActionConnector.Enumerations;
using System;
using System.Collections.Generic;
using System.Xml;
using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Models
{
public sealed class QuickActionDefinition : ActionDefinitionBase
{
private const string ParametersNodeName = "Parameters";
private const string ParameterNodeName = "Parameter";
public override ActionType Type => ActionType.QuickAction;
/// <summary>
/// Reference to the Quick Action to invoke, e.g. "Protocol case in JIRA".
/// </summary>
public string QuickActionRef { get; set; }
public IDictionary<string, string> Parameters { get; private set; } = new Dictionary<string, string>();
internal QuickActionDefinition(XmlElement xNode, cXmlParser parser) : base(xNode, parser)
using C4IT.XML;
using F4SD.ActionConnector.Enumerations;
using System;
using System.Collections.Generic;
using System.Xml;
using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Models
{
public sealed class QuickActionDefinition : ActionDefinitionBase
{
private const string ParametersNodeName = "Parameters";
private const string ParameterNodeName = "Parameter";
public override ActionType Type => ActionType.QuickAction;
/// <summary>
/// Reference to the Quick Action to invoke, e.g. "Protocol case in JIRA".
/// </summary>
public string QuickActionRef { get; internal set; }
public IDictionary<string, string> Parameters { get; private set; } = new Dictionary<string, string>();
internal QuickActionDefinition(XmlElement xNode, cXmlParser parser) : base(xNode, parser)
{
try
{
@@ -81,7 +81,7 @@ namespace F4SD.ActionConnector.Models
{
try
{
string parameterName = cXmlParser.GetStringFromXmlAttribute(parameterNode, "name");
string parameterName = cXmlParser.GetStringFromXmlAttribute(parameterNode, "Name");
string parameterVariable = cXmlParser.GetInnerTextFromXmlElement(parameterNode);
if (string.IsNullOrEmpty(parameterName) || string.IsNullOrEmpty(parameterVariable))
@@ -102,4 +102,4 @@ namespace F4SD.ActionConnector.Models
}
}
}
}
}

View File

@@ -8,7 +8,7 @@ using static C4IT.Logging.cLogManager;
namespace F4SD.ActionConnector.Models
{
internal class Trigger
public class Trigger
{
public TriggerEvent Event { get; set; }
@@ -23,7 +23,7 @@ namespace F4SD.ActionConnector.Models
{
try
{
Event = cXmlParser.GetEnumFromAttribute(xNode, "action", TriggerEvent.Unknown);
Event = cXmlParser.GetEnumFromAttribute(xNode, "event", TriggerEvent.Unknown);
XmlNode actionsNode = xNode.SelectSingleNode(ActionsNodeName);
@@ -56,7 +56,7 @@ namespace F4SD.ActionConnector.Models
{
try
{
ActionType type = cXmlParser.GetEnumFromAttribute(actionNode, "type", ActionType.Unknown);
ActionType type = cXmlParser.GetEnumFromAttribute(actionNode, "Type", ActionType.Unknown);
ActionDefinitionBase actionDefinition = null;