using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Csob.Configuration; using Csob.Credentials; namespace Csob.Client; /// /// Thin HTTP wrapper around the ČSOB PSD2 resource API for a single request. It attaches the /// mandatory COBS headers (Authorization, APIKEY, TPP-Name, X-Request-ID, /// Date, User-Involved), sends the call over the per-request mutual-TLS client and /// returns the response JSON verbatim () so no field is lost in translation. /// Non-success responses become a . /// public sealed class CsobApiClient { /// Web defaults (camelCase) match the COBS JSON contract; null properties are omitted on write. public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }; private readonly HttpClient _http; private readonly CsobCredentials _credentials; private readonly string _baseUrl; public CsobApiClient(HttpClient http, CsobCredentials credentials, CsobSettings settings) { _http = http; _credentials = credentials; _baseUrl = settings.ApiBaseUrl.TrimEnd('/'); } public Task GetAsync(string path, IReadOnlyDictionary? query, CancellationToken ct) => SendAsync(HttpMethod.Get, path, query, body: null, ct); public Task PostAsync(string path, object? body, CancellationToken ct) => SendAsync(HttpMethod.Post, path, query: null, body, ct); public Task PutAsync(string path, object? body, CancellationToken ct) => SendAsync(HttpMethod.Put, path, query: null, body, ct); public Task DeleteAsync(string path, CancellationToken ct) => SendAsync(HttpMethod.Delete, path, query: null, body: null, ct); private async Task SendAsync( HttpMethod method, string path, IReadOnlyDictionary? query, object? body, CancellationToken ct) { using var request = new HttpRequestMessage(method, _baseUrl + path + BuildQuery(query)); ApplyHeaders(request); if (body is not null) { var json = body is JsonNode node ? node.ToJsonString(JsonOptions) : JsonSerializer.Serialize(body, JsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } using var response = await _http.SendAsync(request, ct); var payload = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { throw new CsobApiException(response.StatusCode, ParseErrorCodes(payload), payload); } return string.IsNullOrWhiteSpace(payload) ? null : JsonNode.Parse(payload); } private void ApplyHeaders(HttpRequestMessage request) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _credentials.AccessToken); request.Headers.Date = DateTimeOffset.UtcNow; request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.TryAddWithoutValidation("APIKEY", _credentials.ApiKey); request.Headers.TryAddWithoutValidation("TPP-Name", _credentials.TppName); request.Headers.TryAddWithoutValidation("X-Request-ID", Guid.NewGuid().ToString()); request.Headers.TryAddWithoutValidation("User-Involved", _credentials.UserInvolved ? "true" : "false"); if (!string.IsNullOrWhiteSpace(_credentials.UserIpAddress)) { request.Headers.TryAddWithoutValidation("User-IP-Address", _credentials.UserIpAddress); } } private static string BuildQuery(IReadOnlyDictionary? query) { if (query is null) { return string.Empty; } var parts = query .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}") .ToArray(); return parts.Length == 0 ? string.Empty : "?" + string.Join("&", parts); } /// Best-effort parse of the COBS error shape { "errors": [ { "error": "CODE" } ] }. private static IReadOnlyList ParseErrorCodes(string payload) { if (string.IsNullOrWhiteSpace(payload)) { return Array.Empty(); } try { var node = JsonNode.Parse(payload); if (node?["errors"] is JsonArray errors) { return errors .Select(e => e?["error"]?.GetValue()) .Where(code => !string.IsNullOrWhiteSpace(code)) .Select(code => code!) .ToArray(); } } catch (JsonException) { // Upstream returned a non-JSON error body; the raw payload is still carried on the exception. } return Array.Empty(); } }