using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// /// WP-4: Route.To() helper for cross-site calls from inbound API scripts. /// Resolves instance to site, routes via , blocks until /// response or timeout. Site unreachable returns error (no store-and-forward). /// /// InboundAPI-016: the helper carries the executing method's /// (the method-level timeout). Routed calls inherit that deadline by default, so a /// natural script — Route.To("inst").Call("doWork", p) — is timeout-bounded /// without the script having to thread a token explicitly. /// public class RouteHelper { private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly Guid? _parentExecutionId; /// /// Initializes a new with no deadline token and no parent execution id. /// /// Service to resolve the site id for a given instance code. /// Service to route cross-site calls to the resolved site. public RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter) : this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null) { } private RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, Guid? parentExecutionId) { _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _parentExecutionId = parentExecutionId; } /// /// InboundAPI-016: returns a whose routed calls inherit /// (the executing method's timeout) by default. /// calls this when it builds the script /// context so the method timeout actually covers routed calls, as the design doc /// requires. /// /// The executing method's timeout cancellation token to inherit for routed calls. public RouteHelper WithDeadline(CancellationToken deadlineToken) => new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId); /// /// Audit Log #23 (ParentExecutionId): returns a whose /// routed requests carry /// as . /// For an inbound API request this is the inbound request's own per-request /// execution id, so the routed site script records the inbound request as its /// parent. calls this when it builds the /// script context. /// /// The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs. public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId); /// /// Creates a route target for the specified instance. /// /// The unique code of the instance to route calls to. public RouteTarget To(string instanceCode) { return new RouteTarget( instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId); } } /// /// WP-4: Represents a route target (an instance) for cross-site calls. /// public class RouteTarget { private readonly string _instanceCode; private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; private readonly Guid? _parentExecutionId; /// /// Initializes a new for the specified instance. /// /// The unique code of the target instance. /// Service to resolve the site id for the instance. /// Service to route cross-site calls. /// Cancellation token representing the method-level deadline. /// Optional parent execution id for audit correlation on routed calls. internal RouteTarget( string instanceCode, IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, CancellationToken deadlineToken, Guid? parentExecutionId) { _instanceCode = instanceCode; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; _parentExecutionId = parentExecutionId; } /// /// Calls a script on the remote instance. Synchronous from API caller's /// perspective. may be a dictionary or an /// anonymous object (new { name = "Bob" }) — see . /// /// InboundAPI-016: when is not supplied the /// routed call inherits the executing method's timeout, so the call is bounded by /// the method-level deadline with no token argument. /// /// Name of the script to call on the remote instance. /// Optional parameters passed to the script; may be a dictionary or anonymous object. /// Optional cancellation token; defaults to the method deadline when not supplied. public async Task Call( string scriptName, object? parameters = null, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log #23 (ParentExecutionId): stamp the spawning execution's id // (the inbound API request's ExecutionId) so the routed site script // records this call's parent. CorrelationId above is a separate concern // — the per-operation lifecycle id, freshly minted per routed call. var request = new RouteToCallRequest( correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow, _parentExecutionId); var response = await _instanceRouter.RouteToCallAsync(siteId, request, token); if (!response.Success) { throw new InvalidOperationException( response.ErrorMessage ?? "Remote script call failed"); } return response.ReturnValue; } /// /// Gets a single attribute value from the remote instance. /// /// Name of the attribute to read. /// Optional cancellation token; defaults to the method deadline. public async Task GetAttribute( string attributeName, CancellationToken cancellationToken = default) { var result = await GetAttributes(new[] { attributeName }, cancellationToken); return result.TryGetValue(attributeName, out var value) ? value : null; } /// /// Gets multiple attribute values from the remote instance (batch read). /// /// Names of the attributes to read. /// Optional cancellation token; defaults to the method deadline. public async Task> GetAttributes( IEnumerable attributeNames, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed reads can record this read's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToGetAttributesRequest( correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow, _parentExecutionId); var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token); if (!response.Success) { throw new InvalidOperationException( response.ErrorMessage ?? "Remote attribute read failed"); } return response.Values; } /// /// Sets a single attribute value on the remote instance. /// /// Name of the attribute to write. /// Value to set on the attribute. /// Optional cancellation token; defaults to the method deadline. public async Task SetAttribute( string attributeName, string value, CancellationToken cancellationToken = default) { await SetAttributes( new Dictionary { { attributeName, value } }, cancellationToken); } /// /// Sets multiple attribute values on the remote instance (batch write). /// /// Map of attribute names to values to write. /// Optional cancellation token; defaults to the method deadline. public async Task SetAttributes( IReadOnlyDictionary attributeValues, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); // Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the // spawning inbound request's ExecutionId so future site-side audit // emission for routed writes can record this write's parent. Symmetric // with RouteToCallRequest so script authors get the same correlation // across Call / GetAttributes / SetAttributes. var request = new RouteToSetAttributesRequest( correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow, _parentExecutionId); var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token); if (!response.Success) { throw new InvalidOperationException( response.ErrorMessage ?? "Remote attribute write failed"); } } /// /// InboundAPI-016: a routed call with no explicit token inherits the executing /// method's deadline. An explicitly supplied token (for a tighter bound) wins. /// private CancellationToken Effective(CancellationToken explicitToken) => explicitToken.CanBeCanceled ? explicitToken : _deadlineToken; private async Task ResolveSiteAsync(CancellationToken cancellationToken) { var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken); if (siteId == null) { throw new InvalidOperationException( $"Instance '{_instanceCode}' not found or has no assigned site"); } return siteId; } }