using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace PayRollBLL.SAPPaySlip.Client
{
///
/// HTTP client for the SAP ZPY_PAYSLIP_SRV OData service (read-only GET).
///
/// Uses named HttpClient "sapPaySlip" registered in Program.cs — only the base address
/// and Accept: application/json header are set centrally.
///
/// Credentials (username, password, sap-client) are applied per-request via
/// headers so they are never cached or shared across tenants.
///
/// All HTTP and network errors are captured in ;
/// this class never throws except on caller-initiated cancellation.
///
public class SapPaySlipApiClient : ISapPaySlipApiClient
{
private const string ODataEntitySet = "/sap/opu/odata/sap/ZPY_PAYSLIP_SRV/PayslipSet";
private static readonly JsonSerializerOptions _jsonOptions =
new() { PropertyNameCaseInsensitive = true };
private readonly IHttpClientFactory _factory;
private readonly ILogger _logger;
public SapPaySlipApiClient(IHttpClientFactory factory, ILogger logger)
{
_factory = factory;
_logger = logger;
}
public async Task GetAsync(
string employeeId,
string fiscalYear,
string month,
string username,
string password,
string sapClient,
CancellationToken ct)
{
var client = _factory.CreateClient("sapPaySlip");
// ── Guard: base URL must be configured ───────────────────────────────────────
if (client.BaseAddress is null)
{
_logger.LogError(
"SAP PaySlip GET aborted — SapPaySlip:BaseUrl is not configured in appsettings.json.");
return new SapPaySlipApiResultDTO
{
IsSuccess = false,
StatusCode = 0,
ResponseBody = string.Empty,
NetworkFailureReason =
"SAP PaySlip base URL is not configured on this server. " +
"Set SapPaySlip:BaseUrl in appsettings.json " +
"(e.g. \"https://TS4QDSVR.example.com:5284\") and restart the service.",
AttemptedUri = ODataEntitySet
};
}
// ── Build OData $filter and full URI ─────────────────────────────────────────
// OData v2 string literals use single quotes; spaces → %20.
string filter = $"EmployeeID eq '{employeeId}' and FiscalYear eq '{fiscalYear}' and Month eq '{month}'";
string relativeUri = $"{ODataEntitySet}?$filter={Uri.EscapeDataString(filter)}";
string attemptedUri = $"{client.BaseAddress.ToString().TrimEnd('/')}{relativeUri}";
_logger.LogDebug("SAP PaySlip GET → {AttemptedUri}", attemptedUri);
// ── Build per-request message ─────────────────────────────────────────────────
using var request = new HttpRequestMessage(HttpMethod.Get, relativeUri);
// Basic auth per RFC 7617 — credentials applied per-request only.
string credentials = Convert.ToBase64String(
Encoding.ASCII.GetBytes($"{username}:{password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
// sap-client = SAP company code / mandt (e.g. "633").
request.Headers.Add("sap-client", sapClient);
// ── Execute ───────────────────────────────────────────────────────────────────
try
{
using var response = await client.SendAsync(request, ct).ConfigureAwait(false);
string body = await response.Content
.ReadAsStringAsync(ct)
.ConfigureAwait(false);
// ── Success path ──────────────────────────────────────────────────────────
if (response.IsSuccessStatusCode)
{
return new SapPaySlipApiResultDTO
{
IsSuccess = true,
StatusCode = (int)response.StatusCode,
ResponseBody = body,
AttemptedUri = attemptedUri
};
}
// ── Non-2xx: parse SAP OData JSON error body ──────────────────────────────
var (sapCode, sapMessage, sapTxnId) = ParseSapErrorBody(body, attemptedUri);
_logger.LogError(
"SAP PaySlip GET {AttemptedUri} → HTTP {StatusCode} | " +
"SAP Error Code: {SapErrorCode} | SAP Message: {SapErrorMessage} | " +
"Transaction ID: {SapTransactionId}",
attemptedUri, (int)response.StatusCode, sapCode, sapMessage, sapTxnId);
return new SapPaySlipApiResultDTO
{
IsSuccess = false,
StatusCode = (int)response.StatusCode,
ResponseBody = body,
SapErrorCode = sapCode,
SapErrorMessage = sapMessage,
SapTransactionId = sapTxnId,
AttemptedUri = attemptedUri
};
}
catch (OperationCanceledException ex) when (ex.CancellationToken != ct)
{
// HttpClient's internal timeout fired — not the caller's cancellation token.
_logger.LogError(ex,
"SAP PaySlip GET {AttemptedUri} timed out (no response within the configured timeout).",
attemptedUri);
return new SapPaySlipApiResultDTO
{
IsSuccess = false,
StatusCode = 0,
ResponseBody = string.Empty,
NetworkFailureReason =
"The SAP server did not respond within the allowed time. " +
"The SAP host may be overloaded or unreachable. " +
"Retry later or contact the SAP BASIS team.",
AttemptedUri = attemptedUri
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("SAP PaySlip GET {AttemptedUri} was cancelled by the caller.", attemptedUri);
throw;
}
catch (HttpRequestException ex)
{
string reason = ClassifyNetworkError(ex, client.BaseAddress);
_logger.LogError(ex,
"SAP PaySlip GET {AttemptedUri} — network failure ({HttpRequestError}): {Reason}",
attemptedUri, ex.HttpRequestError, reason);
return new SapPaySlipApiResultDTO
{
IsSuccess = false,
StatusCode = 0,
ResponseBody = ex.Message,
NetworkFailureReason = reason,
AttemptedUri = attemptedUri
};
}
catch (Exception ex)
{
string reason =
$"Unexpected error communicating with SAP: {ex.GetType().Name} — {ex.Message}";
_logger.LogError(ex,
"SAP PaySlip GET {AttemptedUri} — unexpected error.", attemptedUri);
return new SapPaySlipApiResultDTO
{
IsSuccess = false,
StatusCode = 0,
ResponseBody = ex.Message,
NetworkFailureReason = reason,
AttemptedUri = attemptedUri
};
}
}
// ─────────────────────────────────────────────────────────────────────────────────
// SAP OData JSON error body parser
// ─────────────────────────────────────────────────────────────────────────────────
///
/// Attempts to extract error.code, error.message.value, and
/// error.innererror.transactionid from the SAP OData v2 error JSON envelope.
/// Returns (null, null, null) silently when the body is empty or not SAP OData JSON.
///
private (string? code, string? message, string? transactionId) ParseSapErrorBody(
string body, string attemptedUri)
{
if (string.IsNullOrWhiteSpace(body))
return (null, null, null);
try
{
var envelope = JsonSerializer.Deserialize(body, _jsonOptions);
var err = envelope?.Error;
if (err is null)
return (null, null, null);
return (
string.IsNullOrWhiteSpace(err.Code) ? null : err.Code.Trim(),
string.IsNullOrWhiteSpace(err.Message?.Value) ? null : err.Message!.Value.Trim(),
string.IsNullOrWhiteSpace(err.InnerError?.TransactionId) ? null : err.InnerError!.TransactionId!.Trim()
);
}
catch (JsonException ex)
{
// Body is not SAP OData JSON (e.g. HTML gateway error, plain text).
// Log for diagnostics but do not surface the parse error to the caller.
_logger.LogDebug(ex,
"SAP error body at {AttemptedUri} is not a SAP OData JSON error envelope. " +
"Raw preview: {Preview}",
attemptedUri,
body.Length > 200 ? body[..200] : body);
return (null, null, null);
}
}
// ─────────────────────────────────────────────────────────────────────────────────
// Network error classifier
// ─────────────────────────────────────────────────────────────────────────────────
///
/// Maps enum values to precise, actionable descriptions.
/// Uses .NET 8+ property.
///
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 the hostname in SapPaySlip:BaseUrl is correct and that " +
"DNS is reachable from this application server.",
HttpRequestError.ConnectionError =>
$"TCP connection to '{host}:{port}' was refused or could not be established. " +
"Verify that the SAP application server is running and that the port is " +
"open in the network firewall between this server and the SAP host.",
HttpRequestError.SecureConnectionError =>
$"TLS/SSL handshake failed connecting to '{host}:{port}'. " +
"The SAP server certificate may be self-signed, expired, or not trusted by " +
"this application server. Install the SAP server certificate in the trusted " +
"root certificate store, or configure certificate validation accordingly.",
HttpRequestError.HttpProtocolError =>
$"The SAP host '{host}' responded with an invalid or unsupported HTTP protocol message. " +
"This may indicate a proxy or load-balancer between this server and SAP.",
HttpRequestError.ResponseEnded =>
$"The SAP host '{host}' closed the connection before the response was complete. " +
"The SAP server may have crashed or forcibly reset the connection. " +
"Contact the SAP BASIS team.",
HttpRequestError.ConfigurationLimitExceeded =>
"The HTTP client configuration limit was exceeded " +
"(response headers or body exceeded the allowed maximum size).",
_ =>
$"Network error communicating with SAP host '{host}:{port}': " +
(ex.InnerException?.Message ?? ex.Message)
};
}
}
}