Skip to content

Commit

Permalink
Modify behaviour of the API client to return token renewal errors imm…
Browse files Browse the repository at this point in the history
…ediately
  • Loading branch information
RobinTTY committed Apr 19, 2024
1 parent a3c59b3 commit c1f7ba7
Show file tree
Hide file tree
Showing 8 changed files with 112 additions and 52 deletions.
18 changes: 9 additions & 9 deletions src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -29,23 +30,23 @@ 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
var institutionResponse = await apiClient.InstitutionsEndpoint.GetInstitutions();
Assert.Multiple(() =>
{
Assert.That(institutionResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
Assert.That(ErrorMatchesExpectation(institutionResponse.Error!), Is.True);
AssertErrorMatchesExpectation(institutionResponse.Error!);
});

// Returns AccountsError
var balancesResponse = await apiClient.AccountsEndpoint.GetBalances(_secrets[9]);
Assert.Multiple(() =>
{
Assert.That(balancesResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
Assert.That(ErrorMatchesExpectation(balancesResponse.Error!), Is.True);
AssertErrorMatchesExpectation(balancesResponse.Error!);
Assert.That(
new object?[]
{
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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");
}
7 changes: 7 additions & 0 deletions src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class AccountsError : BasicResponse
#endif
[JsonPropertyName("date_from")]
public BasicResponse? StartDateError { get; }

#if NET6_0_OR_GREATER
/// <summary>
/// An error that was returned related to the
Expand All @@ -48,6 +49,12 @@ public class AccountsError : BasicResponse
#endif
[JsonPropertyName("date_to")]
public BasicResponse? EndDateError { get; }

/// <summary>
/// Creates a new instance of <see cref="AccountsError" />.
/// </summary>
public AccountsError(){}

#if NET6_0_OR_GREATER
/// <summary>
/// Creates a new instance of <see cref="AccountsError" />.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public class CreateAgreementError : BasicResponse
/// </summary>
[JsonPropertyName("agreement")]
public BasicResponse? AgreementError { get; }

/// <summary>
/// Creates a new instance of <see cref="CreateAgreementError" />.
/// </summary>
public CreateAgreementError(){}

/// <summary>
/// Creates a new instance of <see cref="CreateAgreementError" />.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public class CreateRequisitionError : BasicResponse
/// </summary>
[JsonPropertyName("institution_id")]
public BasicResponse? InstitutionIdError { get; }

/// <summary>
/// Creates a new instance of <see cref="CreateRequisitionError" />.
/// </summary>
public CreateRequisitionError(){}

/// <summary>
/// Creates a new instance of <see cref="CreateRequisitionError" />.
Expand Down
22 changes: 10 additions & 12 deletions src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ namespace RobinTTY.NordigenApiClient.Models.Errors;
/// </summary>
public class InstitutionsError : BasicResponse
{
/// <summary>
/// Creates a new instance of <see cref="InstitutionsError" />.
/// </summary>
public InstitutionsError(){}

/// <summary>
/// Creates a new instance of <see cref="InstitutionsError" />.
/// </summary>
Expand All @@ -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.
/// </summary>
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;
}
11 changes: 8 additions & 3 deletions src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ public class BasicResponse
/// The summary text of the response/error.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; }
public string? Summary { get; init; }

/// <summary>
/// The detailed description of the response/error.
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; }

public string? Detail { get; init; }

/// <summary>
/// Creates a new instance of <see cref="BasicResponse" />.
/// </summary>
public BasicResponse(){}

/// <summary>
/// Creates a new instance of <see cref="BasicResponse" />.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
using RobinTTY.NordigenApiClient.Models.Errors;

namespace RobinTTY.NordigenApiClient.Models.Responses;

Expand Down
95 changes: 68 additions & 27 deletions src/RobinTTY.NordigenApiClient/NordigenClient.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,7 +35,7 @@ public class NordigenClient : INordigenClient

/// <inheritdoc />
public IAccountsEndpoint AccountsEndpoint { get; }

/// <inheritdoc />
public event EventHandler<TokenPairUpdatedEventArgs>? TokenPairUpdated;

Expand Down Expand Up @@ -69,23 +70,48 @@ public NordigenClient(HttpClient httpClient, NordigenClientCredentials credentia
AccountsEndpoint = new AccountsEndpoint(this);
}

/// <summary>
/// Carries out the request to the GoCardless API, gathering a valid JWT if necessary.
/// </summary>
/// <param name="uri">The URI of the API endpoint.</param>
/// <param name="method">The <see cref="HttpMethod"/> to use for this request.</param>
/// <param name="cancellationToken">Token to signal cancellation of the operation.</param>
/// <param name="query">Optional query parameters to add to the request.</param>
/// <param name="body">Optional body to add to the request.</param>
/// <param name="useAuthentication">Whether to use authentication.</param>
/// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TError">The type of the error.</typeparam>
/// <returns>The response to the request.</returns>
internal async Task<NordigenApiResponse<TResponse, TError>> MakeRequest<TResponse, TError>(
string uri,
HttpMethod method,
CancellationToken cancellationToken,
IEnumerable<KeyValuePair<string, string>>? 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<TResponse, TError>(tokenResponse.StatusCode, tokenResponse.IsSuccess,
null, new TError {Summary = tokenResponse.Error.Summary, Detail = tokenResponse.Error.Detail});
}
finally
{
Expand All @@ -99,38 +125,47 @@ internal async Task<NordigenApiResponse<TResponse, TError>> MakeRequest<TRespons
client = _httpClient;
}

HttpResponseMessage? response;
if (method == HttpMethod.Get)
response = await client.GetAsync(requestUri, cancellationToken);
else if (method == HttpMethod.Post)
response = await client.PostAsync(requestUri, body, cancellationToken);
else if (method == HttpMethod.Delete)
response = await client.DeleteAsync(requestUri, cancellationToken);
else if (method == HttpMethod.Put)
response = await client.PutAsync(requestUri, body, cancellationToken);
else
throw new NotImplementedException();
var response = await ExecuteRequest(client, method, requestUri, cancellationToken, body);

return await NordigenApiResponse<TResponse, TError>.FromHttpResponse(response, _serializerOptions,
cancellationToken);
}

private static async Task<HttpResponseMessage> 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();
}

/// <summary>
/// Tries to retrieve a valid <see cref="Models.Jwt.JsonWebTokenPair" />.
/// </summary>
/// <param name="cancellationToken">An optional token to signal cancellation of the operation.</param>
/// <returns>
/// A valid <see cref="Models.Jwt.JsonWebTokenPair" /> if the operation was successful.
/// Otherwise returns null.
/// A <see cref="NordigenApiResponse{TResult,TError}"/> containing the <see cref="JsonWebTokenPair"/> or
/// the error of the operation.
/// </returns>
private async Task<JsonWebTokenPair?> TryGetValidTokenPair(CancellationToken cancellationToken = default)
private async Task<NordigenApiResponse<JsonWebTokenPair, BasicResponse>> 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)
Expand All @@ -139,15 +174,21 @@ internal async Task<NordigenApiResponse<TResponse, TError>> MakeRequest<TRespons
var response = await TokenEndpoint.RefreshAccessToken(JsonWebTokenPair.RefreshToken, cancellationToken);
// Create a new token pair consisting of the new access token and existing refresh token
var token = response.IsSuccess
? new JsonWebTokenPair(response.Result!.AccessToken, JsonWebTokenPair.RefreshToken,
? new JsonWebTokenPair(response.Result.AccessToken, JsonWebTokenPair.RefreshToken,
response.Result!.AccessExpires, JsonWebTokenPair.RefreshExpires)
: null;
TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(token));
return token;
var tokenPairResponse = new NordigenApiResponse<JsonWebTokenPair, BasicResponse>(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<JsonWebTokenPair, BasicResponse>(HttpStatusCode.OK, true, JsonWebTokenPair,
null);
}
}

Expand All @@ -159,13 +200,13 @@ public class TokenPairUpdatedEventArgs : EventArgs
/// <summary>
/// The updated <see cref="Models.Jwt.JsonWebTokenPair" />.
/// </summary>
public JsonWebTokenPair? JsonWebTokenPair { get; set; }
public JsonWebTokenPair JsonWebTokenPair { get; set; }

/// <summary>
/// Creates a new instance of <see cref="TokenPairUpdatedEventArgs" />.
/// </summary>
/// <param name="jsonWebTokenPair">The updated <see cref="Models.Jwt.JsonWebTokenPair" />.</param>
public TokenPairUpdatedEventArgs(JsonWebTokenPair? jsonWebTokenPair)
public TokenPairUpdatedEventArgs(JsonWebTokenPair jsonWebTokenPair)
{
JsonWebTokenPair = jsonWebTokenPair;
}
Expand Down

0 comments on commit c1f7ba7

Please sign in to comment.