Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for cancellation tokens #178

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## [6.2.0] -

* Added support for cancellation of async requests using a `CancellationToken`.

## [6.1.0] - 2022-09-27

* Add support for new security features when sending a file by email:
Expand Down
12 changes: 6 additions & 6 deletions src/GovukNotify.Tests/GovukNotify.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.3.0" />
<PackageReference Include="Moq" Version="4.8.0" />
<PackageReference Include="MSTest.TestAdapter" Version="1.1.18" />
<PackageReference Include="MSTest.TestFramework" Version="1.1.18" />
<PackageReference Include="NUnit" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Moq" Version="4.20.69" />
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>

<ItemGroup>
Expand Down
76 changes: 48 additions & 28 deletions src/GovukNotify/Client/BaseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Notify.Client
Expand Down Expand Up @@ -34,55 +35,71 @@ public BaseClient(IHttpClient client, string apiKey, string baseUrl = "https://a
this.client.BaseAddress = ValidateBaseUri(BaseUrl);
this.client.AddContentHeader("application/json");

var productVersion = typeof(BaseClient).GetTypeInfo().Assembly.GetName().Version.ToString();
var productVersion = typeof(BaseClient).GetTypeInfo().Assembly.GetName().Version?.ToString();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is required as there was the possibility of a NullReferenceException in the original code - the Version attribute may be null.

If nullable types were enabled for the project it would likely help catch other similar problems.

this.client.AddUserAgent(NOTIFY_CLIENT_NAME + productVersion);
}

