using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
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;
///
/// HTTP/REST client that invokes external APIs.
/// Dual call modes — Call (synchronous) and CachedCall (S&F on transient failure).
/// Error classification applied to HTTP responses and exceptions.
///
public class ExternalSystemClient : IExternalSystemClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IExternalSystemRepository _repository;
private readonly StoreAndForwardService? _storeAndForward;
private readonly ILogger _logger;
private readonly ExternalSystemGatewayOptions _options;
///
/// Initializes a new instance of the ExternalSystemClient.
///
/// HTTP client factory for creating typed clients.
/// External system repository for loading definitions.
/// Logger instance.
/// Store-and-forward service for buffering transient failures, or null if not available.
/// Configuration options, or null for defaults.
public ExternalSystemClient(
IHttpClientFactory httpClientFactory,
IExternalSystemRepository repository,
ILogger logger,
StoreAndForwardService? storeAndForward = null,
IOptions? options = null)
{
_httpClientFactory = httpClientFactory;
_repository = repository;
_logger = logger;
_storeAndForward = storeAndForward;
_options = options?.Value ?? new ExternalSystemGatewayOptions();
}
///
public async Task CallAsync(
string systemName,
string methodName,
IReadOnlyDictionary? 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}");
}
}
///
public async Task CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary? 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.
//
// 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,
// 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. Null -> S&F
// mints its own GUID (legacy behaviour).
messageId: trackedOperationId?.ToString(),
// 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,
// 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);
}
}
///
/// 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 on a
/// transient failure so the engine retries. Deterministic-poison failures against
/// the CURRENT method definition — malformed JSON, or an
/// from an unresolvable {param} path
/// template or an unsupported HTTP verb — return false (park immediately) rather
/// than retry, because re-running the same stored payload can never succeed.
///
/// The buffered message to deliver.
/// Cancellation token.
/// True if delivered successfully, false if a permanent error occurred.
public async Task DeliverBufferedAsync(
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
{
// 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(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;
}
catch (ArgumentException ex)
{
// N2: same poison-message shape as the JsonException catch above. BuildUrl's
// {param} path-template substitution (placeholder with no matching/non-null
// parameter) and ValidateHttpMethod both throw ArgumentException
// deterministically for the SAME stored payload — the method definition
// changed underneath the buffered call, and retrying can never succeed.
// Return false so the S&F engine parks the message instead of counting the
// throw as transient and burning MaxRetries.
_logger.LogError(
ex,
"Buffered call to '{System}'/'{Method}' fails deterministically against the current method definition; parking.",
payload.SystemName, payload.MethodName);
return false;
}
// TransientExternalSystemException propagates — the S&F engine retries.
}
private sealed record CachedCallPayload(
string SystemName,
string MethodName,
Dictionary? Parameters);
///
/// Executes the HTTP request against the external system.
///
/// The external system definition.
/// The external system method to invoke.
/// Method parameters as a dictionary, or null if none.
/// Cancellation token.
/// The response string, or null if no response body.
internal async Task InvokeHttpAsync(
ExternalSystemDefinition system,
ExternalSystemMethod method,
IReadOnlyDictionary? parameters,
CancellationToken cancellationToken)
{
// Validate the verb against the documented set
// (GET/POST/PUT/PATCH/DELETE)
// 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}");
// 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, out var consumedParams);
// The request and response own IDisposable resources (StringContent, the
// response content stream). Dispose both, including on the exception paths.
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))
{
// Parameters consumed by `{param}` path-template substitution are
// already carried in the URL — exclude them from the JSON body so a
// path parameter is not duplicated as a body field.
var bodyParameters = consumedParams.Count == 0
? parameters
: parameters?.Where(p => !consumedParams.Contains(p.Key))
.ToDictionary(p => p.Key, p => p.Value);
if (bodyParameters != null && bodyParameters.Count > 0)
{
request.Content = new StringContent(
JsonSerializer.Serialize(bodyParameters),
Encoding.UTF8,
"application/json");
}
}
// Enforce the per-call timeout (the design's "timeout applies to the HTTP
// request round-trip" guarantee). Resolution order: a positive per-system
// ExternalSystemDefinition.TimeoutSeconds wins; otherwise the configured
// DefaultHttpTimeout is the effective round-trip limit. A linked CTS lets us
// distinguish a timeout from a caller-initiated cancellation: only the
// timeout is reclassified as transient.
var effectiveTimeout = system.TimeoutSeconds > 0
? TimeSpan.FromSeconds(system.TimeoutSeconds)
: _options.DefaultHttpTimeout;
using var timeoutCts = new CancellationTokenSource(effectiveTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
HttpResponseMessage response;
try
{
// ResponseHeadersRead returns as soon as the headers are in, so the
// body is not eagerly buffered — the bounded read below streams it and
// aborts once MaxResponseBodyBytes is exceeded, keeping a hostile or
// misbehaving endpoint from inflating the active node's memory.
response = await client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, 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 {effectiveTimeout.TotalSeconds:0.##}s", ex);
}
catch (Exception ex) when (ErrorClassifier.IsTransient(ex, cancellationToken))
{
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 ReadBodyBoundedAsync(
response.Content, system.Name, _options.MaxResponseBodyBytes, linkedCts.Token);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested)
{
throw ErrorClassifier.AsTransient(
$"Timeout reading response from {system.Name} after {effectiveTimeout.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.
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.
_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);
}
}
///
/// Upper bound (characters) on an external error response body echoed into a
/// script-visible error message.
///
private const int MaxErrorBodyChars = 2048;
///
/// Documented HTTP-verb allowlist. Matches the
/// design doc's enumerated set (GET/POST/PUT/PATCH/DELETE) and
/// the body-vs-query branching above; any addition here must update both.
///
private static readonly HashSet SupportedHttpMethods = new(StringComparer.OrdinalIgnoreCase)
{
"GET", "POST", "PUT", "PATCH", "DELETE",
};
///
/// Rejects HTTP verbs the gateway does not support. Throws
/// for null/empty input or any string outside
/// the documented allowlist. Case-insensitive — the entity column carries
/// operator-authored strings.
///
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));
}
}
///
/// Reads an HTTP response body into a string while enforcing an upper size
/// bound. When the Content-Length header is present it fails fast;
/// otherwise the body is streamed in 16 KiB chunks and the read is aborted the
/// moment the accumulated length exceeds (cap+1
/// pattern), so the whole oversized body is never buffered. Oversize is a
/// permanent failure — retrying will not shrink the response, and buffering it
/// into store-and-forward would defeat the cap. Cancellation
/// () is allowed to propagate so the
/// caller's ordered timeout/cancellation catch filters classify it.
///
/// N4: the accumulated bytes are decoded using the response's declared charset
/// (Content-Type: …; charset=…) via — parity
/// with the pre-bounded-read ReadAsStringAsync. A null, empty, or
/// unrecognized charset falls back to UTF-8.
///
///
private static async Task ReadBodyBoundedAsync(
HttpContent content, string systemName, long maxBytes, CancellationToken token)
{
// Fast path: a declared Content-Length above the cap is rejected before a
// single body byte is read.
var declaredLength = content.Headers.ContentLength;
if (declaredLength.HasValue && declaredLength.Value > maxBytes)
{
throw new PermanentExternalSystemException(
$"Response from {systemName} exceeded the {maxBytes}-byte limit " +
$"(declared Content-Length {declaredLength.Value}).");
}
await using var stream = await content.ReadAsStreamAsync(token);
using var buffer = new MemoryStream();
var chunk = new byte[16 * 1024];
int read;
while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), token)) > 0)
{
buffer.Write(chunk, 0, read);
// cap+1: as soon as we hold more than maxBytes we know it is oversized,
// without reading the remainder of the stream.
if (buffer.Length > maxBytes)
{
throw new PermanentExternalSystemException(
$"Response from {systemName} exceeded the {maxBytes}-byte limit.");
}
}
// N4: honor the response's declared charset (parity with the pre-bounded-read
// ReadAsStringAsync). Unknown/invalid charset → UTF-8 fallback: mojibake beats
// failing the call over a cosmetic header.
return ResolveEncoding(content.Headers.ContentType?.CharSet)
.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
}
///
/// Resolves the declared response charset to an , tolerating
/// the quoted form some servers emit (charset="iso-8859-1"). Null, empty,
/// or unrecognized values fall back to UTF-8.
///
private static Encoding ResolveEncoding(string? charset)
{
if (string.IsNullOrWhiteSpace(charset))
{
return Encoding.UTF8;
}
try
{
return Encoding.GetEncoding(charset.Trim().Trim('"'));
}
catch (ArgumentException)
{
return Encoding.UTF8;
}
}
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]";
}
///
/// Matches a `{param}` path-template placeholder. The name must be a valid
/// identifier (letter/underscore start, then letters/digits/underscores) so the
/// pattern cannot accidentally swallow JSON-ish braces in a hand-authored path.
///
private static readonly Regex PathParamRegex = new(
@"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
private static string BuildUrl(
string baseUrl,
string path,
IReadOnlyDictionary? parameters,
string httpMethod,
out ISet consumedParams)
{
consumedParams = new HashSet(StringComparer.Ordinal);
// 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.
var trimmedBase = baseUrl.TrimEnd('/');
var trimmedPath = path.Trim().TrimStart('/');
// Substitute `{param}` placeholders in the path with escaped parameter
// values. Each substituted name is recorded so it is excluded from BOTH
// the query string (GET/DELETE) and the JSON body (POST/PUT/PATCH) — a
// path parameter must not be duplicated. A placeholder with no matching
// parameter (or a null value) is an authoring error: throw a clear
// ArgumentException naming it, mirroring ValidateHttpMethod.
if (trimmedPath.Length > 0 && trimmedPath.Contains('{'))
{
var consumed = consumedParams;
trimmedPath = PathParamRegex.Replace(trimmedPath, match =>
{
var name = match.Groups[1].Value;
if (parameters == null ||
!parameters.TryGetValue(name, out var value) ||
value == null)
{
throw new ArgumentException(
$"Path template parameter '{{{name}}}' has no value — supply a non-null '{name}' parameter for this method.",
nameof(path));
}
consumed.Add(name);
return Uri.EscapeDataString(value.ToString() ?? string.Empty);
});
}
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 consumed = consumedParams;
var queryString = string.Join("&",
parameters.Where(p => p.Value != null && !consumed.Contains(p.Key))
.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 "?".
if (queryString.Length > 0)
{
url += "?" + queryString;
}
}
return url;
}
private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
{
// 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;
}
// A config whose trimmed value starts with '{' is the structured JSON form
// promised by the entity doc-comment and the Central UI placeholders. Parse
// it first; only a non-JSON value falls through to the legacy colon-split.
var isJson = system.AuthConfiguration.TrimStart().StartsWith('{');
Dictionary? jsonFields = null;
var jsonParsed = isJson && TryParseJsonAuth(system.AuthConfiguration, out jsonFields);
switch (authType)
{
case "apikey":
// JSON form: {"header"?: string, "key": string} (default header X-API-Key).
if (isJson)
{
if (jsonParsed &&
jsonFields!.TryGetValue("key", out var jsonKey) &&
!string.IsNullOrEmpty(jsonKey))
{
var header = jsonFields.TryGetValue("header", out var h) && !string.IsNullOrEmpty(h)
? h!
: "X-API-Key";
request.Headers.TryAddWithoutValidation(header, jsonKey);
}
else
{
// Malformed JSON, or JSON with no usable "key": send without a
// header and warn (the value is never logged). Never throw.
_logger.LogWarning(
"ApplyAuth: External system '{System}' AuthType 'apikey' AuthConfiguration is malformed JSON (expected {{\"header\"?:...,\"key\":...}}); request will be sent without an auth header.",
system.Name);
}
break;
}
// Legacy 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":
// JSON form: {"username": string, "password": string}.
if (isJson)
{
if (jsonParsed &&
jsonFields!.TryGetValue("username", out var jsonUser) &&
!string.IsNullOrEmpty(jsonUser))
{
var password = jsonFields.TryGetValue("password", out var p) ? p ?? string.Empty : string.Empty;
var jsonEncoded = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{jsonUser}:{password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", jsonEncoded);
}
else
{
_logger.LogWarning(
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed JSON (expected {{\"username\":...,\"password\":...}}); request will be sent without an Authorization header.",
system.Name);
}
break;
}
// Legacy 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
{
// 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:
// 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;
}
}
///
/// Parses a structured JSON AuthConfiguration into a case-insensitive
/// field map (e.g. {"header":"X-Auth","key":"..."} or
/// {"username":"u","password":"p"}). Returns false on malformed
/// JSON () instead of throwing, so the caller can
/// fall through to the "send without header" warning path. Values are never
/// logged.
///
private static bool TryParseJsonAuth(string config, out Dictionary fields)
{
try
{
fields = JsonSerializer.Deserialize>(
config,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new Dictionary(StringComparer.OrdinalIgnoreCase);
// Normalize to case-insensitive so "Header"/"header" both resolve.
if (!ReferenceEquals(fields.Comparer, StringComparer.OrdinalIgnoreCase))
{
fields = new Dictionary(fields, StringComparer.OrdinalIgnoreCase);
}
return true;
}
catch (JsonException)
{
fields = new Dictionary(StringComparer.OrdinalIgnoreCase);
return false;
}
}
private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
string systemName,
string methodName,
CancellationToken cancellationToken)
{
// 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);
}
}