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)
};
}
}
}