using FrameworkDAL.CustomCode.EIPConversation.EIPRestActionCfg; using FrameworkDAL.DTO.EIPConversation; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.ActionHandler { public class EIPRestActionHandler : IEIPActionHandler { // Empty ActionCode = wildcard; EIPActionExecutor falls back to this handler // when no exact-match handler is registered for the requested ActionCode. public string ActionCode => string.Empty; // Always returns true: the executor will only call CanHandle as a final fallback. public bool CanHandle(string actionCode) => true; private readonly IEIPRestActionCfgDAL _cfgDAL; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private static readonly JsonSerializerOptions _jsonOpts = new JsonSerializerOptions { PropertyNamingPolicy = null }; public EIPRestActionHandler( IEIPRestActionCfgDAL cfgDAL, IHttpClientFactory httpClientFactory, ILogger logger) { _cfgDAL = cfgDAL ?? throw new ArgumentNullException(nameof(cfgDAL)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task HandleAsync( EIPActionContextDTO context, LoginDTO loginDTO, CancellationToken ct = default) { try { // 1. Load REST config from MEIPRESTACTIONCFG via MEIPACTIONREGISTRY var cfg = await _cfgDAL.GetByActionCodeAsync(context.ActionCode, loginDTO, ct) .ConfigureAwait(false); if (cfg == null) return Fail($"No REST action config found in MEIPRESTACTIONCFG for ActionCode='{context.ActionCode}'. " + "Please insert a row in MEIPACTIONREGISTRY and MEIPRESTACTIONCFG."); // 2. Substitute {Key} tokens in PayloadTemplate from collected context variables var body = SubstituteTokens(cfg.PayloadTemplate ?? string.Empty, context.Payload); // 3. Build HTTP method var method = cfg.HttpMethod switch { 1 => HttpMethod.Post, 2 => HttpMethod.Get, 3 => HttpMethod.Put, 4 => new HttpMethod("PATCH"), 5 => HttpMethod.Delete, _ => HttpMethod.Post }; // 4. Resolve full URL from MEIPRESTACTIONCFG.BASEURL. // When BASEURL is null/empty, fall back to loginDTO.BaseUri so that actions // targeting the same gateway (fws/* endpoints) work without hard-coding the host. var effectiveBase = !string.IsNullOrWhiteSpace(cfg.BaseUrl) ? cfg.BaseUrl : loginDTO?.BaseUri; if (string.IsNullOrWhiteSpace(effectiveBase)) return Fail($"MEIPRESTACTIONCFG.BASEURL is empty for ActionCode='{context.ActionCode}' " + "and loginDTO.BaseUri is also unavailable. " + "Set BASEURL to the target service base URL (e.g. http://localhost:5110)."); var baseUrl = effectiveBase.TrimEnd('/'); // Substitute {Key} tokens in the path so GET query params like // ?PeriodFrom={PeriodFrom}&PeriodTo={PeriodTo} are resolved from context. var resolvedPath = SubstituteTokens(cfg.EndpointPath ?? string.Empty, context.Payload); var endpoint = $"{baseUrl}{resolvedPath}"; _logger.LogInformation( "EIPRestActionHandler | {Method} {Endpoint} | ActionCode={ActionCode} | User={UserId}", method, endpoint, context.ActionCode, loginDTO.UserId); // 5. Serialize full LoginDTO as "Login" header — standard GB5 inter-service convention. // All GB5 microservices (PayRoll, CRM, ESS, …) validate this header. var loginHeader = JsonSerializer.Serialize(loginDTO, _jsonOpts); // 6. Send HTTP request var client = _httpClientFactory.CreateClient("eip-internal"); using var request = new HttpRequestMessage(method, endpoint); request.Headers.TryAddWithoutValidation("Login", loginHeader); // Apply any extra headers from MEIPRESTACTIONCFG.HEADERSJSON ApplyExtraHeaders(request, cfg.HeadersJson); if (method != HttpMethod.Get && !string.IsNullOrWhiteSpace(body)) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(cfg.TimeoutSeconds > 0 ? cfg.TimeoutSeconds : 30)); var httpResponse = await client.SendAsync(request, cts.Token).ConfigureAwait(false); var responseJson = await httpResponse.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); // 7. Handle non-success HTTP status — extract the server's error message when possible. if (!httpResponse.IsSuccessStatusCode) { var errMsg = ExtractErrorMessage(responseJson); _logger.LogError( "EIPRestActionHandler | HTTP {Status} | ActionCode={ActionCode} | Error={Error}", (int)httpResponse.StatusCode, context.ActionCode, errMsg); return Fail(errMsg); } var message = ExtractBodyMessage(responseJson); _logger.LogInformation( "EIPRestActionHandler | Success | ActionCode={ActionCode} | Message={Message}", context.ActionCode, message); return new EIPActionResultDTO { IsSuccess = true, Message = message }; } catch (OperationCanceledException) { _logger.LogWarning("EIPRestActionHandler | Timeout | ActionCode={ActionCode}", context.ActionCode); return Fail("The request timed out. Please try again."); } catch (HttpRequestException ex) { _logger.LogError(ex, "EIPRestActionHandler | HTTP error | ActionCode={ActionCode}", context.ActionCode); return Fail("Unable to complete the request. Please try again later."); } catch (Exception ex) { _logger.LogError(ex, "EIPRestActionHandler | Error | ActionCode={ActionCode}", context.ActionCode); return Fail("An unexpected error occurred. Please try again."); } } // Replaces {Key} tokens in the template with values from the payload dictionary. // Pattern: {Identifier} where Identifier starts with a letter and contains only // letters, digits, or underscores. This prevents the regex from matching the // opening { of JSON objects/arrays in the template, which would silently consume // the first real token it encountered (e.g. {LeaveStartDateISO} in TLeaveLeaveDate // would be eaten by the outer JSON object's { brace). private static string SubstituteTokens(string template, Dictionary payload) { if (string.IsNullOrEmpty(template) || payload.Count == 0) return template; return Regex.Replace(template, @"\{([A-Za-z][A-Za-z0-9_]*)\}", m => { var key = m.Groups[1].Value; if (!payload.TryGetValue(key, out var val)) return m.Value; return Stringify(val); }); } // Converts a payload value to a plain string. // Handles JsonElement values that arrive when variables are restored from session JSON. private static string Stringify(object? val) { if (val is null) return string.Empty; if (val is System.Text.Json.JsonElement je) { return je.ValueKind switch { System.Text.Json.JsonValueKind.String => je.GetString() ?? string.Empty, System.Text.Json.JsonValueKind.Number => je.GetRawText(), System.Text.Json.JsonValueKind.True => "true", System.Text.Json.JsonValueKind.False => "false", System.Text.Json.JsonValueKind.Null => string.Empty, _ => je.GetRawText() }; } return val.ToString() ?? string.Empty; } // Applies additional HTTP headers defined in MEIPRESTACTIONCFG.HEADERSJSON. // HEADERSJSON is a flat JSON object: {"X-Custom-Header": "value", ...} private static void ApplyExtraHeaders(HttpRequestMessage request, string? headersJson) { if (string.IsNullOrWhiteSpace(headersJson)) return; try { var headers = JsonSerializer.Deserialize>(headersJson); if (headers == null) return; foreach (var kv in headers) request.Headers.TryAddWithoutValidation(kv.Key, kv.Value); } catch { /* malformed headers JSON — silently skip */ } } // Extracts the user-facing message from a successful GB5 API response. // Handles string Body (simple confirmation), array Body (leave balance list), // and object Body. Falls back to the raw response text. private static string ExtractBodyMessage(string responseJson) { if (string.IsNullOrWhiteSpace(responseJson)) return "Done."; try { using var doc = JsonDocument.Parse(responseJson); if (doc.RootElement.TryGetProperty("Body", out var bodyEl)) { return bodyEl.ValueKind switch { JsonValueKind.String => bodyEl.GetString() is { Length: > 0 } s ? s : "Done.", JsonValueKind.Array => FormatBodyArray(bodyEl), JsonValueKind.Null => "Done.", JsonValueKind.Undefined => "Done.", _ => bodyEl.GetRawText() }; } } catch { /* non-JSON — fall through */ } return responseJson.Trim(); } // Formats a JSON array from Body into a readable multi-line leave balance summary. // Recognises LeaveStatusDTO fields (LeaveName + LeaveBalance). // Falls back to item count when the structure is unrecognised. private static string FormatBodyArray(JsonElement arr) { var sb = new StringBuilder(); int count = 0; foreach (var item in arr.EnumerateArray()) { count++; if (item.TryGetProperty("LeaveName", out var nameEl) && item.TryGetProperty("LeaveBalance", out var balEl)) { var name = nameEl.GetString() ?? "Leave"; var bal = balEl.GetRawText(); sb.AppendLine($" {name}: {bal} day(s)"); } else { sb.AppendLine(item.GetRawText()); } } return count == 0 ? "No leave balance records found." : sb.ToString().TrimEnd(); } // Extracts the server's error message from a non-2xx GB5 API response. // Reads GeneralErrors[0].ErrorMessage; falls back to HTTP status text. private static string ExtractErrorMessage(string responseJson) { if (!string.IsNullOrWhiteSpace(responseJson)) { try { using var doc = JsonDocument.Parse(responseJson); if (doc.RootElement.TryGetProperty("GeneralErrors", out var errArr) && errArr.ValueKind == JsonValueKind.Array) { var msgs = new StringBuilder(); foreach (var err in errArr.EnumerateArray()) { if (err.TryGetProperty("ErrorMessage", out var msgEl)) { var line = msgEl.GetString(); if (!string.IsNullOrWhiteSpace(line)) msgs.AppendLine(line.Trim()); } } if (msgs.Length > 0) return msgs.ToString().TrimEnd(); } } catch { /* non-JSON body */ } } return "The service returned an error. Please try again later."; } private static EIPActionResultDTO Fail(string message) => new EIPActionResultDTO { IsSuccess = false, Message = message }; } }