diff --git a/Cloudflare/CloudflareClient.cs b/Cloudflare/CloudflareClient.cs index ecf0fca..7b44488 100644 --- a/Cloudflare/CloudflareClient.cs +++ b/Cloudflare/CloudflareClient.cs @@ -230,7 +230,8 @@ namespace AMWD.Net.Api.Cloudflare var errorResponse = JsonConvert.DeserializeObject>(content, _jsonSerializerSettings) ?? throw new CloudflareException("Response is not a valid Cloudflare API response."); - throw new AuthenticationException(string.Join(Environment.NewLine, errorResponse.Errors.Select(e => $"{e.Code}: {e.Message}"))); + string[] errors = errorResponse.Errors?.Select(e => $"{e.Code}: {e.Message}").ToArray() ?? []; + throw new AuthenticationException(string.Join(Environment.NewLine, errors)); default: try diff --git a/Extensions/Cloudflare.Zones/Models/AvailableRatePlan.cs b/Extensions/Cloudflare.Zones/Models/AvailableRatePlan.cs new file mode 100644 index 0000000..e4f9e7c --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/AvailableRatePlan.cs @@ -0,0 +1,69 @@ +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// A Cloudflare available plan. + /// Source + /// + public class AvailableRatePlan + { + /// + /// Identifier. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Indicates whether you can subscribe to this plan. + /// + [JsonProperty("can_subscribe")] + public bool? CanSubscribe { get; set; } + + /// + /// The monetary unit in which pricing information is displayed. + /// + [JsonProperty("currency")] + public string? Currency { get; set; } + + /// + /// Indicates whether this plan is managed externally. + /// + [JsonProperty("externally_managed")] + public bool? ExternallyManaged { get; set; } + + /// + /// The frequency at which you will be billed for this plan. + /// + [JsonProperty("frequency")] + public RenewFrequency? Frequency { get; set; } + + /// + /// Indicates whether you are currently subscribed to this plan. + /// + [JsonProperty("is_subscribed")] + public bool? IsSubscribed { get; set; } + + /// + /// Indicates whether this plan has a legacy discount applied. + /// + [JsonProperty("legacy_discount")] + public bool? LegacyDiscount { get; set; } + + /// + /// The legacy identifier for this rate plan, if any. + /// + [JsonProperty("legacy_id")] + public string? LegacyId { get; set; } + + /// + /// The plan name. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// The amount you will be billed for this plan. + /// + [JsonProperty("price")] + public decimal? Price { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/README.md b/Extensions/Cloudflare.Zones/README.md index 1d337c0..314fc20 100644 --- a/Extensions/Cloudflare.Zones/README.md +++ b/Extensions/Cloudflare.Zones/README.md @@ -33,6 +33,16 @@ This package contains the feature set of the _Domain/Zone Management_ section of - [Get Zone Hold](https://developers.cloudflare.com/api/resources/zones/subresources/holds/methods/get/) +##### [Plans] + +- [Available Plan Details](https://developers.cloudflare.com/api/resources/zones/subresources/plans/methods/get/) +- [List Available Plans](https://developers.cloudflare.com/api/resources/zones/subresources/plans/methods/list/) + + +##### [Rate Plans] + +- [List Available Rate Plans](https://developers.cloudflare.com/api/resources/zones/subresources/rate_plans/methods/get/) + @@ -49,3 +59,5 @@ Published under MIT License (see [choose a license]) [Zones]: https://developers.cloudflare.com/api/resources/zones/ [Activation Check]: https://developers.cloudflare.com/api/resources/zones/subresources/activation_check/ [Holds]: https://developers.cloudflare.com/api/resources/zones/subresources/holds/ +[Plans]: https://developers.cloudflare.com/api/resources/zones/subresources/plans/ +[Rate Plans]: https://developers.cloudflare.com/api/resources/zones/subresources/rate_plans/ diff --git a/Extensions/Cloudflare.Zones/Responses/RatePlanGetResponse.cs b/Extensions/Cloudflare.Zones/Responses/RatePlanGetResponse.cs new file mode 100644 index 0000000..cccb15b --- /dev/null +++ b/Extensions/Cloudflare.Zones/Responses/RatePlanGetResponse.cs @@ -0,0 +1,103 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Source + /// + public class RatePlanGetResponse + { + /// + /// Plan identifier tag. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Array of available components values for the plan. + /// + [JsonProperty("components")] + public IReadOnlyCollection? Components { get; set; } + + /// + /// The monetary unit in which pricing information is displayed. + /// + [JsonProperty("currency")] + public string? Currency { get; set; } + + /// + /// The duration of the plan subscription. + /// + [JsonProperty("duration")] + public int? Duration { get; set; } + + /// + /// The frequency at which you will be billed for this plan. + /// + [JsonProperty("frequency")] + public RenewFrequency? Frequency { get; set; } + + /// + /// The plan name. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// Rate plan component. + /// + public class Component + { + /// + /// The default amount allocated. + /// + [JsonProperty("default")] + public decimal? Default { get; set; } + + /// + /// The unique component. + /// + [JsonProperty("name")] + public ComponentName? Name { get; set; } + + /// + /// The unit price of the addon. + /// + [JsonProperty("unit_price")] + public decimal? UnitPrice { get; set; } + } + + /// + /// Rate plan component name. + /// Source + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum ComponentName + { + /// + /// Zones + /// + [EnumMember(Value = "zones")] + Zones = 1, + + /// + /// Page rules + /// + [EnumMember(Value = "page_rules")] + PageRules = 2, + + /// + /// Dedicated certificates + /// + [EnumMember(Value = "dedicated_certificates")] + DedicatedCertificatese = 3, + + /// + /// Custom dedicated certificates + /// + [EnumMember(Value = "dedicated_certificates_custom")] + DedicatedCertificatesCustom = 4 + } + } +} diff --git a/Extensions/Cloudflare.Zones/ZonePlansExtensions.cs b/Extensions/Cloudflare.Zones/ZonePlansExtensions.cs new file mode 100644 index 0000000..552c61e --- /dev/null +++ b/Extensions/Cloudflare.Zones/ZonePlansExtensions.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Extensions for Zone Plans. + /// + public static class ZonePlansExtensions + { + /// + /// Details of the available plan that the zone can subscribe to. + /// + /// The instance. + /// The zone identifier. + /// The plan identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> AvailablePlanDetails(this ICloudflareClient client, string zoneId, string planId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + planId.ValidateCloudflareId(); + + return client.GetAsync($"/zones/{zoneId}/available_plans/{planId}", cancellationToken: cancellationToken); + } + + /// + /// Lists available plans the zone can subscribe to. + /// + /// The instance. + /// The zone identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task>> ListAvailablePlans(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + return client.GetAsync>($"/zones/{zoneId}/available_plans", cancellationToken: cancellationToken); + } + + /// + /// Lists all rate plans the zone can subscribe to. + /// + /// The instance. + /// The zone identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> ListAvailableRatePlans(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + return client.GetAsync($"/zones/{zoneId}/available_rate_plans", cancellationToken: cancellationToken); + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/AvailablePlanDetailsTest.cs b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/AvailablePlanDetailsTest.cs new file mode 100644 index 0000000..83aa3ef --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/AvailablePlanDetailsTest.cs @@ -0,0 +1,75 @@ +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.ZonePlansExtensions +{ + [TestClass] + public class AvailablePlanDetailsTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + private const string PlanId = "023e105f4ecef8ad9ca31a8372d0c354"; + + 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(1000, "Message 1") + ], + Errors = [ + new ResponseInfo(1000, "Error 1") + ], + Result = new AvailableRatePlan() + }; + } + + [TestMethod] + public async Task ShouldReturnAvailablePlan() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.AvailablePlanDetails(ZoneId, PlanId); + + // 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}/available_plans/{PlanId}", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync($"/zones/{ZoneId}/available_plans/{PlanId}", 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/ZonePlansExtensions/ListAvailablePlansTest.cs b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/ListAvailablePlansTest.cs new file mode 100644 index 0000000..81fbcb4 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/ListAvailablePlansTest.cs @@ -0,0 +1,74 @@ +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.ZonePlansExtensions +{ + [TestClass] + public class ListAvailablePlansTest + { + 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(1000, "Message 1") + ], + Errors = [ + new ResponseInfo(1000, "Error 1") + ], + Result = [] + }; + } + + [TestMethod] + public async Task ShouldReturnAvailablePlan() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.ListAvailablePlans(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}/available_plans", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync>($"/zones/{ZoneId}/available_plans", 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/ZonePlansExtensions/ListAvailableRatePlansTest.cs b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/ListAvailableRatePlansTest.cs new file mode 100644 index 0000000..ffc917d --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/ZonePlansExtensions/ListAvailableRatePlansTest.cs @@ -0,0 +1,74 @@ +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.ZonePlansExtensions +{ + [TestClass] + public class ListAvailableRatePlansTest + { + 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(1000, "Message 1") + ], + Errors = [ + new ResponseInfo(1000, "Error 1") + ], + Result = new() + }; + } + + [TestMethod] + public async Task ShouldReturnAvailablePlans() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.ListAvailableRatePlans(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}/available_rate_plans", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync($"/zones/{ZoneId}/available_rate_plans", 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; + } + } +}