refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9: Database access from scripts.
|
||||
/// Database.Connection("name") — returns ADO.NET SqlConnection (connection pooling).
|
||||
/// Database.CachedWrite("name", "sql", params) — submits to S&F engine.
|
||||
/// </summary>
|
||||
public class DatabaseGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly IExternalSystemRepository _repository;
|
||||
private readonly StoreAndForwardService? _storeAndForward;
|
||||
private readonly ILogger<DatabaseGateway> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="DatabaseGateway"/>.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository for resolving database connection definitions.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
/// <param name="storeAndForward">Optional store-and-forward service for cached writes; null disables buffering.</param>
|
||||
public DatabaseGateway(
|
||||
IExternalSystemRepository repository,
|
||||
ILogger<DatabaseGateway> logger,
|
||||
StoreAndForwardService? storeAndForward = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_storeAndForward = storeAndForward;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DbConnection> GetConnectionAsync(
|
||||
string connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||
if (definition == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
||||
}
|
||||
|
||||
var connection = CreateConnection(definition.ConnectionString);
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// OpenAsync failed (unreachable server, bad credentials, cancellation) —
|
||||
// dispose the just-created connection before the exception propagates so
|
||||
// it is not leaked (ExternalSystemGateway-010).
|
||||
await connection.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the underlying ADO.NET connection for a connection string. Virtual so
|
||||
/// tests can substitute a connection whose <c>OpenAsync</c> fails.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The ADO.NET connection string.</param>
|
||||
internal virtual DbConnection CreateConnection(string connectionString) =>
|
||||
new SqlConnection(connectionString);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CachedWriteAsync(
|
||||
string connectionName,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
TrackedOperationId? trackedOperationId = null,
|
||||
Guid? executionId = null,
|
||||
string? sourceScript = null,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||
if (definition == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
||||
}
|
||||
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
throw new InvalidOperationException("Store-and-forward service not available for cached writes");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
ConnectionName = connectionName,
|
||||
Sql = sql,
|
||||
Parameters = parameters
|
||||
});
|
||||
|
||||
// ExternalSystemGateway-015: the entity's MaxRetries is a non-nullable int
|
||||
// whose default is 0, and the Store-and-Forward engine interprets a stored
|
||||
// MaxRetries of 0 as "no limit" (retry forever) — see
|
||||
// StoreAndForwardMessage.MaxRetries ("0 = no limit") and the retry-sweep
|
||||
// guard `MaxRetries > 0 && ...`. Passing 0 verbatim would turn every
|
||||
// unconfigured cached write into an unbounded retry loop. A 0 is treated as
|
||||
// "unset" and passed as null so the bounded S&F default applies; the
|
||||
// RetryDelay default of TimeSpan.Zero is likewise unset.
|
||||
await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.CachedDbWrite,
|
||||
connectionName,
|
||||
payload,
|
||||
originInstanceName,
|
||||
definition.MaxRetries > 0 ? definition.MaxRetries : null,
|
||||
definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null,
|
||||
// Audit Log #23 (M3): pin the S&F message id to the
|
||||
// TrackedOperationId so the retry loop (Bundle E Tasks E4/E5) can
|
||||
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
|
||||
// terminal cached-write telemetry. Null -> S&F mints its own GUID
|
||||
// (legacy pre-M3 behaviour).
|
||||
messageId: trackedOperationId?.ToString(),
|
||||
// Audit Log #23 (ExecutionId Task 4): thread the originating script
|
||||
// execution's ExecutionId + SourceScript onto the buffered row so
|
||||
// the retry-loop cached-write audit rows carry the same provenance
|
||||
// the script-side cached rows do.
|
||||
executionId: executionId,
|
||||
sourceScript: sourceScript,
|
||||
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
|
||||
// inbound-API request's ExecutionId onto the buffered row so the
|
||||
// retry-loop cached-write audit rows correlate back to the
|
||||
// cross-execution chain. Null for a non-routed run.
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-9/10: Delivers a buffered CachedDbWrite during a store-and-forward retry
|
||||
/// sweep — executes the SQL against the named connection. Returns true on
|
||||
/// success, false if the connection no longer exists (the message is parked);
|
||||
/// throws on any execution error so the engine retries.
|
||||
/// </summary>
|
||||
/// <param name="message">The buffered store-and-forward message to deliver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the delivery operation.</param>
|
||||
public async Task<bool> DeliverBufferedAsync(
|
||||
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ExternalSystemGateway-018: a malformed (not just empty/null-fielded)
|
||||
// PayloadJson would otherwise throw `JsonException` here, which the S&F
|
||||
// engine treats as a transient failure and retries forever (poison
|
||||
// message). Re-running the same deserialization against the same payload
|
||||
// will throw deterministically, so JsonException is permanent — log,
|
||||
// and return false so the S&F engine parks the message instead.
|
||||
CachedWritePayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<CachedWritePayload>(message.PayloadJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Buffered CachedDbWrite message {Id} has malformed JSON payload; parking.",
|
||||
message.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload == null || string.IsNullOrEmpty(payload.ConnectionName) || string.IsNullOrEmpty(payload.Sql))
|
||||
{
|
||||
_logger.LogError("Buffered CachedDbWrite message {Id} has an unreadable payload; parking.", message.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
var definition = await ResolveConnectionAsync(payload.ConnectionName, cancellationToken);
|
||||
if (definition == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Buffered DB write to '{Connection}' cannot be delivered — the connection no longer exists; parking.",
|
||||
payload.ConnectionName);
|
||||
return false;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(definition.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = payload.Sql;
|
||||
if (payload.Parameters != null)
|
||||
{
|
||||
foreach (var (key, value) in payload.Parameters)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = key.StartsWith('@') ? key : "@" + key;
|
||||
parameter.Value = JsonElementToParameterValue(value);
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must
|
||||
// prefer decimal over double — a script's decimal SQL parameter is
|
||||
// serialised as JSON without a type tag, and downcasting it to double on
|
||||
// the cached-write retry path silently loses precision (e.g.
|
||||
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
|
||||
// long first (whole-number fast path), then decimal (preserves authored
|
||||
// precision for typical money/measurement values), and only fall through
|
||||
// to double for genuinely out-of-decimal-range values (very large
|
||||
// scientific-notation floats).
|
||||
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l)
|
||||
? l
|
||||
: element.TryGetDecimal(out var dec)
|
||||
? dec
|
||||
: element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
|
||||
private sealed record CachedWritePayload(
|
||||
string ConnectionName,
|
||||
string Sql,
|
||||
Dictionary<string, JsonElement>? Parameters);
|
||||
|
||||
private async Task<DatabaseConnectionDefinition?> ResolveConnectionAsync(
|
||||
string connectionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// ExternalSystemGateway-011: name-keyed repository lookup instead of
|
||||
// fetch-all-then-filter — connection definitions are resolved on every
|
||||
// cached write / connection request, so the repository performs an indexed
|
||||
// query rather than loading every connection into memory.
|
||||
return await _repository.GetDatabaseConnectionByNameAsync(connectionName, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Classifies HTTP errors as transient or permanent.
|
||||
/// Transient: connection refused, timeout, HTTP 408/429/5xx.
|
||||
/// Permanent: HTTP 4xx (except 408/429).
|
||||
/// </summary>
|
||||
public static class ErrorClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether an HTTP status code represents a transient failure.
|
||||
/// Transient: HTTP 5xx, 408 (Request Timeout) and 429 (Too Many Requests).
|
||||
/// Every other non-success status (the remaining 4xx) defaults to permanent —
|
||||
/// a permanent failure is the safe default because retrying a 4xx is unlikely to
|
||||
/// succeed and risks duplicate side effects.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The HTTP status code to classify.</param>
|
||||
public static bool IsTransient(HttpStatusCode statusCode)
|
||||
{
|
||||
var code = (int)statusCode;
|
||||
return code >= 500 || code == 408 || code == 429;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an exception represents a transient failure.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception to classify.</param>
|
||||
public static bool IsTransient(Exception exception)
|
||||
{
|
||||
return exception is HttpRequestException
|
||||
or TaskCanceledException
|
||||
or TimeoutException
|
||||
or OperationCanceledException;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TransientException for S&F buffering.
|
||||
/// </summary>
|
||||
/// <param name="message">Human-readable failure description.</param>
|
||||
/// <param name="inner">Optional inner exception that caused the transient failure.</param>
|
||||
public static TransientExternalSystemException AsTransient(string message, Exception? inner = null)
|
||||
{
|
||||
return new TransientExternalSystemException(message, inner);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception type that signals a transient failure suitable for store-and-forward retry.
|
||||
/// </summary>
|
||||
public class TransientExternalSystemException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new <see cref="TransientExternalSystemException"/> with a message and optional inner exception.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="innerException">Optional inner exception.</param>
|
||||
public TransientExternalSystemException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception type that signals a permanent failure (should not be retried).
|
||||
/// </summary>
|
||||
public class PermanentExternalSystemException : Exception
|
||||
{
|
||||
/// <summary>Gets the HTTP status code that caused the permanent failure, if applicable.</summary>
|
||||
public int? HttpStatusCode { get; }
|
||||
|
||||
/// <summary>Initializes a new <see cref="PermanentExternalSystemException"/> with a message, optional HTTP status code, and optional inner exception.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="httpStatusCode">The HTTP status code that triggered the failure, if available.</param>
|
||||
/// <param name="innerException">Optional inner exception.</param>
|
||||
public PermanentExternalSystemException(string message, int? httpStatusCode = null, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
HttpStatusCode = httpStatusCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: HTTP/REST client that invokes external APIs.
|
||||
/// WP-7: Dual call modes — Call (synchronous) and CachedCall (S&F on transient failure).
|
||||
/// WP-8: Error classification applied to HTTP responses and exceptions.
|
||||
/// </summary>
|
||||
public class ExternalSystemClient : IExternalSystemClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IExternalSystemRepository _repository;
|
||||
private readonly StoreAndForwardService? _storeAndForward;
|
||||
private readonly ILogger<ExternalSystemClient> _logger;
|
||||
private readonly ExternalSystemGatewayOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExternalSystemClient.
|
||||
/// </summary>
|
||||
/// <param name="httpClientFactory">HTTP client factory for creating typed clients.</param>
|
||||
/// <param name="repository">External system repository for loading definitions.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="storeAndForward">Store-and-forward service for buffering transient failures, or null if not available.</param>
|
||||
/// <param name="options">Configuration options, or null for defaults.</param>
|
||||
public ExternalSystemClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IExternalSystemRepository repository,
|
||||
ILogger<ExternalSystemClient> logger,
|
||||
StoreAndForwardService? storeAndForward = null,
|
||||
IOptions<ExternalSystemGatewayOptions>? options = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_storeAndForward = storeAndForward;
|
||||
_options = options?.Value ?? new ExternalSystemGatewayOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalCallResult> CallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||
if (system == null || method == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
|
||||
return new ExternalCallResult(true, response, null);
|
||||
}
|
||||
catch (TransientExternalSystemException ex)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"Transient error: {ex.Message}");
|
||||
}
|
||||
catch (PermanentExternalSystemException ex)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalCallResult> CachedCallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
TrackedOperationId? trackedOperationId = null,
|
||||
Guid? executionId = null,
|
||||
string? sourceScript = null,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||
if (system == null || method == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
|
||||
return new ExternalCallResult(true, response, null);
|
||||
}
|
||||
catch (PermanentExternalSystemException ex)
|
||||
{
|
||||
// Permanent failures returned to script, never buffered
|
||||
return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
|
||||
}
|
||||
catch (TransientExternalSystemException)
|
||||
{
|
||||
// Transient failure — hand to S&F
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, "Transient error and store-and-forward not available");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
SystemName = systemName,
|
||||
MethodName = methodName,
|
||||
Parameters = parameters
|
||||
});
|
||||
|
||||
// attemptImmediateDelivery: false — this method already made the HTTP
|
||||
// attempt above; letting EnqueueAsync re-invoke the handler would
|
||||
// dispatch the same request a second time.
|
||||
//
|
||||
// ExternalSystemGateway-015: the entity's MaxRetries is a non-nullable
|
||||
// int whose default is 0, and the Store-and-Forward engine interprets a
|
||||
// stored MaxRetries of 0 as "no limit" (retry forever) — see
|
||||
// StoreAndForwardMessage.MaxRetries ("0 = no limit") and the retry-sweep
|
||||
// guard `MaxRetries > 0 && ...`. Passing 0 verbatim would therefore turn
|
||||
// every unconfigured cached call into an unbounded retry loop. A 0 is
|
||||
// treated as "unset" and passed as null so the bounded S&F default
|
||||
// applies; the RetryDelay default of TimeSpan.Zero is likewise unset.
|
||||
await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem,
|
||||
systemName,
|
||||
payload,
|
||||
originInstanceName,
|
||||
system.MaxRetries > 0 ? system.MaxRetries : null,
|
||||
system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null,
|
||||
attemptImmediateDelivery: false,
|
||||
// Audit Log #23 (M3): pin the S&F message id to the
|
||||
// TrackedOperationId so the retry loop can read it back via
|
||||
// StoreAndForwardMessage.Id and emit per-attempt + terminal
|
||||
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
|
||||
// mints its own GUID (legacy pre-M3 behaviour).
|
||||
messageId: trackedOperationId?.ToString(),
|
||||
// Audit Log #23 (ExecutionId Task 4): thread the originating
|
||||
// script execution's ExecutionId + SourceScript onto the
|
||||
// buffered row so the retry-loop cached-call audit rows carry
|
||||
// the same provenance the script-side cached rows do.
|
||||
executionId: executionId,
|
||||
sourceScript: sourceScript,
|
||||
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
|
||||
// inbound-API request's ExecutionId onto the buffered row so
|
||||
// the retry-loop cached-call audit rows correlate back to the
|
||||
// cross-execution chain. Null for a non-routed run.
|
||||
parentExecutionId: parentExecutionId);
|
||||
|
||||
return new ExternalCallResult(true, null, null, WasBuffered: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-7/10: Delivers a buffered ExternalSystem call during a store-and-forward
|
||||
/// retry sweep. Returns true on success, false on permanent failure (the message
|
||||
/// is parked); throws <see cref="TransientExternalSystemException"/> on a
|
||||
/// transient failure so the engine retries.
|
||||
/// </summary>
|
||||
/// <param name="message">The buffered message to deliver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if delivered successfully, false if a permanent error occurred.</returns>
|
||||
public async Task<bool> DeliverBufferedAsync(
|
||||
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ExternalSystemGateway-018: a malformed (not just empty/null-fielded)
|
||||
// PayloadJson would otherwise throw `JsonException` here, which the S&F
|
||||
// engine treats as a transient failure and retries forever (poison
|
||||
// message). Re-running the same deserialization against the same payload
|
||||
// will throw deterministically, so JsonException is permanent — log,
|
||||
// and return false so the S&F engine parks the message instead.
|
||||
CachedCallPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<CachedCallPayload>(message.PayloadJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Buffered ExternalSystem message {Id} has malformed JSON payload; parking.",
|
||||
message.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload == null || string.IsNullOrEmpty(payload.SystemName) || string.IsNullOrEmpty(payload.MethodName))
|
||||
{
|
||||
_logger.LogError("Buffered ExternalSystem message {Id} has an unreadable payload; parking.", message.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
var (system, method) = await ResolveSystemAndMethodAsync(
|
||||
payload.SystemName, payload.MethodName, cancellationToken);
|
||||
if (system == null || method == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Buffered call to '{System}'/'{Method}' cannot be delivered — the system or method no longer exists; parking.",
|
||||
payload.SystemName, payload.MethodName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameters = payload.Parameters?.ToDictionary(kv => kv.Key, kv => (object?)kv.Value);
|
||||
try
|
||||
{
|
||||
await InvokeHttpAsync(system, method, parameters, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (PermanentExternalSystemException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Buffered call to '{System}' failed permanently; parking.", payload.SystemName);
|
||||
return false;
|
||||
}
|
||||
// TransientExternalSystemException propagates — the S&F engine retries.
|
||||
}
|
||||
|
||||
private sealed record CachedCallPayload(
|
||||
string SystemName,
|
||||
string MethodName,
|
||||
Dictionary<string, JsonElement>? Parameters);
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: Executes the HTTP request against the external system.
|
||||
/// </summary>
|
||||
/// <param name="system">The external system definition.</param>
|
||||
/// <param name="method">The external system method to invoke.</param>
|
||||
/// <param name="parameters">Method parameters as a dictionary, or null if none.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The response string, or null if no response body.</returns>
|
||||
internal async Task<string?> InvokeHttpAsync(
|
||||
ExternalSystemDefinition system,
|
||||
ExternalSystemMethod method,
|
||||
IReadOnlyDictionary<string, object?>? parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// ExternalSystemGateway-022: validate the verb against the documented set
|
||||
// (GET/POST/PUT/PATCH/DELETE — per ESG-023's design-doc reconciliation)
|
||||
// BEFORE constructing the request. `new HttpMethod(string)` accepts any
|
||||
// token-character string (e.g. "FOO", "DLETE"), and the body-vs-query
|
||||
// branch below only knows POST/PUT/PATCH and GET/DELETE — so an
|
||||
// unsupported verb would dispatch silently with parameters sent to
|
||||
// neither body nor query, and the script would only see a remote 4xx.
|
||||
// Rejecting at the gateway entry surfaces the misconfiguration with a
|
||||
// clear ArgumentException naming the offending verb. Case-insensitive
|
||||
// match: the entity column carries free-form strings.
|
||||
ValidateHttpMethod(method.HttpMethod);
|
||||
|
||||
var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");
|
||||
|
||||
// ExternalSystemGateway-019: HttpClient.Timeout defaults to 100 seconds
|
||||
// and is enforced internally by SendAsync via its own private CTS — a
|
||||
// TaskCanceledException raised by that internal CTS does not trip
|
||||
// either the caller's token or the gateway's timeout CTS, so it falls
|
||||
// through the ordered catch filters below into the generic "connection
|
||||
// error" branch and is misclassified. Any operator-configured
|
||||
// DefaultHttpTimeout greater than 100 s would therefore be silently
|
||||
// clipped to 100 s, breaking the design's "timeout applies to the HTTP
|
||||
// request round-trip" guarantee. Disable the framework default so the
|
||||
// linked CancellationTokenSource(DefaultHttpTimeout) below is the sole
|
||||
// timeout source — DefaultHttpTimeout is then honoured verbatim for
|
||||
// every value, including ones well above 100 s. Setting this on the
|
||||
// factory-supplied HttpClient before any request is the safe time:
|
||||
// IHttpClientFactory rents typed clients backed by pooled message
|
||||
// handlers, but the HttpClient instance itself is per-call and the
|
||||
// Timeout property is per-instance.
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
|
||||
var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod);
|
||||
|
||||
// The request and response own IDisposable resources (StringContent, the
|
||||
// response content stream). Dispose both, including on the exception paths
|
||||
// (ExternalSystemGateway-005).
|
||||
using var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url);
|
||||
|
||||
// Apply authentication
|
||||
ApplyAuth(request, system);
|
||||
|
||||
// For POST/PUT/PATCH, send parameters as JSON body
|
||||
if (method.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.HttpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (parameters != null && parameters.Count > 0)
|
||||
{
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(parameters),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the per-call timeout. ExternalSystemDefinition has no per-system
|
||||
// Timeout field yet, so the configured DefaultHttpTimeout is the effective
|
||||
// round-trip limit (the design's "timeout applies to the HTTP request
|
||||
// round-trip" guarantee). A linked CTS lets us distinguish a timeout from a
|
||||
// caller-initiated cancellation: only the timeout is reclassified as transient.
|
||||
using var timeoutCts = new CancellationTokenSource(_options.DefaultHttpTimeout);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, timeoutCts.Token);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller asked to abandon the work — do not reclassify as transient.
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
// Our own timeout elapsed — a transient failure per the design.
|
||||
throw ErrorClassifier.AsTransient(
|
||||
$"Timeout calling {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex);
|
||||
}
|
||||
catch (Exception ex) when (ErrorClassifier.IsTransient(ex))
|
||||
{
|
||||
throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
// The timeout also covers reading the response body (the design's
|
||||
// "round-trip" guarantee), so the linked token is used for the read too.
|
||||
string body;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync(linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
throw ErrorClassifier.AsTransient(
|
||||
$"Timeout reading response from {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
// Bound the external error body before embedding it into a
|
||||
// script-visible message / event-log entry — a misbehaving or hostile
|
||||
// endpoint must not be able to inflate every error string
|
||||
// (ExternalSystemGateway-007).
|
||||
var errorBody = Truncate(body, MaxErrorBodyChars);
|
||||
|
||||
if (ErrorClassifier.IsTransient(response.StatusCode))
|
||||
{
|
||||
// Transient failures are normal operation (handled by retry / S&F) —
|
||||
// record at debug level only so the event log is not noisy.
|
||||
_logger.LogDebug(
|
||||
"Transient HTTP {StatusCode} from external system {System} calling {Method}.",
|
||||
(int)response.StatusCode, system.Name, method.Name);
|
||||
throw ErrorClassifier.AsTransient(
|
||||
$"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}");
|
||||
}
|
||||
|
||||
// The design requires permanent failures to be visible in Site Event
|
||||
// Logging — emit a warning so the gateway is not silent on a permanent
|
||||
// failure (ExternalSystemGateway-012).
|
||||
_logger.LogWarning(
|
||||
"Permanent HTTP {StatusCode} from external system {System} calling {Method}: {Error}",
|
||||
(int)response.StatusCode, system.Name, method.Name, errorBody);
|
||||
throw new PermanentExternalSystemException(
|
||||
$"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound (characters) on an external error response body echoed into a
|
||||
/// script-visible error message — see ExternalSystemGateway-007.
|
||||
/// </summary>
|
||||
private const int MaxErrorBodyChars = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// ExternalSystemGateway-022: documented HTTP-verb allowlist. Matches the
|
||||
/// design doc's enumerated set (GET/POST/PUT/PATCH/DELETE per ESG-023) and
|
||||
/// the body-vs-query branching above; any addition here must update both.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> SupportedHttpMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"GET", "POST", "PUT", "PATCH", "DELETE",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rejects HTTP verbs the gateway does not support. Throws
|
||||
/// <see cref="ArgumentException"/> for null/empty input or any string outside
|
||||
/// the documented allowlist. Case-insensitive — the entity column carries
|
||||
/// operator-authored strings.
|
||||
/// </summary>
|
||||
private static void ValidateHttpMethod(string httpMethod)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(httpMethod))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"HTTP method must be one of GET/POST/PUT/PATCH/DELETE; got null or empty.",
|
||||
nameof(httpMethod));
|
||||
}
|
||||
|
||||
if (!SupportedHttpMethods.Contains(httpMethod))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"HTTP method '{httpMethod}' is not supported. Allowed verbs: GET, POST, PUT, PATCH, DELETE.",
|
||||
nameof(httpMethod));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Substring(0, maxChars) + $"… [truncated, {value.Length} chars total]";
|
||||
}
|
||||
|
||||
private static string BuildUrl(string baseUrl, string path, IReadOnlyDictionary<string, object?>? parameters, string httpMethod)
|
||||
{
|
||||
// A method that targets the base URL itself has an empty (or "/") path.
|
||||
// Appending a trailing "/" in that case yields ".../api/" which some
|
||||
// servers treat as a distinct resource — only append a segment when the
|
||||
// method actually defines a non-empty relative path (ExternalSystemGateway-006).
|
||||
var trimmedBase = baseUrl.TrimEnd('/');
|
||||
var trimmedPath = path.Trim().TrimStart('/');
|
||||
var url = string.IsNullOrEmpty(trimmedPath)
|
||||
? trimmedBase
|
||||
: trimmedBase + "/" + trimmedPath;
|
||||
|
||||
// For GET/DELETE, append parameters as query string
|
||||
if ((httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) ||
|
||||
httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) &&
|
||||
parameters != null && parameters.Count > 0)
|
||||
{
|
||||
var queryString = string.Join("&",
|
||||
parameters.Where(p => p.Value != null)
|
||||
.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value?.ToString() ?? "")}"));
|
||||
|
||||
// Only append "?" when the effective query string is non-empty — a method
|
||||
// whose parameter values are all null produces no query string, and the
|
||||
// URL must then be identical to the no-parameters case rather than ending
|
||||
// in a bare "?" (ExternalSystemGateway-017).
|
||||
if (queryString.Length > 0)
|
||||
{
|
||||
url += "?" + queryString;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
|
||||
{
|
||||
// ESG-021: distinguish "intentionally unauthenticated" (AuthType = none)
|
||||
// from "AuthConfiguration is missing or empty for a type that requires it"
|
||||
// (deployment glitch, decryption failure, operator typo). The unauthenticated
|
||||
// case is silent; the requires-creds-but-empty case logs a Warning so an
|
||||
// operator debugging a recurring 401 sees the cause inside ScadaBridge instead
|
||||
// of having to read the remote system's logs. The value of AuthConfiguration
|
||||
// is NEVER logged.
|
||||
var authType = system.AuthType?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(system.AuthConfiguration))
|
||||
{
|
||||
if (authType is "apikey" or "basic")
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' has AuthType '{AuthType}' but AuthConfiguration is empty; request will be sent without an auth header.",
|
||||
system.Name, system.AuthType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (authType)
|
||||
{
|
||||
case "apikey":
|
||||
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
|
||||
var parts = system.AuthConfiguration.Split(':', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(parts[0], parts[1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-API-Key", system.AuthConfiguration);
|
||||
}
|
||||
break;
|
||||
|
||||
case "basic":
|
||||
// Auth config format: "username:password"
|
||||
var basicParts = system.AuthConfiguration.Split(':', 2);
|
||||
if (basicParts.Length == 2)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ESG-021: malformed Basic config (no ':' separator) means the
|
||||
// request goes out with no Authorization header. Warn so the
|
||||
// failure mode is visible inside ZB.MOM.WW.ScadaBridge.
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed (expected 'username:password'); request will be sent without an Authorization header.",
|
||||
system.Name);
|
||||
}
|
||||
break;
|
||||
|
||||
case "none":
|
||||
// Documented sentinel for unauthenticated systems — silent by design.
|
||||
break;
|
||||
|
||||
default:
|
||||
// ESG-021: unknown AuthType silently fell through here before. Warn.
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' has unknown AuthType '{AuthType}'; request will be sent without an auth header. Allowed values: apikey, basic, none.",
|
||||
system.Name, system.AuthType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// ExternalSystemGateway-011: name-keyed repository lookups instead of
|
||||
// fetch-all-then-filter — definitions are resolved on every hot-path call
|
||||
// (a script's ExternalSystem.Call()), so the repository performs an indexed
|
||||
// query rather than loading every system / every method into memory.
|
||||
var system = await _repository.GetExternalSystemByNameAsync(systemName, cancellationToken);
|
||||
if (system == null)
|
||||
return (null, null);
|
||||
|
||||
var method = await _repository.GetMethodByNameAsync(system.Id, methodName, cancellationToken);
|
||||
|
||||
return (system, method);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the External System Gateway component.
|
||||
/// </summary>
|
||||
public class ExternalSystemGatewayOptions
|
||||
{
|
||||
/// <summary>Default HTTP request timeout per external system call.</summary>
|
||||
public TimeSpan DefaultHttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Maximum number of concurrent HTTP connections per external system.</summary>
|
||||
public int MaxConcurrentConnectionsPerSystem { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name prefix of the per-system <see cref="System.Net.Http.HttpClient"/> clients
|
||||
/// created by <see cref="ExternalSystemClient"/> (<c>ExternalSystem_{systemName}</c>).
|
||||
/// </summary>
|
||||
internal const string GatewayClientNamePrefix = "ExternalSystem_";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the External System Gateway services, HTTP client factory, and options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
public static IServiceCollection AddExternalSystemGateway(this IServiceCollection services)
|
||||
{
|
||||
services.AddOptions<ExternalSystemGatewayOptions>()
|
||||
.BindConfiguration("ScadaBridge:ExternalSystemGateway");
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
// ExternalSystemGateway-013 / -016: wire MaxConcurrentConnectionsPerSystem
|
||||
// into the primary handler of the gateway's per-system named clients
|
||||
// ("ExternalSystem_{name}") only. The names are created dynamically, so a
|
||||
// static AddHttpClient("name") registration is not possible; instead a
|
||||
// post-configure on HttpClientFactoryOptions is applied, filtered by the
|
||||
// client-name prefix. ConfigureHttpClientDefaults is deliberately NOT used —
|
||||
// it is process-global and would replace the primary handler of every
|
||||
// HttpClient in the host (e.g. the Notification Service's OAuth2 token
|
||||
// client), silently capping and overriding unrelated components.
|
||||
services.AddSingleton<IConfigureOptions<HttpClientFactoryOptions>>(sp =>
|
||||
new GatewayHttpClientConfigurator(
|
||||
sp.GetRequiredService<IOptionsMonitor<ExternalSystemGatewayOptions>>()));
|
||||
|
||||
services.AddScoped<ExternalSystemClient>();
|
||||
services.AddScoped<IExternalSystemClient>(sp => sp.GetRequiredService<ExternalSystemClient>());
|
||||
services.AddScoped<DatabaseGateway>();
|
||||
services.AddScoped<IDatabaseGateway>(sp => sp.GetRequiredService<DatabaseGateway>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for External System Gateway Akka.NET actor registrations (handled in AkkaHostedService).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
public static IServiceCollection AddExternalSystemGatewayActors(this IServiceCollection services)
|
||||
{
|
||||
// WP-10: Actor registration happens in AkkaHostedService.
|
||||
// Script Execution Actors run on dedicated blocking I/O dispatcher.
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ExternalSystemGateway-016: configures the primary HTTP message handler with the
|
||||
/// gateway's <see cref="ExternalSystemGatewayOptions.MaxConcurrentConnectionsPerSystem"/>
|
||||
/// cap, but only for the gateway's own named clients
|
||||
/// (<see cref="GatewayClientNamePrefix"/>). Clients owned by other host components
|
||||
/// are left untouched, so the cap does not leak process-wide.
|
||||
/// </summary>
|
||||
private sealed class GatewayHttpClientConfigurator
|
||||
: IConfigureNamedOptions<HttpClientFactoryOptions>
|
||||
{
|
||||
private readonly IOptionsMonitor<ExternalSystemGatewayOptions> _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the configurator with the gateway options monitor.
|
||||
/// </summary>
|
||||
/// <param name="options">Live options providing the max-connections-per-system cap.</param>
|
||||
public GatewayHttpClientConfigurator(IOptionsMonitor<ExternalSystemGatewayOptions> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>No-op: the default unnamed client is not a gateway client.</summary>
|
||||
/// <param name="options">Options for the default client (ignored).</param>
|
||||
public void Configure(HttpClientFactoryOptions options)
|
||||
{
|
||||
// The default (unnamed) client is not a gateway client — do nothing.
|
||||
}
|
||||
|
||||
/// <summary>Applies the max-connections cap to gateway-owned named clients only.</summary>
|
||||
/// <param name="name">Client name; non-gateway names are skipped.</param>
|
||||
/// <param name="options">Factory options whose primary handler is configured.</param>
|
||||
public void Configure(string? name, HttpClientFactoryOptions options)
|
||||
{
|
||||
if (name == null || !name.StartsWith(GatewayClientNamePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
builder.PrimaryHandler = new SocketsHttpHandler
|
||||
{
|
||||
MaxConnectionsPerServer = _options.CurrentValue.MaxConcurrentConnectionsPerSystem,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user