This commit is contained in:
JiriUhlir
2026-06-18 11:05:46 +02:00
parent f44ffae9fc
commit 09db30a3ca
84 changed files with 3952 additions and 15 deletions
+42
View File
@@ -0,0 +1,42 @@
using Csob.Configuration;
using Csob.Credentials;
namespace Csob.Client;
/// <summary>
/// Scoped accessor that resolves the credentials for the current request and lazily builds a
/// single <see cref="CsobApiClient"/> (bound to the request's mutual-TLS client) shared by all
/// services handling that request.
/// </summary>
public sealed class CsobApiAccessor
{
private readonly RequestCredentialsProvider _credentialsProvider;
private readonly CsobHttpClientProvider _httpClientProvider;
private readonly CsobSettings _settings;
private CsobApiClient? _client;
public CsobApiAccessor(
RequestCredentialsProvider credentialsProvider,
CsobHttpClientProvider httpClientProvider,
CsobSettings settings)
{
_credentialsProvider = credentialsProvider;
_httpClientProvider = httpClientProvider;
_settings = settings;
}
public CsobApiClient Client
{
get
{
if (_client is null)
{
var credentials = _credentialsProvider.Resolve();
var http = _httpClientProvider.GetClient(credentials.Certificate!);
_client = new CsobApiClient(http, credentials, _settings);
}
return _client;
}
}
}
+130
View File
@@ -0,0 +1,130 @@
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>();
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Net;
namespace Csob.Client;
/// <summary>
/// Raised when the ČSOB PSD2 API returns a non-success HTTP status. Carries the upstream status
/// and the raw error body so the middleware can surface it without leaking credentials.
/// </summary>
public sealed class CsobApiException : Exception
{
public HttpStatusCode StatusCode { get; }
/// <summary>Machine-readable error codes parsed from the ČSOB <c>errors[].error</c> array (best effort).</summary>
public IReadOnlyList<string> ErrorCodes { get; }
/// <summary>Raw response body (already credential-free — it is the upstream's own error payload).</summary>
public string? RawBody { get; }
public CsobApiException(HttpStatusCode statusCode, IReadOnlyList<string> errorCodes, string? rawBody)
: base($"ČSOB PSD2 API returned {(int)statusCode} ({statusCode}).")
{
StatusCode = statusCode;
ErrorCodes = errorCodes;
RawBody = rawBody;
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Csob.Client;
/// <summary>
/// Central registry of ČSOB PSD2 resource path templates (relative to <c>CSOB_API_BASE_URL</c>).
///
/// Paths follow the Czech Open Banking Standard (COBS) as implemented by ČSOB. The account-scoped
/// AISP paths and the PISP <c>/my/payments/.../sign/{signId}</c> authorization flow are confirmed
/// against the ČSOB developer portal; the remaining COBS resources use the same <c>/my/</c> prefix.
/// Keep every path here so a portal-specific correction is a single-file change.
/// </summary>
public static class CsobApiPaths
{
// ----- AISP (Account Information) -----
public const string Accounts = "/my/accounts";
public static string Account(string id) => $"/my/accounts/{Esc(id)}";
public static string Balance(string accountId) => $"/my/accounts/{Esc(accountId)}/balance";
public static string Transactions(string accountId) => $"/my/accounts/{Esc(accountId)}/transactions";
public static string AwaitingTransactions(string accountId) => $"/my/accounts/{Esc(accountId)}/transactions/awaiting";
public static string AccountStandingOrders(string accountId) => $"/my/accounts/{Esc(accountId)}/standingorders";
public static string AccountStandingOrder(string accountId, string standingOrderId) => $"/my/accounts/{Esc(accountId)}/standingorders/{Esc(standingOrderId)}";
public static string AccountDirectDebits(string accountId) => $"/my/accounts/{Esc(accountId)}/directdebits";
// ----- PISP (Payment Initiation) -----
public const string Payments = "/my/payments";
public static string Payment(string id) => $"/my/payments/{Esc(id)}";
public static string PaymentStatus(string id) => $"/my/payments/{Esc(id)}/status";
public static string PaymentSign(string id, string signId) => $"/my/payments/{Esc(id)}/sign/{Esc(signId)}";
public const string StandingOrders = "/my/standingorders";
public static string StandingOrder(string id) => $"/my/standingorders/{Esc(id)}";
public static string StandingOrderStatus(string id) => $"/my/standingorders/{Esc(id)}/status";
public static string StandingOrderSign(string id, string signId) => $"/my/standingorders/{Esc(id)}/sign/{Esc(signId)}";
public const string DirectDebits = "/my/directdebits";
public static string DirectDebit(string id) => $"/my/directdebits/{Esc(id)}";
public static string DirectDebitStatus(string id) => $"/my/directdebits/{Esc(id)}/status";
public static string DirectDebitSign(string id, string signId) => $"/my/directdebits/{Esc(id)}/sign/{Esc(signId)}";
// ----- Common (Consents) -----
public const string Consents = "/consents";
public static string Consent(string id) => $"/consents/{Esc(id)}";
private static string Esc(string segment) => Uri.EscapeDataString(segment);
}
+77
View File
@@ -0,0 +1,77 @@
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using Csob.Configuration;
namespace Csob.Client;
/// <summary>
/// Provides <see cref="HttpClient"/> instances configured for mutual TLS with a per-request eIDAS
/// client certificate. ČSOB requires the client certificate at the transport layer, so a single
/// shared client cannot be used across tenants.
///
/// Clients are cached by certificate thumbprint and reused for connection pooling. In practice the
/// certificate identifies the TPP application (not the PSU), so the number of distinct certificates
/// is small and bounded by the set of calling tenants. Certificates and clients live in memory only
/// and never touch disk.
/// </summary>
public sealed class CsobHttpClientProvider : IDisposable
{
private readonly CsobSettings _settings;
private readonly ConcurrentDictionary<string, HttpClient> _clients = new();
private readonly object _buildLock = new();
public CsobHttpClientProvider(CsobSettings settings) => _settings = settings;
/// <summary>
/// Returns a (cached) mutual-TLS <see cref="HttpClient"/> presenting <paramref name="certificate"/>.
/// A fresh certificate instance is built per request; if an equivalent one (same thumbprint) is
/// already cached, the redundant instance is disposed so it does not leak.
/// </summary>
public HttpClient GetClient(X509Certificate2 certificate)
{
var thumbprint = certificate.Thumbprint;
if (_clients.TryGetValue(thumbprint, out var existing))
{
certificate.Dispose();
return existing;
}
lock (_buildLock)
{
if (_clients.TryGetValue(thumbprint, out existing))
{
certificate.Dispose();
return existing;
}
var client = BuildClient(certificate);
_clients[thumbprint] = client;
return client;
}
}
private HttpClient BuildClient(X509Certificate2 certificate)
{
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
};
handler.SslOptions.ClientCertificates = new X509CertificateCollection { certificate };
return new HttpClient(handler, disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(_settings.RequestTimeoutSeconds),
};
}
public void Dispose()
{
foreach (var client in _clients.Values)
{
client.Dispose();
}
_clients.Clear();
}
}