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; using ScadaLink.Commons.Types.Enums; using ScadaLink.StoreAndForward; namespace ScadaLink.ExternalSystemGateway; /// /// 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. /// public class ExternalSystemClient : IExternalSystemClient { private readonly IHttpClientFactory _httpClientFactory; private readonly IExternalSystemRepository _repository; private readonly StoreAndForwardService? _storeAndForward; private readonly ILogger _logger; private readonly ExternalSystemGatewayOptions _options; 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(); } /// /// WP-7: Synchronous call — all failures returned to caller. /// 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}"); } } /// /// WP-7: CachedCall — attempt immediate, transient failure goes to S&F, permanent returned to script. /// /// /// Audit Log #23 (M3): used as the S&F message id so the retry loop can /// recover it from StoreAndForwardMessage.Id and emit per-attempt / /// terminal cached-call telemetry (Tasks E4/E5). When null the S&F engine /// mints its own GUID — preserving the pre-M3 behaviour for callers that /// don't participate in the M3 audit pipeline. /// 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. // // 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); } } /// /// 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 on a /// transient failure so the engine retries. /// public async Task DeliverBufferedAsync( StoreAndForwardMessage message, CancellationToken cancellationToken = default) { var payload = JsonSerializer.Deserialize(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? Parameters); /// /// WP-6: Executes the HTTP request against the external system. /// internal async Task InvokeHttpAsync( ExternalSystemDefinition system, ExternalSystemMethod method, IReadOnlyDictionary? 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); } } /// /// Upper bound (characters) on an external error response body echoed into a /// script-visible error message — see ExternalSystemGateway-007. /// 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? 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); } }