using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PayRollBLL.SAP.Config; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; namespace PayRollBLL.SAP.Client { /// /// HTTP client for SAP IT Declaration OData POSTs. /// /// Flow per call: /// 1. Verify SAP host is TCP-reachable (port check, 5 s timeout). /// 2. Fetch a fresh CSRF token via GET <serviceRoot>/ (one level above the entity-set) /// with "X-CSRF-Token: Fetch" and Basic Auth. /// 3. POST the payload with the returned CSRF token + session cookies + Basic Auth. /// 4. Parse the SAP OData response (JSON or XML) and extract the SAP message text. /// /// Only SAP's own message text is returned — this class never invents its own /// business error messages. Network / configuration errors are described precisely. /// public class SapApiClient : ISapApiClient { private readonly IHttpClientFactory _factory; private readonly SapItDeclarationOptions _cfg; private readonly ILogger _logger; private static readonly JsonSerializerOptions _jsonRead = new() { PropertyNameCaseInsensitive = true }; public SapApiClient( IHttpClientFactory factory, IOptions options, ILogger logger) { _factory = factory; _cfg = options.Value; _logger = logger; } public async Task PostAsync( string endpointPath, string msgNavProperty, string jsonPayload, CancellationToken ct) { var client = _factory.CreateClient("sapItDeclaration"); // ── Guard: base URL must be set in appsettings ─────────────────────────────── if (client.BaseAddress is null) { const string cfgErr = "SAP IT Declaration base URL is not configured. " + "Set SapItDeclaration:BaseUrl in appsettings.json and restart the service."; _logger.LogError("SAP POST {Path} aborted — {Reason}", endpointPath, cfgErr); return new SapApiResultDTO(false, 0, string.Empty, cfgErr); } // ── Step 1: Pre-flight TCP reachability check ──────────────────────────────── string? reachErr = await CheckHostReachableAsync(client.BaseAddress, ct) .ConfigureAwait(false); if (reachErr is not null) { _logger.LogError( "SAP host unreachable before POST to {Path}: {Reason}", endpointPath, reachErr); return new SapApiResultDTO(false, 0, string.Empty, reachErr); } // ── Step 2: Fetch CSRF token ───────────────────────────────────────────────── var (csrfToken, cookieHeader, csrfErr) = await FetchCsrfTokenAsync(client, endpointPath, ct).ConfigureAwait(false); if (csrfErr is not null) { _logger.LogError( "SAP CSRF fetch failed for {Path}: {Reason}", endpointPath, csrfErr); return new SapApiResultDTO(false, 0, string.Empty, csrfErr); } // ── Step 3: POST payload ───────────────────────────────────────────────────── _logger.LogInformation( "SAP POST {Path} — payload {Size} bytes", endpointPath, jsonPayload.Length); try { using var request = BuildPostRequest( endpointPath, csrfToken!, cookieHeader, jsonPayload); using var response = await client.SendAsync(request, ct).ConfigureAwait(false); string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); int statusCode = (int)response.StatusCode; // ── Step 4: Parse SAP response for business messages ───────────────────── var (isSuccess, message) = ParseSapResponse(body, msgNavProperty, statusCode); if (isSuccess) _logger.LogInformation( "SAP POST {Path} → HTTP {Status} — {Message}", endpointPath, statusCode, message); else _logger.LogError( "SAP POST {Path} → HTTP {Status} — SAP error: {Message}", endpointPath, statusCode, message); return new SapApiResultDTO(isSuccess, statusCode, body, message); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogWarning("SAP POST {Path} was cancelled by the caller.", endpointPath); throw; } catch (OperationCanceledException) { // HttpClient internal timeout fired — not the caller's token string msg = $"SAP server at '{client.BaseAddress.Host}' did not respond within the configured " + "timeout. The server may be overloaded or unreachable. Retry later or contact " + "the SAP BASIS team."; _logger.LogError("SAP POST {Path} timed out.", endpointPath); return new SapApiResultDTO(false, 0, string.Empty, msg); } catch (HttpRequestException ex) { string msg = ClassifyNetworkError(ex, client.BaseAddress); _logger.LogError(ex, "SAP POST {Path} — network error: {Reason}", endpointPath, msg); return new SapApiResultDTO(false, 0, ex.Message, msg); } } // ───────────────────────────────────────────────────────────────────────────────── // CSRF Token Fetch // ───────────────────────────────────────────────────────────────────────────────── private async Task<(string? csrfToken, string cookieHeader, string? error)> FetchCsrfTokenAsync(HttpClient client, string endpointPath, CancellationToken ct) { // SAP OData issues CSRF tokens at the service document root, not the entity-set level. // Strip the entity-set segment to get the service root: // /sap/opu/odata/sap/ZPY_ITDEC_HOUSING_LOAN_SRV/ITDecHL_HeaderSet // → /sap/opu/odata/sap/ZPY_ITDEC_HOUSING_LOAN_SRV/ string trimmed = endpointPath.TrimEnd('/'); int lastSlash = trimmed.LastIndexOf('/'); string fetchPath = lastSlash > 0 ? trimmed[..lastSlash] + "/" : trimmed + "/"; if (string.IsNullOrWhiteSpace(_cfg.Username) || string.IsNullOrWhiteSpace(_cfg.Password)) return (null, string.Empty, "SAP credentials are not configured. " + "Ensure SapItDeclaration:Username and SapItDeclaration:Password are set " + "in appsettings.json (or environment variables) and the service has been restarted."); using var req = new HttpRequestMessage(HttpMethod.Get, fetchPath); AddBasicAuth(req, _cfg.Username, _cfg.Password); req.Headers.Add("X-CSRF-Token", "Fetch"); try { using var res = await client.SendAsync(req, ct).ConfigureAwait(false); int fetchStatus = (int)res.StatusCode; if (fetchStatus == 401 || fetchStatus == 403) return (null, string.Empty, $"SAP authentication failed (HTTP {fetchStatus}) while fetching the CSRF token from '{fetchPath}'. " + "Verify that SapItDeclaration:Username and SapItDeclaration:Password in appsettings.json " + "match the SAP system credentials."); if (!res.Headers.TryGetValues("x-csrf-token", out var tokens)) return (null, string.Empty, $"SAP returned HTTP {fetchStatus} from '{fetchPath}' but did not include an x-csrf-token header. " + "The SAP service may be unavailable, the URL may be wrong, or the credentials may be incorrect. " + $"Response content-type: {res.Content.Headers.ContentType?.MediaType ?? "unknown"}."); string csrfToken = tokens.First(); string cookieHeader = BuildCookieHeader(res); _logger.LogDebug("CSRF token fetched from {Path} (HTTP {Status}).", fetchPath, fetchStatus); return (csrfToken, cookieHeader, null); } catch (Exception ex) when (ex is not OperationCanceledException) { return (null, string.Empty, $"Failed to fetch SAP CSRF token from '{fetchPath}': {ex.GetType().Name} — {ex.Message}"); } } // ───────────────────────────────────────────────────────────────────────────────── // Build POST Request // ───────────────────────────────────────────────────────────────────────────────── private HttpRequestMessage BuildPostRequest( string endpointPath, string csrfToken, string cookieHeader, string jsonPayload) { var req = new HttpRequestMessage(HttpMethod.Post, endpointPath); AddBasicAuth(req, _cfg.Username, _cfg.Password); req.Headers.Add("X-CSRF-Token", csrfToken); if (!string.IsNullOrWhiteSpace(cookieHeader)) req.Headers.Add("Cookie", cookieHeader); req.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); return req; } // ───────────────────────────────────────────────────────────────────────────────── // SAP Response Parser // Extracts SAP's own message text — never manufactures its own business error text. // ───────────────────────────────────────────────────────────────────────────────── private (bool isSuccess, string message) ParseSapResponse( string body, string msgNavProperty, int httpStatus) { // HTTP-level failures: try to surface SAP's error message from the body if (httpStatus >= 400) { string? sapErr = TryExtractSapErrorMessage(body); return (false, sapErr ?? $"SAP returned HTTP {httpStatus}. " + "Check the request payload and SAP configuration."); } if (string.IsNullOrWhiteSpace(body)) return (true, "IT Declaration data posted to SAP successfully."); // ── Try JSON parsing ───────────────────────────────────────────────────────── try { using var doc = JsonDocument.Parse(body); // SAP OData error envelope: { "error": { "message": { "value": "..." } } } if (doc.RootElement.TryGetProperty("error", out var errEl)) { string? msg = null; if (errEl.TryGetProperty("message", out var msgEl) && msgEl.TryGetProperty("value", out var valEl)) msg = valEl.GetString()?.Trim(); return (false, msg ?? "SAP returned an OData error (no message text in the response body)."); } // OData v2 success: { "d": { "": { "results": [...] } } } if (!string.IsNullOrWhiteSpace(msgNavProperty) && doc.RootElement.TryGetProperty("d", out var d) && d.TryGetProperty(msgNavProperty, out var msgNav)) { var errors = ExtractMessagesByType(msgNav, "E"); if (errors.Count > 0) return (false, string.Join(" | ", errors)); } return (true, "IT Declaration data posted to SAP successfully."); } catch (JsonException) { // SAP responded with XML (common when Accept: application/json is not honoured) return ParseXmlSapResponse(body, httpStatus); } } private static List ExtractMessagesByType(JsonElement msgNavEl, string typeFilter) { var list = new List(); // OData v2 wraps arrays in { "results": [...] }; OData v4 uses a bare array var arr = msgNavEl.ValueKind == JsonValueKind.Array ? msgNavEl : (msgNavEl.TryGetProperty("results", out var r) ? r : default); if (arr.ValueKind != JsonValueKind.Array) return list; foreach (var entry in arr.EnumerateArray()) { bool typeMatches = string.IsNullOrEmpty(typeFilter) || (entry.TryGetProperty("Type", out var t) && string.Equals(t.GetString(), typeFilter, StringComparison.OrdinalIgnoreCase)); if (typeMatches && entry.TryGetProperty("Message", out var m) && !string.IsNullOrWhiteSpace(m.GetString())) { list.Add(m.GetString()!.Trim()); } } return list; } private (bool isSuccess, string message) ParseXmlSapResponse(string xml, int httpStatus) { try { var doc = XDocument.Parse(xml); var dsNs = XNamespace.Get("http://schemas.microsoft.com/ado/2007/08/dataservices"); // Look for E siblings of var errors = doc.Descendants(dsNs + "Type") .Where(t => t.Value == "E") .Select(t => t.Parent?.Element(dsNs + "Message")?.Value?.Trim()) .Where(m => !string.IsNullOrWhiteSpace(m)) .Select(m => m!) .ToList(); if (errors.Count > 0) return (false, string.Join(" | ", errors)); return (true, "IT Declaration data posted to SAP successfully."); } catch { // Cannot parse body; trust HTTP status code return (httpStatus < 400, httpStatus < 400 ? "IT Declaration data posted to SAP successfully." : "SAP returned an error. Raw response logged for diagnostics."); } } private static string? TryExtractSapErrorMessage(string body) { if (string.IsNullOrWhiteSpace(body)) return null; try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("error", out var err) && err.TryGetProperty("message", out var msg) && msg.TryGetProperty("value", out var val)) return val.GetString()?.Trim(); } catch { } try { var doc = XDocument.Parse(body); var dsNs = XNamespace.Get("http://schemas.microsoft.com/ado/2007/08/dataservices"); var m = doc.Descendants(dsNs + "message").FirstOrDefault()?.Value; if (!string.IsNullOrWhiteSpace(m)) return m.Trim(); } catch { } return null; } // ───────────────────────────────────────────────────────────────────────────────── // Pre-flight Host Reachability Check (TCP) // ───────────────────────────────────────────────────────────────────────────────── private static async Task CheckHostReachableAsync(Uri baseUri, CancellationToken ct) { string host = baseUri.Host; int port = baseUri.Port > 0 ? baseUri.Port : string.Equals(baseUri.Scheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80; using var tcp = new TcpClient(); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); return null; // host is reachable } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return $"SAP host '{host}:{port}' did not respond within 5 seconds. " + "The server may be down or the network path is blocked. " + "Contact the SAP BASIS team."; } catch (SocketException ex) { return ex.SocketErrorCode switch { SocketError.HostNotFound or SocketError.HostUnreachable => $"DNS lookup failed — the SAP hostname '{host}' could not be resolved. " + "Verify that SapItDeclaration:BaseUrl is correct and that DNS is reachable " + "from this application server.", SocketError.ConnectionRefused => $"TCP connection to '{host}:{port}' was refused. " + $"Verify the SAP application server is running and port {port} is open " + "in the firewall between this server and the SAP host.", _ => $"Network error connecting to SAP host '{host}:{port}': {ex.Message}" }; } catch (Exception ex) { return $"Unexpected error checking SAP host '{host}:{port}': {ex.GetType().Name} — {ex.Message}"; } } // ───────────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────────── private static void AddBasicAuth(HttpRequestMessage req, string username, string password) { string encoded = Convert.ToBase64String( Encoding.ASCII.GetBytes($"{username}:{password}")); req.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded); } /// /// Extracts name=value pairs from all Set-Cookie response headers. /// Path, Secure, HttpOnly, SameSite and other attributes are stripped — /// only the cookie name=value is sent back in the POST Cookie header. /// private static string BuildCookieHeader(HttpResponseMessage response) { var cookies = response.Headers .Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase)) .SelectMany(h => h.Value) .Select(c => c.Split(';')[0].Trim()) .Where(c => !string.IsNullOrWhiteSpace(c)); return string.Join("; ", cookies); } private static string ClassifyNetworkError(HttpRequestException ex, Uri baseAddress) { string host = baseAddress.Host; int port = baseAddress.Port; return ex.HttpRequestError switch { HttpRequestError.NameResolutionError => $"DNS lookup failed — the SAP hostname '{host}' could not be resolved. " + "Verify that SapItDeclaration:BaseUrl is correct and DNS is reachable.", HttpRequestError.ConnectionError => $"TCP connection to '{host}:{port}' was refused or could not be established. " + "Verify the SAP server is running and the port is open in the firewall.", HttpRequestError.SecureConnectionError => $"TLS/SSL handshake failed connecting to '{host}:{port}'. " + "The SAP server certificate may be self-signed or not trusted by this server. " + "Install the SAP server certificate in the trusted root certificate store.", HttpRequestError.HttpProtocolError => $"The SAP host '{host}' responded with an invalid HTTP message. " + "A proxy or load-balancer between this server and SAP may be interfering.", HttpRequestError.ResponseEnded => $"The SAP host '{host}' closed the connection before the response was complete. " + "The SAP server may have crashed or reset the connection. " + "Contact the SAP BASIS team.", _ => $"Network error communicating with SAP host '{host}:{port}': " + (ex.InnerException?.Message ?? ex.Message) }; } } }