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. /// /// 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; } // 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. /// 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."); } } return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); } 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); } }