fix(inbound-api): resolve InboundAPI-014..017 — return-value validation, reflection-gateway hardening, deadline-bound routed calls, RouteHelper test coverage

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent aca65e85bb
commit 73a393076a
12 changed files with 993 additions and 34 deletions

View File

@@ -1,34 +1,58 @@
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
namespace ScadaLink.InboundAPI;
/// <summary>
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
/// Resolves instance to site, routes via CommunicationService, blocks until response or timeout.
/// Site unreachable returns error (no store-and-forward).
/// Resolves instance to site, routes via <see cref="IInstanceRouter"/>, blocks until
/// response or timeout. Site unreachable returns error (no store-and-forward).
///
/// InboundAPI-016: the helper carries the executing method's <see cref="CancellationToken"/>
/// (the method-level timeout). Routed calls inherit that deadline by default, so a
/// natural script — <c>Route.To("inst").Call("doWork", p)</c> — is timeout-bounded
/// without the script having to thread a token explicitly.
/// </summary>
public class RouteHelper
{
private readonly IInstanceLocator _instanceLocator;
private readonly CommunicationService _communicationService;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
public RouteHelper(
IInstanceLocator instanceLocator,
CommunicationService communicationService)
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None)
{
}
private RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
{
_instanceLocator = instanceLocator;
_communicationService = communicationService;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
}
/// <summary>
/// InboundAPI-016: returns a <see cref="RouteHelper"/> whose routed calls inherit
/// <paramref name="deadlineToken"/> (the executing method's timeout) by default.
/// <see cref="InboundScriptExecutor"/> calls this when it builds the script
/// context so the method timeout actually covers routed calls, as the design doc
/// requires.
/// </summary>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken);
/// <summary>
/// Creates a route target for the specified instance.
/// </summary>
public RouteTarget To(string instanceCode)
{
return new RouteTarget(instanceCode, _instanceLocator, _communicationService);
return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken);
}
}
@@ -39,36 +63,43 @@ public class RouteTarget
{
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly CommunicationService _communicationService;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
CommunicationService communicationService)
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_communicationService = communicationService;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
}
/// <summary>
/// Calls a script on the remote instance. Synchronous from API caller's
/// perspective. <paramref name="parameters"/> may be a dictionary or an
/// anonymous object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
///
/// InboundAPI-016: when <paramref name="cancellationToken"/> 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.
/// </summary>
public async Task<object?> Call(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
var token = Effective(cancellationToken);
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
var response = await _communicationService.RouteToCallAsync(
siteId, request, cancellationToken);
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
if (!response.Success)
{
@@ -97,14 +128,14 @@ public class RouteTarget
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
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 _communicationService.RouteToGetAttributesAsync(
siteId, request, cancellationToken);
var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token);
if (!response.Success)
{
@@ -135,14 +166,14 @@ public class RouteTarget
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
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 _communicationService.RouteToSetAttributesAsync(
siteId, request, cancellationToken);
var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token);
if (!response.Success)
{
@@ -151,6 +182,13 @@ public class RouteTarget
}
}
/// <summary>
/// InboundAPI-016: a routed call with no explicit token inherits the executing
/// method's deadline. An explicitly supplied token (for a tighter bound) wins.
/// </summary>
private CancellationToken Effective(CancellationToken explicitToken) =>
explicitToken.CanBeCanceled ? explicitToken : _deadlineToken;
private async Task<string> ResolveSiteAsync(CancellationToken cancellationToken)
{
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);