402 lines
17 KiB
C#
402 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.StoreAndForward;
|
|
|
|
namespace ScadaLink.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;
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-7: Synchronous call — all failures returned to caller.
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-7: CachedCall — attempt immediate, transient failure goes to S&F, permanent returned to script.
|
|
/// </summary>
|
|
public async Task<ExternalCallResult> CachedCallAsync(
|
|
string systemName,
|
|
string methodName,
|
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
|
string? originInstanceName = 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 (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);
|
|
|
|
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>
|
|
public async Task<bool> DeliverBufferedAsync(
|
|
StoreAndForwardMessage message, CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = JsonSerializer.Deserialize<CachedCallPayload>(message.PayloadJson);
|
|
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>
|
|
internal async Task<string?> InvokeHttpAsync(
|
|
ExternalSystemDefinition system,
|
|
ExternalSystemMethod method,
|
|
IReadOnlyDictionary<string, object?>? parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");
|
|
|
|
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;
|
|
|
|
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 static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
|
|
{
|
|
if (string.IsNullOrEmpty(system.AuthConfiguration))
|
|
return;
|
|
|
|
switch (system.AuthType.ToLowerInvariant())
|
|
{
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
}
|