Files
csob/Client/CsobApiClient.cs
JiriUhlir 09db30a3ca first
2026-06-18 11:05:46 +02:00

131 lines
5.1 KiB
C#

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;
/// <summary>
/// Thin HTTP wrapper around the ČSOB PSD2 resource API for a single request. It attaches the
/// mandatory COBS headers (<c>Authorization</c>, <c>APIKEY</c>, <c>TPP-Name</c>, <c>X-Request-ID</c>,
/// <c>Date</c>, <c>User-Involved</c>), sends the call over the per-request mutual-TLS client and
/// returns the response JSON verbatim (<see cref="JsonNode"/>) so no field is lost in translation.
/// Non-success responses become a <see cref="CsobApiException"/>.
/// </summary>
public sealed class CsobApiClient
{
/// <summary>Web defaults (camelCase) match the COBS JSON contract; null properties are omitted on write.</summary>
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<JsonNode?> GetAsync(string path, IReadOnlyDictionary<string, string?>? query, CancellationToken ct)
=> SendAsync(HttpMethod.Get, path, query, body: null, ct);
public Task<JsonNode?> PostAsync(string path, object? body, CancellationToken ct)
=> SendAsync(HttpMethod.Post, path, query: null, body, ct);
public Task<JsonNode?> PutAsync(string path, object? body, CancellationToken ct)
=> SendAsync(HttpMethod.Put, path, query: null, body, ct);
public Task<JsonNode?> DeleteAsync(string path, CancellationToken ct)
=> SendAsync(HttpMethod.Delete, path, query: null, body: null, ct);
private async Task<JsonNode?> SendAsync(
HttpMethod method, string path, IReadOnlyDictionary<string, string?>? 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<string, string?>? 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);
}
/// <summary>Best-effort parse of the COBS error shape <c>{ "errors": [ { "error": "CODE" } ] }</c>.</summary>
private static IReadOnlyList<string> ParseErrorCodes(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return Array.Empty<string>();
}
try
{
var node = JsonNode.Parse(payload);
if (node?["errors"] is JsonArray errors)
{
return errors
.Select(e => e?["error"]?.GetValue<string>())
.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<string>();
}
}