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