using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Types; namespace ScadaLink.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; 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. /// 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. /// public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId); /// /// Creates a route target for the specified instance. /// 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; 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. /// 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. /// 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). /// public async Task> GetAttributes( IEnumerable attributeNames, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); var request = new RouteToGetAttributesRequest( correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow); 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. /// 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). /// public async Task SetAttributes( IReadOnlyDictionary attributeValues, CancellationToken cancellationToken = default) { var token = Effective(cancellationToken); var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); var request = new RouteToSetAttributesRequest( correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow); 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; } }