diff --git a/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs index a5dd405..d8cbdd0 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs @@ -2,6 +2,7 @@ using RobinTTY.NordigenApiClient.Models; using RobinTTY.NordigenApiClient.Models.Requests; using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; namespace RobinTTY.NordigenApiClient.Tests.LiveApi; @@ -29,7 +30,7 @@ public async Task MakeRequestWithInvalidCredentials() Assert.Multiple(() => { Assert.That(agreementsResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - Assert.That(ErrorMatchesExpectation(agreementsResponse.Error!), Is.True); + AssertErrorMatchesExpectation(agreementsResponse.Error!); }); // Returns InstitutionsError @@ -37,7 +38,7 @@ public async Task MakeRequestWithInvalidCredentials() Assert.Multiple(() => { Assert.That(institutionResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - Assert.That(ErrorMatchesExpectation(institutionResponse.Error!), Is.True); + AssertErrorMatchesExpectation(institutionResponse.Error!); }); // Returns AccountsError @@ -45,7 +46,7 @@ public async Task MakeRequestWithInvalidCredentials() Assert.Multiple(() => { Assert.That(balancesResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - Assert.That(ErrorMatchesExpectation(balancesResponse.Error!), Is.True); + AssertErrorMatchesExpectation(balancesResponse.Error!); Assert.That( new object?[] { @@ -61,7 +62,7 @@ public async Task MakeRequestWithInvalidCredentials() Assert.Multiple(() => { Assert.That(balancesResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - Assert.That(ErrorMatchesExpectation(createAgreementResponse.Error!), Is.True); + AssertErrorMatchesExpectation(createAgreementResponse.Error!); Assert.That(new object?[] { createAgreementResponse.Error!.AccessScopeError, createAgreementResponse.Error.AccessValidForDaysError, @@ -78,7 +79,7 @@ public async Task MakeRequestWithInvalidCredentials() Assert.Multiple(() => { Assert.That(balancesResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); - Assert.That(ErrorMatchesExpectation(requisitionResponse.Error!), Is.True); + AssertErrorMatchesExpectation(requisitionResponse.Error!); Assert.That(new object?[] { requisitionResponse.Error!.AgreementError, requisitionResponse.Error.InstitutionIdError, @@ -89,8 +90,7 @@ public async Task MakeRequestWithInvalidCredentials() }); } - private static bool ErrorMatchesExpectation(BasicResponse error) - { - return error is {Detail: "Authentication credentials were not provided.", Summary: "Authentication failed"}; - } + private static void AssertErrorMatchesExpectation(BasicResponse error) => + AssertionHelpers.AssertBasicResponseMatchesExpectations(error, "Authentication failed", + "No active account found with the given credentials"); } diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs index e016350..71f5be1 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs @@ -31,6 +31,7 @@ public class AccountsError : BasicResponse #endif [JsonPropertyName("date_from")] public BasicResponse? StartDateError { get; } + #if NET6_0_OR_GREATER /// /// An error that was returned related to the @@ -48,6 +49,12 @@ public class AccountsError : BasicResponse #endif [JsonPropertyName("date_to")] public BasicResponse? EndDateError { get; } + + /// + /// Creates a new instance of . + /// + public AccountsError(){} + #if NET6_0_OR_GREATER /// /// Creates a new instance of . diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs index abbe5a7..4ae401b 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs @@ -43,6 +43,11 @@ public class CreateAgreementError : BasicResponse /// [JsonPropertyName("agreement")] public BasicResponse? AgreementError { get; } + + /// + /// Creates a new instance of . + /// + public CreateAgreementError(){} /// /// Creates a new instance of . diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs index 14c3ae2..07a8885 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs @@ -57,6 +57,11 @@ public class CreateRequisitionError : BasicResponse /// [JsonPropertyName("institution_id")] public BasicResponse? InstitutionIdError { get; } + + /// + /// Creates a new instance of . + /// + public CreateRequisitionError(){} /// /// Creates a new instance of . diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs index f9201b0..09684ac 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs @@ -8,6 +8,11 @@ namespace RobinTTY.NordigenApiClient.Models.Errors; /// public class InstitutionsError : BasicResponse { + /// + /// Creates a new instance of . + /// + public InstitutionsError(){} + /// /// Creates a new instance of . /// @@ -23,19 +28,12 @@ public InstitutionsError(BasicResponse country) : base(country.Summary, country. /// Since this representation doesn't add any useful information (only extra encapsulation) /// it is transformed to align this error with other errors returned by the API. /// -internal class InstitutionsErrorInternal +[method: JsonConstructor] +internal class InstitutionsErrorInternal(BasicResponse? country, string? summary, string? detail) { - [JsonPropertyName("country")] public BasicResponse? Country { get; } - - [JsonPropertyName("summary")] public string? Summary { get; } + [JsonPropertyName("country")] public BasicResponse? Country { get; } = country; - [JsonPropertyName("detail")] public string? Detail { get; } + [JsonPropertyName("summary")] public string? Summary { get; } = summary; - [JsonConstructor] - public InstitutionsErrorInternal(BasicResponse? country, string? summary, string? detail) - { - Country = country; - Summary = summary; - Detail = detail; - } + [JsonPropertyName("detail")] public string? Detail { get; } = detail; } diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs index 314f86a..d192839 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs @@ -11,14 +11,19 @@ public class BasicResponse /// The summary text of the response/error. /// [JsonPropertyName("summary")] - public string? Summary { get; } + public string? Summary { get; init; } /// /// The detailed description of the response/error. /// [JsonPropertyName("detail")] - public string? Detail { get; } - + public string? Detail { get; init; } + + /// + /// Creates a new instance of . + /// + public BasicResponse(){} + /// /// Creates a new instance of . /// diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs index 6038a92..4b3c80c 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using RobinTTY.NordigenApiClient.Models.Errors; namespace RobinTTY.NordigenApiClient.Models.Responses; diff --git a/src/RobinTTY.NordigenApiClient/NordigenClient.cs b/src/RobinTTY.NordigenApiClient/NordigenClient.cs index 54cd3aa..ec93b7c 100644 --- a/src/RobinTTY.NordigenApiClient/NordigenClient.cs +++ b/src/RobinTTY.NordigenApiClient/NordigenClient.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using RobinTTY.NordigenApiClient.Contracts; using RobinTTY.NordigenApiClient.Endpoints; using RobinTTY.NordigenApiClient.JsonConverters; @@ -34,7 +35,7 @@ public class NordigenClient : INordigenClient /// public IAccountsEndpoint AccountsEndpoint { get; } - + /// public event EventHandler? TokenPairUpdated; @@ -69,6 +70,18 @@ public NordigenClient(HttpClient httpClient, NordigenClientCredentials credentia AccountsEndpoint = new AccountsEndpoint(this); } + /// + /// Carries out the request to the GoCardless API, gathering a valid JWT if necessary. + /// + /// The URI of the API endpoint. + /// The to use for this request. + /// Token to signal cancellation of the operation. + /// Optional query parameters to add to the request. + /// Optional body to add to the request. + /// Whether to use authentication. + /// The type of the response. + /// The type of the error. + /// The response to the request. internal async Task> MakeRequest( string uri, HttpMethod method, @@ -76,16 +89,29 @@ internal async Task> MakeRequest>? query = null, HttpContent? body = null, bool useAuthentication = true - ) where TResponse : class where TError : class + ) where TResponse : class where TError : BasicResponse, new() { var requestUri = query != null ? uri + UriQueryBuilder.GetQueryString(query) : uri; HttpClient client; + + // When an endpoint that requires authentication is called the client tries to update the JWT first + // - The updating is done using a semaphore to avoid multiple threads trying to update the token simultaneously + // - If the request to get the token succeeds, the subsequent request is executed + // - If the request to get the token fails, the error response from the token endpoint is returned instead if (useAuthentication) { await TokenSemaphore.WaitAsync(cancellationToken); + try { - JsonWebTokenPair = await TryGetValidTokenPair(cancellationToken); + var tokenResponse = await TryGetValidTokenPair(cancellationToken); + + + if (tokenResponse.IsSuccess) + JsonWebTokenPair = tokenResponse.Result; + else + return new NordigenApiResponse(tokenResponse.StatusCode, tokenResponse.IsSuccess, + null, new TError {Summary = tokenResponse.Error.Summary, Detail = tokenResponse.Error.Detail}); } finally { @@ -99,38 +125,47 @@ internal async Task> MakeRequest.FromHttpResponse(response, _serializerOptions, cancellationToken); } + private static async Task ExecuteRequest(HttpClient client, HttpMethod method, + string requestUri, + CancellationToken cancellationToken, HttpContent? body = null) + { + if (method == HttpMethod.Get) + return await client.GetAsync(requestUri, cancellationToken); + if (method == HttpMethod.Post) + return await client.PostAsync(requestUri, body, cancellationToken); + if (method == HttpMethod.Delete) + return await client.DeleteAsync(requestUri, cancellationToken); + if (method == HttpMethod.Put) + return await client.PutAsync(requestUri, body, cancellationToken); + + throw new NotImplementedException(); + } + /// /// Tries to retrieve a valid . /// /// An optional token to signal cancellation of the operation. /// - /// A valid if the operation was successful. - /// Otherwise returns null. + /// A containing the or + /// the error of the operation. /// - private async Task TryGetValidTokenPair(CancellationToken cancellationToken = default) + private async Task> TryGetValidTokenPair( + CancellationToken cancellationToken = default) { // Request a new token if it is null or if the refresh token has expired if (JsonWebTokenPair == null || JsonWebTokenPair.RefreshToken.IsExpired(TimeSpan.FromMinutes(1))) { var response = await TokenEndpoint.GetTokenPair(cancellationToken); - TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(response.Result)); - return response.Result; + if (response.IsSuccess) + TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(response.Result)); + + return response; } // Refresh the current access token if it's expired (or valid for less than a minute) @@ -139,15 +174,21 @@ internal async Task> MakeRequest(response.StatusCode, + response.IsSuccess, token, response.Error); + + if (token is not null) + TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(token)); + + return tokenPairResponse; } - // Token pair is still valid and can be returned - return JsonWebTokenPair; + // Token pair is still valid and can be returned - wrap in NordigenApiResponse + return new NordigenApiResponse(HttpStatusCode.OK, true, JsonWebTokenPair, + null); } } @@ -159,13 +200,13 @@ public class TokenPairUpdatedEventArgs : EventArgs /// /// The updated . /// - public JsonWebTokenPair? JsonWebTokenPair { get; set; } + public JsonWebTokenPair JsonWebTokenPair { get; set; } /// /// Creates a new instance of . /// /// The updated . - public TokenPairUpdatedEventArgs(JsonWebTokenPair? jsonWebTokenPair) + public TokenPairUpdatedEventArgs(JsonWebTokenPair jsonWebTokenPair) { JsonWebTokenPair = jsonWebTokenPair; }