From b2b47e31f6ccc412ee40d540a86d2ffe43e27a33 Mon Sep 17 00:00:00 2001 From: Valters Melnalksnis Date: Wed, 15 Jun 2022 20:00:31 +0300 Subject: [PATCH] feat: Add IAgreementClient (#5) --- .../ServiceCollectionExtensions.cs | 2 + .../Agreements/AgreementClient.cs | 66 +++++++++++++++++++ .../Agreements/EndUserAgreement.cs | 34 ++++++++++ .../Agreements/EndUserAgreementAcceptance.cs | 14 ++++ .../Agreements/EndUserAgreementCreation.cs | 56 ++++++++++++++++ .../Agreements/IAgreementClient.cs | 42 ++++++++++++ .../INordigenClient.cs | 4 ++ .../NordigenClient.cs | 12 +++- .../NordigenHttpClient.cs | 65 ++++++++++++++++-- .../Requisitions/RequisitionClient.cs | 32 +-------- source/VMelnalksnis.NordigenDotNet/Routes.cs | 9 +++ .../Agreements/AgreementClientTests.cs | 54 +++++++++++++++ .../ServiceProviderFixture.cs | 3 + 13 files changed, 359 insertions(+), 34 deletions(-) create mode 100644 source/VMelnalksnis.NordigenDotNet/Agreements/AgreementClient.cs create mode 100644 source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreement.cs create mode 100644 source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementAcceptance.cs create mode 100644 source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementCreation.cs create mode 100644 source/VMelnalksnis.NordigenDotNet/Agreements/IAgreementClient.cs create mode 100644 tests/VMelnalksnis.NordigenDotNet.Tests.Integration/Agreements/AgreementClientTests.cs diff --git a/source/VMelnalksnis.NordigenDotNet.DependencyInjection/ServiceCollectionExtensions.cs b/source/VMelnalksnis.NordigenDotNet.DependencyInjection/ServiceCollectionExtensions.cs index 927dce8..722ff58 100644 --- a/source/VMelnalksnis.NordigenDotNet.DependencyInjection/ServiceCollectionExtensions.cs +++ b/source/VMelnalksnis.NordigenDotNet.DependencyInjection/ServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Options; using NodaTime; using VMelnalksnis.NordigenDotNet.Accounts; +using VMelnalksnis.NordigenDotNet.Agreements; using VMelnalksnis.NordigenDotNet.Institutions; using VMelnalksnis.NordigenDotNet.Requisitions; @@ -54,6 +55,7 @@ public static class ServiceCollectionExtensions return serviceCollection .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient(provider => provider.GetRequiredService>().Value) diff --git a/source/VMelnalksnis.NordigenDotNet/Agreements/AgreementClient.cs b/source/VMelnalksnis.NordigenDotNet/Agreements/AgreementClient.cs new file mode 100644 index 0000000..5acbc2e --- /dev/null +++ b/source/VMelnalksnis.NordigenDotNet/Agreements/AgreementClient.cs @@ -0,0 +1,66 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace VMelnalksnis.NordigenDotNet.Agreements; + +/// +public sealed class AgreementClient : IAgreementClient +{ + private readonly NordigenHttpClient _nordigenHttpClient; + + /// Initializes a new instance of the class. + /// Http client configured for making requests to the Nordigen API. + public AgreementClient(NordigenHttpClient nordigenHttpClient) + { + _nordigenHttpClient = nordigenHttpClient; + } + + /// + public IAsyncEnumerable Get(int pageSize = 100, CancellationToken cancellationToken = default) + { + var requestUri = $"{Routes.Agreements.Uri}?limit={pageSize}&offset=0"; + return _nordigenHttpClient.GetAsJsonPaginated(requestUri, cancellationToken); + } + + /// + public async Task Get(Guid id, CancellationToken cancellationToken = default) + { + var agreement = await _nordigenHttpClient + .GetAsJson(Routes.Agreements.IdUri(id), cancellationToken) + .ConfigureAwait(false); + + return agreement!; + } + + /// + public async Task Post(EndUserAgreementCreation agreementCreation) + { + var agreement = await _nordigenHttpClient + .PostAsJson(Routes.Agreements.Uri, agreementCreation) + .ConfigureAwait(false); + + return agreement!; + } + + /// + public async Task Delete(Guid id) + { + await _nordigenHttpClient.Delete(Routes.Agreements.IdUri(id)).ConfigureAwait(false); + } + + /// + public async Task Put(Guid id, EndUserAgreementAcceptance acceptance) + { + var agreement = await _nordigenHttpClient + .PutAsJson(Routes.Agreements.AcceptUri(id), acceptance) + .ConfigureAwait(false); + + return agreement!; + } +} diff --git a/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreement.cs b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreement.cs new file mode 100644 index 0000000..25ee3fc --- /dev/null +++ b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreement.cs @@ -0,0 +1,34 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using NodaTime; + +using VMelnalksnis.NordigenDotNet.Institutions; + +#pragma warning disable SA1623 + +namespace VMelnalksnis.NordigenDotNet.Agreements; + +/// Details the end-user has agreement to share from a specific institution. +/// The id of the end-user agreement. +/// The point in time when the agreement was created. +/// Maximum number of days of transaction data to retrieve. +/// Number of days from acceptance that the access can be used. +/// Level of information to access. +/// An ID for the agreement. +public record EndUserAgreement( + Guid Id, + Instant Created, + [property: JsonPropertyName("max_historical_days")] int MaxHistoricalDays, + [property: JsonPropertyName("access_valid_for_days")] int AccessValidForDays, + [property: JsonPropertyName("access_scope")] List AccessScope, + [property: JsonPropertyName("institution_id")] string InstitutionId) +{ + /// The point int time when the end user accepted the agreement. + public Instant? Accepted { get; init; } +} diff --git a/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementAcceptance.cs b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementAcceptance.cs new file mode 100644 index 0000000..85d70dc --- /dev/null +++ b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementAcceptance.cs @@ -0,0 +1,14 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace VMelnalksnis.NordigenDotNet.Agreements; + +/// Details needed to accept an end-user agreement. +/// User agent string for the end user. +/// End user IP address. +public record EndUserAgreementAcceptance( + [property: JsonPropertyName("user_agent")] string UserAgent, + [property: JsonPropertyName("ip_address")] string IpAddress); diff --git a/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementCreation.cs b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementCreation.cs new file mode 100644 index 0000000..7d24f2a --- /dev/null +++ b/source/VMelnalksnis.NordigenDotNet/Agreements/EndUserAgreementCreation.cs @@ -0,0 +1,56 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using VMelnalksnis.NordigenDotNet.Institutions; + +#pragma warning disable SA1623 + +namespace VMelnalksnis.NordigenDotNet.Agreements; + +/// Details needed to create a new end-user agreement. +public record EndUserAgreementCreation +{ + /// Initializes a new instance of the class. + /// An id for this agreement. + /// Maximum number of days of transaction data to retrieve. + /// Number of days from acceptance that the access can be used. + /// Level of information to access. + public EndUserAgreementCreation( + string institutionId, + int maxHistoricalDays, + int accessValidForDays, + List accessScope) + { + InstitutionId = institutionId; + MaxHistoricalDays = maxHistoricalDays; + AccessValidForDays = accessValidForDays; + AccessScope = accessScope; + } + + /// Initializes a new instance of the class with the default values. + /// An id for this agreement. + public EndUserAgreementCreation(string institutionId) + : this(institutionId, 90, 90, new() { "balances", "details", "transactions" }) + { + } + + /// An id for this agreement. + [JsonPropertyName("institution_id")] + public string InstitutionId { get; } + + /// Maximum number of days of transaction data to retrieve. + [JsonPropertyName("max_historical_days")] + public int MaxHistoricalDays { get; } + + /// Number of days from acceptance that the access can be used. + [JsonPropertyName("access_valid_for_days")] + public int AccessValidForDays { get; } + + /// Level of information to access. + [JsonPropertyName("access_scope")] + public List AccessScope { get; } +} diff --git a/source/VMelnalksnis.NordigenDotNet/Agreements/IAgreementClient.cs b/source/VMelnalksnis.NordigenDotNet/Agreements/IAgreementClient.cs new file mode 100644 index 0000000..458e98e --- /dev/null +++ b/source/VMelnalksnis.NordigenDotNet/Agreements/IAgreementClient.cs @@ -0,0 +1,42 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace VMelnalksnis.NordigenDotNet.Agreements; + +/// Nordigen API client for managing agreements. +public interface IAgreementClient +{ + /// Gets all agreements. + /// The number of agreements to get in a single request. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// A enumerable which will asynchronously iterate over all available pages. + IAsyncEnumerable Get(int pageSize = 100, CancellationToken cancellationToken = default); + + /// Gets the agreement with the specified id. + /// The id of the agreement to get. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The agreement with the specified id. + Task Get(Guid id, CancellationToken cancellationToken = default); + + /// Creates a new agreement. + /// The agreement to create. + /// The created agreement. + Task Post(EndUserAgreementCreation agreementCreation); + + /// Deletes the specified agreement. + /// The id of the agreement to delete. + /// A representing the asynchronous operation. + Task Delete(Guid id); + + /// Accepts the specified agreement. + /// The id of the agreement to accept. + /// Details from the end-user needed to accept the agreement. + /// The accepted agreement. + Task Put(Guid id, EndUserAgreementAcceptance agreementAcceptance); +} diff --git a/source/VMelnalksnis.NordigenDotNet/INordigenClient.cs b/source/VMelnalksnis.NordigenDotNet/INordigenClient.cs index b93b1d3..4a4da2b 100644 --- a/source/VMelnalksnis.NordigenDotNet/INordigenClient.cs +++ b/source/VMelnalksnis.NordigenDotNet/INordigenClient.cs @@ -3,6 +3,7 @@ // See LICENSE file in the project root for full license information. using VMelnalksnis.NordigenDotNet.Accounts; +using VMelnalksnis.NordigenDotNet.Agreements; using VMelnalksnis.NordigenDotNet.Institutions; using VMelnalksnis.NordigenDotNet.Requisitions; @@ -14,6 +15,9 @@ public interface INordigenClient /// Gets the accounts API client. IAccountClient Accounts { get; } + /// Gets the agreements API client. + IAgreementClient Agreements { get; } + /// Gets the institutions API client. IInstitutionClient Institutions { get; } diff --git a/source/VMelnalksnis.NordigenDotNet/NordigenClient.cs b/source/VMelnalksnis.NordigenDotNet/NordigenClient.cs index 1b03d23..d99dbbc 100644 --- a/source/VMelnalksnis.NordigenDotNet/NordigenClient.cs +++ b/source/VMelnalksnis.NordigenDotNet/NordigenClient.cs @@ -3,6 +3,7 @@ // See LICENSE file in the project root for full license information. using VMelnalksnis.NordigenDotNet.Accounts; +using VMelnalksnis.NordigenDotNet.Agreements; using VMelnalksnis.NordigenDotNet.Institutions; using VMelnalksnis.NordigenDotNet.Requisitions; @@ -13,11 +14,17 @@ public sealed class NordigenClient : INordigenClient { /// Initializes a new instance of the class. /// Accounts API client. + /// Agreements API client. /// Institutions API client. /// Requisitions API client. - public NordigenClient(IAccountClient accounts, IInstitutionClient institutions, IRequisitionClient requisitions) + public NordigenClient( + IAccountClient accounts, + IAgreementClient agreementClient, + IInstitutionClient institutions, + IRequisitionClient requisitions) { Accounts = accounts; + Agreements = agreementClient; Institutions = institutions; Requisitions = requisitions; } @@ -25,6 +32,9 @@ public sealed class NordigenClient : INordigenClient /// public IAccountClient Accounts { get; } + /// + public IAgreementClient Agreements { get; } + /// public IInstitutionClient Institutions { get; } diff --git a/source/VMelnalksnis.NordigenDotNet/NordigenHttpClient.cs b/source/VMelnalksnis.NordigenDotNet/NordigenHttpClient.cs index f66a0cc..6a018ca 100644 --- a/source/VMelnalksnis.NordigenDotNet/NordigenHttpClient.cs +++ b/source/VMelnalksnis.NordigenDotNet/NordigenHttpClient.cs @@ -2,8 +2,10 @@ // Licensed under the Apache License 2.0. // See LICENSE file in the project root for full license information. +using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -47,21 +49,76 @@ public sealed class NordigenHttpClient internal async Task GetAsJson(string requestUri, CancellationToken cancellationToken) { var client = await GetClient().ConfigureAwait(false); - return await client.GetFromJsonAsync(requestUri, _serializerOptions, cancellationToken) - .ConfigureAwait(false); + return await client.GetFromJsonAsync(requestUri, _serializerOptions, cancellationToken).ConfigureAwait(false); + } + + internal async IAsyncEnumerable GetAsJsonPaginated( + string requestUri, + [EnumeratorCancellation] CancellationToken cancellationToken) + where TResult : class + { + var next = requestUri; + do + { + if (cancellationToken.IsCancellationRequested) + { + yield break; + } + + var paginatedList = await GetAsJson>(next, cancellationToken).ConfigureAwait(false); + + if (paginatedList?.Results is null) + { + yield break; + } + + foreach (var requisition in paginatedList.Results) + { + yield return requisition; + } + + next = paginatedList.Next?.PathAndQuery; + } + while (next is not null); } internal async Task PostAsJson(string requestUri, TRequest request) { var client = await GetClient().ConfigureAwait(false); var response = await client.PostAsJsonAsync(requestUri, request, _serializerOptions).ConfigureAwait(false); - return await response.Content.ReadFromJsonAsync(_serializerOptions).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(_serializerOptions).ConfigureAwait(false); + } + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException(content, null, response.StatusCode); + } + + internal async Task PutAsJson(string requestUri, TRequest request) + { + var client = await GetClient().ConfigureAwait(false); + var response = await client.PutAsJsonAsync(requestUri, request, _serializerOptions).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(_serializerOptions).ConfigureAwait(false); + } + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException(content, null, response.StatusCode); } internal async Task Delete(string requestUri) { var client = await GetClient().ConfigureAwait(false); - await client.DeleteAsync(requestUri).ConfigureAwait(false); + var response = await client.DeleteAsync(requestUri).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException(content, null, response.StatusCode); } private async Task GetClient() diff --git a/source/VMelnalksnis.NordigenDotNet/Requisitions/RequisitionClient.cs b/source/VMelnalksnis.NordigenDotNet/Requisitions/RequisitionClient.cs index 28cce31..39e90ed 100644 --- a/source/VMelnalksnis.NordigenDotNet/Requisitions/RequisitionClient.cs +++ b/source/VMelnalksnis.NordigenDotNet/Requisitions/RequisitionClient.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -23,35 +22,10 @@ public sealed class RequisitionClient : IRequisitionClient } /// - public async IAsyncEnumerable Get( - int pageSize = 100, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public IAsyncEnumerable Get(int pageSize = 100, CancellationToken cancellationToken = default) { - var next = $"{Routes.Requisitions.Uri}?limit={pageSize}&offset=0"; - do - { - if (cancellationToken.IsCancellationRequested) - { - yield break; - } - - var paginatedResponse = await _nordigenHttpClient - .GetAsJson>(next, cancellationToken) - .ConfigureAwait(false); - - if (paginatedResponse?.Results is null) - { - yield break; - } - - foreach (var requisition in paginatedResponse.Results) - { - yield return requisition; - } - - next = paginatedResponse.Next?.PathAndQuery; - } - while (next is not null); + var requestUri = $"{Routes.Requisitions.Uri}?limit={pageSize}&offset=0"; + return _nordigenHttpClient.GetAsJsonPaginated(requestUri, cancellationToken); } /// diff --git a/source/VMelnalksnis.NordigenDotNet/Routes.cs b/source/VMelnalksnis.NordigenDotNet/Routes.cs index 4c5b0ec..401f647 100644 --- a/source/VMelnalksnis.NordigenDotNet/Routes.cs +++ b/source/VMelnalksnis.NordigenDotNet/Routes.cs @@ -8,6 +8,15 @@ namespace VMelnalksnis.NordigenDotNet; internal static class Routes { + internal static class Agreements + { + internal const string Uri = "api/v2/agreements/enduser/"; + + internal static string IdUri(Guid id) => $"{Uri}{id:N}/"; + + internal static string AcceptUri(Guid id) => $"{IdUri(id)}accept/"; + } + internal static class Accounts { private const string _uri = "api/v2/accounts/"; diff --git a/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/Agreements/AgreementClientTests.cs b/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/Agreements/AgreementClientTests.cs new file mode 100644 index 0000000..7e36a35 --- /dev/null +++ b/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/Agreements/AgreementClientTests.cs @@ -0,0 +1,54 @@ +// Copyright 2022 Valters Melnalksnis +// Licensed under the Apache License 2.0. +// See LICENSE file in the project root for full license information. + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +using VMelnalksnis.NordigenDotNet.Agreements; + +namespace VMelnalksnis.NordigenDotNet.Tests.Integration.Agreements; + +public sealed class AgreementClientTests : IClassFixture +{ + private readonly IAgreementClient _agreementClient; + + public AgreementClientTests(ServiceProviderFixture serviceProviderFixture) + { + _agreementClient = serviceProviderFixture.AgreementClient; + } + + [Fact] + public async Task Get() + { + var creation = new EndUserAgreementCreation("CITADELE_PARXLV22"); + + var createdAgreement = await _agreementClient.Post(creation); + var agreements = await _agreementClient.Get().ToListAsync(); + + agreements + .Should() + .ContainSingle(agreement => agreement.Id == createdAgreement.Id) + .Which.Should() + .BeEquivalentTo(createdAgreement); + + var acceptance = new EndUserAgreementAcceptance("NordigenDotNet integration test", "127.0.0.1"); + (await FluentActions + .Awaiting(() => _agreementClient.Put(createdAgreement.Id, acceptance)) + .Should() + .ThrowExactlyAsync()) + .Which.StatusCode.Should() + .Be(HttpStatusCode.Forbidden, "test company cannot create agreements"); + + await _agreementClient.Delete(createdAgreement.Id); + + (await FluentActions + .Awaiting(() => _agreementClient.Get(createdAgreement.Id)) + .Should() + .ThrowExactlyAsync()) + .Which.StatusCode.Should() + .Be(HttpStatusCode.NotFound); + } +} diff --git a/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/ServiceProviderFixture.cs b/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/ServiceProviderFixture.cs index b5a7a66..9df369b 100644 --- a/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/ServiceProviderFixture.cs +++ b/tests/VMelnalksnis.NordigenDotNet.Tests.Integration/ServiceProviderFixture.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using NodaTime; using VMelnalksnis.NordigenDotNet.Accounts; +using VMelnalksnis.NordigenDotNet.Agreements; using VMelnalksnis.NordigenDotNet.DependencyInjection; using VMelnalksnis.NordigenDotNet.Institutions; using VMelnalksnis.NordigenDotNet.Requisitions; @@ -38,6 +39,8 @@ public sealed class ServiceProviderFixture public IAccountClient AccountClient => _serviceProvider.GetRequiredService(); + public IAgreementClient AgreementClient => _serviceProvider.GetRequiredService(); + public IInstitutionClient InstitutionClient => _serviceProvider.GetRequiredService(); public IRequisitionClient RequisitionClient => _serviceProvider.GetRequiredService();