public async Task<string> GET(string url)
public async Task<string> GET(string url, CancellationToken cancellationToken = default)
{
return await MakeRequest(url, HttpMethod.Get).ConfigureAwait(false);
return await MakeRequest(url, HttpMethod.Get, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}

public async Task<string> POST(string url, string json)
public async Task<string> POST(string url, string json, CancellationToken cancellationToken = default)
{
var content = new StringContent(json, Encoding.UTF8, "application/json");
return await MakeRequest(url, HttpMethod.Post, content).ConfigureAwait(false);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await MakeRequest(url, HttpMethod.Post, content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}

public async Task<byte[]> GETBytes(string url)
public async Task<byte[]> GETBytes(string url, CancellationToken cancellationToken = default)
{
return await MakeRequestBytes(url, HttpMethod.Get).ConfigureAwait(false);
return await MakeRequestBytes(url, HttpMethod.Get, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}

public async Task<byte[]> MakeRequestBytes(string url, HttpMethod method, HttpContent content = null) {
var response = SendRequest(url, method, content).Result;

var responseContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);

public async Task<byte[]> MakeRequestBytes(string url, HttpMethod method, HttpContent content = null, CancellationToken cancellationToken = default) {
var response = await SendRequest(url, method, content, cancellationToken);

#if NET6_0_OR_GREATER
var responseContent = await response.Content.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
#else
var responseContent = await response.Content.ReadAsByteArrayAsync()
.ConfigureAwait(false);
#endif
Comment on lines +64 to +70
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing cancellation tokens when reading content is only supported in .NET standard 2.1 and up. This project currently targets .net standard 2.0 and .net 6, so this conditional compilation should work.


if (!response.IsSuccessStatusCode)
{
// if there was an error, rather than a binary pdf, the http body will be a json error message, so
// encode the bytes as UTF8
HandleHTTPErrors(response, Encoding.UTF8.GetString(responseContent));
BaseClient.HandleHTTPErrors(response, Encoding.UTF8.GetString(responseContent));
}
return responseContent;

return responseContent;
}

public async Task<string> MakeRequest(string url, HttpMethod method, HttpContent content = null)
public async Task<string> MakeRequest(string url, HttpMethod method, HttpContent content = null, CancellationToken cancellationToken = default)
{
var response = SendRequest(url, method, content).Result;

var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);

var response = await SendRequest(url, method, content, cancellationToken);

#if NET6_0_OR_GREATER
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
#else
var responseContent = await response.Content.ReadAsStringAsync()
.ConfigureAwait(false);
#endif
Comment on lines +86 to +92
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, as before this conditional compilation is required due to the target framework.


if (!response.IsSuccessStatusCode)
{
HandleHTTPErrors(response, responseContent);
}

return responseContent;
}

private async Task<HttpResponseMessage> SendRequest(string url, HttpMethod method, HttpContent content)
private async Task<HttpResponseMessage> SendRequest(string url, HttpMethod method, HttpContent content, CancellationToken cancellationToken = default)
{
var request = new HttpRequestMessage(method, url);

Expand All @@ -98,15 +115,15 @@ private async Task<HttpResponseMessage> SendRequest(string url, HttpMethod metho

try
{
response = await this.client.SendAsync(request).ConfigureAwait(false);
response = await this.client.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (AggregateException ae)
{
ae.Handle(x =>
{
if (x is HttpRequestException)
{
throw new NotifyClientException(x.InnerException.Message);
throw new NotifyClientException(x.InnerException?.Message ?? x.Message);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible for InnerException to be null here, which would lead to an uncaught NullReferenceException.

}
throw x;
});
Expand All @@ -115,7 +132,7 @@ private async Task<HttpResponseMessage> SendRequest(string url, HttpMethod metho
return response;
}

private void HandleHTTPErrors(HttpResponseMessage response, string errorResponseContent)
private static void HandleHTTPErrors(HttpResponseMessage response, string errorResponseContent)
{
try
{
Expand All @@ -129,8 +146,12 @@ private void HandleHTTPErrors(HttpResponseMessage response, string errorResponse
}

public Tuple<string, string> ExtractServiceIdAndApiKey(string fromApiKey)
{
if (fromApiKey.Length < 74 || string.IsNullOrWhiteSpace(fromApiKey) || fromApiKey.Contains(" "))
{
#if NET6_0_OR_GREATER
if (string.IsNullOrWhiteSpace(fromApiKey) || fromApiKey.Length < 74 || fromApiKey.Contains(' '))
#else
if (string.IsNullOrWhiteSpace(fromApiKey) || fromApiKey.Length < 74 || fromApiKey.Contains(" "))
Comment on lines +151 to +153
Copy link
Author

@euantorano euantorano Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the original code the .Length property was accessed before checking for null, which led to NullReferenceExceptions when an api key was not set.

#endif
{
throw new NotifyAuthException("The API Key provided is invalid. Please ensure you are using a v2 API Key that is not empty or null");
}
Expand All @@ -144,15 +165,14 @@ public Tuple<string, string> ExtractServiceIdAndApiKey(string fromApiKey)
public Uri ValidateBaseUri(string baseUrl)
{
var result = Uri.TryCreate(baseUrl, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == "http" || uriResult.Scheme == "https");
&& uriResult.Scheme is "http" or "https";

if (!result)
{
throw new NotifyAuthException("Invalid URL provided");
}

return uriResult;

}

public string GetUserAgent()
Expand Down
130 changes: 75 additions & 55 deletions src/GovukNotify/Client/HttpClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,55 +1,75 @@
using Notify.Interfaces;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace Notify.Client
{
public class HttpClientWrapper : IHttpClient
{
private readonly HttpClient _client;
private Uri _baseAddress;

public Uri BaseAddress
{
get => _baseAddress;
set
{
_baseAddress = value;
SetClientBaseAddress();
}
}

public HttpClientWrapper(HttpClient client)
{
_client = client;
}

public void Dispose()
{
_client.Dispose();
}

public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
return await _client.SendAsync(request).ConfigureAwait(false);
}

public void SetClientBaseAddress()
{
_client.BaseAddress = _baseAddress;
}

public void AddContentHeader(string header)
{
_client.DefaultRequestHeaders.Accept
.Add(new MediaTypeWithQualityHeaderValue(header));
}

public void AddUserAgent(string userAgent)
{
_client.DefaultRequestHeaders.Add("User-Agent", userAgent);
}
}
}
using Notify.Interfaces;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;

namespace Notify.Client
{
public class HttpClientWrapper : IHttpClient
{
private readonly HttpClient _client;

private Uri _baseAddress;

private bool _disposed;

public Uri BaseAddress
{
get => _baseAddress;
set
{
_baseAddress = value;
SetClientBaseAddress();
}
}

public HttpClientWrapper(HttpClient client)
{
_client = client;
}

public void Dispose()
{
Dispose(true);
}

protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}

if (disposing)
{
_client.Dispose();
}

_disposed = true;
}

public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
{
return await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
}

public void SetClientBaseAddress()
{
_client.BaseAddress = _baseAddress;
}

public void AddContentHeader(string header)
{
_client.DefaultRequestHeaders.Accept
.Add(new MediaTypeWithQualityHeaderValue(header));
}

public void AddUserAgent(string userAgent)
{
_client.DefaultRequestHeaders.Add("User-Agent", userAgent);
}
}
}
Loading