← Code knowledge base
Reviewed Flutter implementation · 2026-10-28

HoppaClient.cs

backend/src/NeoBanking.Infrastructure/Hoppa/HoppaClient.cs

Immutable source snapshot based on revision ce45c9c3bbde. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.

The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.

Lines 1–377

using System.Net;using System.Net.Http.Json;using System.Diagnostics;using System.Text.Json;using System.Text.Json.Nodes;using Microsoft.AspNetCore.WebUtilities;using Microsoft.Extensions.Options;using NeoBanking.Application.Common;using NeoBanking.Application.Interfaces; namespace NeoBanking.Infrastructure.Hoppa; public sealed class HoppaClient : IHoppaClient{    private static readonly HashSet<string> SensitiveJsonProperties = new(StringComparer.OrdinalIgnoreCase)    {        "password",        "adminPassword",        "uboIdNumber",        "uboDob",        "widgetUrl",        "widgetToken",        "code",        "otp",        "otpCode",        "token",        "accessToken",        "refreshToken",        "apiKey",        "serverObservedIp", "server_observed_ip",        "installationToken", "installation_token",        "invitationToken", "invitation_token",        "consentToken", "consent_token",        "quoteToken", "quote_token",        "quoteReceipt", "quote_receipt", "referralQuoteReceipt", "referral_quote_receipt",        "secret"    };     private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.General)    {        PropertyNameCaseInsensitive = true    };     private readonly HttpClient _httpClient;    private readonly HoppaOptions _options;    private readonly IHoppaFlowLogCollector _flowLogCollector;     public HoppaClient(        HttpClient httpClient,        IOptions<HoppaOptions> options,        IHoppaFlowLogCollector flowLogCollector)    {        _httpClient = httpClient;        _options = options.Value;        _flowLogCollector = flowLogCollector;    }     public async Task<ApplicationResult<HoppaDownload>> DownloadAsync(HoppaRequest<object?> request,CancellationToken ct)    {        var configurationError=ValidateConfiguration<HoppaDownload>();        if(configurationError is not null) return configurationError;        // Downloads are confined to existing referral resources and the exact        // user-scoped payment-confirmation route; never accept arbitrary URLs.        var paymentConfirmation = System.Text.RegularExpressions.Regex.IsMatch(request.Path,            @"^/api/v2/transactions/[1-9][0-9]*/equals-money/payment-confirmation\z",            System.Text.RegularExpressions.RegexOptions.CultureInvariant);        if (paymentConfirmation && (!request.Query.TryGetValue("userId", out var userId) ||            !int.TryParse(userId, out var numericUserId) || numericUserId <= 0))            return Failure<HoppaDownload>("hoppa.download.invalid_identity", "A user is required for payment confirmations.", HttpStatusCode.BadRequest);        if(request.Method!=HttpMethod.Get || (!paymentConfirmation && !request.Path.StartsWith("/api/v2/referrals/",StringComparison.Ordinal)) ||            request.Path.Contains("..",StringComparison.Ordinal) || request.Path.Contains('\\'))            return Failure<HoppaDownload>("hoppa.download.invalid_path","Invalid download path.",HttpStatusCode.BadRequest);        using var outgoing=new HttpRequestMessage(HttpMethod.Get,BuildRequestUri(request.Path,request.Query));        outgoing.Headers.TryAddWithoutValidation("x-api-key",_options.ApiKey);        await ReferralActorSigner.SignAsync(outgoing,request.TrustedReferralActorId,_options.ReferralAuditDelegation,ct);        HttpResponseMessage? response=null;        try        {            response=await _httpClient.SendAsync(outgoing,HttpCompletionOption.ResponseHeadersRead,ct);            if(!response.IsSuccessStatusCode)            {                var detail=await response.Content.ReadAsStringAsync(ct);                var error=Failure<HoppaDownload>(request.FailureCode,request.FailureMessage,response.StatusCode,detail);                response.Dispose(); return error;            }            if (paymentConfirmation && !string.Equals(response.Content.Headers.ContentType?.MediaType,                "application/pdf", StringComparison.OrdinalIgnoreCase))            {                response.Dispose();                return Failure<HoppaDownload>("hoppa.download.invalid_pdf", "The payment confirmation was not a PDF.", HttpStatusCode.BadGateway);            }            var stream=await response.Content.ReadAsStreamAsync(ct);            return ApplicationResult<HoppaDownload>.Success(new HoppaDownload(response,stream));        }        catch(HttpRequestException)        { response?.Dispose(); return Failure<HoppaDownload>("hoppa.unavailable","The export provider is unavailable.",HttpStatusCode.BadGateway); }        catch(OperationCanceledException) when(!ct.IsCancellationRequested)        { response?.Dispose(); return Failure<HoppaDownload>("hoppa.timeout","The export request timed out.",HttpStatusCode.GatewayTimeout); }        catch { response?.Dispose(); throw; }    }     public async Task<ApplicationResult<TResponse>> SendAsync<TRequest, TResponse>(        HoppaRequest<TRequest> request,        CancellationToken cancellationToken)    {        var configurationError = ValidateConfiguration<TResponse>();        if (configurationError is not null)        {            return configurationError;        }         var requestUri = BuildRequestUri(request.Path, request.Query);        var requestJson = SerializeRequestBody(request.Body);        using var httpRequest = new HttpRequestMessage(request.Method, requestUri);        httpRequest.Headers.TryAddWithoutValidation("x-api-key", _options.ApiKey);        if (request.Method == HttpMethod.Post && request.Path.StartsWith("/api/v2/mor/public/", StringComparison.Ordinal)            && !string.IsNullOrWhiteSpace(request.IdempotencyKey))            httpRequest.Headers.TryAddWithoutValidation("Idempotency-Key", request.IdempotencyKey);         if (request.Body is HttpContent httpContent)        {            httpRequest.Content = httpContent;        }        else if (request.Body is not null)        {            httpRequest.Content = JsonContent.Create(request.Body, options: JsonOptions);        }         await ReferralActorSigner.SignAsync(httpRequest, request.TrustedReferralActorId, _options.ReferralAuditDelegation, cancellationToken);         var stopwatch = Stopwatch.StartNew();         try        {            using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);            stopwatch.Stop();            RecordExchange(                request,                requestUri,                requestJson,                response.StatusCode,                stopwatch.ElapsedMilliseconds,                response.IsSuccessStatusCode,                response.IsSuccessStatusCode ? null : request.FailureCode,                response.IsSuccessStatusCode ? null : request.FailureMessage,                responseBody);             if (!response.IsSuccessStatusCode)            {                // A registered user may not have a provider account yet. Only this                // explicit card-list refusal represents an empty collection.                if (request.Method == HttpMethod.Get &&                    request.Path == "/api/v2/cards" &&                    response.StatusCode == HttpStatusCode.NotFound &&                    IsMissingCardAccount(responseBody))                {                    var empty = JsonSerializer.Deserialize<TResponse>(                        """{"cards":[],"total":0,"pageTotal":0}""", JsonOptions);                    return ApplicationResult<TResponse>.Success(empty!);                }                 return Failure<TResponse>(                    request.FailureCode,                    request.FailureMessage,                    response.StatusCode,                    responseBody);            }             if (string.IsNullOrWhiteSpace(responseBody))            {                return ApplicationResult<TResponse>.Success(default!);            }             var value = JsonSerializer.Deserialize<TResponse>(responseBody, JsonOptions);            return ApplicationResult<TResponse>.Success(value!);        }        catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)        {            stopwatch.Stop();            RecordExchange(                request,                requestUri,                requestJson,                HttpStatusCode.GatewayTimeout,                stopwatch.ElapsedMilliseconds,                false,                "hoppa.timeout",                "The provider request timed out.",                null);            return Failure<TResponse>("hoppa.timeout", "The provider request timed out.", HttpStatusCode.GatewayTimeout);        }        catch (HttpRequestException exception)        {            stopwatch.Stop();            RecordExchange(                request,                requestUri,                requestJson,                HttpStatusCode.BadGateway,                stopwatch.ElapsedMilliseconds,                false,                "hoppa.unavailable",                "The provider is unavailable right now. Try again shortly.",                exception.Message);            return Failure<TResponse>("hoppa.unavailable", "The provider is unavailable right now. Try again shortly.", HttpStatusCode.BadGateway, exception.Message);        }        catch (JsonException exception)        {            stopwatch.Stop();            RecordExchange(                request,                requestUri,                requestJson,                HttpStatusCode.BadGateway,                stopwatch.ElapsedMilliseconds,                false,                "hoppa.invalid_response",                "The provider returned an invalid response.",                exception.Message);            return Failure<TResponse>("hoppa.invalid_response", "The provider returned an invalid response.", HttpStatusCode.BadGateway, exception.Message);        }    }     private void RecordExchange<TRequest>(        HoppaRequest<TRequest> request,        string requestUri,        string? requestJson,        HttpStatusCode statusCode,        long durationMs,        bool succeeded,        string? failureCode,        string? failureMessage,        string? responseJson)    {        if (request.SuppressPayloadLogging) return;        var questionMarkIndex = requestUri.IndexOf('?', StringComparison.Ordinal);        _flowLogCollector.AddHoppaExchange(new HoppaExchangeLog        {            Method = request.Method,            Endpoint = questionMarkIndex < 0 ? requestUri : requestUri[..questionMarkIndex],            QueryString = questionMarkIndex < 0 ? null : requestUri[questionMarkIndex..],            StatusCode = (int)statusCode,            DurationMs = durationMs,            Succeeded = succeeded,            FailureCode = failureCode,            FailureMessage = failureMessage,            RequestJson = requestJson,            ResponseJson = (request.Path.StartsWith("/api/v2/mor/public/", StringComparison.Ordinal) || request.Path.EndsWith("/widget", StringComparison.Ordinal))                ? RedactSensitiveResponse(responseJson)                : responseJson        });    }     private static string? RedactSensitiveResponse(string? json)    {        if (string.IsNullOrWhiteSpace(json)) return json;        try        {            var node = JsonNode.Parse(json);            RedactSensitiveProperties(node);            return node?.ToJsonString(JsonOptions);        }        catch (JsonException)        {            return "[Unstructured sensitive response omitted]";        }    }     private static string? SerializeRequestBody<TRequest>(TRequest? body)    {        if (body is null)        {            return null;        }         if (body is HttpContent)        {            return "{\"content\":\"HttpContent body was streamed to Hoppa.\"}";        }         var node = JsonSerializer.SerializeToNode(body, JsonOptions);        RedactSensitiveProperties(node);        return node?.ToJsonString(JsonOptions);    }     private static void RedactSensitiveProperties(JsonNode? node)    {        if (node is JsonObject jsonObject)        {            foreach (var property in jsonObject.ToList())            {                if (SensitiveJsonProperties.Contains(property.Key))                {                    jsonObject[property.Key] = "[REDACTED]";                }                else                {                    RedactSensitiveProperties(property.Value);                }            }        }        else if (node is JsonArray jsonArray)        {            foreach (var item in jsonArray)            {                RedactSensitiveProperties(item);            }        }    }     private ApplicationResult<TResponse>? ValidateConfiguration<TResponse>()    {        if (_httpClient.BaseAddress is null)        {            return Failure<TResponse>(                "hoppa.configuration.missing_base_url",                "Hoppa base URL is not configured.",                HttpStatusCode.InternalServerError);        }         if (string.IsNullOrWhiteSpace(_options.ApiKey))        {            return Failure<TResponse>(                "hoppa.configuration.missing_api_key",                "Hoppa API key is not configured.",                HttpStatusCode.InternalServerError);        }         return null;    }     private static string BuildRequestUri(string path, IReadOnlyDictionary<string, string?> query)    {        if (query.Count == 0)        {            return path;        }         var queryParameters = query            .Where(pair => !string.IsNullOrWhiteSpace(pair.Value))            .ToDictionary(pair => pair.Key, pair => pair.Value);         return queryParameters.Count == 0            ? path            : QueryHelpers.AddQueryString(path, queryParameters);    }     private static bool IsMissingCardAccount(string body)    {        try        {            using var document = JsonDocument.Parse(body);            if (document.RootElement.ValueKind != JsonValueKind.Object)                return false;             return document.RootElement.EnumerateObject().Any(property =>                string.Equals(property.Name, "code", StringComparison.OrdinalIgnoreCase) &&                property.Value.ValueKind == JsonValueKind.String &&                property.Value.GetString() == "ACCOUNT_NOT_FOUND");        }        catch (JsonException)        {            return false;        }    }     private static ApplicationResult<TResponse> Failure<TResponse>(        string code,        string message,        HttpStatusCode statusCode,        string? detail = null)    {        return ApplicationResult<TResponse>.Failure(            new ApplicationError(code, message, (int)statusCode, detail));    }}
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources ↗

Your question and recent conversation are sent to our AI provider. Don’t include API keys, personal data or card details.