131 lines
5.1 KiB
C#
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>();
|
|
}
|
|
}
|