From 83620cb450a27f93960844e480b68d5216efcdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 23 Oct 2024 21:00:23 +0200 Subject: [PATCH] Implemented Core client --- .editorconfig | 159 ++++++++ .gitlab-ci.yml | 182 +++++++++ CHANGELOG.md | 1 + .../Auth/ApiKeyAuthenticationTest.cs | 80 ++++ .../Auth/ApiTokenAuthenticationTest.cs | 44 ++ Cloudflare.Tests/Cloudflare.Tests.csproj | 30 ++ Cloudflare.Tests/CloudflareClientTest.cs | 251 ++++++++++++ .../CloudflareClientTests/DeleteAsyncTest.cs | 326 +++++++++++++++ .../CloudflareClientTests/GetAsyncTest.cs | 326 +++++++++++++++ .../CloudflareClientTests/PatchAsyncTest.cs | 386 ++++++++++++++++++ .../CloudflareClientTests/PostAsyncTest.cs | 386 ++++++++++++++++++ .../CloudflareClientTests/PutAsyncTest.cs | 386 ++++++++++++++++++ .../Extensions/EnumExtensionsTest.cs | 64 +++ Cloudflare.Tests/MessageHandlerMock.cs | 52 +++ Cloudflare/Auth/ApiKeyAuthentication.cs | 43 ++ Cloudflare/Auth/ApiTokenAuthentication.cs | 32 ++ Cloudflare/ClientOptions.cs | 55 +++ Cloudflare/Cloudflare.csproj | 62 +++ Cloudflare/CloudflareClient.cs | 324 +++++++++++++++ Cloudflare/Enums/FilterMatchType.cs | 24 ++ Cloudflare/Enums/OrderDirection.cs | 24 ++ Cloudflare/Exceptions/CloudflareException.cs | 61 +++ Cloudflare/Extensions/EnumExtensions.cs | 33 ++ Cloudflare/Interfaces/IAuthentication.cs | 16 + Cloudflare/Interfaces/ICloudflareClient.cs | 75 ++++ .../Interfaces/IQueryParameterFilter.cs | 15 + Cloudflare/Models/AccountBase.cs | 21 + Cloudflare/Models/OwnerBase.cs | 27 ++ Cloudflare/README.md | 21 + Cloudflare/Responses/CloudflareResponse.cs | 47 +++ Cloudflare/Responses/ResponseInfo.cs | 20 + Cloudflare/Responses/ResultInfo.cs | 32 ++ CodeMaid.config | 337 +++++++++++++++ Directory.Build.props | 20 + .../Cloudflare.Zones/Cloudflare.Zones.csproj | 20 + Extensions/Cloudflare.Zones/README.md | 20 + Extensions/Directory.Build.props | 56 +++ LICENSE.txt | 5 +- README.md | 34 ++ .../Cloudflare.Zones.Tests.csproj | 11 + UnitTests/Directory.Build.props | 28 ++ cloudflare-api.sln | 84 ++++ cloudflare-api.snk | Bin 0 -> 596 bytes 43 files changed, 4218 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 Cloudflare.Tests/Auth/ApiKeyAuthenticationTest.cs create mode 100644 Cloudflare.Tests/Auth/ApiTokenAuthenticationTest.cs create mode 100644 Cloudflare.Tests/Cloudflare.Tests.csproj create mode 100644 Cloudflare.Tests/CloudflareClientTest.cs create mode 100644 Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs create mode 100644 Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs create mode 100644 Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs create mode 100644 Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs create mode 100644 Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs create mode 100644 Cloudflare.Tests/Extensions/EnumExtensionsTest.cs create mode 100644 Cloudflare.Tests/MessageHandlerMock.cs create mode 100644 Cloudflare/Auth/ApiKeyAuthentication.cs create mode 100644 Cloudflare/Auth/ApiTokenAuthentication.cs create mode 100644 Cloudflare/ClientOptions.cs create mode 100644 Cloudflare/Cloudflare.csproj create mode 100644 Cloudflare/CloudflareClient.cs create mode 100644 Cloudflare/Enums/FilterMatchType.cs create mode 100644 Cloudflare/Enums/OrderDirection.cs create mode 100644 Cloudflare/Exceptions/CloudflareException.cs create mode 100644 Cloudflare/Extensions/EnumExtensions.cs create mode 100644 Cloudflare/Interfaces/IAuthentication.cs create mode 100644 Cloudflare/Interfaces/ICloudflareClient.cs create mode 100644 Cloudflare/Interfaces/IQueryParameterFilter.cs create mode 100644 Cloudflare/Models/AccountBase.cs create mode 100644 Cloudflare/Models/OwnerBase.cs create mode 100644 Cloudflare/README.md create mode 100644 Cloudflare/Responses/CloudflareResponse.cs create mode 100644 Cloudflare/Responses/ResponseInfo.cs create mode 100644 Cloudflare/Responses/ResultInfo.cs create mode 100644 CodeMaid.config create mode 100644 Directory.Build.props create mode 100644 Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj create mode 100644 Extensions/Cloudflare.Zones/README.md create mode 100644 Extensions/Directory.Build.props create mode 100644 README.md create mode 100644 UnitTests/Cloudflare.Zones.Tests/Cloudflare.Zones.Tests.csproj create mode 100644 UnitTests/Directory.Build.props create mode 100644 cloudflare-api.sln create mode 100644 cloudflare-api.snk diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..92ed11e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,159 @@ +# Documentation: +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference + +# Top-most EditorConfig file +root = true + +[*] +insert_final_newline = true +end_of_line = crlf +indent_style = tab + +[*.{cs,vb}] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Suggest explicit accessibility modifiers +dotnet_style_require_accessibility_modifiers = always:suggestion + +# Suggest more modern language features when available +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:none +dotnet_style_prefer_conditional_expression_over_return = true:none +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion + +# Definitions +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate, type_parameter +dotnet_naming_symbols.methods_properties.applicable_kinds = method, local_function, property +dotnet_naming_symbols.public_symbols.applicable_kinds = property, method, field, event +dotnet_naming_symbols.public_symbols.applicable_accessibilities = public +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_symbols.private_protected_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_protected_internal_fields.applicable_accessibilities = private, protected, internal +dotnet_naming_symbols.parameters_locals.applicable_kinds = parameter, local +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Name all types using PascalCase +dotnet_naming_rule.types_must_be_capitalized.symbols = types +dotnet_naming_rule.types_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.types_must_be_capitalized.severity = warning + +# Name all methods and properties using PascalCase +dotnet_naming_rule.methods_properties_must_be_capitalized.symbols = methods_properties +dotnet_naming_rule.methods_properties_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.methods_properties_must_be_capitalized.severity = warning + +# Name all public members using PascalCase +dotnet_naming_rule.public_members_must_be_capitalized.symbols = public_symbols +dotnet_naming_rule.public_members_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.public_members_must_be_capitalized.severity = warning + +# Name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_must_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_must_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_must_be_pascal_case.severity = suggestion + +# Name all private and internal fields using camelCase +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.symbols = private_protected_internal_fields +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.style = camel_case_style +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.severity = warning + +# Name all parameters and locals using camelCase +dotnet_naming_rule.parameters_locals_must_be_camel_case.symbols = parameters_locals +dotnet_naming_rule.parameters_locals_must_be_camel_case.style = camel_case_style +dotnet_naming_rule.parameters_locals_must_be_camel_case.severity = warning + +# Name all private fields starting with underscore +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +[*.cs] +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion + +# Only use "var" when it's obvious what the variable type is +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:none + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +[*.{xml,csproj,targets,props,json,yml}] +indent_size = 2 +indent_style = space diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..0778d47 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,182 @@ +image: mcr.microsoft.com/dotnet/sdk:8.0 + +variables: + TZ: "Europe/Berlin" + LANG: "de" + +stages: + - build + - test + - deploy + + + +default-build: + stage: build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG == null + script: + - dotnet build -c Debug --nologo + - mkdir ./artifacts + - shopt -s globstar + - mv ./**/*.nupkg ./artifacts/ || true + - mv ./**/*.snupkg ./artifacts/ || true + artifacts: + paths: + - artifacts/*.nupkg + - artifacts/*.snupkg + expire_in: 1 days + +default-test: + stage: test + dependencies: + - default-build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG == null + coverage: /Branch coverage[\s\S].+%/ + before_script: + - dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools + script: + - dotnet test -c Debug --nologo /p:CoverletOutputFormat=Cobertura + - /dotnet-tools/reportgenerator "-reports:${CI_PROJECT_DIR}/**/coverage.cobertura.xml" "-targetdir:/reports" -reportType:TextSummary + - cat /reports/Summary.txt + artifacts: + when: always + reports: + coverage_report: + coverage_format: cobertura + path: ./**/coverage.cobertura.xml + +default-deploy: + stage: deploy + dependencies: + - default-build + - default-test + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG == null + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg || true + + + +core-build: + stage: build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9.]+/ + script: + - dotnet build -c Release --nologo Cloudflare/Cloudflare.csproj + - mkdir ./artifacts + - shopt -s globstar + - mv ./**/*.nupkg ./artifacts/ || true + - mv ./**/*.snupkg ./artifacts/ || true + artifacts: + paths: + - artifacts/*.nupkg + - artifacts/*.snupkg + expire_in: 1 days + +core-test: + stage: test + dependencies: + - core-build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9.]+/ + script: + - dotnet test -c Release --nologo /p:CoverletOutputFormat=Cobertura Cloudflare.Tests/Cloudflare.Tests.csproj + artifacts: + when: always + reports: + coverage_report: + coverage_format: cobertura + path: ./**/coverage.cobertura.xml + +core-deploy: + stage: deploy + dependencies: + - core-build + - core-test + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg || true +# - dotnet nuget push -k $NUGET_APIKEY -s https://api.nuget.org/v3/index.json --skip-duplicate artifacts/*.nupkg || true + + + +extensions-build: + stage: build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^[a-z]+\/v[0-9.]+/ + script: + - dotnet build -c Release --nologo + - mkdir ./artifacts + - shopt -s globstars + - mv ./**/*.nupkg ./artifacts/ || true + - mv ./**/*.snupkg ./artifacts/ || true + artifacts: + paths: + - artifacts/*.nupkg + - artifacts/*.snupkg + expire_in: 1 days + +extensions-test: + stage: test + dependencies: + - extensions-build + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^[a-z]+\/v[0-9.]+/ + script: + - dotnet test -c Release --nologo /p:CoverletOutputFormat=Cobertura + artifacts: + when: always + reports: + coverage_report: + coverage_format: cobertura + path: ./**/coverage.cobertura.xml + +extensions-deploy: + stage: deploy + dependencies: + - extensions-build + - extensions-test + tags: + - docker + - lnx + - 64bit + rules: + - if: $CI_COMMIT_TAG =~ /^[a-z]+\/v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg || true +# - dotnet nuget push -k $NUGET_APIKEY -s https://api.nuget.org/v3/index.json --skip-duplicate artifacts/*.nupkg || true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1021240 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/Cloudflare.Tests/Auth/ApiKeyAuthenticationTest.cs b/Cloudflare.Tests/Auth/ApiKeyAuthenticationTest.cs new file mode 100644 index 0000000..e676c55 --- /dev/null +++ b/Cloudflare.Tests/Auth/ApiKeyAuthenticationTest.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Net.Http; +using AMWD.Net.Api.Cloudflare.Auth; + +namespace Cloudflare.Core.Tests.Auth +{ + [TestClass] + public class ApiKeyAuthenticationTest + { + [TestMethod] + public void ShouldAddHeaders() + { + // Arrange + string emailAddress = "test@example.com"; + string apiKey = "some-api-key"; + + var auth = new ApiKeyAuthentication(emailAddress, apiKey); + using var clt = new HttpClient(); + + // Act + auth.AddHeader(clt); + + // Assert + Assert.IsTrue(clt.DefaultRequestHeaders.Contains("X-Auth-Email")); + Assert.IsTrue(clt.DefaultRequestHeaders.Contains("X-Auth-Key")); + + Assert.AreEqual(emailAddress, clt.DefaultRequestHeaders.GetValues("X-Auth-Email").First()); + Assert.AreEqual(apiKey, clt.DefaultRequestHeaders.GetValues("X-Auth-Key").First()); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldArgumentNullExceptionForEmailAddress(string emailAddress) + { + // Arrange + string apiKey = "some-api-key"; + + // Act + new ApiKeyAuthentication(emailAddress, apiKey); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldArgumentNullExceptionForApiKey(string apiKey) + { + // Arrange + string emailAddress = "test@example.com"; + + // Act + new ApiKeyAuthentication(emailAddress, apiKey); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow("test")] + [DataRow("test@example")] + [DataRow("example.com")] + [ExpectedException(typeof(ArgumentException))] + public void ShouldArgumentExceptionForInvalidEmailAddress(string emailAddress) + { + // Arrange + string apiKey = "some-api-key"; + + // Act + new ApiKeyAuthentication(emailAddress, apiKey); + + // Assert - ArgumentException + } + } +} diff --git a/Cloudflare.Tests/Auth/ApiTokenAuthenticationTest.cs b/Cloudflare.Tests/Auth/ApiTokenAuthenticationTest.cs new file mode 100644 index 0000000..1b88408 --- /dev/null +++ b/Cloudflare.Tests/Auth/ApiTokenAuthenticationTest.cs @@ -0,0 +1,44 @@ +using System; +using System.Net.Http; +using AMWD.Net.Api.Cloudflare.Auth; + +namespace Cloudflare.Core.Tests.Auth +{ + [TestClass] + public class ApiTokenAuthenticationTest + { + [TestMethod] + public void ShouldAddHeader() + { + // Arrange + string apiToken = "some-api-token"; + + var auth = new ApiTokenAuthentication(apiToken); + using var clt = new HttpClient(); + + // Act + auth.AddHeader(clt); + + // Assert + Assert.IsTrue(clt.DefaultRequestHeaders.Contains("Authorization")); + + Assert.AreEqual("Bearer", clt.DefaultRequestHeaders.Authorization.Scheme); + Assert.AreEqual(apiToken, clt.DefaultRequestHeaders.Authorization.Parameter); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldArgumentNullExceptionForEmailAddress(string apiToken) + { + // Arrange + + // Act + new ApiTokenAuthentication(apiToken); + + // Assert - ArgumentNullException + } + } +} diff --git a/Cloudflare.Tests/Cloudflare.Tests.csproj b/Cloudflare.Tests/Cloudflare.Tests.csproj new file mode 100644 index 0000000..0ef22b7 --- /dev/null +++ b/Cloudflare.Tests/Cloudflare.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + + false + true + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/Cloudflare.Tests/CloudflareClientTest.cs b/Cloudflare.Tests/CloudflareClientTest.cs new file mode 100644 index 0000000..385f084 --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTest.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests +{ + [TestClass] + public class CloudflareClientTest + { + private Mock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new Mock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns("http://localhost/api/v4/"); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + } + + [TestMethod] + public void ShouldInitializeWithEmailAndKey() + { + // Arrange + string email = "test@example.com"; + string apiKey = "some-api-key"; + + // Act + using var client = new CloudflareClient(email, apiKey); + + // Assert + var httpClient = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(client) as HttpClient; + + Assert.IsNotNull(httpClient); + Assert.AreEqual(email, httpClient.DefaultRequestHeaders.GetValues("X-Auth-Email").First()); + Assert.AreEqual(apiKey, httpClient.DefaultRequestHeaders.GetValues("X-Auth-Key").First()); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldInitializeWithApiToken() + { + // Arrange + string token = "some-special-api-token"; + + // Act + using var client = new CloudflareClient(token); + + // Assert + var httpClient = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(client) as HttpClient; + + Assert.IsNotNull(httpClient); + Assert.AreEqual($"Bearer {token}", httpClient.DefaultRequestHeaders.GetValues("Authorization").First()); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullOnMissingAuthentication() + { + // Arrange + + // Act + using var client = new CloudflareClient((IAuthentication)null); + + // Assert - ArgumentNullException + } + + [TestMethod] + public void ShouldAddCustomDefaultHeaders() + { + // Arrange + var clientOptions = new ClientOptions(); + clientOptions.DefaultHeaders.Add("SomeKey", "SomeValue"); + + // Act + using var client = new CloudflareClient("token", clientOptions); + + // Assert + var httpClient = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(client) as HttpClient; + + Assert.IsNotNull(httpClient); + Assert.IsTrue(httpClient.DefaultRequestHeaders.Contains("SomeKey")); + Assert.AreEqual("SomeValue", httpClient.DefaultRequestHeaders.GetValues("SomeKey").First()); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldDisposeHttpClient() + { + // Arrange + var client = GetClient(); + + // Act + client.Dispose(); + + // Assert + _httpHandlerMock.Protected().Verify("Dispose", Times.Once(), exactParameterMatch: true, true); + + VerifyDefault(); + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldAllowMultipleDispose() + { + // Arrange + var client = GetClient(); + + // Act + client.Dispose(); + client.Dispose(); + + // Assert + _httpHandlerMock.Protected().Verify("Dispose", Times.Once(), exactParameterMatch: true, true); + + VerifyDefault(); + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldAssertClientOptions() + { + // Arrange + Act + var client = GetClient(); + + // Assert + VerifyDefault(); + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullForBaseUrlOnAssertClientOptions() + { + // Arrange + _clientOptionsMock + .Setup(o => o.BaseUrl) + .Returns((string)null); + + // Act + var client = GetClient(); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArgumentOutOfRangeForTimeoutOnAssertClientOptions() + { + // Arrange + _clientOptionsMock + .Setup(o => o.Timeout) + .Returns(TimeSpan.Zero); + + // Act + var client = GetClient(); + + // Assert - ArgumentOutOfRangeException + } + + [DataTestMethod] + [DataRow(-1)] + [DataRow(11)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArgumentOutOfRangeForMaxRetriesOnAssertClientOptions(int maxRetries) + { + // Arrange + _clientOptionsMock + .Setup(o => o.MaxRetries) + .Returns(maxRetries); + + // Act + var client = GetClient(); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullForUseProxyOnAssertClientOptions() + { + // Arrange + _clientOptionsMock + .Setup(o => o.UseProxy) + .Returns(true); + + // Act + var client = GetClient(); + + // Assert - ArgumentNullException + } + + private void VerifyDefault() + { + _clientOptionsMock.VerifyGet(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.VerifyGet(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.VerifyGet(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.VerifyGet(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.VerifyGet(o => o.Proxy, Times.Once); + _clientOptionsMock.VerifyGet(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.VerifyGet(o => o.UseProxy, Times.Exactly(2)); + + _authenticationMock.Verify(a => a.AddHeader(It.IsAny()), Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + } + + private CloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Object); + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + } +} diff --git a/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs new file mode 100644 index 0000000..4b77b1c --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTests/DeleteAsyncTest.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests.CloudflareClientTests +{ + [TestClass] + public class DeleteAsyncTest + { + private const string _baseUrl = "http://localhost/api/v4/"; + + private HttpMessageHandlerMock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new HttpMessageHandlerMock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposed() + { + // Arrange + var client = GetClient() as CloudflareClient; + client.Dispose(); + + // Act + await client.DeleteAsync("test"); + + // Assert - ObjectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullOnRequestPath(string path) + { + // Arrange + var client = GetClient(); + + // Act + await client.DeleteAsync(path); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act + await client.DeleteAsync("foo?bar=baz"); + + // Assert - ArgumentException + } + + [TestMethod] + public async Task ShouldDelete() + { + // 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.DeleteAsync("test"); + + // 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.Delete, callback.Method); + Assert.AreEqual("http://localhost/api/v4/test", 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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(@"{""success"": false, ""errors"": [{ ""code"": ""4711"", ""message"": ""foo & baz."" }, { ""code"": ""4712"", ""message"": ""Happy Error!"" }], ""messages"": []}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + try + { + // Act + await client.DeleteAsync("foo"); + Assert.Fail(); + } + catch (AuthenticationException ex) + { + // Assert + Assert.IsNull(ex.InnerException); + Assert.AreEqual($"4711: foo & baz.{Environment.NewLine}4712: Happy Error!", ex.Message); + } + } + + [TestMethod] + public async Task ShouldReturnPlainText() + { + // Arrange + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary { { "bar", "08/15" } }); + _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.DeleteAsync("some-awesome-path"); + + // 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.Delete, callback.Method); + Assert.AreEqual("http://localhost/api/v4/some-awesome-path?bar=08%2F15", 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()); + + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(JsonReaderException))] + public async Task ShouldThrowExceptionOnInvalidResponse() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is a bad text :p", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + await client.DeleteAsync("some-path"); + } + + private void VerifyDefaults() + { + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Once); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.Mock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) + { + BaseAddress = new Uri(_baseUrl), + Timeout = _clientOptionsMock.Object.Timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) + httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + + private class TestClass + { + [JsonProperty("string")] + public string Str { get; set; } + + [JsonProperty("integer")] + public int Int { get; set; } + } + + private class TestFilter : IQueryParameterFilter + { + public IDictionary GetQueryParameters() + { + return new Dictionary + { + { "test", "filter-text" } + }; + } + } + } +} diff --git a/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs new file mode 100644 index 0000000..5d94ab1 --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTests/GetAsyncTest.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests.CloudflareClientTests +{ + [TestClass] + public class GetAsyncTest + { + private const string _baseUrl = "http://localhost/api/v4/"; + + private HttpMessageHandlerMock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new HttpMessageHandlerMock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposed() + { + // Arrange + var client = GetClient() as CloudflareClient; + client.Dispose(); + + // Act + await client.GetAsync("/test"); + + // Assert - ObjectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullOnRequestPath(string path) + { + // Arrange + var client = GetClient(); + + // Act + await client.GetAsync(path); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act + await client.GetAsync("/foo?bar=baz"); + + // Assert - ArgumentException + } + + [TestMethod] + public async Task ShouldGet() + { + // 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.GetAsync("test"); + + // 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.Get, callback.Method); + Assert.AreEqual("http://localhost/api/v4/test", 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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(@"{""success"": false, ""errors"": [{ ""code"": ""4711"", ""message"": ""foo & baz."" }, { ""code"": ""4712"", ""message"": ""Happy Error!"" }], ""messages"": []}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + try + { + // Act + await client.GetAsync("foo"); + Assert.Fail(); + } + catch (AuthenticationException ex) + { + // Assert + Assert.IsNull(ex.InnerException); + Assert.AreEqual($"4711: foo & baz.{Environment.NewLine}4712: Happy Error!", ex.Message); + } + } + + [TestMethod] + public async Task ShouldReturnPlainText() + { + // Arrange + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary { { "bar", "08/15" } }); + _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.GetAsync("some-awesome-path", new TestFilter()); + + // 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.Get, callback.Method); + Assert.AreEqual("http://localhost/api/v4/some-awesome-path?bar=08%2F15&test=filter-text", 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()); + + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(JsonReaderException))] + public async Task ShouldThrowExceptionOnInvalidResponse() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is a bad text :p", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + await client.GetAsync("some-path"); + } + + private void VerifyDefaults() + { + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Once); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.Mock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) + { + BaseAddress = new Uri(_baseUrl), + Timeout = _clientOptionsMock.Object.Timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) + httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + + private class TestClass + { + [JsonProperty("string")] + public string Str { get; set; } + + [JsonProperty("integer")] + public int Int { get; set; } + } + + private class TestFilter : IQueryParameterFilter + { + public IDictionary GetQueryParameters() + { + return new Dictionary + { + { "test", "filter-text" } + }; + } + } + } +} diff --git a/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs new file mode 100644 index 0000000..4833cfc --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTests/PatchAsyncTest.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests.CloudflareClientTests +{ + [TestClass] + public class PatchAsyncTest + { + private const string _baseUrl = "https://localhost/api/v4/"; + + private HttpMessageHandlerMock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + private TestClass _request; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new HttpMessageHandlerMock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + + _request = new TestClass + { + Int = 54321, + Str = "Happy Testing!" + }; + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposed() + { + // Arrange + var client = GetClient() as CloudflareClient; + client.Dispose(); + + // Act + await client.PatchAsync("test", _request); + + // Assert - ObjectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullOnRequestPath(string path) + { + // Arrange + var client = GetClient(); + + // Act + await client.PatchAsync(path, _request); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act + await client.PatchAsync("foo?bar=baz", _request); + + // Assert - ArgumentException + } + + [TestMethod] + public async Task ShouldPatch() + { + // 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.PatchAsync("test", _request); + + // 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.Patch, callback.Method); + Assert.AreEqual("https://localhost/api/v4/test", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldPatchHttpContentDirectly() + { + // Arrange + var stringContent = new StringContent(@"{""test"":""HERE ?""}", Encoding.UTF8, "application/json"); + _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.PatchAsync("test", stringContent); + + // 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.Patch, callback.Method); + Assert.AreEqual("https://localhost/api/v4/test", callback.Url); + Assert.AreEqual(@"{""test"":""HERE ?""}", 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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(@"{""success"": false, ""errors"": [{ ""code"": ""4711"", ""message"": ""foo & baz."" }, { ""code"": ""4712"", ""message"": ""Happy Error!"" }], ""messages"": []}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + try + { + // Act + await client.PatchAsync("foo", _request); + Assert.Fail(); + } + catch (AuthenticationException ex) + { + // Assert + Assert.IsNull(ex.InnerException); + Assert.AreEqual($"4711: foo & baz.{Environment.NewLine}4712: Happy Error!", ex.Message); + } + } + + [TestMethod] + public async Task ShouldReturnPlainText() + { + // Arrange + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary { { "bar", "08/15" } }); + _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.PatchAsync("some-awesome-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.Patch, callback.Method); + Assert.AreEqual("https://localhost/api/v4/some-awesome-path?bar=08%2F15", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(JsonReaderException))] + public async Task ShouldThrowExceptionOnInvalidResponse() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is a bad text :p", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + await client.PatchAsync("some-path", _request); + } + + private void VerifyDefaults() + { + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Once); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.Mock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) + { + BaseAddress = new Uri(_baseUrl), + Timeout = _clientOptionsMock.Object.Timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) + httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + + private class TestClass + { + [JsonProperty("string")] + public string Str { get; set; } + + [JsonProperty("integer")] + public int Int { get; set; } + } + + private class TestFilter : IQueryParameterFilter + { + public IDictionary GetQueryParameters() + { + return new Dictionary + { + { "test", "filter-text" } + }; + } + } + } +} diff --git a/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs new file mode 100644 index 0000000..f0ff5fc --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTests/PostAsyncTest.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests.CloudflareClientTests +{ + [TestClass] + public class PostAsyncTest + { + private const string _baseUrl = "https://localhost/api/v4/"; + + private HttpMessageHandlerMock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + private TestClass _request; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new HttpMessageHandlerMock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + + _request = new TestClass + { + Int = 54321, + Str = "Happy Testing!" + }; + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposed() + { + // Arrange + var client = GetClient() as CloudflareClient; + client.Dispose(); + + // Act + await client.PostAsync("test", _request); + + // Assert - ObjectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullOnRequestPath(string path) + { + // Arrange + var client = GetClient(); + + // Act + await client.PostAsync(path, _request); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act + await client.PostAsync("foo?bar=baz", _request); + + // Assert - ArgumentException + } + + [TestMethod] + public async Task ShouldPost() + { + // 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("test", _request); + + // 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/test", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldPostHttpContentDirectly() + { + // Arrange + var stringContent = new StringContent(@"{""test"":""HERE ?""}", Encoding.UTF8, "application/json"); + _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("test", stringContent); + + // 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/test", callback.Url); + Assert.AreEqual(@"{""test"":""HERE ?""}", 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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(@"{""success"": false, ""errors"": [{ ""code"": ""4711"", ""message"": ""foo & baz."" }, { ""code"": ""4712"", ""message"": ""Happy Error!"" }], ""messages"": []}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + try + { + // Act + await client.PostAsync("foo", _request); + Assert.Fail(); + } + catch (AuthenticationException ex) + { + // Assert + Assert.IsNull(ex.InnerException); + Assert.AreEqual($"4711: foo & baz.{Environment.NewLine}4712: Happy Error!", ex.Message); + } + } + + [TestMethod] + public async Task ShouldReturnPlainText() + { + // Arrange + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary { { "bar", "08/15" } }); + _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("some-awesome-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/some-awesome-path?bar=08%2F15", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(JsonReaderException))] + public async Task ShouldThrowExceptionOnInvalidResponse() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is a bad text :p", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + await client.PostAsync("some-path", _request); + } + + private void VerifyDefaults() + { + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Once); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.Mock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) + { + BaseAddress = new Uri(_baseUrl), + Timeout = _clientOptionsMock.Object.Timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) + httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + + private class TestClass + { + [JsonProperty("string")] + public string Str { get; set; } + + [JsonProperty("integer")] + public int Int { get; set; } + } + + private class TestFilter : IQueryParameterFilter + { + public IDictionary GetQueryParameters() + { + return new Dictionary + { + { "test", "filter-text" } + }; + } + } + } +} diff --git a/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs new file mode 100644 index 0000000..294bcd5 --- /dev/null +++ b/Cloudflare.Tests/CloudflareClientTests/PutAsyncTest.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Auth; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests.CloudflareClientTests +{ + [TestClass] + public class PutAsyncTest + { + private const string _baseUrl = "https://localhost/api/v4/"; + + private HttpMessageHandlerMock _httpHandlerMock; + private Mock _clientOptionsMock; + private Mock _authenticationMock; + + private TestClass _request; + + [TestInitialize] + public void Initialize() + { + _httpHandlerMock = new HttpMessageHandlerMock(); + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "Some-API-Token")); + + _clientOptionsMock.Setup(o => o.BaseUrl).Returns(_baseUrl); + _clientOptionsMock.Setup(o => o.Timeout).Returns(TimeSpan.FromSeconds(60)); + _clientOptionsMock.Setup(o => o.MaxRetries).Returns(2); + _clientOptionsMock.Setup(o => o.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(o => o.AllowRedirects).Returns(false); + _clientOptionsMock.Setup(o => o.UseProxy).Returns(false); + + _request = new TestClass + { + Int = 54321, + Str = "Happy Testing!" + }; + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposed() + { + // Arrange + var client = GetClient() as CloudflareClient; + client.Dispose(); + + // Act + await client.PutAsync("test", _request); + + // Assert - ObjectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullOnRequestPath(string path) + { + // Arrange + var client = GetClient(); + + // Act + await client.PutAsync(path, _request); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act + await client.PutAsync("foo?bar=baz", _request); + + // Assert - ArgumentException + } + + [TestMethod] + public async Task ShouldPut() + { + // 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("test", _request); + + // 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/test", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldPutHttpContentDirectly() + { + // Arrange + var stringContent = new StringContent(@"{""test"":""HERE ?""}", Encoding.UTF8, "application/json"); + _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("test", stringContent); + + // 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/test", callback.Url); + Assert.AreEqual(@"{""test"":""HERE ?""}", 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()); + + VerifyDefaults(); + VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(HttpStatusCode.Unauthorized)] + [DataRow(HttpStatusCode.Forbidden)] + public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode) + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(@"{""success"": false, ""errors"": [{ ""code"": ""4711"", ""message"": ""foo & baz."" }, { ""code"": ""4712"", ""message"": ""Happy Error!"" }], ""messages"": []}", Encoding.UTF8, MediaTypeNames.Application.Json), + }); + + var client = GetClient(); + + try + { + // Act + await client.PutAsync("foo", _request); + Assert.Fail(); + } + catch (AuthenticationException ex) + { + // Assert + Assert.IsNull(ex.InnerException); + Assert.AreEqual($"4711: foo & baz.{Environment.NewLine}4712: Happy Error!", ex.Message); + } + } + + [TestMethod] + public async Task ShouldReturnPlainText() + { + // Arrange + _clientOptionsMock.Setup(o => o.DefaultQueryParams).Returns(new Dictionary { { "bar", "08/15" } }); + _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.PutAsync("some-awesome-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.Put, callback.Method); + Assert.AreEqual("https://localhost/api/v4/some-awesome-path?bar=08%2F15", callback.Url); + Assert.AreEqual(@"{""string"":""Happy Testing!"",""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); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(JsonReaderException))] + public async Task ShouldThrowExceptionOnInvalidResponse() + { + // Arrange + _httpHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("This is a bad text :p", Encoding.UTF8, MediaTypeNames.Text.Plain), + }); + + var client = GetClient(); + + // Act + await client.PutAsync("some-path", _request); + } + + private void VerifyDefaults() + { + _authenticationMock.Verify(m => m.AddHeader(It.IsAny()), Times.Once); + + _clientOptionsMock.Verify(o => o.BaseUrl, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Timeout, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.MaxRetries, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.DefaultHeaders, Times.Once); + _clientOptionsMock.Verify(o => o.DefaultQueryParams, Times.Once); + _clientOptionsMock.Verify(o => o.AllowRedirects, Times.Once); + _clientOptionsMock.Verify(o => o.UseProxy, Times.Exactly(2)); + _clientOptionsMock.Verify(o => o.Proxy, Times.Once); + } + + private void VerifyNoOtherCalls() + { + _httpHandlerMock.Mock.VerifyNoOtherCalls(); + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + var httpClient = new HttpClient(_httpHandlerMock.Mock.Object) + { + BaseAddress = new Uri(_baseUrl), + Timeout = _clientOptionsMock.Object.Timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) + httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + var client = new CloudflareClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClientField = client.GetType() + .GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance); + + (httpClientField.GetValue(client) as HttpClient).Dispose(); + httpClientField.SetValue(client, httpClient); + + return client; + } + + private class TestClass + { + [JsonProperty("string")] + public string Str { get; set; } + + [JsonProperty("integer")] + public int Int { get; set; } + } + + private class TestFilter : IQueryParameterFilter + { + public IDictionary GetQueryParameters() + { + return new Dictionary + { + { "test", "filter-text" } + }; + } + } + } +} diff --git a/Cloudflare.Tests/Extensions/EnumExtensionsTest.cs b/Cloudflare.Tests/Extensions/EnumExtensionsTest.cs new file mode 100644 index 0000000..570543e --- /dev/null +++ b/Cloudflare.Tests/Extensions/EnumExtensionsTest.cs @@ -0,0 +1,64 @@ +using System.Runtime.Serialization; +using AMWD.Net.Api.Cloudflare; + +namespace Cloudflare.Core.Tests.Extensions +{ + [TestClass] + public class EnumExtensionsTest + { + [TestMethod] + public void ShouldReturnEnumMemberValue() + { + // Arrange + var enumValue = EnumWithAttribute.One; + + // Act + string val = enumValue.GetEnumMemberValue(); + + // Assert + Assert.AreEqual("eins", val); + } + + [TestMethod] + public void ShouldReturnStringMissingAttribute() + { + // Arrange + var enumValue = EnumWithoutAttribute.Two; + + // Act + string val = enumValue.GetEnumMemberValue(); + + // Assert + Assert.AreEqual("Two", val); + } + + [TestMethod] + public void ShouldReturnString() + { + // Arrange + EnumWithAttribute enumValue = 0; + + // Act + string val = enumValue.GetEnumMemberValue(); + + // Assert + Assert.AreEqual("0", val); + } + + public enum EnumWithAttribute + { + [EnumMember(Value = "eins")] + One = 1, + + [EnumMember(Value = "zwei")] + Two = 2, + } + + public enum EnumWithoutAttribute + { + One = 1, + + Two = 2, + } + } +} diff --git a/Cloudflare.Tests/MessageHandlerMock.cs b/Cloudflare.Tests/MessageHandlerMock.cs new file mode 100644 index 0000000..45ac95c --- /dev/null +++ b/Cloudflare.Tests/MessageHandlerMock.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +namespace Cloudflare.Core.Tests +{ + internal class HttpMessageHandlerMock + { + public HttpMessageHandlerMock() + { + Mock = new(); + Mock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback(async (request, ct) => + { + var callback = new HttpMessageRequestCallback + { + Method = request.Method, + Url = request.RequestUri.ToString(), + Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.First()), + }; + + if (request.Content != null) + callback.Content = await request.Content.ReadAsStringAsync(); + + Callbacks.Add(callback); + }) + .ReturnsAsync(() => Responses.Dequeue()); + } + + public List Callbacks { get; } = []; + + public Queue Responses { get; } = new(); + + public Mock Mock { get; } + } + + internal class HttpMessageRequestCallback + { + public HttpMethod Method { get; set; } + + public string Url { get; set; } + + public Dictionary Headers { get; set; } + + public string Content { get; set; } + } +} diff --git a/Cloudflare/Auth/ApiKeyAuthentication.cs b/Cloudflare/Auth/ApiKeyAuthentication.cs new file mode 100644 index 0000000..fe255be --- /dev/null +++ b/Cloudflare/Auth/ApiKeyAuthentication.cs @@ -0,0 +1,43 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; + +namespace AMWD.Net.Api.Cloudflare.Auth +{ + /// + /// Implements the interface to authenticate using an API key and email address. + /// + 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; + + /// + /// Initializes a new instance of the class. + /// + /// The email address. + /// The global API key. + public ApiKeyAuthentication(string emailAddress, string apiKey) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + throw new ArgumentNullException(nameof(emailAddress)); + + 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; + } + + /// + public void AddHeader(HttpClient httpClient) + { + httpClient.DefaultRequestHeaders.Add("X-Auth-Email", _emailAddress); + httpClient.DefaultRequestHeaders.Add("X-Auth-Key", _apiKey); + } + } +} diff --git a/Cloudflare/Auth/ApiTokenAuthentication.cs b/Cloudflare/Auth/ApiTokenAuthentication.cs new file mode 100644 index 0000000..aa08f51 --- /dev/null +++ b/Cloudflare/Auth/ApiTokenAuthentication.cs @@ -0,0 +1,32 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace AMWD.Net.Api.Cloudflare.Auth +{ + /// + /// Implements the interface to authenticate using an API token. + /// + public class ApiTokenAuthentication : IAuthentication + { + private readonly string _apiToken; + + /// + /// Initializes a new instance of the class. + /// + /// The API token. + public ApiTokenAuthentication(string apiToken) + { + if (string.IsNullOrWhiteSpace(apiToken)) + throw new ArgumentNullException(nameof(apiToken)); + + _apiToken = apiToken; + } + + /// + public void AddHeader(HttpClient httpClient) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiToken); + } + } +} diff --git a/Cloudflare/ClientOptions.cs b/Cloudflare/ClientOptions.cs new file mode 100644 index 0000000..0b45b25 --- /dev/null +++ b/Cloudflare/ClientOptions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Net; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Options for the Cloudflare API. + /// + public class ClientOptions + { + /// + /// Gets or sets the default base url for the API. + /// + public virtual string BaseUrl { get; set; } = "https://api.cloudflare.com/client/v4/"; + + /// + /// Gets or sets the default timeout for the API. + /// + public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets the maximum number of retries for the API. + /// + /// + /// The API may respond with an 5xx error and a X-Should-Retry header indicating that the request should be retried. + /// + public virtual int MaxRetries { get; set; } = 2; + + /// + /// Gets or sets additional default headers to every request. + /// + public virtual IDictionary DefaultHeaders { get; set; } = new Dictionary(); + + /// + /// Gets or sets additional default query parameters to every request. + /// + public virtual IDictionary DefaultQueryParams { get; set; } = new Dictionary(); + + /// + /// Gets or sets a value indicating whether to allow redirects. + /// + public virtual bool AllowRedirects { get; set; } + + /// + /// Gets or sets a value indicating whether to use a proxy. + /// + public virtual bool UseProxy { get; set; } + + /// + /// Gets or sets the proxy information. + /// + public virtual IWebProxy Proxy { get; set; } + } +} diff --git a/Cloudflare/Cloudflare.csproj b/Cloudflare/Cloudflare.csproj new file mode 100644 index 0000000..d566fa4 --- /dev/null +++ b/Cloudflare/Cloudflare.csproj @@ -0,0 +1,62 @@ + + + + netstandard2.0;net6.0 + + {semvertag:main}{!:-dev} + + true + false + true + + git + https://github.com/AM-WD/cloudflare-api.git + true + + package-icon.png + README.md + cloudflare api + https://developers.cloudflare.com/api + MIT + + true + snupkg + false + + AMWD.Net.API.Cloudflare + amwd-cloudflare-core + AMWD.Net.Api.Cloudflare + + Cloudflare API - Core + Core features of the Cloudflare API + + + + true + + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Cloudflare/CloudflareClient.cs b/Cloudflare/CloudflareClient.cs new file mode 100644 index 0000000..7469ef7 --- /dev/null +++ b/Cloudflare/CloudflareClient.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Auth; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Implements the Core of the Cloudflare API client. + /// + public partial class CloudflareClient : ICloudflareClient, IDisposable + { + private static readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings + { + Culture = CultureInfo.InvariantCulture, + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + }; + + private readonly ClientOptions _clientOptions; + private readonly HttpClient _httpClient; + + private bool _isDisposed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The email address of the Cloudflare account. + /// The API key of the Cloudflare account. + /// The client options (optional). + public CloudflareClient(string emailAddress, string apiKey, ClientOptions clientOptions = null) + : this(new ApiKeyAuthentication(emailAddress, apiKey), clientOptions) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The API token. + /// The client options (optional). + public CloudflareClient(string apiToken, ClientOptions clientOptions = null) + : this(new ApiTokenAuthentication(apiToken), clientOptions) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The authentication information. + /// The client options (optional). + public CloudflareClient(IAuthentication authentication, ClientOptions clientOptions = null) + { + if (authentication == null) + throw new ArgumentNullException(nameof(authentication)); + + _clientOptions = clientOptions ?? new ClientOptions(); + ValidateClientOptions(); + + _httpClient = CreateHttpClient(); + authentication.AddHeader(_httpClient); + } + + /// + /// Disposes of the resources used by the object. + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } + + /// + public async Task> GetAsync(string requestPath, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ValidateRequestPath(requestPath); + + string requestUrl = BuildRequestUrl(requestPath, queryFilter); + + var response = await _httpClient.GetAsync(requestUrl, cancellationToken).ConfigureAwait(false); + return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> PostAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ValidateRequestPath(requestPath); + + string requestUrl = BuildRequestUrl(requestPath); + + HttpContent httpRequestContent; + if (request is HttpContent httpContent) + { + httpRequestContent = httpContent; + } + else + { + string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); + httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); + } + + var response = await _httpClient.PostAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); + return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> PutAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ValidateRequestPath(requestPath); + + string requestUrl = BuildRequestUrl(requestPath); + + HttpContent httpRequestContent; + if (request is HttpContent httpContent) + { + httpRequestContent = httpContent; + } + else + { + string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); + httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); + } + + var response = await _httpClient.PutAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); + return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> DeleteAsync(string requestPath, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ValidateRequestPath(requestPath); + + string requestUrl = BuildRequestUrl(requestPath); + + var response = await _httpClient.DeleteAsync(requestUrl, cancellationToken).ConfigureAwait(false); + return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> PatchAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ValidateRequestPath(requestPath); + + string requestUrl = BuildRequestUrl(requestPath); + + HttpContent httpRequestContent; + if (request is HttpContent httpContent) + { + httpRequestContent = httpContent; + } + else + { + string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); + httpRequestContent = new StringContent(json, Encoding.UTF8, "application/json"); + } + +#if NET6_0_OR_GREATER + var response = await _httpClient.PatchAsync(requestUrl, httpRequestContent, cancellationToken).ConfigureAwait(false); +#else + var httpRequestMessage = new HttpRequestMessage + { + Version = HttpVersion.Version11, + Method = new HttpMethod("PATCH"), + RequestUri = new Uri(requestUrl), + Content = httpRequestContent, + }; + var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); +#endif + + return await GetCloudflareResponse(response, cancellationToken).ConfigureAwait(false); + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + throw new ObjectDisposedException(GetType().FullName); + } + + private void ValidateClientOptions() + { + if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl)) + throw new ArgumentNullException(nameof(_clientOptions.BaseUrl)); + + if (_clientOptions.Timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(_clientOptions.Timeout), "Timeout must be positive."); + + if (_clientOptions.MaxRetries < 0 || 10 < _clientOptions.MaxRetries) + throw new ArgumentOutOfRangeException(nameof(_clientOptions.MaxRetries), "MaxRetries should be between 0 and 10."); + + if (_clientOptions.UseProxy && _clientOptions.Proxy == null) + throw new ArgumentNullException(nameof(_clientOptions.Proxy)); + } + + private void ValidateRequestPath(string requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) + throw new ArgumentNullException(nameof(requestPath)); + + if (requestPath.Contains("?")) + throw new ArgumentException("Query parameters are not allowed", nameof(requestPath)); + } + + private HttpClient CreateHttpClient() + { + string version = typeof(CloudflareClient).Assembly + .GetCustomAttribute() + .InformationalVersion; + + HttpMessageHandler handler; + try + { + handler = new HttpClientHandler + { + AllowAutoRedirect = _clientOptions.AllowRedirects, + UseProxy = _clientOptions.UseProxy, + Proxy = _clientOptions.Proxy, + }; + } + catch (PlatformNotSupportedException) + { + handler = new HttpClientHandler + { + AllowAutoRedirect = _clientOptions.AllowRedirects, + }; + } + + var client = new HttpClient(handler, true) + { + BaseAddress = new Uri(_clientOptions.BaseUrl), + Timeout = _clientOptions.Timeout, + }; + + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AMWD.CloudflareClient", version)); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (_clientOptions.DefaultHeaders.Count > 0) + { + foreach (var headerKvp in _clientOptions.DefaultHeaders) + client.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); + } + + return client; + } + + private async Task> GetCloudflareResponse(HttpResponseMessage response, CancellationToken cancellationToken) + { +#if NET6_0_OR_GREATER + string content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + switch (response.StatusCode) + { + case HttpStatusCode.Forbidden: + case HttpStatusCode.Unauthorized: + var errorResponse = JsonConvert.DeserializeObject>(content); + throw new AuthenticationException(string.Join(Environment.NewLine, errorResponse.Errors.Select(e => $"{e.Code}: {e.Message}"))); + + default: + try + { + return JsonConvert.DeserializeObject>(content); + } + catch + { + if (typeof(TRes) == typeof(string)) + { + object cObj = content.Replace("\\n", Environment.NewLine); + return new CloudflareResponse + { + Success = true, + ResultInfo = new ResultInfo(), + Result = (TRes)cObj, + }; + } + + throw; + } + } + } + + private string BuildRequestUrl(string requestPath, IQueryParameterFilter queryFilter = null) + { + var dict = new Dictionary(); + + if (_clientOptions.DefaultQueryParams.Count > 0) + { + foreach (var paramKvp in _clientOptions.DefaultQueryParams) + dict[paramKvp.Key] = paramKvp.Value; + } + + var queryParams = queryFilter?.GetQueryParameters(); + if (queryParams?.Count > 0) + { + foreach (var kvp in queryParams) + dict[kvp.Key] = kvp.Value; + } + + if (dict.Count == 0) + return requestPath; + + string[] param = dict.Select(kvp => $"{kvp.Key}={WebUtility.UrlEncode(kvp.Value)}").ToArray(); + string query = string.Join("&", param); + + return $"{requestPath}?{query}"; + } + } +} diff --git a/Cloudflare/Enums/FilterMatchType.cs b/Cloudflare/Enums/FilterMatchType.cs new file mode 100644 index 0000000..fed5beb --- /dev/null +++ b/Cloudflare/Enums/FilterMatchType.cs @@ -0,0 +1,24 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Whether to match all search requirements or at least one (any). + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum FilterMatchType + { + /// + /// Match all search requirements. + /// + [EnumMember(Value = "all")] + All = 1, + + /// + /// Match at least one search requirement. + /// + [EnumMember(Value = "any")] + Any = 2, + } +} diff --git a/Cloudflare/Enums/OrderDirection.cs b/Cloudflare/Enums/OrderDirection.cs new file mode 100644 index 0000000..f16101a --- /dev/null +++ b/Cloudflare/Enums/OrderDirection.cs @@ -0,0 +1,24 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// The direction to order the entity. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum OrderDirection + { + /// + /// Order in ascending order. + /// + [EnumMember(Value = "asc")] + Asc = 1, + + /// + /// Order in descending order. + /// + [EnumMember(Value = "desc")] + Desc = 2 + } +} diff --git a/Cloudflare/Exceptions/CloudflareException.cs b/Cloudflare/Exceptions/CloudflareException.cs new file mode 100644 index 0000000..ee7ab51 --- /dev/null +++ b/Cloudflare/Exceptions/CloudflareException.cs @@ -0,0 +1,61 @@ +using System; +using System.Runtime.Serialization; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Represents errors that occur during Cloudflare API calls. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public class CloudflareException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public CloudflareException() + { + } + + /// + /// Initializes a new instance of the class with a specified error + /// message. + /// + /// The message that describes the error. + public CloudflareException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error + /// message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or a reference + /// if no inner exception is specified. + /// + public CloudflareException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with serialized data. + /// + /// + /// The that holds the serialized + /// object data about the exception being thrown. + /// + /// + /// The that contains contextual information + /// about the source or destination. + /// + /// The info parameter is null. + /// The class name is or is zero (0). + protected CloudflareException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/Cloudflare/Extensions/EnumExtensions.cs b/Cloudflare/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..ac5c556 --- /dev/null +++ b/Cloudflare/Extensions/EnumExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Runtime.Serialization; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Extension methods for s. + /// + public static class EnumExtensions + { + /// + /// Gets the of the when available, otherwise the . + /// + /// The enum value. + public static string GetEnumMemberValue(this Enum value) + { + var fieldInfo = value.GetType().GetField(value.ToString()); + if (fieldInfo == null) + return value.ToString(); + + var enumMember = fieldInfo + .GetCustomAttributes(typeof(EnumMemberAttribute), inherit: false) + .Cast() + .FirstOrDefault(); + + if (enumMember == null) + return value.ToString(); + + return enumMember.Value; + } + } +} diff --git a/Cloudflare/Interfaces/IAuthentication.cs b/Cloudflare/Interfaces/IAuthentication.cs new file mode 100644 index 0000000..3e65bb4 --- /dev/null +++ b/Cloudflare/Interfaces/IAuthentication.cs @@ -0,0 +1,16 @@ +using System.Net.Http; + +namespace AMWD.Net.Api.Cloudflare.Auth +{ + /// + /// Defines the interface to add authentication information. + /// + public interface IAuthentication + { + /// + /// Adds authentication headers to the given . + /// + /// The to add the headers to. + void AddHeader(HttpClient httpClient); + } +} diff --git a/Cloudflare/Interfaces/ICloudflareClient.cs b/Cloudflare/Interfaces/ICloudflareClient.cs new file mode 100644 index 0000000..32fc851 --- /dev/null +++ b/Cloudflare/Interfaces/ICloudflareClient.cs @@ -0,0 +1,75 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Represents a client for the Cloudflare API. + /// + public interface ICloudflareClient + { + /// + /// Makes a GET request to the Cloudflare API. + /// + /// + /// The GET method requests a representation of the specified resource. + /// Requests using GET should only retrieve data and should not contain a request content. + /// + /// 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> GetAsync(string requestPath, IQueryParameterFilter queryFilter = null, CancellationToken cancellationToken = default); + + /// + /// Makes a POST request to the Cloudflare API. + /// + /// + /// The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server. + /// + /// The response type. + /// The request type. + /// The request path (extending the base URL). + /// The request content. + /// A cancellation token used to propagate notification that this operation should be canceled. + Task> PostAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default); + + /// + /// Makes a PUT request to the Cloudflare API. + /// + /// + /// The PUT method replaces all current representations of the target resource with the request content. + /// + /// The response type. + /// The request type. + /// The request path (extending the base URL). + /// The request content. + /// A cancellation token used to propagate notification that this operation should be canceled. + Task> PutAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default); + + /// + /// Makes a DELETE request to the Cloudflare API. + /// + /// + /// The DELETE method deletes the specified resource. + /// + /// The response type. + /// The request path (extending the base URL). + /// A cancellation token used to propagate notification that this operation should be canceled. + /// + Task> DeleteAsync(string requestPath, CancellationToken cancellationToken = default); + + /// + /// Makes a PATCH request to the Cloudflare API. + /// + /// + /// The PATCH method applies partial modifications to a resource. + /// + /// The response type. + /// The request type. + /// The request path (extending the base URL). + /// The request content. + /// A cancellation token used to propagate notification that this operation should be canceled. + Task> PatchAsync(string requestPath, TRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/Cloudflare/Interfaces/IQueryParameterFilter.cs b/Cloudflare/Interfaces/IQueryParameterFilter.cs new file mode 100644 index 0000000..a264677 --- /dev/null +++ b/Cloudflare/Interfaces/IQueryParameterFilter.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Represents filter options defined via query parameters. + /// + public interface IQueryParameterFilter + { + /// + /// Gets the query parameters. + /// + IDictionary GetQueryParameters(); + } +} diff --git a/Cloudflare/Models/AccountBase.cs b/Cloudflare/Models/AccountBase.cs new file mode 100644 index 0000000..ad49c41 --- /dev/null +++ b/Cloudflare/Models/AccountBase.cs @@ -0,0 +1,21 @@ +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Base implementation of an account. + /// + public class AccountBase + { + /// + /// Identifier + /// + // <= 32 characters + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The name of the account. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/Cloudflare/Models/OwnerBase.cs b/Cloudflare/Models/OwnerBase.cs new file mode 100644 index 0000000..59d19a7 --- /dev/null +++ b/Cloudflare/Models/OwnerBase.cs @@ -0,0 +1,27 @@ +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Base implementation of an owner. + /// + public class OwnerBase + { + /// + /// Identifier. + /// + // <= 32 characters + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Name of the owner. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// The type of owner. + /// + [JsonProperty("type")] + public string Type { get; set; } + } +} diff --git a/Cloudflare/README.md b/Cloudflare/README.md new file mode 100644 index 0000000..7c39b74 --- /dev/null +++ b/Cloudflare/README.md @@ -0,0 +1,21 @@ +# Cloudflare API for .NET | Core + +This is the core package for all extensions of the Cloudflare API implemented by [AM.WD]. + +## Contents + +- The `(I)CloudflareClient` with base calls for `GET`, `POST`, `PUT`, `PATCH` and `DELETE` requests. +- Base classes to receive responses. +- `CloudflareException` to specify some errors. +- `IAuthentication` implementations to allow API-Token and API-Key (legacy) authentication. + +Any specific request will be defined via extension packages. + +--- + +Published under MIT License (see [choose a license]) + + + +[AM.WD]: https://www.nuget.org/packages?q=AMWD.&sortby=created-desc +[choose a license]: https://choosealicense.com/licenses/mit/ diff --git a/Cloudflare/Responses/CloudflareResponse.cs b/Cloudflare/Responses/CloudflareResponse.cs new file mode 100644 index 0000000..b5a88f1 --- /dev/null +++ b/Cloudflare/Responses/CloudflareResponse.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// The base Cloudflare response. + /// + public class CloudflareResponse + { + /// + /// Information about the result of the request. + /// + [JsonProperty("result_info")] + public ResultInfo ResultInfo { get; set; } + + /// + /// Whether the API call was successful. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// Errors returned by the API call. + /// + [JsonProperty("errors")] + public IReadOnlyList Errors { get; set; } = []; + + /// + /// Messages returned by the API call. + /// + [JsonProperty("messages")] + public IReadOnlyList Messages { get; set; } = []; + } + + /// + /// The base Cloudflare response with a result. + /// + /// + public class CloudflareResponse : CloudflareResponse + { + /// + /// The result of the API call. + /// + [JsonProperty("result")] + public T Result { get; set; } + } +} diff --git a/Cloudflare/Responses/ResponseInfo.cs b/Cloudflare/Responses/ResponseInfo.cs new file mode 100644 index 0000000..747901f --- /dev/null +++ b/Cloudflare/Responses/ResponseInfo.cs @@ -0,0 +1,20 @@ +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// A Cloudflare response information. + /// + public class ResponseInfo + { + /// + /// The message code. + /// + [JsonProperty("code")] + public int Code { get; set; } + + /// + /// The message. + /// + [JsonProperty("message")] + public string Message { get; set; } + } +} diff --git a/Cloudflare/Responses/ResultInfo.cs b/Cloudflare/Responses/ResultInfo.cs new file mode 100644 index 0000000..1635d86 --- /dev/null +++ b/Cloudflare/Responses/ResultInfo.cs @@ -0,0 +1,32 @@ +namespace AMWD.Net.Api.Cloudflare +{ + /// + /// Cloudflare Result Information. + /// + public class ResultInfo + { + /// + /// Total number of results for the requested service. + /// + [JsonProperty("count")] + public int Count { get; set; } + + /// + /// Current page within paginated list of results. + /// + [JsonProperty("page")] + public int Page { get; set; } + + /// + /// Number of results per page of results. + /// + [JsonProperty("per_page")] + public int PerPage { get; set; } + + /// + /// Total results available without any search parameters. + /// + [JsonProperty("total_count")] + public int TotalCount { get; set; } + } +} diff --git a/CodeMaid.config b/CodeMaid.config new file mode 100644 index 0000000..02255f6 --- /dev/null +++ b/CodeMaid.config @@ -0,0 +1,337 @@ + + + + +
+ + + + + + False + + + True + + + .*\.Designer\.cs||.*\.resx||packages.config||.*\.min\.js||.*\.min\.css + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + True + + + False + + + True + + + True + + + True + + + False + + + False + + + True + + + True + + + False + + + False + + + True + + + True + + + True + + + False + + + True + + + False + + + True + + + True + + + True + + + False + + + False + + + True + + + False + + + True + + + False + + + False + + + False + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + True + + + True + + + 0 + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + + + + + False + + + False + + + True + + + 100 + + + False + + + False + + + False + + + False + + + False + + + False + + + 0 + + + False + + + False + + + False + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..43a77c9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + 12.0 + + Modular Cloudflare API implementation in .NET + AM.WD + Andreas Müller + © {copyright:2024-} AM.WD + + + + true + $(SolutionDir)/cloudflare-api.snk + + + + + + + diff --git a/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj new file mode 100644 index 0000000..de8d9a7 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + + AMWD.Net.API.Cloudflare.Zones + cloudflare api zones + + amwd-cloudflare-zones + AMWD.Net.Api.Cloudflare.Zones + + Cloudflare API - Zones + Zone management features of the Cloudflare API + + + + true + + + diff --git a/Extensions/Cloudflare.Zones/README.md b/Extensions/Cloudflare.Zones/README.md new file mode 100644 index 0000000..068fb0c --- /dev/null +++ b/Extensions/Cloudflare.Zones/README.md @@ -0,0 +1,20 @@ +# Cloudflare API for .NET | Zones + +With this extension package, you'll get all features available to manage a Zone on Cloudflare. + +## Methods + +- [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) +- [EditZone](https://developers.cloudflare.com/api/operations/zones-0-patch) +- TBD + +--- + +Published under MIT License (see [choose a license]) + + + +[choose a license]: https://choosealicense.com/licenses/mit/ diff --git a/Extensions/Directory.Build.props b/Extensions/Directory.Build.props new file mode 100644 index 0000000..db5b780 --- /dev/null +++ b/Extensions/Directory.Build.props @@ -0,0 +1,56 @@ + + + {semvertag:main}{!:-dev} + + true + false + true + + git + https://github.com/AM-WD/cloudflare-api.git + true + + package-icon.png + README.md + https://developers.cloudflare.com/api + MIT + + true + snupkg + false + + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/LICENSE.txt b/LICENSE.txt index 3daebdd..7ac1e82 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -9,8 +9,9 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of +the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, diff --git a/README.md b/README.md new file mode 100644 index 0000000..556c85a --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Cloudflare API for .NET + +This project aims to implement the [Cloudflare API] in an extensible way. + +## Overview + +There should be a package for each API section as defined by Cloudflare. + + +### [Cloudflare] + +This is the base client, that will perform the request itself. It has the base url and holds the credentials. + + +### [Cloudflare.Zones] + +If you install this package, you will get all methods to handle a DNS zone. + + +--- + +Published under [MIT License] (see [choose a license]) + +[![Buy me a Coffee](https://shields.am-wd.de/badge/PayPal-Buy_me_a_Coffee-yellow?style=flat&logo=paypal)](https://link.am-wd.de/donate) +[![built with Codeium](https://codeium.com/badges/main)](https://link.am-wd.de/codeium) + + + +[Cloudflare]: Cloudflare/README.md +[Cloudflare.Zones]: Extensions/Cloudflare.Zones/README.md + +[Cloudflare API]: https://developers.cloudflare.com/api/ +[MIT License]: LICENSE.txt +[choose a license]: https://choosealicense.com/licenses/mit/ diff --git a/UnitTests/Cloudflare.Zones.Tests/Cloudflare.Zones.Tests.csproj b/UnitTests/Cloudflare.Zones.Tests/Cloudflare.Zones.Tests.csproj new file mode 100644 index 0000000..0028ae9 --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/Cloudflare.Zones.Tests.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/UnitTests/Directory.Build.props b/UnitTests/Directory.Build.props new file mode 100644 index 0000000..a7fbc1e --- /dev/null +++ b/UnitTests/Directory.Build.props @@ -0,0 +1,28 @@ + + + false + true + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/cloudflare-api.sln b/cloudflare-api.sln new file mode 100644 index 0000000..83e4354 --- /dev/null +++ b/cloudflare-api.sln @@ -0,0 +1,84 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35013.160 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cloudflare", "Cloudflare\Cloudflare.csproj", "{9D98650A-01CC-44B1-AC1E-D6323E1777C5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5D69F102-CF03-4175-8C59-D457450B28E0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{EE760850-ED97-4493-B0AE-326289A60145}" + ProjectSection(SolutionItems) = preProject + Extensions\Directory.Build.props = Extensions\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{BA35336B-7640-4C0C-B93E-06BDC1EE1872}" + ProjectSection(SolutionItems) = preProject + .gitlab-ci.yml = .gitlab-ci.yml + Directory.Build.props = Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{E72A0B89-A37E-4BB3-B2EF-26AB24D3D716}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + CodeMaid.config = CodeMaid.config + nuget.config = nuget.config + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{5AF54083-1A93-4C43-B36C-EDD9E5DE0695}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + LICENSE.txt = LICENSE.txt + package-icon.png = package-icon.png + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cloudflare.Zones", "Extensions\Cloudflare.Zones\Cloudflare.Zones.csproj", "{290D0987-D295-4DBD-9090-14D3DED63281}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTests", "UnitTests", "{A31B4929-190B-4AB8-984B-E284BB159F04}" + ProjectSection(SolutionItems) = preProject + UnitTests\Directory.Build.props = UnitTests\Directory.Build.props + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cloudflare.Tests", "Cloudflare.Tests\Cloudflare.Tests.csproj", "{2491D707-E845-49DF-8D94-0154AAD36E42}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cloudflare.Zones.Tests", "UnitTests\Cloudflare.Zones.Tests\Cloudflare.Zones.Tests.csproj", "{835705E5-D9F9-4236-9E25-898A851C8165}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9D98650A-01CC-44B1-AC1E-D6323E1777C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D98650A-01CC-44B1-AC1E-D6323E1777C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D98650A-01CC-44B1-AC1E-D6323E1777C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D98650A-01CC-44B1-AC1E-D6323E1777C5}.Release|Any CPU.Build.0 = Release|Any CPU + {290D0987-D295-4DBD-9090-14D3DED63281}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {290D0987-D295-4DBD-9090-14D3DED63281}.Debug|Any CPU.Build.0 = Debug|Any CPU + {290D0987-D295-4DBD-9090-14D3DED63281}.Release|Any CPU.ActiveCfg = Release|Any CPU + {290D0987-D295-4DBD-9090-14D3DED63281}.Release|Any CPU.Build.0 = Release|Any CPU + {2491D707-E845-49DF-8D94-0154AAD36E42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2491D707-E845-49DF-8D94-0154AAD36E42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2491D707-E845-49DF-8D94-0154AAD36E42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2491D707-E845-49DF-8D94-0154AAD36E42}.Release|Any CPU.Build.0 = Release|Any CPU + {835705E5-D9F9-4236-9E25-898A851C8165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {835705E5-D9F9-4236-9E25-898A851C8165}.Debug|Any CPU.Build.0 = Debug|Any CPU + {835705E5-D9F9-4236-9E25-898A851C8165}.Release|Any CPU.ActiveCfg = Release|Any CPU + {835705E5-D9F9-4236-9E25-898A851C8165}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BA35336B-7640-4C0C-B93E-06BDC1EE1872} = {5D69F102-CF03-4175-8C59-D457450B28E0} + {E72A0B89-A37E-4BB3-B2EF-26AB24D3D716} = {5D69F102-CF03-4175-8C59-D457450B28E0} + {5AF54083-1A93-4C43-B36C-EDD9E5DE0695} = {5D69F102-CF03-4175-8C59-D457450B28E0} + {290D0987-D295-4DBD-9090-14D3DED63281} = {EE760850-ED97-4493-B0AE-326289A60145} + {835705E5-D9F9-4236-9E25-898A851C8165} = {A31B4929-190B-4AB8-984B-E284BB159F04} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A465B60D-C946-4381-835C-29303EA4FAD1} + EndGlobalSection +EndGlobal diff --git a/cloudflare-api.snk b/cloudflare-api.snk new file mode 100644 index 0000000000000000000000000000000000000000..78abbd9680a7d633c8bcea5182d7398b9810f214 GIT binary patch literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa500980YXmi^?w#5lXq$DEC9o!I{0a2sYU%q2(=5s)B`))q3017j#YBtKXDK639 z2{E$;;Jr=xW zmu%ZuoG6xhb}#97%R6on=zj>fP^a>QPP9$SToye3V*%QSC5R3k2c?a}$fIc8QwbQa z9V#0EZF&Q<({qgA5q)3d_;eI(bEW0s>NRw+JqfgmEpTeuR6=5&c9WjhHC?P;@<#Dl z{f_0TTTzH}8A2q$|D_COhw5hLi5sXFz=4Sfes7D>4t_h6xI1JM7>V~_WkVo&2tUF iOCp?F-&c(9#K4nxIq3X3T=?t|l-5 literal 0 HcmV?d00001