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