first
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user