From 25c8e9b5b00e4aef6c183232c48e40e5c912e134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 31 Oct 2024 13:01:06 +0100 Subject: [PATCH] Implemented PATCH as defined in .NET 6.0 for .NET Standard 2.0 --- .../CloudflareClientTests/DeleteAsyncTest.cs | 6 +- .../CloudflareClientTests/GetAsyncTest.cs | 46 ++++++++- .../CloudflareClientTests/PatchAsyncTest.cs | 6 +- .../CloudflareClientTests/PostAsyncTest.cs | 6 +- .../CloudflareClientTests/PutAsyncTest.cs | 6 +- Cloudflare/CloudflareClient.cs | 95 ++++++------------- Cloudflare/Extensions/HttpClientExtensions.cs | 53 +++++++++++ Cloudflare/Responses/CloudflareResponse.cs | 2 +- 8 files changed, 140 insertions(+), 80 deletions(-) create mode 100644 Cloudflare/Extensions/HttpClientExtensions.cs diff --git a/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs index 4b77b1c..3a1f8a5 100644 --- a/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs @@ -20,7 +20,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests [TestClass] public class DeleteAsyncTest { - private const string _baseUrl = "http://localhost/api/v4/"; + private const string BaseUrl = "http://localhost/api/v4/"; private HttpMessageHandlerMock _httpHandlerMock; private Mock _clientOptionsMock; @@ -37,7 +37,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests .Setup(a => a.AddHeader(It.IsAny())) .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); - _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(BaseUrl); _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); @@ -275,8 +275,8 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests { var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) { - BaseAddress = new Uri(_baseUrl), Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(BaseUrl), }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); diff --git a/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs index 5d94ab1..09caa52 100644 --- a/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs @@ -20,7 +20,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests [TestClass] public class GetAsyncTest { - private const string _baseUrl = "http://localhost/api/v4/"; + private const string BaseUrl = "http://localhost/api/v4/"; private HttpMessageHandlerMock _httpHandlerMock; private Mock _clientOptionsMock; @@ -37,7 +37,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests .Setup(a => a.AddHeader(It.IsAny())) .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); - _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(BaseUrl); _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); @@ -168,6 +168,27 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests } } + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + [ExpectedException(typeof(CloudflareException))] + public async Task ShouldThrowCloudflareExceptionOnStatusCodeWhenDeserializeFails(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock?.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent("", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + // Act + await client.GetAsync("foo"); + + // Assert - CloudflareException + } + [TestMethod] public async Task ShouldReturnPlainText() { @@ -250,6 +271,25 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests await client.GetAsync("some-path"); } + [TestMethod] + [ExpectedException(typeof(CloudflareException))] + public async Task ShouldThrowCloudflareExceptionWhenDeserializeFails() + { + // Arrange + _httpHandlerMock?.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + // Act + await client.GetAsync("foo"); + + // Assert - CloudflareException + } + private void VerifyDefaults() { _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); @@ -275,8 +315,8 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests { var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) { - BaseAddress = new Uri(_baseUrl), Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(BaseUrl), }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); diff --git a/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs index 4833cfc..2ed766c 100644 --- a/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs @@ -20,7 +20,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests [TestClass] public class PatchAsyncTest { - private const string _baseUrl = "https://localhost/api/v4/"; + private const string BaseUrl = "https://localhost/api/v4/"; private HttpMessageHandlerMock _httpHandlerMock; private Mock _clientOptionsMock; @@ -39,7 +39,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests .Setup(a => a.AddHeader(It.IsAny())) .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); - _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(BaseUrl); _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); @@ -335,8 +335,8 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests { var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) { - BaseAddress = new Uri(_baseUrl), Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(BaseUrl), }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); diff --git a/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs index 35150c8..35c8268 100644 --- a/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs @@ -20,7 +20,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests [TestClass] public class PostAsyncTest { - private const string _baseUrl = "https://localhost/api/v4/"; + private const string BaseUrl = "https://localhost/api/v4/"; private HttpMessageHandlerMock _httpHandlerMock; private Mock _clientOptionsMock; @@ -39,7 +39,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests .Setup(a => a.AddHeader(It.IsAny())) .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); - _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(BaseUrl); _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); @@ -440,8 +440,8 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests { var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) { - BaseAddress = new Uri(_baseUrl), Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(BaseUrl), }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); diff --git a/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs index 9f7b5ba..e0d6e81 100644 --- a/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs @@ -20,7 +20,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests [TestClass] public class PutAsyncTest { - private const string _baseUrl = "https://localhost/api/v4/"; + private const string BaseUrl = "https://localhost/api/v4/"; private HttpMessageHandlerMock _httpHandlerMock; private Mock _clientOptionsMock; @@ -39,7 +39,7 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests .Setup(a => a.AddHeader(It.IsAny())) .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); - _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(BaseUrl); _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); @@ -383,8 +383,8 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests { var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) { - BaseAddress = new Uri(_baseUrl), Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(BaseUrl), }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); diff --git a/Cloudflare/CloudflareClient.cs b/Cloudflare/CloudflareClient.cs index 587e791..d78de10 100644 --- a/Cloudflare/CloudflareClient.cs +++ b/Cloudflare/CloudflareClient.cs @@ -17,9 +17,9 @@ namespace AMWD.Net.Api.Cloudflare /// /// Implements the Core of the Cloudflare API client. /// - public partial class CloudflareClient : ICloudflareClient, IDisposable + public class CloudflareClient : ICloudflareClient, IDisposable { - private static readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings + private static readonly JsonSerializerSettings _jsonSerializerSettings = new() { Culture = CultureInfo.InvariantCulture, Formatting = Formatting.None, @@ -100,23 +100,9 @@ namespace AMWD.Net.Api.Cloudflare ValidateRequestPath(requestPath); string requestUrl = BuildRequestUrl(requestPath, queryFilter); + var httpContent = ConvertRequest(request); - HttpContent httpRequestContent; - if (request == null) - { - httpRequestContent = null; - } - else if (request is HttpContent httpContent) - { - httpRequestContent = httpContent; - } - else - { - string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); - httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var response = await _httpClient.PostAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); + var response = await _httpClient.PostAsync(requestUrl, httpContent, cancellationToken).ConfigureAwait(false); return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); } @@ -127,23 +113,9 @@ namespace AMWD.Net.Api.Cloudflare ValidateRequestPath(requestPath); string requestUrl = BuildRequestUrl(requestPath); + var httpContent = ConvertRequest(request); - HttpContent httpRequestContent; - if (request == null) - { - httpRequestContent = null; - } - else if (request is HttpContent httpContent) - { - httpRequestContent = httpContent; - } - else - { - string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); - httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var response = await _httpClient.PutAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); + var response = await _httpClient.PutAsync(requestUrl, httpContent, cancellationToken).ConfigureAwait(false); return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); } @@ -166,31 +138,9 @@ namespace AMWD.Net.Api.Cloudflare ValidateRequestPath(requestPath); string requestUrl = BuildRequestUrl(requestPath); + var httpContent = ConvertRequest(request); - HttpContent httpRequestContent; - if (request is HttpContent httpContent) - { - httpRequestContent = httpContent; - } - else - { - string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); - httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); - } - -#if NET6_0_OR_GREATER - var response = await _httpClient.PatchAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); -#else - var httpRequestMessage = new HttpRequestMessage - { - Version = HttpVersion.Version11, - Method = new HttpMethod("PATCH"), - RequestUri = new Uri(requestUrl), - Content = httpRequestContent, - }; - var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); -#endif - + var response = await _httpClient.PatchAsync(requestUrl, httpContent, cancellationToken).ConfigureAwait(false); return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); } @@ -266,24 +216,29 @@ namespace AMWD.Net.Api.Cloudflare return client; } - private async Task> GetCloudflareResponse(HttpResponseMessage response, CancellationToken cancellationToken) + private static async Task> GetCloudflareResponse(HttpResponseMessage httpResponse, CancellationToken cancellationToken) { #if NET6_0_OR_GREATER - string content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + string content = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); #else - string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + string content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - switch (response.StatusCode) + switch (httpResponse.StatusCode) { case HttpStatusCode.Forbidden: case HttpStatusCode.Unauthorized: - var errorResponse = JsonConvert.DeserializeObject>(content); + var errorResponse = JsonConvert.DeserializeObject>(content, _jsonSerializerSettings) + ?? throw new CloudflareException("Response is not a valid Cloudflare API response."); + throw new AuthenticationException(string.Join(Environment.NewLine, errorResponse.Errors.Select(e => $"{e.Code}: {e.Message}"))); default: try { - return JsonConvert.DeserializeObject>(content); + var response = JsonConvert.DeserializeObject>(content) + ?? throw new CloudflareException("Response is not a valid Cloudflare API response."); + + return response; } catch { @@ -328,5 +283,17 @@ namespace AMWD.Net.Api.Cloudflare return $"{requestPath}?{query}"; } + + private static HttpContent ConvertRequest(T request) + { + if (request == null) + return null; + + if (request is HttpContent httpContent) + return httpContent; + + string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); + return new StringContent(json, Encoding.UTF8, "application/json"); + } } } diff --git a/Cloudflare/Extensions/HttpClientExtensions.cs b/Cloudflare/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..b21f04a --- /dev/null +++ b/Cloudflare/Extensions/HttpClientExtensions.cs @@ -0,0 +1,53 @@ +#if ! NET6_0_OR_GREATER + +using System.Threading; +using System.Threading.Tasks; + +namespace System.Net.Http +{ + /// + /// Extension methods for s. + /// + /// + /// Copied from .NET 6 runtime / HttpClient. + /// + [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class HttpClientExtensions + { + private static readonly HttpMethod _httpMethodPatch = new("PATCH"); + + /// + /// Sends a PATCH request with a cancellation token to a Uri represented as a string as an asynchronous operation. + /// + /// A instance. + /// The Uri the request is sent to. + /// The HTTP request content sent to the server. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The task object representing the asynchronous operation. + public static Task PatchAsync(this HttpClient client, string? requestUri, HttpContent? content, CancellationToken cancellationToken) => + client.PatchAsync(CreateUri(requestUri), content, cancellationToken); + + /// + /// Sends a PATCH request with a cancellation token as an asynchronous operation. + /// + /// A instance. + /// The Uri the request is sent to. + /// The HTTP request content sent to the server. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The task object representing the asynchrnous operation. + public static Task PatchAsync(this HttpClient client, Uri? requestUri, HttpContent? content, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(_httpMethodPatch, requestUri) + { + Version = HttpVersion.Version11, + Content = content, + }; + return client.SendAsync(request, cancellationToken); + } + + private static Uri? CreateUri(string? uri) => + string.IsNullOrEmpty(uri) ? null : new Uri(uri, UriKind.RelativeOrAbsolute); + } +} + +#endif diff --git a/Cloudflare/Responses/CloudflareResponse.cs b/Cloudflare/Responses/CloudflareResponse.cs index ec48547..772a023 100644 --- a/Cloudflare/Responses/CloudflareResponse.cs +++ b/Cloudflare/Responses/CloudflareResponse.cs @@ -35,7 +35,7 @@ namespace AMWD.Net.Api.Cloudflare /// /// The base Cloudflare response with a result. /// - /// + /// The result type. public class CloudflareResponse : CloudflareResponse { ///