using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.Login; using GB5Shared.GB5CommonFunction; using IceImportDAL.CustomCode.MenuResolution; using IceImportDAL.DTO.MenuResolution; using IceImportDAL.DTO.MenuWebServiceDispatcher; using Microsoft.Extensions.Logging; namespace IceImportBLL.MenuWebServiceDispatcher; // Reuses FrameworkBLL.ActionProcessor.Handlers.IWebhookEndpointResolver (the exact // resolve-URL/substitute-placeholder mechanism WebhookActionHandler already uses live for SysJob) // plus IHttpClientFactory — deliberately not a second HTTP dispatch mechanism. Every call is a // POST of a JSON payload, mirroring the one confirmed-working precedent (WebhookActionHandler is // POST-only); a target entity's Get/GetSelectList handler is expected to accept a JSON body the // same way FastEndpoints binds any other Endpoint. If a specific target's Get // endpoint is strictly query-string bound, register a dedicated ILookupProvider/ // IEntityPersistenceAdapter for that EntityId instead of relying on this generic dispatcher. public class MenuWebServiceDispatcher : IMenuWebServiceDispatcher { // Legacy GB4/WCF endpoints expect dates as "/Date()/" (the classic // ASP.NET AJAX JSON date format), not System.Text.Json's default ISO-8601 string — used only // for the "/gb4/" URL branch below; native GB5 endpoints keep the default ISO-8601 output. private static readonly JsonSerializerOptions Gb4JsonOptions = new() { Converters = { new Gb4EpochDateTimeConverter() } }; private sealed class Gb4EpochDateTimeConverter : JsonConverter { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException("Gb4EpochDateTimeConverter is write-only — used for outgoing GB4 requests only."); public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { // Treat the wall-clock value as-is (no local-to-UTC conversion) — ICE import dates are // calendar dates with no meaningful timezone of their own, matching legacy's own // behavior of epoch-encoding the DateTime it was given without an offset adjustment. var epochMillis = new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc)) .ToUnixTimeMilliseconds(); writer.WriteStringValue($"/Date({epochMillis})/"); } } private readonly IMenuResolutionDAL _menuDal; private readonly IWebhookEndpointResolver _endpointResolver; private readonly IHttpClientFactory _httpFactory; private readonly IGB5CommonFunction _gb5CommonFunction; private readonly IceImportBLL.IceImportRun.IIceImportRunBLL _runBll; private readonly ILogger _logger; // MenuWebServiceDispatcher is DI-Scoped and a fresh instance is created per import run (see // CommitImport/RetryImportRun/ScheduledImportService's own IServiceScopeFactory.CreateScope() // per run) — so these caches live for exactly one run's duration, never leak across runs, and // need no explicit invalidation. Without them, a 2000-row per-row (REQUESTSCHEMATYPE=1) import // re-queried MMENU and MWEBSERVICE from scratch on every single row even though the same // EntityId/WebServiceId resolves to the exact same Menu/URL every time within one run. private readonly Dictionary _menuCache = new(); private readonly Dictionary _urlCache = new(); public MenuWebServiceDispatcher( IMenuResolutionDAL menuDal, IWebhookEndpointResolver endpointResolver, IHttpClientFactory httpFactory, IGB5CommonFunction gb5CommonFunction, IceImportBLL.IceImportRun.IIceImportRunBLL runBll, ILogger logger) { _menuDal = menuDal; _endpointResolver = endpointResolver; _httpFactory = httpFactory; _gb5CommonFunction = gb5CommonFunction; _runBll = runBll; _logger = logger; } public async Task InvokeSaveAsync(int entityId, object payload, LoginDTO login, long runId, CancellationToken ct) { try { var menu = await ResolveMenuAsync(entityId, login, ct).ConfigureAwait(false); if (menu.SaveWebServiceId == -1) throw new InvalidOperationException($"No Menu with a SaveWebServiceId is configured for EntityId {entityId}."); return await InvokeAsync(menu.SaveWebServiceId, payload, login, runId, ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "IceImport: InvokeSaveAsync failed for EntityId {EntityId}", entityId); return WebServiceCallResultDTO.Fail(null, null, ex.Message); } } public async Task InvokeSaveBatchAsync(int entityId, List payloads, LoginDTO login, long runId, CancellationToken ct) { try { var menu = await ResolveMenuAsync(entityId, login, ct).ConfigureAwait(false); if (menu.SaveWebServiceId == -1) throw new InvalidOperationException($"No Menu with a SaveWebServiceId is configured for EntityId {entityId}."); return await InvokeAsync(menu.SaveWebServiceId, payloads, login, runId, ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "IceImport: InvokeSaveBatchAsync failed for EntityId {EntityId} ({Count} rows)", entityId, payloads.Count); return WebServiceCallResultDTO.Fail(null, null, ex.Message); } } public async Task GetSaveRequestSchemaTypeAsync(int entityId, LoginDTO login, CancellationToken ct) { try { var menu = await ResolveMenuAsync(entityId, login, ct).ConfigureAwait(false); if (menu.SaveWebServiceId == -1) return 1; // no Save WebService configured — nothing to batch against return await _endpointResolver .GetRequestSchemaTypeAsync(menu.SaveWebServiceId, login.ClientId, login.DatabaseName, ct) .ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogWarning(ex, "IceImport: GetSaveRequestSchemaTypeAsync failed for EntityId {EntityId} — defaulting to per-row (1)", entityId); return 1; } } private async Task ResolveMenuAsync(int entityId, LoginDTO login, CancellationToken ct) { if (_menuCache.TryGetValue(entityId, out var cached)) return cached; try { var menu = await _menuDal.GetMenuByEntityIdAsync(entityId, login, ct).ConfigureAwait(false); if (menu is null) throw new InvalidOperationException($"No active Menu is configured for EntityId {entityId}."); _menuCache[entityId] = menu; return menu; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "IceImport: ResolveMenuAsync failed for EntityId {EntityId}", entityId); throw; } } private async Task InvokeAsync(int webServiceId, object payload, LoginDTO login, long runId, CancellationToken ct) { string? url = null; string? json = null; try { bool urlCached = _urlCache.TryGetValue(webServiceId, out url); if (!urlCached) { url = await _endpointResolver .ResolveAsync(webServiceId, uriParameterValue: null, login.ClientId, login.DatabaseName, ct) .ConfigureAwait(false); _urlCache[webServiceId] = url; } if (string.IsNullOrWhiteSpace(url)) { var message = $"URL is not defined for WebServiceId {webServiceId} — check SysJobSettings:Gb4ServiceBaseUrl " + "configuration, or the WebService's SecondURITemplate/URITemplate."; // Only log/append once per WebServiceId per run — a per-row (REQUESTSCHEMATYPE=1) // import of thousands of rows would otherwise write the identical message to // TICEIMPORTRUNLOG once per row. if (!urlCached) { _logger.LogWarning("IceImport: {Message}", message); // Best-effort — a logging failure (e.g. bad runId) must never mask the real // URL-resolution failure being reported below. try { await _runBll.AppendLogAsync(runId, "Error", message, login, ct).ConfigureAwait(false); } catch (Exception logEx) { _logger.LogWarning(logEx, "IceImport: failed to append TICEIMPORTRUNLOG for RunId {RunId}", runId); } } json = JsonSerializer.Serialize(payload); return WebServiceCallResultDTO.Fail(null, json, message); } var isGb4 = url.Contains("/gb4/", StringComparison.OrdinalIgnoreCase); var http = _httpFactory.CreateClient("webhook"); // Legacy GB4 endpoints expect "/Date(epochMillis)/" for every DateTime field (nested // arrays included, e.g. BOMDetailArray's own date fields) — native GB5 endpoints keep // the default ISO-8601 serialization. json = isGb4 ? JsonSerializer.Serialize(payload, Gb4JsonOptions) : JsonSerializer.Serialize(payload); using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; // Downstream endpoints read LoginDTO from the "Login" header, same as any other // FastEndpoints request (see BaseEndpoint.GetLoginDTOFromRequest) — not a body field. login.IsValidationRequired = 1; request.Headers.Add("Login", JsonSerializer.Serialize(login)); using var response = await http.SendAsync(request, ct).ConfigureAwait(false); // Legacy GB4 endpoints (URL contains "/gb4/") wrap their payload in a Base64+Gzip // compressed "Body" envelope; native GB5 FastEndpoints already return the real data // directly in "Body" (see GB5Shared.ResponseStandard.Response.CreateSuccessResponse) — // no decompression needed there. var body = isGb4 ? await ReadGb4ResponseBodyAsync(response, ct).ConfigureAwait(false) : await ReadGb5ResponseBodyAsync(response, ct).ConfigureAwait(false); // Both GB4 and GB5-native envelopes carry their OWN "Status" field // (GB5Shared.DTO.Framework.ResponseStandard.ResponseStandardDTO.Status / // GB4ResponseStandardDTO.Status — same FrameworkEnumDTO.ResponseStatus enum, // Ok = 200). Legacy SOAP/WCF-style targets routinely return HTTP 200 at the transport // level even when the call logically failed (e.g. ValidationFailed = 412 embedded in the // body) — so success is decided by the embedded Status, never by // response.IsSuccessStatusCode alone. var hasEmbeddedStatus = TryGetEmbeddedStatus(body, out var embeddedStatus); var isSuccess = hasEmbeddedStatus && embeddedStatus == (int)FrameworkEnumDTO.ResponseStatus.Ok; if (!isSuccess) { _logger.LogWarning( "IceImport: WebServiceId {WebServiceId} returned Status {Status} (HTTP {StatusCode}): {Body}", webServiceId, hasEmbeddedStatus ? embeddedStatus.ToString() : "unknown", (int)response.StatusCode, body); // ErrorMessage carries the decoded response body — the caller's best available // explanation of why the target endpoint rejected the call. var statusLabel = hasEmbeddedStatus ? embeddedStatus.ToString() : $"HTTP {(int)response.StatusCode}"; return WebServiceCallResultDTO.Fail(url, json, $"Status {statusLabel}: {body}", body); } return WebServiceCallResultDTO.Ok(url, json, body); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "IceImport: dispatch failed for WebServiceId {WebServiceId}", webServiceId); return WebServiceCallResultDTO.Fail(url, json, ex.Message); } } // Reads the top-level "Status" property common to both ResponseStandardDTO (GB5-native) and // GB4ResponseStandardDTO (legacy) envelopes. Returns false (no embedded status found) for a // non-JSON body, a JSON body without a "Status" property, or an empty body — callers treat that // the same as failure, since a call with no verifiable Status can't be trusted as successful. private static bool TryGetEmbeddedStatus(string body, out int status) { status = 0; if (string.IsNullOrWhiteSpace(body)) return false; try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("Status", out var statusEl) && statusEl.ValueKind == JsonValueKind.Number && statusEl.TryGetInt32(out var value)) { status = value; return true; } return false; } catch (JsonException) { return false; } } // GB4 path (URL contains "/gb4/"): outer envelope is plain JSON shaped like // GB5Shared.DTO.Framework.ResponseStandard.GB4ResponseStandardDTO — {"Status": , "Body": // "", ...}. Only the "Body" field's *value* is Base64+Gzip compressed (mirrors // legacy ServiceHandler.CallServiceWithLoginDTO / CommonFunctionDAL.GZipDecompress). // ValidationFailed (412) responses carry their message in "Body" uncompressed, same as // legacy's DeCompressResponse — skip decompression in that case. private async Task ReadGb4ResponseBodyAsync(HttpResponseMessage response, CancellationToken ct) { var rawText = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(rawText)) return rawText; try { using var doc = JsonDocument.Parse(rawText); var root = doc.RootElement; var isValidationFailed = root.TryGetProperty("Status", out var statusEl) && statusEl.ValueKind == JsonValueKind.Number && statusEl.GetInt32() == (int)FrameworkEnumDTO.ResponseStatus.ValidationFailed; if (!isValidationFailed && root.TryGetProperty("Body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.String) { var compressed = bodyEl.GetString(); if (!string.IsNullOrWhiteSpace(compressed)) return await _gb5CommonFunction.GZipDecompress(compressed).ConfigureAwait(false); } return rawText; } catch (JsonException) { // Not our envelope shape (or not JSON at all) — hand back the raw text as-is. return rawText; } } // GB5-native path (URL does not contain "/gb4/"): FastEndpoints already returns the real data // directly in "Body" — see GB5Shared.ResponseStandard.Response.CreateSuccessResponse, which sets // Body = data with no compression. No decoding needed beyond reading the raw text. private async Task ReadGb5ResponseBodyAsync(HttpResponseMessage response, CancellationToken ct) { return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } }