using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.GOP; using Microsoft.Extensions.Logging; namespace FrameworkBLL.GOP.Worker.NodeExecutors { // ============================================================ // ApiCallNodeExecutor — executes an ApiCall node type. // // Makes an HTTP call using a named HttpClient (registered by the // logical service name). The request payload is the current // pipeline payload (JSON). The response body becomes the next // step's payload. // // Timeout: per-step TimeoutSeconds from GopFlowSnapshotStepDTO. // Retry: handled by GopExecutionPipeline (MaxRetries). // Non-2xx responses → throws so the pipeline retry/DLQ handles it. // // Every GB5Framework endpoint (BaseEndpoint/FastEndpoints convention) // always returns transport-level HTTP 200 and encodes the real outcome // in the body's own ResponseStandardDTO.Status field instead — so a // business-logic failure from an internal GB5 target (e.g. a downstream // save endpoint rejecting bad data) would otherwise look identical to a // real success. TryDetectEnvelopeFailure() opportunistically checks for // that shape (top-level numeric "Status" >= 400) on top of the existing // transport check; arbitrary external, non-GB5 endpoints without that // shape are unaffected and fall back to transport-status-only. // ============================================================ public class ApiCallNodeExecutor : INodeExecutor { private readonly IHttpClientFactory _HttpClientFactory; private readonly ILogger _Logger; public ApiCallNodeExecutor( IHttpClientFactory httpClientFactory, ILogger logger) { _HttpClientFactory = httpClientFactory; _Logger = logger; } public string NodeType => "ApiCall"; public async Task ExecuteAsync( GopFlowSnapshotStepDTO step, GopExecutionHeaderDTO header, string? inputPayloadJson, LoginDTO loginDTO, CancellationToken ct) { var httpMethod = new HttpMethod( string.IsNullOrWhiteSpace(step.HttpMethod) ? "POST" : step.HttpMethod.ToUpper()); // Use the LogicalServiceName as the HttpClient named-client key. var client = _HttpClientFactory.CreateClient(step.LogicalServiceName); // Apply per-step timeout. using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds( step.TimeoutSeconds > 0 ? step.TimeoutSeconds : 60)); var requestUrl = $"{step.ResolvedEndpoint.TrimEnd('/')}/{step.RelativePath.TrimStart('/')}"; using var request = new HttpRequestMessage(httpMethod, requestUrl); if (!string.IsNullOrWhiteSpace(inputPayloadJson) && httpMethod != HttpMethod.Get && httpMethod != HttpMethod.Delete) { request.Content = new StringContent( inputPayloadJson, Encoding.UTF8, "application/json"); } // Propagate tenant context in header so downstream services can authenticate. request.Headers.TryAddWithoutValidation("X-Tenant-Id", loginDTO.ClientId.ToString()); request.Headers.TryAddWithoutValidation("X-Correlation-Id", header.ExecutionId.ToString()); // Every GB5Framework endpoint (BaseEndpoint.GetLoginDTOFromRequest) requires a // "Login" header carrying the raw LoginDTO JSON -- without it, any ApiCall step // targeting an internal GB5 endpoint fails with HTTP 400 "This header is missing // from the request!" regardless of how correctly the URL/payload are built. request.Headers.TryAddWithoutValidation("Login", JsonSerializer.Serialize(loginDTO)); _Logger.LogDebug( "ApiCallNodeExecutor: execution {ExecutionId} → {Method} {Url}", header.ExecutionId, httpMethod, requestUrl); using var response = await client.SendAsync(request, timeoutCts.Token); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { throw new HttpRequestException( $"ApiCall step '{step.NodeCode}' returned HTTP {(int)response.StatusCode}: {body}", null, response.StatusCode); } if (TryDetectEnvelopeFailure(body, out var envelopeStatus, out var envelopeError)) { throw new HttpRequestException( $"ApiCall step '{step.NodeCode}' returned HTTP {(int)response.StatusCode} " + $"but the response envelope reported Status={envelopeStatus}: {envelopeError ?? body}", null, response.StatusCode); } _Logger.LogDebug( "ApiCallNodeExecutor: execution {ExecutionId} step {NodeCode} — HTTP {Status}", header.ExecutionId, step.NodeCode, (int)response.StatusCode); return string.IsNullOrWhiteSpace(body) ? null : body; } // Detects GB5's own ResponseStandardDTO envelope (top-level numeric "Status" field, // HTTP-status-coded — see FrameworkEnumDTO.ResponseStatus) reporting a business-logic // failure underneath a transport-level 2xx. Returns false for anything that doesn't // match that exact shape, so non-GB5 external endpoints are never misclassified. private static bool TryDetectEnvelopeFailure( string? body, out int? envelopeStatus, out string? errorDetail) { envelopeStatus = null; errorDetail = null; if (string.IsNullOrWhiteSpace(body)) return false; try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return false; if (!doc.RootElement.TryGetProperty("Status", out var statusEl) && !doc.RootElement.TryGetProperty("status", out statusEl)) return false; if (statusEl.ValueKind != JsonValueKind.Number || !statusEl.TryGetInt32(out var status)) return false; if (status < 400) return false; envelopeStatus = status; if (doc.RootElement.TryGetProperty("Body", out var bodyEl) || doc.RootElement.TryGetProperty("body", out bodyEl)) { errorDetail = bodyEl.ToString(); } else if (doc.RootElement.TryGetProperty("ErrorBody", out var errEl) && errEl.ValueKind == JsonValueKind.String) { errorDetail = errEl.GetString(); } return true; } catch (JsonException) { return false; } } } }