diff --git a/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs index f0ff5fc..35150c8 100644 --- a/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs @@ -200,6 +200,54 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests VerifyNoOtherCalls(); } + [TestMethod] + public async Task ShouldPostWithoutContent() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{""success"": true, ""errors"": [], ""messages"": [], ""result"": { ""string"": ""some-string"", ""integer"": 123 }}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + // Act + var response = await client.PostAsync("posting", null); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Errors); + Assert.IsNotNull(response.Messages); + Assert.IsNull(response.ResultInfo); + + Assert.AreEqual(0, response.Errors.Count); + Assert.AreEqual(0, response.Messages.Count); + + Assert.IsNotNull(response.Result); + Assert.AreEqual("some-string", response.Result.Str); + Assert.AreEqual(123, response.Result.Int); + + Assert.AreEqual(1, _httpHandlerMock.Callbacks.Count); + + var callback = _httpHandlerMock.Callbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.Method); + Assert.AreEqual("https://localhost/api/v4/posting", callback.Url); + Assert.IsNull(callback.Content); + + Assert.AreEqual(3, callback.Headers.Count); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Bearer Some-API-Token", callback.Headers["Authorization"]); + Assert.AreEqual("AMWD.CloudflareClient/1.0.0", callback.Headers["User-Agent"]); + + _httpHandlerMock.Mock.Protected().Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + } + [DataTestMethod] [DataRow(HttpStatusCode.Unauthorized)] [DataRow(HttpStatusCode.Forbidden)] @@ -310,6 +358,63 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests await client.PostAsync("some-path", _request); } + [TestMethod] + public async Task ShouldOnlySerializeNonNullValues() + { + // Arrange + _request.Str = null; + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is an awesome text ;-)", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + var response = await client.PostAsync("path", _request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Errors); + Assert.IsNotNull(response.Messages); + Assert.IsNotNull(response.ResultInfo); + + Assert.AreEqual(0, response.Errors.Count); + Assert.AreEqual(0, response.Messages.Count); + + Assert.AreEqual(0, response.ResultInfo.Count); + Assert.AreEqual(0, response.ResultInfo.Page); + Assert.AreEqual(0, response.ResultInfo.PerPage); + Assert.AreEqual(0, response.ResultInfo.TotalCount); + + Assert.AreEqual("This is an awesome text ;-)", response.Result); + + Assert.AreEqual(1, _httpHandlerMock.Callbacks.Count); + + var callback = _httpHandlerMock.Callbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.Method); + Assert.AreEqual("https://localhost/api/v4/path", callback.Url); + Assert.AreEqual(@"{""integer"":54321}", callback.Content); + + Assert.AreEqual(3, callback.Headers.Count); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Bearer Some-API-Token", callback.Headers["Authorization"]); + Assert.AreEqual("AMWD.CloudflareClient/1.0.0", callback.Headers["User-Agent"]); + + _httpHandlerMock.Mock.Protected().Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + private void VerifyDefaults() { _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); diff --git a/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs index 294bcd5..9f7b5ba 100644 --- a/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs +++ b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs @@ -200,6 +200,54 @@ namespace Cloudflare.Core.Tests.CloudflareClientTests VerifyNoOtherCalls(); } + [TestMethod] + public async Task ShouldPutWithoutContent() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{""success"": true, ""errors"": [], ""messages"": [], ""result"": { ""string"": ""some-string"", ""integer"": 123 }}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + // Act + var response = await client.PutAsync("putput", null); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Errors); + Assert.IsNotNull(response.Messages); + Assert.IsNull(response.ResultInfo); + + Assert.AreEqual(0, response.Errors.Count); + Assert.AreEqual(0, response.Messages.Count); + + Assert.IsNotNull(response.Result); + Assert.AreEqual("some-string", response.Result.Str); + Assert.AreEqual(123, response.Result.Int); + + Assert.AreEqual(1, _httpHandlerMock.Callbacks.Count); + + var callback = _httpHandlerMock.Callbacks.First(); + Assert.AreEqual(HttpMethod.Put, callback.Method); + Assert.AreEqual("https://localhost/api/v4/putput", callback.Url); + Assert.IsNull(callback.Content); + + Assert.AreEqual(3, callback.Headers.Count); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Bearer Some-API-Token", callback.Headers["Authorization"]); + Assert.AreEqual("AMWD.CloudflareClient/1.0.0", callback.Headers["User-Agent"]); + + _httpHandlerMock.Mock.Protected().Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + } + [DataTestMethod] [DataRow(HttpStatusCode.Unauthorized)] [DataRow(HttpStatusCode.Forbidden)] diff --git a/Cloudflare.Tests/Extensions/StringExtensionsTest.cs b/Cloudflare.Tests/Extensions/StringExtensionsTest.cs new file mode 100644 index 0000000..3f6428d --- /dev/null +++ b/Cloudflare.Tests/Extensions/StringExtensionsTest.cs @@ -0,0 +1,131 @@ +using System; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Tests.Extensions +{ + [TestClass] + public class StringExtensionsTest + { + [TestMethod] + public void ShouldValidateId() + { + // Arrange + string id = "023e105f4ecef8ad9ca31a8372d0c353"; + + // Act + id.ValidateCloudflareId(); + + // Assert - no exception thrown + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullExceptionForValidateId(string name) + { + // Arrange + + // Act + name.ValidateCloudflareId(); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArgumentOutOfRangeExceptionForValidateId() + { + // Arrange + string id = new('a', 33); + + // Act + id.ValidateCloudflareId(); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldValidateName() + { + // Arrange + string name = "Example Account Name"; + + // Act + name.ValidateCloudflareName(); + + // Assert - no exception thrown + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullExceptionForValidateName(string name) + { + // Arrange + + // Act + name.ValidateCloudflareName(); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArgumentOutOfRangeExceptionForValidateName() + { + // Arrange + string name = new('a', 254); + + // Act + name.ValidateCloudflareName(); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldValidateEmail() + { + // Arrange + string email = "test@example.com"; + + // Act + email.ValidateCloudflareEmailAddress(); + + // Assert - no exception thrown + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullExceptionForValidateEmail(string email) + { + // Arrange + + // Act + email.ValidateCloudflareEmailAddress(); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow("test")] + [DataRow("test@example")] + [DataRow("example.com")] + [ExpectedException(typeof(ArgumentException))] + public void ShouldThrowArgumentExceptionForValidateEmail(string email) + { + // Arrange + + // Act + email.ValidateCloudflareEmailAddress(); + + // Assert - ArgumentException + } + } +} diff --git a/Cloudflare/Auth/ApiKeyAuthentication.cs b/Cloudflare/Auth/ApiKeyAuthentication.cs index fe255be..ff27398 100644 --- a/Cloudflare/Auth/ApiKeyAuthentication.cs +++ b/Cloudflare/Auth/ApiKeyAuthentication.cs @@ -1,6 +1,5 @@ using System; using System.Net.Http; -using System.Text.RegularExpressions; namespace AMWD.Net.Api.Cloudflare.Auth { @@ -9,7 +8,6 @@ namespace AMWD.Net.Api.Cloudflare.Auth /// public class ApiKeyAuthentication : IAuthentication { - private static readonly Regex _emailCheckRegex = new(@"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$", RegexOptions.Compiled); private readonly string _emailAddress; private readonly string _apiKey; @@ -20,15 +18,11 @@ namespace AMWD.Net.Api.Cloudflare.Auth /// The global API key. public ApiKeyAuthentication(string emailAddress, string apiKey) { - if (string.IsNullOrWhiteSpace(emailAddress)) - throw new ArgumentNullException(nameof(emailAddress)); + emailAddress.ValidateCloudflareEmailAddress(); if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentNullException(nameof(apiKey)); - if (!_emailCheckRegex.IsMatch(emailAddress)) - throw new ArgumentException("Invalid email address", nameof(emailAddress)); - _emailAddress = emailAddress; _apiKey = apiKey; } diff --git a/Cloudflare/CloudflareClient.cs b/Cloudflare/CloudflareClient.cs index 7469ef7..587e791 100644 --- a/Cloudflare/CloudflareClient.cs +++ b/Cloudflare/CloudflareClient.cs @@ -94,15 +94,19 @@ namespace AMWD.Net.Api.Cloudflare } /// - public async Task> PostAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default) + public async Task> PostAsync(string requestPath, TRequest request, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); ValidateRequestPath(requestPath); - string requestUrl = BuildRequestUrl(requestPath); + string requestUrl = BuildRequestUrl(requestPath, queryFilter); HttpContent httpRequestContent; - if (request is HttpContent httpContent) + if (request == null) + { + httpRequestContent = null; + } + else if (request is HttpContent httpContent) { httpRequestContent = httpContent; } @@ -125,7 +129,11 @@ namespace AMWD.Net.Api.Cloudflare string requestUrl = BuildRequestUrl(requestPath); HttpContent httpRequestContent; - if (request is HttpContent httpContent) + if (request == null) + { + httpRequestContent = null; + } + else if (request is HttpContent httpContent) { httpRequestContent = httpContent; } @@ -140,12 +148,12 @@ namespace AMWD.Net.Api.Cloudflare } /// - public async Task> DeleteAsync(string requestPath, CancellationToken cancellationToken = default) + public async Task> DeleteAsync(string requestPath, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); ValidateRequestPath(requestPath); - string requestUrl = BuildRequestUrl(requestPath); + string requestUrl = BuildRequestUrl(requestPath, queryFilter); var response = await _httpClient.DeleteAsync(requestUrl, cancellationToken).ConfigureAwait(false); return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); @@ -285,7 +293,7 @@ namespace AMWD.Net.Api.Cloudflare return new CloudflareResponse { Success = true, - ResultInfo = new ResultInfo(), + ResultInfo = new PaginationInfo(), Result = (TRes)cObj, }; } diff --git a/Cloudflare/Enums/OrderDirection.cs b/Cloudflare/Enums/SortDirection.cs similarity index 63% rename from Cloudflare/Enums/OrderDirection.cs rename to Cloudflare/Enums/SortDirection.cs index f16101a..71c5300 100644 --- a/Cloudflare/Enums/OrderDirection.cs +++ b/Cloudflare/Enums/SortDirection.cs @@ -4,21 +4,21 @@ using Newtonsoft.Json.Converters; namespace AMWD.Net.Api.Cloudflare { /// - /// The direction to order the entity. + /// The direction to sort the entity. /// [JsonConverter(typeof(StringEnumConverter))] - public enum OrderDirection + public enum SortDirection { /// - /// Order in ascending order. + /// Sort in ascending order. /// [EnumMember(Value = "asc")] - Asc = 1, + Ascending = 1, /// - /// Order in descending order. + /// Sort in descending order. /// [EnumMember(Value = "desc")] - Desc = 2 + Descending = 2 } } diff --git a/Cloudflare/Extensions/StringExtensions.cs b/Cloudflare/Extensions/StringExtensions.cs new file mode 100644 index 0000000..4117bc6 --- /dev/null +++ b/Cloudflare/Extensions/StringExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.Text.RegularExpressions; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Extension methods for s. + /// + public static class StringExtensions + { + private static readonly Regex _emailCheckRegex = new(@"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$", RegexOptions.Compiled); + + /// + /// Validate basic information for a Cloudflare ID. + /// + /// + /// An Cloudflare ID has max. 32 characters. + /// + /// The string to check. + /// The is or any kind of whitespace. + /// The has more than 32 characters. + public static void ValidateCloudflareId(this string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentNullException(nameof(id)); + + if (id.Length > 32) + throw new ArgumentOutOfRangeException(nameof(id)); + } + + /// + /// Validate basic information for a Cloudflare name. + /// + /// + /// An Cloudflare name has max. 253 characters. + /// + /// The string to check. + /// The is or any kind of whitespace. + /// The has more than 253 characters. + public static void ValidateCloudflareName(this string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name)); + + if (name.Length > 253) + throw new ArgumentOutOfRangeException(nameof(name)); + } + + /// + /// Validate basic information for an email address. + /// + /// The string to check. + /// The is or any kind of whitespace. + /// The does not match the regular expression pattern for an email address. + public static void ValidateCloudflareEmailAddress(this string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + throw new ArgumentNullException(nameof(emailAddress)); + + if (!_emailCheckRegex.IsMatch(emailAddress)) + throw new ArgumentException("Invalid email address", nameof(emailAddress)); + } + } +} diff --git a/Cloudflare/Interfaces/ICloudflareClient.cs b/Cloudflare/Interfaces/ICloudflareClient.cs index 32fc851..03e985b 100644 --- a/Cloudflare/Interfaces/ICloudflareClient.cs +++ b/Cloudflare/Interfaces/ICloudflareClient.cs @@ -31,8 +31,9 @@ namespace AMWD.Net.Api.Cloudflare /// The request type. /// The request path (extending the base URL). /// The request content. + /// The query parameters. /// A cancellation token used to propagate notification that this operation should be canceled. - Task> PostAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default); + Task> PostAsync(string requestPath, TRequest request, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default); /// /// Makes a PUT request to the Cloudflare API. @@ -55,9 +56,10 @@ namespace AMWD.Net.Api.Cloudflare /// /// The response type. /// The request path (extending the base URL). + /// The query parameters. /// A cancellation token used to propagate notification that this operation should be canceled. /// - Task> DeleteAsync(string requestPath, CancellationToken cancellationToken = default); + Task> DeleteAsync(string requestPath, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default); /// /// Makes a PATCH request to the Cloudflare API. diff --git a/Cloudflare/Responses/CloudflareResponse.cs b/Cloudflare/Responses/CloudflareResponse.cs index b5a88f1..ec48547 100644 --- a/Cloudflare/Responses/CloudflareResponse.cs +++ b/Cloudflare/Responses/CloudflareResponse.cs @@ -11,7 +11,7 @@ namespace AMWD.Net.Api.Cloudflare /// Information about the result of the request. /// [JsonProperty("result_info")] - public ResultInfo ResultInfo { get; set; } + public PaginationInfo ResultInfo { get; set; } /// /// Whether the API call was successful. diff --git a/Cloudflare/Responses/ResultInfo.cs b/Cloudflare/Responses/PaginationInfo.cs similarity index 87% rename from Cloudflare/Responses/ResultInfo.cs rename to Cloudflare/Responses/PaginationInfo.cs index 1635d86..523666a 100644 --- a/Cloudflare/Responses/ResultInfo.cs +++ b/Cloudflare/Responses/PaginationInfo.cs @@ -1,9 +1,9 @@ namespace AMWD.Net.Api.Cloudflare { /// - /// Cloudflare Result Information. + /// Cloudflare pagination information. /// - public class ResultInfo + public class PaginationInfo { /// /// Total number of results for the requested service. diff --git a/Directory.Build.props b/Directory.Build.props index 43a77c9..c789469 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,6 +6,8 @@ AM.WD Andreas Müller © {copyright:2024-} AM.WD + + 0024000004800000940000000602000000240000525341310004000001000100a96b0435a48fcae5d097c19c0c3a312d0316c1217a7d5984236f430625510dfdbedc3ffdaea7b3bad77adbe5d85cecdd788a43cd02a8a4950313587bbcb804ff2ef68346f9d6a79f79338e4f12293f216df0536d2b05ab7977b6c50946a42422cb1ddc109c0151a3d65fbe636ce6734070fb6e3eaf000a33ac6a36cab5292ed1 diff --git a/Extensions/Cloudflare.Zones/Cache/InternalRequests/PurgeRequest.cs b/Extensions/Cloudflare.Zones/Cache/InternalRequests/PurgeRequest.cs new file mode 100644 index 0000000..2091f21 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Cache/InternalRequests/PurgeRequest.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests +{ + internal class PurgeRequest + { + [JsonProperty("purge_everything")] + public bool? PurgeEverything { get; set; } + + [JsonProperty("tags")] + public IList Tags { get; set; } + + [JsonProperty("hosts")] + public IList Hostnames { get; set; } + + [JsonProperty("prefixes")] + public IList Prefixes { get; set; } + + [JsonProperty("files")] + public IList Urls { get; set; } + + [JsonProperty("files")] + public IList UrlsWithHeaders { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Cache/InternalRequests/UrlWithHeaders.cs b/Extensions/Cloudflare.Zones/Cache/InternalRequests/UrlWithHeaders.cs new file mode 100644 index 0000000..19e9d59 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Cache/InternalRequests/UrlWithHeaders.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests +{ + internal class UrlWithHeaders + { + [JsonProperty("headers")] + public Dictionary Headers { get; set; } = []; + + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Cache/Requests/ZonePurgeCachedUrlRequest.cs b/Extensions/Cloudflare.Zones/Cache/Requests/ZonePurgeCachedUrlRequest.cs new file mode 100644 index 0000000..86bca0b --- /dev/null +++ b/Extensions/Cloudflare.Zones/Cache/Requests/ZonePurgeCachedUrlRequest.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Url with headers to purge. + /// + public class ZonePurgeCachedUrlRequest + { + /// + /// Defined headers to specifiy the purge request. + /// + public Dictionary Headers { get; set; } = []; + + /// + /// The file url to purge. + /// + public string Url { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Cache/ZoneCacheExtensions.cs b/Extensions/Cloudflare.Zones/Cache/ZoneCacheExtensions.cs new file mode 100644 index 0000000..d4694e1 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Cache/ZoneCacheExtensions.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Extends the with methods for working with zones. + /// + public static class ZoneCacheExtensions + { + /// + /// Purges all cached contents for a zone. + /// + /// + /// Removes ALL files from Cloudflare's cache. All tiers can purge everything. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> PurgeCachedContent(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + var req = new PurgeRequest + { + PurgeEverything = true + }; + return client.PostAsync($"zones/{zoneId}/purge_cache", req, cancellationToken: cancellationToken); + } + + /// + /// Purges cached contents by URLs. + ///
+ /// Granularly removes one or more files from Cloudflare's cache by specifying URLs. + /// All tiers can purge by URL. + ///
+ /// + /// + /// To purge files with custom cache keys, include the headers used to compute the cache key as in the example. + /// If you have a device type or geo in your cache key, you will need to include the CF-Device-Type or CF-IPCountry headers. + /// If you have lang in your cache key, you will need to include the Accept-Language header. + /// + /// + /// NB: When including the Origin header, be sure to include the scheme and hostname. + /// The port number can be omitted if it is the default port (80 for http, 443 for https), but must be included otherwise. + /// + /// + /// NB: For Zones on Free/Pro/Business plan, you may purge up to 30 URLs in one API call. + /// For Zones on Enterprise plan, you may purge up to 500 URLs in one API call. + /// + /// + /// The . + /// The zone ID. + /// List of URLs to purge. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> PurgeCachedContentByUrl(this ICloudflareClient client, string zoneId, IReadOnlyList urls, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + if (urls == null) + throw new ArgumentNullException(nameof(urls)); + + var req = new PurgeRequest(); + + if (urls.Any(u => u.Headers.Count > 0)) + { + req.UrlsWithHeaders = urls.Where(u => !string.IsNullOrWhiteSpace(u.Url)) + .Select(u => new UrlWithHeaders + { + Url = u.Url, + Headers = u.Headers + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + }).ToList(); + } + else + { + req.Urls = urls.Where(u => !string.IsNullOrWhiteSpace(u.Url)).Select(u => u.Url).ToList(); + } + + return client.PostAsync($"zones/{zoneId}/purge_cache", req, cancellationToken: cancellationToken); + } + + /// + /// Purges cached contents by cache-tags. + /// + /// + /// + /// For more information on cache tags and purging by tags, please refer to + /// purge by cache-tags documentation page. + /// + /// + /// Cache-Tag purging has a rate limit of 30_000 purge API calls in every 24 hour period. + /// You may purge up to 30 tags in one API call. + /// + /// + /// This rate limit can be raised for customers who need to purge at higher volume. + /// + /// + /// The . + /// The zone ID. + /// List of tags to purge. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> PurgeCachedContentByTag(this ICloudflareClient client, string zoneId, IReadOnlyList tags, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + if (tags == null) + throw new ArgumentNullException(nameof(tags)); + + var req = new PurgeRequest + { + Tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() + }; + return client.PostAsync($"zones/{zoneId}/purge_cache", req, cancellationToken: cancellationToken); + } + + /// + /// Purges cached contents by hosts. + /// + /// + /// + /// For more information purging by hostnames, please refer to + /// purge by hostname documentation page. + /// + /// + /// Host purging has a rate limit of 30_000 purge API calls in every 24 hour period. + /// You may purge up to 30 hosts in one API call. + /// + /// + /// This rate limit can be raised for customers who need to purge at higher volume. + /// + /// + /// The . + /// The zone ID. + /// List of hostnames to purge. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> PurgeCachedContentByHost(this ICloudflareClient client, string zoneId, IReadOnlyList hosts, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + if (hosts == null) + throw new ArgumentNullException(nameof(hosts)); + + var req = new PurgeRequest + { + Hostnames = hosts.Where(h => !string.IsNullOrWhiteSpace(h)).ToList() + }; + return client.PostAsync($"zones/{zoneId}/purge_cache", req, cancellationToken: cancellationToken); + } + + /// + /// Purges cached contents by prefixes. + /// + /// + /// + /// For more information purging by prefixes, please refer to + /// purge by prefix documentation page. + /// + /// + /// Prefix purging has a rate limit of 30_000 purge API calls in every 24 hour period. + /// You may purge up to 30 prefixes in one API call. + /// + /// + /// This rate limit can be raised for customers who need to purge at higher volume. + /// + /// + /// The . + /// The zone ID. + /// List of prefixes to purge. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> PurgeCachedContentByPrefix(this ICloudflareClient client, string zoneId, IReadOnlyList prefixes, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + if (prefixes == null) + throw new ArgumentNullException(nameof(prefixes)); + + var req = new PurgeRequest + { + Prefixes = prefixes.Where(h => !string.IsNullOrWhiteSpace(h)).ToList() + }; + return client.PostAsync($"zones/{zoneId}/purge_cache", req, cancellationToken: cancellationToken); + } + } +} diff --git a/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj index de8d9a7..4011bae 100644 --- a/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj +++ b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj @@ -17,4 +17,8 @@ true
+ + + + diff --git a/Extensions/Cloudflare.Zones/Hold/Filters/CreateZoneHoldFilter.cs b/Extensions/Cloudflare.Zones/Hold/Filters/CreateZoneHoldFilter.cs new file mode 100644 index 0000000..5a5023f --- /dev/null +++ b/Extensions/Cloudflare.Zones/Hold/Filters/CreateZoneHoldFilter.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + internal class CreateZoneHoldFilter : IQueryParameterFilter + { + public bool IncludeSubdomains { get; set; } + + public IDictionary GetQueryParameters() + { + var dict = new Dictionary(); + + if (IncludeSubdomains) + dict.Add("include_subdomains", "true"); + + return dict; + } + } +} diff --git a/Extensions/Cloudflare.Zones/Hold/Filters/DeleteZoneHoldFilter.cs b/Extensions/Cloudflare.Zones/Hold/Filters/DeleteZoneHoldFilter.cs new file mode 100644 index 0000000..a1204d4 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Hold/Filters/DeleteZoneHoldFilter.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + internal class DeleteZoneHoldFilter : IQueryParameterFilter + { + public DateTime? HoldAfter { get; set; } + + public IDictionary GetQueryParameters() + { + var dict = new Dictionary(); + + if (HoldAfter.HasValue) + dict.Add("hold_after", HoldAfter.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")); + + return dict; + } + } +} diff --git a/Extensions/Cloudflare.Zones/Hold/ZoneHoldExtensions.cs b/Extensions/Cloudflare.Zones/Hold/ZoneHoldExtensions.cs new file mode 100644 index 0000000..045ed00 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Hold/ZoneHoldExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Extends the with methods for working with zones. + /// + public static class ZoneHoldExtensions + { + /// + /// Retrieve whether the zone is subject to a zone hold, and metadata about the hold. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> GetZoneHold(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return client.GetAsync($"zones/{zoneId}/hold", cancellationToken: cancellationToken); + } + + /// + /// Enforce a zone hold on the zone, blocking the creation and activation of zones with this zone's hostname. + /// + /// The . + /// The zone ID. + /// + /// If set, the zone hold will extend to block any subdomain of the given zone, as well as SSL4SaaS Custom Hostnames. + /// For example, a zone hold on a zone with the hostname 'example.com' and = will block 'example.com', 'staging.example.com', 'api.staging.example.com', etc. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> CreateZoneHold(this ICloudflareClient client, string zoneId, bool includeSubdomains = false, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + var filter = new CreateZoneHoldFilter + { + IncludeSubdomains = includeSubdomains + }; + return client.PostAsync($"zones/{zoneId}/hold", null, filter, cancellationToken); + } + + /// + /// Stop enforcement of a zone hold on the zone, permanently or temporarily, allowing the creation and activation of zones with this zone's hostname. + /// + /// The . + /// The zone ID. + /// + /// If is provided, the hold will be temporarily disabled, then automatically re-enabled by the system at the time specified. + /// Otherwise, the hold will be disabled indefinitely. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DeleteZoneHold(this ICloudflareClient client, string zoneId, DateTime? holdAfter = null, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + var filter = new DeleteZoneHoldFilter + { + HoldAfter = holdAfter + }; + return client.DeleteAsync($"zones/{zoneId}/hold", filter, cancellationToken); + } + } +} diff --git a/Extensions/Cloudflare.Zones/Models/Zone.cs b/Extensions/Cloudflare.Zones/Models/Zone.cs new file mode 100644 index 0000000..e7c2952 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/Zone.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// A DNS Zone. + /// + public class Zone + { + /// + /// Identifier. + /// + // <= 32 characters + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The account the zone belongs to. + /// + [JsonProperty("account")] + public AccountBase Account { get; set; } + + /// + /// The last time proof of ownership was detected and the zone was made active. + /// + [JsonProperty("activated_on")] + public DateTime ActivatedOn { get; set; } + + /// + /// When the zone was created. + /// + [JsonProperty("created_on")] + public DateTime CreatedOn { get; set; } + + /// + /// The interval (in seconds) from when development mode expires (positive integer) + /// or last expired (negative integer) for the domain. + /// If development mode has never been enabled, this value is 0. + /// + [JsonProperty("development_mode")] + public int DevelopmentMode { get; set; } + + /// + /// Metadata about the zone. + /// + [JsonProperty("meta")] + public ZoneMetaData Meta { get; set; } + + /// + /// When the zone was last modified. + /// + [JsonProperty("modified_on")] + public DateTime ModifiedOn { get; set; } + + /// + /// The domain name. + /// + // <= 253 characters + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The name servers Cloudflare assigns to a zone. + /// + [JsonProperty("name_servers")] + public IReadOnlyList NameServers { get; set; } + + /// + /// DNS host at the time of switching to Cloudflare. + /// + [JsonProperty("original_dnshost")] + public string OriginalDnshost { get; set; } + + /// + /// Original name servers before moving to Cloudflare. + /// + [JsonProperty("original_name_servers")] + public IReadOnlyList OriginalNameServers { get; set; } + + /// + /// Registrar for the domain at the time of switching to Cloudflare. + /// + [JsonProperty("original_registrar")] + public string OriginalRegistrar { get; set; } + + /// + /// The owner of the zone. + /// + [JsonProperty("owner")] + public OwnerBase Owner { get; set; } + + /// + /// Indicates whether the zone is only using Cloudflare DNS services. + /// A value means the zone will not receive security or performance benefits. + /// + [JsonProperty("paused")] + public bool Paused { get; set; } + + /// + /// The zone status on Cloudflare. + /// + [JsonProperty("status")] + public ZoneStatus Status { get; set; } + + /// + /// A full zone implies that DNS is hosted with Cloudflare. + /// A partial zone is typically a partner-hosted zone or a CNAME setup.. + /// + [JsonProperty("type")] + public ZoneType Type { get; set; } + + /// + /// An array of domains used for custom name servers. + /// This is only available for Business and Enterprise plans. + /// + [JsonProperty("vanity_name_servers")] + public IReadOnlyList VanityNameServers { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Models/ZoneHold.cs b/Extensions/Cloudflare.Zones/Models/ZoneHold.cs new file mode 100644 index 0000000..5b7ec12 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/ZoneHold.cs @@ -0,0 +1,28 @@ +using System; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// A zone hold. + /// + public class ZoneHold + { + /// + /// Gets or sets a value indicating whether the zone is on hold. + /// + [JsonProperty("hold")] + public bool Hold { get; set; } + + /// + /// Gets or sets an information whether subdomains are included in the hold. + /// + [JsonProperty("include_subdomains")] + public string IncludeSubdomains { get; set; } + + /// + /// Gets or sets the time after which the zone is no longer on hold. + /// + [JsonProperty("hold_after")] + public DateTime HoldAfter { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Models/ZoneIdResponse.cs b/Extensions/Cloudflare.Zones/Models/ZoneIdResponse.cs new file mode 100644 index 0000000..879b548 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/ZoneIdResponse.cs @@ -0,0 +1,15 @@ +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// The deleted zone. + /// + public class ZoneIdResponse + { + /// + /// Identifier. + /// + // <= 32 characters + [JsonProperty("id")] + public string Id { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Models/ZoneMetaData.cs b/Extensions/Cloudflare.Zones/Models/ZoneMetaData.cs new file mode 100644 index 0000000..8cf1050 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/ZoneMetaData.cs @@ -0,0 +1,50 @@ +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// The zone metadata. + /// + public class ZoneMetaData + { + /// + /// The zone is only configured for CDN. + /// + [JsonProperty("cdn_only")] + public bool CdnOnly { get; set; } + + /// + /// Number of Custom Certificates the zone can have. + /// + [JsonProperty("custom_certificate_quota")] + public int CustomCertificateQuota { get; set; } + + /// + /// The zone is only configured for DNS. + /// + [JsonProperty("dns_only")] + public bool DnsOnly { get; set; } + + /// + /// The zone is setup with Foundation DNS. + /// + [JsonProperty("foundation_dns")] + public bool FoundationDns { get; set; } + + /// + /// Number of Page Rules a zone can have. + /// + [JsonProperty("page_rule_quota")] + public int PageRuleQuota { get; set; } + + /// + /// The zone has been flagged for phishing. + /// + [JsonProperty("phishing_detected")] + public bool PhishingDetected { get; set; } + + /// + /// Step. + /// + [JsonProperty("step")] + public int Step { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/README.md b/Extensions/Cloudflare.Zones/README.md index 068fb0c..d168b44 100644 --- a/Extensions/Cloudflare.Zones/README.md +++ b/Extensions/Cloudflare.Zones/README.md @@ -4,13 +4,34 @@ With this extension package, you'll get all features available to manage a Zone ## Methods +### Zone + - [ListZones](https://developers.cloudflare.com/api/operations/zones-get) -- [ZoneDetails](https://developers.cloudflare.com/api/operations/zones-0-get) - [CreateZone](https://developers.cloudflare.com/api/operations/zones-post) - [DeleteZone](https://developers.cloudflare.com/api/operations/zones-0-delete) +- [ZoneDetails](https://developers.cloudflare.com/api/operations/zones-0-get) - [EditZone](https://developers.cloudflare.com/api/operations/zones-0-patch) +- [RerunActivationCheck](https://developers.cloudflare.com/api/operations/put-zones-zone_id-activation_check) +- [PurgeCachedContent](https://developers.cloudflare.com/api/operations/zone-purge) + + +### Zone Holds + +- [DeleteZoneHold](https://developers.cloudflare.com/api/operations/zones-0-hold-delete) +- [GetZoneHold](https://developers.cloudflare.com/api/operations/zones-0-hold-get) +- [CreateZoneHold](https://developers.cloudflare.com/api/operations/zones-0-hold-post) + + +### DNS Settings for a Zone + - TBD + +### DNS Records for a Zone + +- TBD + + --- Published under MIT License (see [choose a license]) diff --git a/Extensions/Cloudflare.Zones/RegexPatterns.cs b/Extensions/Cloudflare.Zones/RegexPatterns.cs new file mode 100644 index 0000000..cede63f --- /dev/null +++ b/Extensions/Cloudflare.Zones/RegexPatterns.cs @@ -0,0 +1,9 @@ +using System.Text.RegularExpressions; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + internal static class RegexPatterns + { + public static readonly Regex ZoneName = new(@"^([a-zA-Z0-9][\-a-zA-Z0-9]*\.)+[\-a-zA-Z0-9]{2,20}$", RegexOptions.Compiled); + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Enums/ZoneStatus.cs b/Extensions/Cloudflare.Zones/Zone/Enums/ZoneStatus.cs new file mode 100644 index 0000000..4573506 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Enums/ZoneStatus.cs @@ -0,0 +1,36 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// A zone status. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum ZoneStatus + { + /// + /// Zone is initializing. + /// + [EnumMember(Value = "initializing")] + Initializing = 1, + + /// + /// Zone is pending. + /// + [EnumMember(Value = "pending")] + Pending = 2, + + /// + /// Zone is active. + /// + [EnumMember(Value = "active")] + Active = 3, + + /// + /// Zone has been moved. + /// + [EnumMember(Value = "moved")] + Moved = 4, + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Enums/ZoneType.cs b/Extensions/Cloudflare.Zones/Zone/Enums/ZoneType.cs new file mode 100644 index 0000000..31a7bbb --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Enums/ZoneType.cs @@ -0,0 +1,46 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Zone type. + /// + /// + /// A full zone implies that DNS is hosted with Cloudflare. + /// A partial zone is typically a partner-hosted zone or a CNAME setup. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum ZoneType + { + /// + /// Full Setup (most common). + /// + /// + /// Use Cloudflare as your primary DNS provider and manage your DNS records on Cloudflare. + /// + [EnumMember(Value = "full")] + Full = 1, + + /// + /// Zone transfers. + /// + /// + /// Use Cloudflare and another DNS provider together across your entire zone to increase availability and fault tolerance. + ///
+ /// DNS records will be transferred between providers using + /// AXFR or IXFR. + ///
+ [EnumMember(Value = "secondary")] + Secondary = 2, + + /// + /// Partial (CNAME) setup. + /// + /// + /// Keep your primary DNS provider and only use Cloudflare's reverse proxy for individual subdomains. + /// + [EnumMember(Value = "partial")] + Partial = 3, + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Enums/ZonesOrderBy.cs b/Extensions/Cloudflare.Zones/Zone/Enums/ZonesOrderBy.cs new file mode 100644 index 0000000..67114e0 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Enums/ZonesOrderBy.cs @@ -0,0 +1,36 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Field to order zones by. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum ZonesOrderBy + { + /// + /// Order by zone name. + /// + [EnumMember(Value = "name")] + Name = 1, + + /// + /// Order by zone status. + /// + [EnumMember(Value = "status")] + Status = 2, + + /// + /// Order by account ID. + /// + [EnumMember(Value = "account.id")] + AccountId = 3, + + /// + /// Order by account name. + /// + [EnumMember(Value = "account.name")] + AccountName = 4, + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Filters/ListZonesFilter.cs b/Extensions/Cloudflare.Zones/Zone/Filters/ListZonesFilter.cs new file mode 100644 index 0000000..b239e6b --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Filters/ListZonesFilter.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Filter for listing zones. + /// + public class ListZonesFilter : IQueryParameterFilter + { + /// + /// An account ID. + /// + /// account.id + public string AccountId { get; set; } + + /// + /// An account Name. + /// + /// + /// Optional filter operators can be provided to extend refine the search: + /// + /// equal (default) + /// not_equal + /// starts_with + /// ends_with + /// contains + /// starts_with_case_sensitive + /// ends_with_case_sensitive + /// contains_case_sensitive + /// + /// + /// Dev Account + /// contains:Test + /// account.name + public string AccountName { get; set; } + + /// + /// Direction to order zones. + /// + /// direction + public SortDirection? OrderDirection { get; set; } + + /// + /// Whether to match all search requirements or at least one (any). + /// + /// match + public FilterMatchType? MatchType { get; set; } + + /// + /// A domain name. + /// + /// + /// Optional filter operators can be provided to extend refine the search: + /// + /// equal (default) + /// not_equal + /// starts_with + /// ends_with + /// contains + /// starts_with_case_sensitive + /// ends_with_case_sensitive + /// contains_case_sensitive + /// + /// + /// example.com + /// contains:.org + /// ends_with:arpa + /// starts_with:dev + /// name + public string Name { get; set; } + + /// + /// Field to order zones by. + /// + /// order + public ZonesOrderBy? OrderBy { get; set; } + + /// + /// Page number of paginated results. + /// + /// page + public int? Page { get; set; } + + /// + /// Number of zones per page. + /// + /// per_page + public int? PerPage { get; set; } + + /// + /// A zone status. + /// + /// status + public ZoneStatus? Status { get; set; } + + /// + public IDictionary GetQueryParameters() + { + var dict = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(AccountId)) + dict.Add("account.id", AccountId); + + if (!string.IsNullOrWhiteSpace(AccountName)) + dict.Add("account.name", AccountName); + + if (OrderDirection.HasValue && Enum.IsDefined(typeof(SortDirection), OrderDirection.Value)) + dict.Add("direction", OrderDirection.Value.GetEnumMemberValue()); + + if (MatchType.HasValue && Enum.IsDefined(typeof(FilterMatchType), MatchType.Value)) + dict.Add("match", MatchType.Value.GetEnumMemberValue()); + + if (!string.IsNullOrWhiteSpace(Name)) + dict.Add("name", Name); + + if (OrderBy.HasValue && Enum.IsDefined(typeof(ZonesOrderBy), OrderBy.Value)) + dict.Add("order", OrderBy.Value.GetEnumMemberValue()); + + if (Page.HasValue && Page.Value >= 1) + dict.Add("page", Page.Value.ToString()); + + if (PerPage.HasValue && PerPage.Value >= 5 && PerPage.Value <= 50) + dict.Add("per_page", PerPage.Value.ToString()); + + if (Status.HasValue && Enum.IsDefined(typeof(ZoneStatus), Status.Value)) + dict.Add("status", Status.Value.GetEnumMemberValue()); + + return dict; + } + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/InternalRequests/CreateRequest.cs b/Extensions/Cloudflare.Zones/Zone/InternalRequests/CreateRequest.cs new file mode 100644 index 0000000..e1b514a --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/InternalRequests/CreateRequest.cs @@ -0,0 +1,14 @@ +namespace AMWD.Net.Api.Cloudflare.Zones.Zones.InternalRequests +{ + internal class CreateRequest + { + [JsonProperty("account")] + public AccountBase Account { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public ZoneType Type { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/InternalRequests/EditRequest.cs b/Extensions/Cloudflare.Zones/Zone/InternalRequests/EditRequest.cs new file mode 100644 index 0000000..07f9b52 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/InternalRequests/EditRequest.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones.Zones.InternalRequests +{ + internal class EditRequest + { + [JsonProperty("type")] + public ZoneType? Type { get; set; } + + [JsonProperty("vanity_name_servers")] + public IList VanityNameServers { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Requests/CreateZoneRequest.cs b/Extensions/Cloudflare.Zones/Zone/Requests/CreateZoneRequest.cs new file mode 100644 index 0000000..ee9d61f --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Requests/CreateZoneRequest.cs @@ -0,0 +1,27 @@ +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Request to create a new zone. + /// + public class CreateZoneRequest + { + /// + /// The account identifier. + /// + public string AccountId { get; set; } + + /// + /// The domain name. + /// + public string Name { get; set; } + + /// + /// The zone type. + /// + /// + /// A full zone implies that DNS is hosted with Cloudflare. + /// A partial zone is typically a partner-hosted zone or a CNAME setup. + /// + public ZoneType Type { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/Requests/EditZoneRequest.cs b/Extensions/Cloudflare.Zones/Zone/Requests/EditZoneRequest.cs new file mode 100644 index 0000000..60abc50 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/Requests/EditZoneRequest.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// A request to edit a zone. + /// + public class EditZoneRequest + { + /// + /// Identifier. + /// + public string Id { get; set; } + + /// + /// A full zone implies that DNS is hosted with Cloudflare. A partial zone is typically a partner-hosted zone or a CNAME setup. + ///
+ /// This parameter is only available to Enterprise customers or if it has been explicitly enabled on a zone. + ///
+ public ZoneType? Type { get; set; } + + /// + /// An array of domains used for custom name servers. + ///
+ /// This is only available for Business and Enterprise plans. + ///
+ public IList VanityNameServers { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Zone/ZoneExtensions.cs b/Extensions/Cloudflare.Zones/Zone/ZoneExtensions.cs new file mode 100644 index 0000000..6c459ec --- /dev/null +++ b/Extensions/Cloudflare.Zones/Zone/ZoneExtensions.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Zones.InternalRequests; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Extends the with methods for working with zones. + /// + public static class ZoneExtensions + { + /// + /// Lists, searches, sorts, and filters your zones. + /// + /// The . + /// Filter options (optional). + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task>> ListZones(this ICloudflareClient client, ListZonesFilter options = null, CancellationToken cancellationToken = default) + { + return client.GetAsync>("zones", options, cancellationToken); + } + + /// + /// Get details for a zone. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> ZoneDetails(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return client.GetAsync($"zones/{zoneId}", cancellationToken: cancellationToken); + } + + /// + /// Create a new zone. + /// + /// The . + /// The request information. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> CreateZone(this ICloudflareClient client, CreateZoneRequest request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + request.AccountId.ValidateCloudflareId(); + request.Name.ValidateCloudflareName(); + + if (!RegexPatterns.ZoneName.IsMatch(request.Name)) + throw new ArgumentException("Does not match the zone name pattern", nameof(request.Name)); + + if (!Enum.IsDefined(typeof(ZoneType), request.Type)) + throw new ArgumentOutOfRangeException(nameof(request.Type)); + + var req = new CreateRequest + { + Account = new AccountBase { Id = request.AccountId }, + Name = request.Name, + Type = request.Type + }; + + return client.PostAsync("zones", req, cancellationToken: cancellationToken); + } + + /// + /// Deletes an existing zone. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DeleteZone(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return client.DeleteAsync($"zones/{zoneId}", cancellationToken: cancellationToken); + } + + /// + /// Edits a zone. + /// + /// + /// Only one zone property can be changed at a time. + /// + /// The . + /// The request information. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> EditZone(this ICloudflareClient client, EditZoneRequest request, CancellationToken cancellationToken = default) + { + request.Id.ValidateCloudflareId(); + + if (request.Type.HasValue && request.VanityNameServers != null) + throw new CloudflareException("Only one zone property can be changed at a time."); + + if (request.Type.HasValue && !Enum.IsDefined(typeof(ZoneType), request.Type.Value)) + throw new ArgumentOutOfRangeException(nameof(request.Type)); + + var req = new EditRequest(); + + if (request.Type.HasValue) + req.Type = request.Type.Value; + + if (request.VanityNameServers != null) + req.VanityNameServers = request.VanityNameServers.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + return client.PatchAsync($"zones/{request.Id}", req, cancellationToken); + } + + // Triggeres a new activation check for a PENDING Zone. This can be triggered every 5 min for paygo/ent customers, every hour for FREE Zones. + + /// + /// Triggeres a new activation check for a zone. + /// + /// + /// This can be triggered every 5 min for paygo/enterprise customers, every hour for FREE Zones. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> RerunActivationCheck(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return client.PutAsync($"zones/{zoneId}/activation_check", null, cancellationToken); + } + } +} diff --git a/Extensions/Directory.Build.props b/Extensions/Directory.Build.props index db5b780..63b103a 100644 --- a/Extensions/Directory.Build.props +++ b/Extensions/Directory.Build.props @@ -32,19 +32,15 @@ + + + + - - - - - - - - all @@ -52,5 +48,12 @@ + + + + + + + diff --git a/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByHostTest.cs b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByHostTest.cs new file mode 100644 index 0000000..d13e996 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByHostTest.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Zones.Tests.Cache +{ + [TestClass] + public class PurgeCachedContentByHostTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, PurgeRequest Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId, + } + }; + } + + [TestMethod] + public async Task ShouldPurgeCachedContent() + { + // Arrange + var list = new List + { + "www.example.com", + "example.org", + }; + + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContentByHost(ZoneId, list); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNull(callback.Request.PurgeEverything); + Assert.IsNull(callback.Request.Urls); + Assert.IsNull(callback.Request.UrlsWithHeaders); + Assert.IsNull(callback.Request.Tags); + Assert.IsNotNull(callback.Request.Hostnames); + Assert.AreEqual(2, callback.Request.Hostnames.Count); + Assert.AreEqual("www.example.com", callback.Request.Hostnames[0]); + Assert.AreEqual("example.org", callback.Request.Hostnames[1]); + Assert.IsNull(callback.Request.Prefixes); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumenNullExceptionIfListIsNull() + { + // Arrange + var client = GetClient(); + + // Act + await client.PurgeCachedContentByHost(ZoneId, null); + + // Assert - ArgumentNullException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByPrefixTest.cs b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByPrefixTest.cs new file mode 100644 index 0000000..cd41da1 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByPrefixTest.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Zones.Tests.Cache +{ + [TestClass] + public class PurgeCachedContentByPrefixTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, PurgeRequest Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId, + } + }; + } + + [TestMethod] + public async Task ShouldPurgeCachedContent() + { + // Arrange + var list = new List + { + null, + "example.com/foo", + "example.com/bar", + "example.org/hello/world", + }; + + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContentByPrefix(ZoneId, list); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNull(callback.Request.PurgeEverything); + Assert.IsNull(callback.Request.Urls); + Assert.IsNull(callback.Request.UrlsWithHeaders); + Assert.IsNull(callback.Request.Tags); + Assert.IsNull(callback.Request.Hostnames); + Assert.IsNotNull(callback.Request.Prefixes); + Assert.AreEqual(3, callback.Request.Prefixes.Count); + Assert.AreEqual("example.com/foo", callback.Request.Prefixes[0]); + Assert.AreEqual("example.com/bar", callback.Request.Prefixes[1]); + Assert.AreEqual("example.org/hello/world", callback.Request.Prefixes[2]); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumenNullExceptionIfListIsNull() + { + // Arrange + var client = GetClient(); + + // Act + await client.PurgeCachedContentByPrefix(ZoneId, null); + + // Assert - ArgumentNullException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByTagTest.cs b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByTagTest.cs new file mode 100644 index 0000000..8553221 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByTagTest.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Zones.Tests.Cache +{ + [TestClass] + public class PurgeCachedContentByTagTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, PurgeRequest Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId, + } + }; + } + + [TestMethod] + public async Task ShouldPurgeCachedContent() + { + // Arrange + var list = new List + { + "Tag-1", + "", + "Tag-2", + }; + + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContentByTag(ZoneId, list); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNull(callback.Request.PurgeEverything); + Assert.IsNull(callback.Request.Urls); + Assert.IsNull(callback.Request.UrlsWithHeaders); + Assert.IsNotNull(callback.Request.Tags); + Assert.AreEqual(2, callback.Request.Tags.Count); + Assert.AreEqual("Tag-1", callback.Request.Tags[0]); + Assert.AreEqual("Tag-2", callback.Request.Tags[1]); + Assert.IsNull(callback.Request.Hostnames); + Assert.IsNull(callback.Request.Prefixes); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumenNullExceptionIfListIsNull() + { + // Arrange + var client = GetClient(); + + // Act + await client.PurgeCachedContentByTag(ZoneId, null); + + // Assert - ArgumentNullException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByUrlTest.cs b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByUrlTest.cs new file mode 100644 index 0000000..ec37b41 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentByUrlTest.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Zones.Tests.Cache +{ + [TestClass] + public class PurgeCachedContentByUrlTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, PurgeRequest Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId, + } + }; + } + + [TestMethod] + public async Task ShouldPurgeCachedContentByUrl() + { + // Arrange + var list = new List + { + new ZonePurgeCachedUrlRequest { Url = "https://example.com/foo.txt" }, + new ZonePurgeCachedUrlRequest { Url = "https://example.com/bar.baz" }, + }; + + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContentByUrl(ZoneId, list); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNull(callback.Request.PurgeEverything); + Assert.IsNotNull(callback.Request.Urls); + Assert.AreEqual(2, callback.Request.Urls.Count); + Assert.AreEqual("https://example.com/foo.txt", callback.Request.Urls[0]); + Assert.AreEqual("https://example.com/bar.baz", callback.Request.Urls[1]); + Assert.IsNull(callback.Request.UrlsWithHeaders); + Assert.IsNull(callback.Request.Tags); + Assert.IsNull(callback.Request.Hostnames); + Assert.IsNull(callback.Request.Prefixes); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldPurgeCachedContentByUrlWithHeader() + { + // Arrange + var list = new List + { + new ZonePurgeCachedUrlRequest { Url = "https://example.com/foo.txt", Headers = new Dictionary { { "X-Test1", "Test" } } }, + new ZonePurgeCachedUrlRequest { Url = "https://example.com/bar.baz", Headers = new Dictionary { { "X-Test2", "Test" } } }, + }; + + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContentByUrl(ZoneId, list); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNull(callback.Request.PurgeEverything); + Assert.IsNull(callback.Request.Urls); + Assert.IsNotNull(callback.Request.UrlsWithHeaders); + Assert.AreEqual(2, callback.Request.UrlsWithHeaders.Count); + Assert.AreEqual("https://example.com/foo.txt", callback.Request.UrlsWithHeaders[0].Url); + Assert.AreEqual("Test", callback.Request.UrlsWithHeaders[0].Headers["X-Test1"]); + Assert.AreEqual("https://example.com/bar.baz", callback.Request.UrlsWithHeaders[1].Url); + Assert.AreEqual("Test", callback.Request.UrlsWithHeaders[1].Headers["X-Test2"]); + Assert.IsNull(callback.Request.Tags); + Assert.IsNull(callback.Request.Hostnames); + Assert.IsNull(callback.Request.Prefixes); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumenNullExceptionIfListIsNull() + { + // Arrange + var client = GetClient(); + + // Act + await client.PurgeCachedContentByUrl(ZoneId, null); + + // Assert - ArgumentNullException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentTest.cs b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentTest.cs new file mode 100644 index 0000000..6cc0dd1 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cache/PurgeCachedContentTest.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using AMWD.Net.Api.Cloudflare.Zones.Cache.InternalRequests; +using Moq; + +namespace Cloudflare.Zones.Tests.Cache +{ + [TestClass] + public class PurgeCachedContentTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, PurgeRequest Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId, + } + }; + } + + [TestMethod] + public async Task ShouldPurgeCachedContent() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.PurgeCachedContent(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/purge_cache", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.IsNotNull(callback.Request.PurgeEverything); + Assert.IsTrue(callback.Request.PurgeEverything.Value); + Assert.IsNull(callback.Request.Urls); + Assert.IsNull(callback.Request.UrlsWithHeaders); + Assert.IsNull(callback.Request.Tags); + Assert.IsNull(callback.Request.Hostnames); + Assert.IsNull(callback.Request.Prefixes); + + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/purge_cache", It.IsAny(), null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Hold/CreateZoneHoldTest.cs b/UnitTests/Cloudflare.Zones.Tests/Hold/CreateZoneHoldTest.cs new file mode 100644 index 0000000..9bab423 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Hold/CreateZoneHoldTest.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Hold +{ + [TestClass] + public class CreateZoneHoldTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, object Request, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneHold + { + Hold = true, + HoldAfter = DateTime, + IncludeSubdomains = "true" + } + }; + } + + [TestMethod] + public async Task ShouldCreateZoneHold() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.CreateZoneHold(ZoneId, includeSubdomains: false); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/hold", callback.RequestPath); + Assert.IsNotNull(callback.QueryFilter); + + Assert.IsInstanceOfType(callback.QueryFilter); + Assert.IsFalse(((CreateZoneHoldFilter)callback.QueryFilter).IncludeSubdomains); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/hold", null, It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldCreateZoneHoldWithSubdomains() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.CreateZoneHold(ZoneId, includeSubdomains: true); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/hold", callback.RequestPath); + Assert.IsNotNull(callback.QueryFilter); + + Assert.IsInstanceOfType(callback.QueryFilter); + Assert.IsTrue(((CreateZoneHoldFilter)callback.QueryFilter).IncludeSubdomains); + + _clientMock.Verify(m => m.PostAsync($"zones/{ZoneId}/hold", null, It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnEmptyDictionary() + { + // Arrange + var filter = new CreateZoneHoldFilter(); + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldReturnQueryParameter() + { + // Arrange + var filter = new CreateZoneHoldFilter { IncludeSubdomains = true }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(1, dict.Count); + Assert.IsTrue(dict.ContainsKey("include_subdomains")); + Assert.AreEqual("true", dict["include_subdomains"]); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, queryFilter, _) => _callbacks.Add((requestPath, request, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Hold/DeleteZoneHoldTest.cs b/UnitTests/Cloudflare.Zones.Tests/Hold/DeleteZoneHoldTest.cs new file mode 100644 index 0000000..6a9b911 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Hold/DeleteZoneHoldTest.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Hold +{ + [TestClass] + public class DeleteZoneHoldTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime Date = new(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneHold + { + Hold = true, + HoldAfter = Date, + IncludeSubdomains = "true" + } + }; + } + + [TestMethod] + public async Task ShouldDeleteZoneHold() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteZoneHold(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/hold", callback.RequestPath); + Assert.IsNotNull(callback.QueryFilter); + + Assert.IsInstanceOfType(callback.QueryFilter); + Assert.IsNull(((DeleteZoneHoldFilter)callback.QueryFilter).HoldAfter); + + _clientMock.Verify(m => m.DeleteAsync($"zones/{ZoneId}/hold", It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldDeleteZoneHoldTemporarily() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteZoneHold(ZoneId, holdAfter: Date); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/hold", callback.RequestPath); + Assert.IsNotNull(callback.QueryFilter); + + Assert.IsInstanceOfType(callback.QueryFilter); + Assert.AreEqual(Date, ((DeleteZoneHoldFilter)callback.QueryFilter).HoldAfter); + + _clientMock.Verify(m => m.DeleteAsync($"zones/{ZoneId}/hold", It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnEmptyDictionary() + { + // Arrange + var filter = new DeleteZoneHoldFilter(); + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldReturnQueryParameter() + { + // Arrange + var filter = new DeleteZoneHoldFilter { HoldAfter = Date }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(1, dict.Count); + Assert.IsTrue(dict.ContainsKey("hold_after")); + Assert.AreEqual("2024-10-10T20:30:40Z", dict["hold_after"]); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Hold/GetZoneHoldTest.cs b/UnitTests/Cloudflare.Zones.Tests/Hold/GetZoneHoldTest.cs new file mode 100644 index 0000000..6232e21 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Hold/GetZoneHoldTest.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Hold +{ + [TestClass] + public class GetZoneHoldTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private readonly DateTime DateTime = new DateTime(2024, 10, 10, 20, 30, 40, 0, DateTimeKind.Utc); + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneHold + { + Hold = true, + HoldAfter = DateTime, + IncludeSubdomains = "false" + } + }; + } + + [TestMethod] + public async Task ShouldGetZoneHold() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.GetZoneHold(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/hold", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync($"zones/{ZoneId}/hold", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/CreateZoneTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/CreateZoneTest.cs new file mode 100644 index 0000000..6ea9077 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/CreateZoneTest.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using AMWD.Net.Api.Cloudflare.Zones.Zones.InternalRequests; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class CreateZoneTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, CreateRequest Request)> _callbacks; + + private CreateZoneRequest _request; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new Zone + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Account = new AccountBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Account Name" + }, + ActivatedOn = DateTime.Parse("2014-01-02T00:01:00.12345Z"), + CreatedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + DevelopmentMode = 7200, + Meta = new ZoneMetaData + { + CdnOnly = true, + CustomCertificateQuota = 1, + DnsOnly = true, + FoundationDns = true, + PageRuleQuota = 100, + PhishingDetected = false, + Step = 2 + }, + ModifiedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + Name = "example.com", + NameServers = + [ + "bob.ns.cloudflare.com", + "lola.ns.cloudflare.com" + ], + OriginalDnshost = "NameCheap", + OriginalNameServers = + [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + OriginalRegistrar = "GoDaddy", + Owner = new OwnerBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Org", + Type = "organization" + }, + Paused = true, + Status = ZoneStatus.Initializing, + Type = ZoneType.Full, + VanityNameServers = + [ + "ns1.example.com", + "ns2.example.com" + ] + } + }; + + _request = new CreateZoneRequest + { + AccountId = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "example.com", + Type = ZoneType.Full + }; + } + + [TestMethod] + public async Task ShouldReturnCreatedZone() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.CreateZone(_request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual("zones", callback.RequestPath); + Assert.IsNotNull(callback.Request); + + Assert.AreEqual(_request.AccountId, callback.Request.Account.Id); + Assert.AreEqual(_request.Name, callback.Request.Name); + Assert.AreEqual(_request.Type, callback.Request.Type); + + _clientMock.Verify(m => m.PostAsync("zones", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionWhenRequestIsNull() + { + // Arrange + var client = GetClient(); + + // Act + await client.CreateZone(null); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(".internal")] + [DataRow("test@example")] + [DataRow("test@example.com")] + [DataRow("häppi.example.com")] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentExceptionForInvalidName(string name) + { + // Arrange + _request.Name = name; + var client = GetClient(); + + // Act + await client.CreateZone(_request); + + // Assert - ArgumentException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public async Task ShouldThrowArgumentOutOfRangeExceptionForInvalidType() + { + // Arrange + _request.Type = 0; + var client = GetClient(); + + // Act + await client.CreateZone(_request); + + // Assert - ArgumentOutOfRangeException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, _, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/DeleteZoneTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/DeleteZoneTest.cs new file mode 100644 index 0000000..d240aa6 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/DeleteZoneTest.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class DeleteZoneTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId + } + }; + } + + [TestMethod] + public async Task ShouldDeleteZone() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteZone(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.DeleteAsync($"zones/{ZoneId}", It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/EditZoneTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/EditZoneTest.cs new file mode 100644 index 0000000..170237e --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/EditZoneTest.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using AMWD.Net.Api.Cloudflare.Zones.Zones.InternalRequests; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class EditZoneTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, EditRequest Request)> _callbacks; + + private EditZoneRequest _request; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new Zone + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Account = new AccountBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Account Name" + }, + ActivatedOn = DateTime.Parse("2014-01-02T00:01:00.12345Z"), + CreatedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + DevelopmentMode = 7200, + Meta = new ZoneMetaData + { + CdnOnly = true, + CustomCertificateQuota = 1, + DnsOnly = true, + FoundationDns = true, + PageRuleQuota = 100, + PhishingDetected = false, + Step = 2 + }, + ModifiedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + Name = "example.com", + NameServers = + [ + "bob.ns.cloudflare.com", + "lola.ns.cloudflare.com" + ], + OriginalDnshost = "NameCheap", + OriginalNameServers = + [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + OriginalRegistrar = "GoDaddy", + Owner = new OwnerBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Org", + Type = "organization" + }, + Paused = true, + Status = ZoneStatus.Initializing, + Type = ZoneType.Full, + VanityNameServers = + [ + "ns1.example.com", + "ns2.example.com" + ] + } + }; + + _request = new EditZoneRequest + { + Id = ZoneId, + Type = ZoneType.Full, + VanityNameServers = ["ns1.example.org", "ns2.example.org"] + }; + } + + [TestMethod] + public async Task ShouldReturnModifiedZoneForType() + { + // Arrange + _request.VanityNameServers = null; + var client = GetClient(); + + // Act + var response = await client.EditZone(_request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}", callback.RequestPath); + Assert.IsNotNull(callback.Request); + + Assert.AreEqual(_request.Type.Value, callback.Request.Type.Value); + Assert.IsNull(callback.Request.VanityNameServers); + + _clientMock.Verify(m => m.PatchAsync($"zones/{ZoneId}", It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldReturnModifiedZoneForVanityNameServers() + { + // Arrange + _request.Type = null; + _request.VanityNameServers.Add(""); + var client = GetClient(); + + // Act + var response = await client.EditZone(_request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}", callback.RequestPath); + Assert.IsNotNull(callback.Request); + + Assert.IsNull(callback.Request.Type); + Assert.AreEqual(2, callback.Request.VanityNameServers.Count); + Assert.IsTrue(callback.Request.VanityNameServers.Contains("ns1.example.org")); + Assert.IsTrue(callback.Request.VanityNameServers.Contains("ns2.example.org")); + + _clientMock.Verify(m => m.PatchAsync($"zones/{ZoneId}", It.IsAny(), It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(CloudflareException))] + public async Task ShouldThrowCloudflareExceptionOnMultiplePropertiesSet() + { + // Arrange + var client = GetClient(); + + // Act + await client.EditZone(_request); + + // Assert - CloudflareException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public async Task ShouldThrowArgumentOutOfRangeExceptionForInvalidType() + { + // Arrange + _request.Type = 0; + _request.VanityNameServers = null; + var client = GetClient(); + + // Act + await client.EditZone(_request); + + // Assert - ArgumentOutOfRangeException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PatchAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/ListZonesTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/ListZonesTest.cs new file mode 100644 index 0000000..c433a6e --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/ListZonesTest.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class ListZonesTest + { + private Mock _clientMock; + + private CloudflareResponse> _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse> + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + ResultInfo = new PaginationInfo + { + Count = 1, + Page = 1, + PerPage = 20, + TotalCount = 2000 + }, + Result = + [ + new Zone + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Account = new AccountBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Account Name" + }, + ActivatedOn = DateTime.Parse("2014-01-02T00:01:00.12345Z"), + CreatedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + DevelopmentMode = 7200, + Meta = new ZoneMetaData + { + CdnOnly = true, + CustomCertificateQuota = 1, + DnsOnly = true, + FoundationDns = true, + PageRuleQuota = 100, + PhishingDetected = false, + Step = 2 + }, + ModifiedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + Name = "example.com", + NameServers = + [ + "bob.ns.cloudflare.com", + "lola.ns.cloudflare.com" + ], + OriginalDnshost = "NameCheap", + OriginalNameServers = + [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + OriginalRegistrar = "GoDaddy", + Owner = new OwnerBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Org", + Type = "organization" + }, + Paused = true, + Status = ZoneStatus.Initializing, + Type = ZoneType.Full, + VanityNameServers = + [ + "ns1.example.com", + "ns2.example.com" + ] + } + ] + }; + } + + [TestMethod] + public async Task ShouldReturnListOfZones() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.ListZones(); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual("zones", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync>("zones", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldReturnListOfZonesWithFilter() + { + // Arrange + var filter = new ListZonesFilter + { + AccountId = "023e105f4ecef8ad9ca31a8372d0c353" + }; + + var client = GetClient(); + + // Act + var response = await client.ListZones(filter); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual("zones", callback.RequestPath); + Assert.AreEqual(filter, callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync>("zones", filter, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnEmptyParameterList() + { + // Arrange + var filter = new ListZonesFilter(); + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldReturnFullParameterList() + { + // Arrange + var filter = new ListZonesFilter + { + AccountId = "023e105f4ecef8ad9ca31a8372d0c353", + AccountName = "Example Account Name", + MatchType = FilterMatchType.Any, + Name = "example.com", + PerPage = 13, + Page = 5, + OrderBy = ZonesOrderBy.AccountName, + OrderDirection = SortDirection.Descending, + Status = ZoneStatus.Active + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(9, dict.Count); + + Assert.IsTrue(dict.ContainsKey("account.id")); + Assert.IsTrue(dict.ContainsKey("account.name")); + Assert.IsTrue(dict.ContainsKey("direction")); + Assert.IsTrue(dict.ContainsKey("match")); + Assert.IsTrue(dict.ContainsKey("name")); + Assert.IsTrue(dict.ContainsKey("order")); + Assert.IsTrue(dict.ContainsKey("page")); + Assert.IsTrue(dict.ContainsKey("per_page")); + Assert.IsTrue(dict.ContainsKey("status")); + + Assert.AreEqual("023e105f4ecef8ad9ca31a8372d0c353", dict["account.id"]); + Assert.AreEqual("Example Account Name", dict["account.name"]); + Assert.AreEqual("desc", dict["direction"]); + Assert.AreEqual("any", dict["match"]); + Assert.AreEqual("example.com", dict["name"]); + Assert.AreEqual("account.name", dict["order"]); + Assert.AreEqual("5", dict["page"]); + Assert.AreEqual("13", dict["per_page"]); + Assert.AreEqual("active", dict["status"]); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ShouldNotAddAccountId(string id) + { + // Arrange + var filter = new ListZonesFilter + { + AccountId = id + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ShouldNotAddAccountName(string name) + { + // Arrange + var filter = new ListZonesFilter + { + AccountName = name + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldNotAddDirection() + { + // Arrange + var filter = new ListZonesFilter + { + OrderDirection = 0 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldNotAddMatch() + { + // Arrange + var filter = new ListZonesFilter + { + MatchType = 0 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ShouldNotAddName(string name) + { + // Arrange + var filter = new ListZonesFilter + { + Name = name + }; + + // Act + var dict = new Dictionary(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldNotAddOrder() + { + // Arrange + var filter = new ListZonesFilter + { + OrderBy = 0 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldNotAddPage() + { + // Arrange + var filter = new ListZonesFilter + { + Page = 0 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [DataTestMethod] + [DataRow(4)] + [DataRow(51)] + public void ShouldNotAddPerPage(int perPage) + { + // Arrange + var filter = new ListZonesFilter + { + PerPage = perPage + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + [TestMethod] + public void ShouldNotAddStatus() + { + // Arrange + var filter = new ListZonesFilter + { + Status = 0 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.AreEqual(0, dict.Count); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync>(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/RerunActivationCheckTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/RerunActivationCheckTest.cs new file mode 100644 index 0000000..b4a655e --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/RerunActivationCheckTest.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class RerunActivationCheckTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, object Request)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new ZoneIdResponse + { + Id = ZoneId + } + }; + } + + [TestMethod] + public async Task ShouldRerunActivationCheck() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.RerunActivationCheck(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/activation_check", callback.RequestPath); + Assert.IsNull(callback.Request); + + _clientMock.Verify(m => m.PutAsync($"zones/{ZoneId}/activation_check", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PutAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/Zones/ZoneDetailsTest.cs b/UnitTests/Cloudflare.Zones.Tests/Zones/ZoneDetailsTest.cs new file mode 100644 index 0000000..af2e944 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Zones/ZoneDetailsTest.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using Moq; + +namespace Cloudflare.Zones.Tests.Zones +{ + [TestClass] + public class ZoneDetailsTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [ + new ResponseInfo + { + Code = 1000, + Message = "Message 1", + } + ], + Errors = [ + new ResponseInfo + { + Code = 1000, + Message = "Error 1", + } + ], + Result = new Zone + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Account = new AccountBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Account Name" + }, + ActivatedOn = DateTime.Parse("2014-01-02T00:01:00.12345Z"), + CreatedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + DevelopmentMode = 7200, + Meta = new ZoneMetaData + { + CdnOnly = true, + CustomCertificateQuota = 1, + DnsOnly = true, + FoundationDns = true, + PageRuleQuota = 100, + PhishingDetected = false, + Step = 2 + }, + ModifiedOn = DateTime.Parse("2014-01-01T05:20:00.12345Z"), + Name = "example.com", + NameServers = + [ + "bob.ns.cloudflare.com", + "lola.ns.cloudflare.com" + ], + OriginalDnshost = "NameCheap", + OriginalNameServers = + [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + OriginalRegistrar = "GoDaddy", + Owner = new OwnerBase + { + Id = "023e105f4ecef8ad9ca31a8372d0c353", + Name = "Example Org", + Type = "organization" + }, + Paused = true, + Status = ZoneStatus.Initializing, + Type = ZoneType.Full, + VanityNameServers = + [ + "ns1.example.com", + "ns2.example.com" + ] + } + }; + } + + [TestMethod] + public async Task ShouldReturnZoneDetails() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.ZoneDetails(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"zones/{ZoneId}", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync($"zones/{ZoneId}", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionWhenZoneIdIsNull(string id) + { + // Arrange + var client = GetClient(); + + // Act + await client.ZoneDetails(id); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public async Task ShouldThrowArgumentOutOfRangeExceptionWhenZoneIdTooLong() + { + // Arrange + string id = new('a', 33); + var client = GetClient(); + + // Act + await client.ZoneDetails(id); + + // Assert - ArgumentOutOfRangeException + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +}