From b4792b93145c7ae0f00bfd2f67d71620836db07d Mon Sep 17 00:00:00 2001 From: Kwok He <105217051+Kwok-he-Chu@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:32:20 +0100 Subject: [PATCH 1/5] Added /Core classes and tests in preparations for the v7-generator-upgrade --- .../Adyen.IntegrationTest.csproj | 2 +- Adyen.Test/Adyen.Test.csproj | 2 +- Adyen.Test/Core/Auth/TokenProviderTest.cs | 49 +++ Adyen.Test/Core/Client/ApiResponseTest.cs | 173 ++++++++ .../Client/HttpRequestMessageExtensions.cs | 95 +++++ .../Core/Client/UrlBuilderExtensionsTest.cs | 154 ++++++++ Adyen.Test/Core/Converters/ByteArrayTest.cs | 114 ++++++ .../Converters/DateOnlyJsonConverterTest.cs | 105 +++++ .../DateOnlyNullableJsonConverterTest.cs | 106 +++++ .../Converters/DateTimeJsonConverterTest.cs | 76 ++++ .../DateTimeNullableJsonConverterTest.cs | 111 ++++++ Adyen.Test/Core/IEnumTest.cs | 359 +++++++++++++++++ .../Utilities/HmacValidatorUtilityTest.cs | 41 ++ Adyen/Adyen.csproj | 13 +- Adyen/Core/Auth/TokenBase.cs | 20 + Adyen/Core/Auth/TokenProvider.cs | 42 ++ Adyen/Core/Client/ApiException.cs | 40 ++ Adyen/Core/Client/ApiFactory.cs | 48 +++ Adyen/Core/Client/ApiResponse.cs | 371 ++++++++++++++++++ Adyen/Core/Client/ApiResponseEventArgs.cs | 24 ++ Adyen/Core/Client/ExceptionEventArgs.cs | 24 ++ .../Extensions/HttpClientBuilderExtensions.cs | 68 ++++ .../HttpRequestMessageExtensions.cs | 63 +++ Adyen/Core/Client/IAdyenApiService.cs | 13 + Adyen/Core/Client/RequestOptions.cs | 75 ++++ Adyen/Core/Client/UrlBuilderExtensions.cs | 80 ++++ Adyen/Core/Converters/ByteArrayConverter.cs | 29 ++ .../Core/Converters/DateOnlyJsonConverter.cs | 52 +++ .../DateOnlyNullableJsonConverter.cs | 57 +++ .../Core/Converters/DateTimeJsonConverter.cs | 66 ++++ .../DateTimeNullableJsonConverter.cs | 71 ++++ Adyen/Core/IEnum.cs | 11 + Adyen/Core/Option.cs | 48 +++ Adyen/Core/Options/AdyenEnvironment.cs | 12 + Adyen/Core/Options/AdyenOptions.cs | 36 ++ Adyen/Core/Utilities/HmacValidatorUtility.cs | 86 ++++ 36 files changed, 2731 insertions(+), 5 deletions(-) create mode 100644 Adyen.Test/Core/Auth/TokenProviderTest.cs create mode 100644 Adyen.Test/Core/Client/ApiResponseTest.cs create mode 100644 Adyen.Test/Core/Client/HttpRequestMessageExtensions.cs create mode 100644 Adyen.Test/Core/Client/UrlBuilderExtensionsTest.cs create mode 100644 Adyen.Test/Core/Converters/ByteArrayTest.cs create mode 100644 Adyen.Test/Core/Converters/DateOnlyJsonConverterTest.cs create mode 100644 Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs create mode 100644 Adyen.Test/Core/Converters/DateTimeJsonConverterTest.cs create mode 100644 Adyen.Test/Core/Converters/DateTimeNullableJsonConverterTest.cs create mode 100644 Adyen.Test/Core/IEnumTest.cs create mode 100644 Adyen.Test/Core/Utilities/HmacValidatorUtilityTest.cs create mode 100644 Adyen/Core/Auth/TokenBase.cs create mode 100644 Adyen/Core/Auth/TokenProvider.cs create mode 100644 Adyen/Core/Client/ApiException.cs create mode 100644 Adyen/Core/Client/ApiFactory.cs create mode 100644 Adyen/Core/Client/ApiResponse.cs create mode 100644 Adyen/Core/Client/ApiResponseEventArgs.cs create mode 100644 Adyen/Core/Client/ExceptionEventArgs.cs create mode 100644 Adyen/Core/Client/Extensions/HttpClientBuilderExtensions.cs create mode 100644 Adyen/Core/Client/Extensions/HttpRequestMessageExtensions.cs create mode 100644 Adyen/Core/Client/IAdyenApiService.cs create mode 100644 Adyen/Core/Client/RequestOptions.cs create mode 100644 Adyen/Core/Client/UrlBuilderExtensions.cs create mode 100644 Adyen/Core/Converters/ByteArrayConverter.cs create mode 100644 Adyen/Core/Converters/DateOnlyJsonConverter.cs create mode 100644 Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs create mode 100644 Adyen/Core/Converters/DateTimeJsonConverter.cs create mode 100644 Adyen/Core/Converters/DateTimeNullableJsonConverter.cs create mode 100644 Adyen/Core/IEnum.cs create mode 100644 Adyen/Core/Option.cs create mode 100644 Adyen/Core/Options/AdyenEnvironment.cs create mode 100644 Adyen/Core/Options/AdyenOptions.cs create mode 100644 Adyen/Core/Utilities/HmacValidatorUtility.cs diff --git a/Adyen.IntegrationTest/Adyen.IntegrationTest.csproj b/Adyen.IntegrationTest/Adyen.IntegrationTest.csproj index 7662e15c7..826f66510 100644 --- a/Adyen.IntegrationTest/Adyen.IntegrationTest.csproj +++ b/Adyen.IntegrationTest/Adyen.IntegrationTest.csproj @@ -1,7 +1,7 @@ - net8.0;net6.0 + net8.0 12 enable disable diff --git a/Adyen.Test/Adyen.Test.csproj b/Adyen.Test/Adyen.Test.csproj index e88cb526f..996b8188b 100644 --- a/Adyen.Test/Adyen.Test.csproj +++ b/Adyen.Test/Adyen.Test.csproj @@ -1,7 +1,7 @@  - net8.0;net6.0 + net8.0 12 enable disable diff --git a/Adyen.Test/Core/Auth/TokenProviderTest.cs b/Adyen.Test/Core/Auth/TokenProviderTest.cs new file mode 100644 index 000000000..db8862722 --- /dev/null +++ b/Adyen.Test/Core/Auth/TokenProviderTest.cs @@ -0,0 +1,49 @@ +using Adyen.Core.Auth; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Auth +{ + [TestClass] + public class TokenProviderTest + { + internal sealed class TestToken : TokenBase + { + public string Value { get; } + + internal TestToken(string value) + { + Value = value; + } + } + + [TestMethod] + public async Task Given_TokenProviderWithToken_When_GetCalled_Then_SameInstanceAndValueAreReturned() + { + // Arrange + var token = new TestToken("apikey"); + var provider = new TokenProvider(token); + + // Act + var result = provider.Get(); + + // Assert + Assert.AreSame(token, result); + Assert.AreEqual("apikey", result.Value); + } + + [TestMethod] + public async Task Given_TokenProvider_When_GetCalledMultipleTimes_Then_SameInstanceReturnedEachTime() + { + // Arrange + var token = new TestToken("apikey"); + var provider = new TokenProvider(token); + + // Act + var first = provider.Get(); + var second = provider.Get(); + + // Assert + Assert.AreSame(first, second); + } + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Client/ApiResponseTest.cs b/Adyen.Test/Core/Client/ApiResponseTest.cs new file mode 100644 index 000000000..d89d88f50 --- /dev/null +++ b/Adyen.Test/Core/Client/ApiResponseTest.cs @@ -0,0 +1,173 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Adyen.Core.Client; + +namespace Adyen.Test.Core.Client +{ + [TestClass] + public class ApiResponseTests + { + private HttpRequestMessage CreateRequest(string? uri = "https://adyen.com") + => new HttpRequestMessage(HttpMethod.Get, uri); + + private HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string reasonPhrase = "") + => new HttpResponseMessage(statusCode) + { + ReasonPhrase = reasonPhrase + }; + + [TestMethod] + [DataRow(HttpStatusCode.OK, true)] + [DataRow(HttpStatusCode.Created, true)] + [DataRow(HttpStatusCode.Accepted, true)] + [DataRow(HttpStatusCode.BadRequest, false)] + [DataRow(HttpStatusCode.Unauthorized, false)] + [DataRow(HttpStatusCode.Forbidden, false)] + [DataRow(HttpStatusCode.NotFound, false)] + [DataRow(HttpStatusCode.TooManyRequests, false)] + [DataRow(HttpStatusCode.UnprocessableEntity, false)] + [DataRow(HttpStatusCode.InternalServerError, false)] + public async Task Given_ApiResponse_When_SuccessStatusCode_Then_Result_ShouldMatchHttpResponseMessage(HttpStatusCode code, bool expected) + { + // Arrange + // Act + var response = new ApiResponse(CreateRequest(), CreateResponse(code), "", "/", DateTime.UtcNow, new JsonSerializerOptions()); + // Assert + Assert.AreEqual(expected, response.IsSuccessStatusCode); + } + + [TestMethod] + public async Task Given_ApiResponse_When_Properties_Are_Set_Then_Object_Should_Return_Correct_Values() + { + // Arrange + var request = CreateRequest("https://adyen.com/"); + + // Act + var responseMessage = CreateResponse(HttpStatusCode.Accepted, "Accepted"); + var apiResponse = new ApiResponse(request, responseMessage, "{\"key\":\"value\"}", "/path", DateTime.UtcNow, new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(responseMessage.StatusCode, apiResponse.StatusCode); + Assert.AreEqual(responseMessage.ReasonPhrase, apiResponse.ReasonPhrase); + Assert.AreEqual(request.RequestUri, apiResponse.RequestUri); + Assert.AreEqual("/path", apiResponse.Path); + Assert.IsNotNull(apiResponse.Headers); + Assert.AreEqual("{\"key\":\"value\"}", apiResponse.RawContent); + Assert.IsNull(apiResponse.ContentStream); + } + + [TestMethod] + public async Task Given_ApiResponse_When_ContentStream_Is_Set_Then_Return_ContentStream_And_Empty_RawContent() + { + // Arrange + var request = CreateRequest(); + var responseMessage = CreateResponse(HttpStatusCode.OK); + + // Act + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var apiResponse = new ApiResponse(request, responseMessage, stream, "/stream", DateTime.UtcNow, new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(stream, apiResponse.ContentStream); + Assert.AreEqual(string.Empty, apiResponse.RawContent); + } + + private class TestApiResponse : ApiResponse, + IOk, ICreated, IAccepted, + IBadRequest, IUnauthorized, IForbidden, ITooManyRequests, INotFound, IUnprocessableContent, IInternalServerError + { + public TestApiResponse(HttpRequestMessage message, HttpResponseMessage response, string raw, string path, DateTime requested, JsonSerializerOptions opts) + : base(message, response, raw, path, requested, opts) { } + + private T DeserializeRaw() => JsonSerializer.Deserialize(RawContent, _jsonSerializerOptions)!; + + public T Ok() => DeserializeRaw(); + public bool TryDeserializeOkResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T Created() => DeserializeRaw(); + public bool TryDeserializeCreatedResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T Accepted() => DeserializeRaw(); + public bool TryDeserializeAcceptedResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T BadRequest() => DeserializeRaw(); + public bool TryDeserializeBadRequestResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T Unauthorized() => DeserializeRaw(); + public bool TryDeserializeUnauthorizedResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T Forbidden() => DeserializeRaw(); + public bool TryDeserializeForbiddenResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T TooManyRequests() => DeserializeRaw(); + public bool TryDeserializeTooManyRequestsResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T NotFound() => DeserializeRaw(); + public bool TryDeserializeNotFoundResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T UnprocessableContent() => DeserializeRaw(); + public bool TryDeserializeUnprocessableContentResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + + public T InternalServerError() => DeserializeRaw(); + public bool TryDeserializeInternalServerErrorResponse(out T? result) { result = string.IsNullOrEmpty(RawContent) ? default : DeserializeRaw(); return result != null; } + } + + private record TestModel(string Foo); + + [TestMethod] + public async Task Given_ApiResponse_When_TypedResponses_Then_Deserialize_Correctly() + { + // Arrange + var model = new TestModel("adyen"); + + // Act + string json = JsonSerializer.Serialize(model, new JsonSerializerOptions()); + var response = new TestApiResponse(CreateRequest(), CreateResponse(HttpStatusCode.OK), json, "/path", DateTime.UtcNow, new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(model, response.Ok()); + Assert.IsTrue(response.TryDeserializeOkResponse(out var okResult)); + Assert.AreEqual(model, okResult); + + Assert.AreEqual(model, response.Created()); + Assert.IsTrue(response.TryDeserializeCreatedResponse(out var createdResult)); + Assert.AreEqual(model, createdResult); + + Assert.AreEqual(model, response.Accepted()); + Assert.IsTrue(response.TryDeserializeAcceptedResponse(out var acceptedResult)); + Assert.AreEqual(model, acceptedResult); + + Assert.AreEqual(model, response.BadRequest()); + Assert.IsTrue(response.TryDeserializeBadRequestResponse(out var badResult)); + Assert.AreEqual(model, badResult); + + Assert.AreEqual(model, response.Unauthorized()); + Assert.IsTrue(response.TryDeserializeUnauthorizedResponse(out var unAuthResult)); + Assert.AreEqual(model, unAuthResult); + + Assert.AreEqual(model, response.Forbidden()); + Assert.IsTrue(response.TryDeserializeForbiddenResponse(out var forbiddenResult)); + Assert.AreEqual(model, forbiddenResult); + + Assert.AreEqual(model, response.TooManyRequests()); + Assert.IsTrue(response.TryDeserializeTooManyRequestsResponse(out var tooManyResult)); + Assert.AreEqual(model, tooManyResult); + + Assert.AreEqual(model, response.NotFound()); + Assert.IsTrue(response.TryDeserializeNotFoundResponse(out var notFoundResult)); + Assert.AreEqual(model, notFoundResult); + + Assert.AreEqual(model, response.UnprocessableContent()); + Assert.IsTrue(response.TryDeserializeUnprocessableContentResponse(out var unprocessableResult)); + Assert.AreEqual(model, unprocessableResult); + + Assert.AreEqual(model, response.InternalServerError()); + Assert.IsTrue(response.TryDeserializeInternalServerErrorResponse(out var internalResult)); + Assert.AreEqual(model, internalResult); + } + } +} diff --git a/Adyen.Test/Core/Client/HttpRequestMessageExtensions.cs b/Adyen.Test/Core/Client/HttpRequestMessageExtensions.cs new file mode 100644 index 000000000..a7652c36e --- /dev/null +++ b/Adyen.Test/Core/Client/HttpRequestMessageExtensions.cs @@ -0,0 +1,95 @@ +using Adyen.Core.Client.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Client +{ + [TestClass] + public class HttpRequestMessageExtensionsTest + { + [TestMethod] + public void Given_AddUserAgentToHeaders_When_ApplicationName_Is_Null_Returns_adyen_dotnet_api_library_And_AdyenLibraryVersion() + { + // Arrange + HttpRequestMessageExtensions.ApplicationName = null; + var request = new HttpRequestMessage(); + + // Act + request.AddUserAgentToHeaders(); + + // Assert + string target = request.Headers.GetValues("UserAgent").First(); + Assert.AreEqual($"adyen-dotnet-api-library/{HttpRequestMessageExtensions.AdyenLibraryVersion}", target); + } + + [TestMethod] + public void Given_AddUserAgentToHeaders_When_ApplicationName_Is_Set_Returns_MyApp_adyen_dotnet_api_library_And_AdyenLibraryVersion() + { + // Arrange + var request = new HttpRequestMessage(); + HttpRequestMessageExtensions.ApplicationName = "MyApp"; + + // Act + request.AddUserAgentToHeaders(); + + // Assert + string target = request.Headers.GetValues("UserAgent").First(); + Assert.AreEqual($"MyApp adyen-dotnet-api-library/{HttpRequestMessageExtensions.AdyenLibraryVersion}", target); + } + + [TestMethod] + public void Given_AddLibraryNameToHeader_When_Provided_Returns_adyen_dotnet_api_library() + { + // Arrange + HttpRequestMessageExtensions.ApplicationName = null; + var request = new HttpRequestMessage(); + + // Act + request.AddLibraryNameToHeader(); + + // Assert + string target = request.Headers.GetValues("adyen-library-name").First(); + Assert.AreEqual("adyen-dotnet-api-library", target); + } + + [TestMethod] + public void Given_AddLibraryNameToHeader_When_Provided_Returns_AdyenLibraryVersion() + { + // Arrange + HttpRequestMessageExtensions.ApplicationName = null; + var request = new HttpRequestMessage(); + + // Act + request.AddLibraryVersionToHeader(); + + // Assert + string target = request.Headers.GetValues("adyen-library-version").First(); + Assert.AreEqual(HttpRequestMessageExtensions.AdyenLibraryVersion, target); + } + + [TestMethod] + public void Given_AddUserAgentToHeaders_When_Called_MultipleTimes_Should_Not_Throw_Any_Exceptions() + { + // Arrange + HttpRequestMessageExtensions.ApplicationName = null; + var request = new HttpRequestMessage(); + + // Act + // Assert + try + { + request.AddUserAgentToHeaders(); + request.AddUserAgentToHeaders(); + + request.AddLibraryNameToHeader(); + request.AddLibraryNameToHeader(); + + request.AddLibraryVersionToHeader(); + request.AddLibraryVersionToHeader(); + } + catch (Exception e) + { + Assert.Fail(); + } + } + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Client/UrlBuilderExtensionsTest.cs b/Adyen.Test/Core/Client/UrlBuilderExtensionsTest.cs new file mode 100644 index 000000000..aeadc3c3a --- /dev/null +++ b/Adyen.Test/Core/Client/UrlBuilderExtensionsTest.cs @@ -0,0 +1,154 @@ +using Adyen.Core.Client; +using Adyen.Core.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Client +{ + [TestClass] + public class UrlBuilderExtensionsTest + { + #region Checkout && POS-SDK + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_Then_CheckoutUrl_Contains_Prefix_And_Live_String() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = "prefix", + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://checkout-test.adyen.com/v71"); + + // Assert + Assert.AreEqual("https://prefix-checkout-live.adyenpayments.com/checkout/v71", target); + } + + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_And_No_Prefix_For_Checkout_Throws_InvalidOperationException() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = null + }; + + // Act + // Assert + Assert.Throws(() => UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://checkout-test.adyen.com/v71")); + } + + #endregion + + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_Then_Checkout_POS_SDK_Contains_Prefix_And_Live_String_Without_Checkout() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = "prefix", + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://checkout-test.adyen.com/checkout/possdk/v68"); + + // Assert + Assert.AreEqual("https://prefix-checkout-live.adyenpayments.com/checkout/possdk/v68", target); + } + + + #region Pal + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_Then_PalUrl_Contains_Prefix_And_Live_String() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = "prefix", + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://pal-test.adyen.com/pal/servlet/Payment/v68"); + + // Assert + Assert.AreEqual("https://prefix-pal-live.adyenpayments.com/pal/servlet/Payment/v68", target); + } + + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_And_No_Prefix_For_Pal_Throws_InvalidOperationException() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = null + }; + + // Act + // Assert + Assert.Throws(() => UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://pal-test.adyen.com/pal/servlet/Payment/v68")); + } + + #endregion + + + #region SessionAuthentication + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_Then_SessionAuthentication_Url_Contains_Prefix_And_Live_String() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = "prefix", + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://test.adyen.com/authe/api/v1"); + + // Assert + Assert.AreEqual("https://authe-live.adyen.com/authe/api/v1", target); + } + + #endregion + + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Test_And_Prefix_Given_Then_Url_Does_Not_Contain_Prefix() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Test, + LiveEndpointUrlPrefix = "prefix", + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://test.adyen.com"); + + // Assert + Assert.IsFalse(target.Contains("prefix")); + } + + [TestMethod] + public async Task Given_ConstructHostUrl_When_Environment_Is_Live_And_No_Prefix_Does_Not_Throw_InvalidOperationException_And_Contains_String_Value_Test() + { + // Arrange + AdyenOptions adyenOptions = new AdyenOptions() + { + Environment = AdyenEnvironment.Live, + LiveEndpointUrlPrefix = null + }; + + // Act + string target = UrlBuilderExtensions.ConstructHostUrl(adyenOptions, "https://test.adyen.com/"); + + // Assert + Assert.IsTrue(target.Contains("test")); + } + + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Converters/ByteArrayTest.cs b/Adyen.Test/Core/Converters/ByteArrayTest.cs new file mode 100644 index 000000000..542775b8e --- /dev/null +++ b/Adyen.Test/Core/Converters/ByteArrayTest.cs @@ -0,0 +1,114 @@ +using System.Text; +using System.Text.Json; +using Adyen.Core.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Converters +{ + [TestClass] + public class ByteArrayTest + { + private readonly ByteArrayConverter _converter = new ByteArrayConverter(); + + [TestMethod] + public void Given_ByteArray_When_Write_Then_Result_Should_Write_UTF8_String() + { + // Arrange + byte[] bytes = Encoding.UTF8.GetBytes("adyen"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + _converter.Write(writer, bytes, new JsonSerializerOptions()); + writer.Flush(); + string target = Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual("\"adyen\"", target); + } + + [TestMethod] + public void Given_ByteArray_Null_When_Write_Then_Result_Returns_Null() + { + // Arrange + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + _converter.Write(writer, null, new JsonSerializerOptions()); + writer.Flush(); + string json = Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual("null", json); + } + + [TestMethod] + public void Given_Json_When_Read_And_Decoded_Then_Should_Return_ByteArray() + { + // Arrange + string json = "\"adyen\""; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + // Act + byte[] result = _converter.Read(ref reader, typeof(byte[]), new JsonSerializerOptions()); + string decoded = Encoding.UTF8.GetString(result); + + // Assert + Assert.AreEqual("adyen", decoded); + } + + [TestMethod] + public void Given_Json_Null_When_Read_Then_Returns_Null() + { + // Arrange + string json = "null"; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + // Act + byte[] result = _converter.Read(ref reader, typeof(byte[]), new JsonSerializerOptions()); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Given_ByteArray_When_Write_And_Read_Then_Result_Returns_Original_Bytes() + { + // Arrange + byte[] original = Encoding.UTF8.GetBytes("adyen"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + _converter.Write(writer, original, new JsonSerializerOptions()); + writer.Flush(); + string json = Encoding.UTF8.GetString(stream.ToArray()); + + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + // Act + byte[] result = _converter.Read(ref reader, typeof(byte[]), new JsonSerializerOptions()); + + // Assert + CollectionAssert.AreEqual(original, result); + } + + [TestMethod] + public void Given_EmptyString_When_Read_Then_ShouldReturnEmptyByteArray() + { + // Arrange + string json = "\"\""; + Utf8JsonReader reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + // Act + byte[] result = _converter.Read(ref reader, typeof(byte[]), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(Array.Empty(), result); + } + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Converters/DateOnlyJsonConverterTest.cs b/Adyen.Test/Core/Converters/DateOnlyJsonConverterTest.cs new file mode 100644 index 000000000..1c7013897 --- /dev/null +++ b/Adyen.Test/Core/Converters/DateOnlyJsonConverterTest.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Adyen.Core.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Converters +{ + [TestClass] + public class DateOnlyJsonConverterTests + { + private readonly DateOnlyJsonConverter _converter = new DateOnlyJsonConverter(); + + [TestMethod] + public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_ReturnsCorrectDate() + { + // Arrange + string json = "\"2025-12-25\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(new DateOnly(2025, 12, 25), result); + } + + [TestMethod] + public void Given_Date_yyyyMMdd_When_Read_Then_ReturnsCorrectDate() + { + // Arrange + string json = "\"20251225\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(new DateOnly(2025, 12, 25), result); + } + + [TestMethod] + public void Given_WrongFormatDateOnlyString_When_Read_Then_ThrowsNotSupportedException() + { + // Arrange + string json = "\"25-12-2025\""; // Incorrect format dd-MM-yyyy + + // Act + // Assert + Assert.ThrowsException(() => + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + }); + } + [TestMethod] + public void Given_InvalidDateOnlyString_When_Read_Then_ThrowsJsonException() + { + // Arrange + string json = "invalid-date"; + + // Act + // Assert + Assert.Throws(() => + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + }); + } + + [TestMethod] + public void Given_NullToken_When_Read_Then_ThrowsNotSupportedException() + { + // Arrange + string json = "null"; + + // Act + // Assert + Assert.ThrowsException(() => { + Utf8JsonReader reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + }); + } + + [TestMethod] + public void Given_DateOnlyValue_When_Write_Then_WritesCorrectDateOnlyValue() + { + // Arrange + var date = new DateOnly(2025, 12, 25); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + _converter.Write(writer, date, new JsonSerializerOptions()); + writer.Flush(); + string json = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual("\"2025-12-25\"", json); + } + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs b/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs new file mode 100644 index 000000000..8f75f6470 --- /dev/null +++ b/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Adyen.Core.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Converters +{ + [TestClass] + public class DateOnlyNullableJsonConverterTest + { + private readonly DateOnlyNullableJsonConverter _converter = new DateOnlyNullableJsonConverter(); + + [TestMethod] + public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_ReturnsCorrectDate() + { + // Arrange + string json = "\"2025-12-25\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(new DateOnly(2025, 12, 25), result); + } + + [TestMethod] + public void Given_Date_yyyyMMdd_When_Read_Then_ReturnsCorrectDate() + { + // Arrange + string json = "\"20251225\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(new DateOnly(2025, 12, 25), result); + } + + [TestMethod] + public void Given_WrongFormatDateOnlyString_When_Read_Then_ThrowsNotSupportedException() + { + // Arrange + string json = "\"25-12-2025\""; // Incorrect format dd-MM-yyyy + + // Act + // Assert + Assert.ThrowsException(() => + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + }); + } + + [TestMethod] + public void Given_InvalidDateOnlyString_When_Read_Then_ThrowsJsonException() + { + // Arrange + string json = "invalid-date"; + + // Act + // Assert + Assert.Throws(() => + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + }); + } + + [TestMethod] + public void Given_NullToken_When_Read_Then_ThrowsNotSupportedException() + { + // Arrange + string json = "null"; + + // Act + Utf8JsonReader reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Given_DateOnlyValue_When_Write_Then_WritesCorrectDateOnlyValue() + { + // Arrange + DateOnly? dateOnly = null; + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + _converter.Write(writer, dateOnly, new JsonSerializerOptions()); + writer.Flush(); + string json = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual("null", json); + } + } +} \ No newline at end of file diff --git a/Adyen.Test/Core/Converters/DateTimeJsonConverterTest.cs b/Adyen.Test/Core/Converters/DateTimeJsonConverterTest.cs new file mode 100644 index 000000000..68938e00c --- /dev/null +++ b/Adyen.Test/Core/Converters/DateTimeJsonConverterTest.cs @@ -0,0 +1,76 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Text.Json; +using Adyen.Core.Converters; + +namespace Adyen.Test.Core.Converters +{ + [TestClass] + public class DateTimeJsonConverterTest + { + private readonly DateTimeJsonConverter _converter = new DateTimeJsonConverter(); + + [TestMethod] + public void Given_ValidDateTimeWithFraction_When_Read_Then_ReturnsCorrectDateTime() + { + // Arrange + string json = "\"2025-12-25T14:30:15.1234567Z\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(DateTime.Parse("2025-12-25T14:30:15.1234567Z").ToUniversalTime(), result); + } + + [TestMethod] + public void Given_ValidDateTimeWithoutFraction_When_Read_Then_ReturnsCorrectDateTime() + { + // Arrange + string json = "\"2025-12-25T14:30:15Z\""; + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + + // Act + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(DateTime.Parse("2025-12-25T14:30:15Z").ToUniversalTime(), result); + } + + [TestMethod] + public void Given_InvalidDateTime_When_Read_Then_ThrowsNotSupportedException() + { + // Arrange + string json = "\"invalid-datetime\""; + + // Act + // Assert + Assert.ThrowsException(() => + { + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + _converter.Read(ref reader, typeof(DateTime), new JsonSerializerOptions()); + }); + } + + [TestMethod] + public void Given_DateTimeValue_When_Write_Then_WritesCorrectJson() + { + // Arrange + var dateTime = new DateTime(2025, 12, 25, 14, 30, 15, 123, DateTimeKind.Utc).AddTicks(4567); + + // Act + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + _converter.Write(writer, dateTime, new JsonSerializerOptions()); + writer.Flush(); + string json = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual($"\"{dateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", System.Globalization.CultureInfo.InvariantCulture)}\"", json); + } + } +} diff --git a/Adyen.Test/Core/Converters/DateTimeNullableJsonConverterTest.cs b/Adyen.Test/Core/Converters/DateTimeNullableJsonConverterTest.cs new file mode 100644 index 000000000..dc528d0a2 --- /dev/null +++ b/Adyen.Test/Core/Converters/DateTimeNullableJsonConverterTest.cs @@ -0,0 +1,111 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Adyen.Core.Converters; + +namespace Adyen.Test.Core.Converters +{ + [TestClass] + public class DateTimeNullableJsonConverterTests + { + private readonly DateTimeNullableJsonConverter _converter = new(); + + [TestMethod] + public void Given_ValidDateTimeWithFraction_When_Read_Then_ReturnsCorrectDateTime() + { + // Arrange + string json = "\"2025-12-25T14:30:15.1234567Z\""; + + // Act + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime?), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(DateTime.Parse("2025-12-25T14:30:15.1234567Z").ToUniversalTime(), result); + } + + [TestMethod] + public void Given_ValidDateTimeWithoutFraction_When_Read_Then_ReturnsCorrectDateTime() + { + // Arrange + string json = "\"2025-12-25T14:30:15Z\""; + + // Act + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime?), new JsonSerializerOptions()); + + // Assert + Assert.AreEqual(DateTime.Parse("2025-12-25T14:30:15Z").ToUniversalTime(), result); + } + + [TestMethod] + public void Given_NullJson_When_Read_Then_ReturnsNull() + { + // Arrange + string json = "null"; + + // Act + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime?), new JsonSerializerOptions()); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Given_InvalidDateTime_When_Read_Then_ReturnsNull() + { + // Arrange + string json = "\"invalid-datetime\""; + + // Act + var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateTime?), new JsonSerializerOptions()); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Given_DateTimeValue_When_Write_Then_WritesCorrectJson() + { + // Arrange + var dateTime = new DateTime(2025, 12, 25, 14, 30, 15, 123, DateTimeKind.Utc).AddTicks(4567); + + // Act + using var stream = new System.IO.MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + _converter.Write(writer, dateTime, new JsonSerializerOptions()); + writer.Flush(); + string json = Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual($"\"{dateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)}\"", json); + } + + [TestMethod] + public void Given_NullDateTime_When_Write_Then_WritesNullJson() + { + // Arrange + DateTime? dateTime = null; + + // Act + using var stream = new System.IO.MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + _converter.Write(writer, dateTime, new JsonSerializerOptions()); + writer.Flush(); + string json = Encoding.UTF8.GetString(stream.ToArray()); + + // Assert + Assert.AreEqual("null", json); + } + } +} diff --git a/Adyen.Test/Core/IEnumTest.cs b/Adyen.Test/Core/IEnumTest.cs new file mode 100644 index 000000000..1923dea98 --- /dev/null +++ b/Adyen.Test/Core/IEnumTest.cs @@ -0,0 +1,359 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Adyen.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core +{ + [TestClass] + public class IEnumTest + { + [TestMethod] + public async Task Given_Enums_When_Equal_Returns_Correct_True() + { + ExampleEnum? nullEnum = null; + + Assert.AreEqual(nullEnum, nullEnum); + Assert.AreEqual(ExampleEnum.A, ExampleEnum.A); + Assert.AreEqual(ExampleEnum.B, ExampleEnum.B); + } + + [TestMethod] + public async Task Given_Enums_When_NotEqual_Returns_Correct_False() + { + ExampleEnum? nullEnum = null; + + Assert.AreNotEqual(ExampleEnum.A, ExampleEnum.B); + Assert.AreNotEqual(ExampleEnum.B, nullEnum); + Assert.AreNotEqual(ExampleEnum.A, nullEnum); + } + + [TestMethod] + public async Task Given_ImplicitConversion_When_Initialized_Then_Returns_Correct_Values() + { + ExampleEnum? resultA = "a"; + ExampleEnum? resultB = "b"; + + Assert.AreEqual(ExampleEnum.A, resultA); + Assert.AreEqual(ExampleEnum.B, resultB); + } + + [TestMethod] + public async Task Given_ImplicitConversion_When_Null_Then_Returns_Null() + { + ExampleEnum? input = null; + string? result = input; + + Assert.IsNull(result); + } + + [TestMethod] + public async Task Given_ToString_When_Called_Then_Returns_Correct_Value() + { + Assert.AreEqual("a", ExampleEnum.A.ToString()); + Assert.AreEqual("b", ExampleEnum.B.ToString()); + } + + [TestMethod] + public async Task Given_ToString_When_Null_Then_Returns_Empty_String() + { + ExampleEnum result = ExampleEnum.FromStringOrDefault("this-is-not-a-valid-enum"); + + Assert.IsNull(result); + } + + [TestMethod] + public async Task Given_Equals_When_ComparingCaseInsensitive_Then_Returns_True() + { + ExampleEnum result = ExampleEnum.A; + + Assert.IsTrue(result.Equals(ExampleEnum.A)); + Assert.IsFalse(result.Equals(ExampleEnum.B)); + } + + [TestMethod] + public async Task Given_FromStringOrDefault_When_InvalidString_Then_Returns_Null() + { + Assert.IsNull(ExampleEnum.FromStringOrDefault("this-is-not-a-valid-enum")); + } + + [TestMethod] + public async Task Given_EqualityOperator_When_ComparingValues_Then_Returns_Correct_Values() + { + ExampleEnum target = ExampleEnum.A; + ExampleEnum otherA = ExampleEnum.A; + ExampleEnum otherB = ExampleEnum.B; + + Assert.IsTrue(target == otherA); + Assert.IsFalse(target == otherB); + Assert.IsTrue(target != otherB); + Assert.IsFalse(target != otherA); + } + + [TestMethod] + public async Task Given_FromStringOrDefault_When_ValidStrings_Then_Returns_Correct_Enum() + { + Assert.AreEqual(ExampleEnum.A, ExampleEnum.FromStringOrDefault("a")); + Assert.AreEqual(ExampleEnum.B, ExampleEnum.FromStringOrDefault("b")); + } + + [TestMethod] + public async Task Given_ToJsonValue_When_KnownEnum_Then_Returns_String() + { + Assert.AreEqual("a", ExampleEnum.ToJsonValue(ExampleEnum.A)); + Assert.AreEqual("b", ExampleEnum.ToJsonValue(ExampleEnum.B)); + } + + [TestMethod] + public async Task Given_ToJsonValue_When_Null_Then_Returns_Null() + { + Assert.IsNull(ExampleEnum.ToJsonValue(null)); + } + + [TestMethod] + public async Task Given_ToJsonValue_When_CustomEnum_Then_Returns_Null() + { + ExampleEnum custom = ExampleEnum.FromStringOrDefault("this-is-not-a-valid-enum"); + Assert.IsNull(ExampleEnum.ToJsonValue(custom)); + } + + [TestMethod] + public async Task Given_JsonSerialization_When_KnownEnum_Then_Serialize_and_Deserialize_Correctly() + { + JsonSerializerOptions options = new JsonSerializerOptions(); + options.Converters.Add(new ExampleEnum.ExampleJsonConverter()); + + string serializedA = JsonSerializer.Serialize(ExampleEnum.A, options); + string serializedB = JsonSerializer.Serialize(ExampleEnum.B, options); + + Assert.AreEqual("\"a\"", serializedA); + Assert.AreEqual("\"b\"", serializedB); + + ExampleEnum? deserializedA = JsonSerializer.Deserialize(serializedA, options); + ExampleEnum? deserializedB = JsonSerializer.Deserialize(serializedB, options); + + Assert.AreEqual(ExampleEnum.A, deserializedA); + Assert.AreEqual(ExampleEnum.B, deserializedB); + } + + [TestMethod] + public async Task Given_JsonSerialization_When_EnumNotInList_Then_Returns_Null() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new ExampleEnum.ExampleJsonConverter()); + + ExampleEnum? value = ExampleEnum.FromStringOrDefault("not-in-list"); + string serialized = JsonSerializer.Serialize(value, options); + Assert.AreEqual("null", serialized); + + ExampleEnum? deserialized = JsonSerializer.Deserialize(serialized, options); + Assert.AreEqual(null, deserialized); + } + + [TestMethod] + public async Task Given_JsonSerialization_When_Null_Value_Then_Serialize_And_Deserialize_Correctly() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new ExampleEnum.ExampleJsonConverter()); + + ExampleEnum? value = null; + string serialized = JsonSerializer.Serialize(value, options); + Assert.AreEqual("null", serialized); + + ExampleEnum? deserialized = JsonSerializer.Deserialize("null", options); + Assert.IsNull(deserialized); + } + + + #region Arrange ExampleModelResponse for testing (an example) model deserialization + + [TestMethod] + public async Task Given_JsonDeserialization_When_ExampleEnum_Is_Null_Then_Deserialize_Correctly_And_Not_Throw_Exception() + { + // Arrange + string json = @" +{ + ""exampleEnum"": null +}"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ExampleEnum.ExampleJsonConverter()); + options.Converters.Add(new ExampleModelResponse.ExampleModelResponseJsonConverter()); + + // Act + ExampleModelResponse result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.IsNull(result.ExampleEnum); + } + + [TestMethod] + public async Task Given_JsonDeserialization_When_ExampleEnum_Is_A_Then_Deserialize_Correctly_To_A() + { + // Arrange + string json = @" +{ + ""exampleEnum"": ""a"" +}"; + var options = new JsonSerializerOptions(); + options.Converters.Add(new ExampleEnum.ExampleJsonConverter()); + options.Converters.Add(new ExampleModelResponse.ExampleModelResponseJsonConverter()); + + // Act + ExampleModelResponse result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.AreEqual(ExampleEnum.A, result.ExampleEnum); + } + + internal class ExampleModelResponse + { + /// + /// The optional enum to test. + /// + [JsonPropertyName("exampleEnum")] + public ExampleEnum? ExampleEnum + { + get { return this._ExampleEnumOption; } + set { this._ExampleEnumOption = new(value); } + } + + /// + /// Used to track if an optional field is set. If so, set the . + /// + [JsonIgnore] + public Option _ExampleEnumOption { get; private set; } + + [JsonConstructor] + public ExampleModelResponse(Option exampleEnum) + { + this._ExampleEnumOption = exampleEnum; + } + + internal class ExampleModelResponseJsonConverter : JsonConverter + { + public override ExampleModelResponse Read(ref Utf8JsonReader utf8JsonReader, Type typeToConvert, JsonSerializerOptions jsonSerializerOptions) + { + int currentDepth = utf8JsonReader.CurrentDepth; + + if (utf8JsonReader.TokenType != JsonTokenType.StartObject && utf8JsonReader.TokenType != JsonTokenType.StartArray) + throw new JsonException(); + + JsonTokenType startingTokenType = utf8JsonReader.TokenType; + + Option exampleEnum = default; + + while (utf8JsonReader.Read()) + { + if (startingTokenType == JsonTokenType.StartObject && utf8JsonReader.TokenType == JsonTokenType.EndObject && currentDepth == utf8JsonReader.CurrentDepth) + break; + + if (startingTokenType == JsonTokenType.StartArray && utf8JsonReader.TokenType == JsonTokenType.EndArray && currentDepth == utf8JsonReader.CurrentDepth) + break; + + if (utf8JsonReader.TokenType == JsonTokenType.PropertyName && currentDepth == utf8JsonReader.CurrentDepth - 1) + { + string? jsonPropertyName = utf8JsonReader.GetString(); + utf8JsonReader.Read(); + + switch (jsonPropertyName) + { + case "exampleEnum": + exampleEnum = new Option(JsonSerializer.Deserialize(ref utf8JsonReader, jsonSerializerOptions)); + break; + default: + break; + } + } + } + + return new ExampleModelResponse(exampleEnum); + } + + public override void Write(Utf8JsonWriter writer, ExampleModelResponse response, JsonSerializerOptions jsonSerializerOptions) + { + writer.WritePropertyName("exampleEnum"); + JsonSerializer.Serialize(writer, response.ExampleEnum, jsonSerializerOptions); + } + } + } + + #endregion + } + + #region Arrange ExampleEnum for testing + + [JsonConverter(typeof(ExampleJsonConverter))] + internal class ExampleEnum : IEnum + { + public string? Value { get; set; } + + /// + /// ExampleEnum.A: a + /// + public static readonly ExampleEnum A = new("a"); + + /// + /// ExampleEnum.B: b + /// + public static readonly ExampleEnum B = new("b"); + + private ExampleEnum(string? value) + { + Value = value; + } + + public static implicit operator ExampleEnum?(string? value) => value == null ? null : new ExampleEnum(value); + + public static implicit operator string?(ExampleEnum? option) => option?.Value; + + public override string ToString() => Value ?? string.Empty; + + public override bool Equals(object? obj) => obj is ExampleEnum other && string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + + public static bool operator ==(ExampleEnum? left, ExampleEnum? right) => + string.Equals(left?.Value, right?.Value, StringComparison.OrdinalIgnoreCase); + + public static bool operator !=(ExampleEnum? left, ExampleEnum? right) => + !string.Equals(left?.Value, right?.Value, StringComparison.OrdinalIgnoreCase); + + public static ExampleEnum? FromStringOrDefault(string value) + { + return value switch { + "a" => ExampleEnum.A, + "b" => ExampleEnum.B, + _ => null, + }; + } + + public static string? ToJsonValue(ExampleEnum? value) + { + if (value == null) + return null; + + if (value == ExampleEnum.A) + return "a"; + + if (value == ExampleEnum.B) + return "b"; + + return null; + } + + public class ExampleJsonConverter : JsonConverter + { + public override ExampleEnum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions jsonOptions) + { + string str = reader.GetString(); + return str == null ? null : ExampleEnum.FromStringOrDefault(str) ?? new ExampleEnum(str); + } + + public override void Write(Utf8JsonWriter writer, ExampleEnum value, JsonSerializerOptions jsonOptions) + { + writer.WriteStringValue(ExampleEnum.ToJsonValue(value)); + } + } + } + #endregion +} \ No newline at end of file diff --git a/Adyen.Test/Core/Utilities/HmacValidatorUtilityTest.cs b/Adyen.Test/Core/Utilities/HmacValidatorUtilityTest.cs new file mode 100644 index 000000000..8c2dd55fc --- /dev/null +++ b/Adyen.Test/Core/Utilities/HmacValidatorUtilityTest.cs @@ -0,0 +1,41 @@ +using Adyen.Core.Utilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Adyen.Test.Core.Utilities +{ + [TestClass] + public class HmacValidatorUtilityTest + { + [TestMethod] + public void TestBalancePlatformHmac() + { + // Arrange + string notification = + "{\"data\":{\"balancePlatform\":\"Integration_tools_test\",\"accountId\":\"BA32272223222H5HVKTBK4MLB\",\"sweep\":{\"id\":\"SWPC42272223222H5HVKV6H8C64DP5\",\"schedule\":{\"type\":\"balance\"},\"status\":\"active\",\"targetAmount\":{\"currency\":\"EUR\",\"value\":0},\"triggerAmount\":{\"currency\":\"EUR\",\"value\":0},\"type\":\"pull\",\"counterparty\":{\"balanceAccountId\":\"BA3227C223222H5HVKT3H9WLC\"},\"currency\":\"EUR\"}},\"environment\":\"test\",\"type\":\"balancePlatform.balanceAccountSweep.updated\"}"; + string hmacKey = "D7DD5BA6146493707BF0BE7496F6404EC7A63616B7158EC927B9F54BB436765F"; + string hmacSignature = "9Qz9S/0xpar1klkniKdshxpAhRKbiSAewPpWoxKefQA="; + + // Act + bool isValidSignature = HmacValidatorUtility.IsHmacSignatureValid(hmacSignature, hmacKey, notification); + + // Assert + Assert.IsTrue(isValidSignature); + } + + [TestMethod] + public void GenerateBase64Sha256HmacSignature_ReturnsCorrectSignature() + { + // Arrange + string notification = + "{\"data\":{\"balancePlatform\":\"Integration_tools_test\",\"accountId\":\"BA32272223222H5HVKTBK4MLB\",\"sweep\":{\"id\":\"SWPC42272223222H5HVKV6H8C64DP5\",\"schedule\":{\"type\":\"balance\"},\"status\":\"active\",\"targetAmount\":{\"currency\":\"EUR\",\"value\":0},\"triggerAmount\":{\"currency\":\"EUR\",\"value\":0},\"type\":\"pull\",\"counterparty\":{\"balanceAccountId\":\"BA3227C223222H5HVKT3H9WLC\"},\"currency\":\"EUR\"}},\"environment\":\"test\",\"type\":\"balancePlatform.balanceAccountSweep.updated\"}"; + string hmacKey = "D7DD5BA6146493707BF0BE7496F6404EC7A63616B7158EC927B9F54BB436765F"; + string expectedHmacSignature = "9Qz9S/0xpar1klkniKdshxpAhRKbiSAewPpWoxKefQA="; + + // Act + string generatedHmacSignature = HmacValidatorUtility.GenerateBase64Sha256HmacSignature(notification, hmacKey); + + // Assert + Assert.AreEqual(expectedHmacSignature, generatedHmacSignature); + } + } +} \ No newline at end of file diff --git a/Adyen/Adyen.csproj b/Adyen/Adyen.csproj index 2e1e4f54c..3e50f1b5f 100644 --- a/Adyen/Adyen.csproj +++ b/Adyen/Adyen.csproj @@ -1,7 +1,7 @@  - net8.0;net6.0;net462;netstandard2.0 + net8.0 12 enable disable @@ -36,10 +36,13 @@ - + + + - + + @@ -54,4 +57,8 @@ + + + + diff --git a/Adyen/Core/Auth/TokenBase.cs b/Adyen/Core/Auth/TokenBase.cs new file mode 100644 index 000000000..a0afd072c --- /dev/null +++ b/Adyen/Core/Auth/TokenBase.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System; + +namespace Adyen.Core.Auth +{ + /// + /// The base class for all auth tokens. + /// + public abstract class TokenBase + { + /// + /// The constructor for the TokenBase object, used by . + /// + protected TokenBase() + { + + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Auth/TokenProvider.cs b/Adyen/Core/Auth/TokenProvider.cs new file mode 100644 index 000000000..745cca691 --- /dev/null +++ b/Adyen/Core/Auth/TokenProvider.cs @@ -0,0 +1,42 @@ +namespace Adyen.Core.Auth +{ + /// + /// An interface for providing tokens in a generic way. + /// + /// + public interface ITokenProvider where TTokenBase : TokenBase + { + /// + /// Retrieves the stored token. + /// + /// + TTokenBase Get(); + } + + /// + /// A class which will provide tokens from type . + /// + /// + public class TokenProvider : ITokenProvider where TTokenBase : TokenBase + { + private readonly TTokenBase _token; + + /// + /// Initializes a token with type . + /// + /// + public TokenProvider(TTokenBase token) + { + _token = token; + } + + /// + /// Retrieves the stored token. + /// + /// + public TTokenBase Get() + { + return _token; + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Client/ApiException.cs b/Adyen/Core/Client/ApiException.cs new file mode 100644 index 000000000..23a732bc9 --- /dev/null +++ b/Adyen/Core/Client/ApiException.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System; + +namespace Adyen.Core.Client +{ + /// + /// API Exception + /// + public class ApiException : Exception + { + /// + /// The reason the api request failed + /// + public string? ReasonPhrase { get; } + + /// + /// The HttpStatusCode + /// + public System.Net.HttpStatusCode StatusCode { get; } + + /// + /// The raw data returned by the API + /// + public string RawContent { get; } + + /// + /// Construct the ApiException from parts of the response + /// + /// Reason for ApiException + /// + /// Raw content + public ApiException(string? reasonPhrase, System.Net.HttpStatusCode statusCode, string rawContent) : base(reasonPhrase ?? rawContent) + { + ReasonPhrase = reasonPhrase; + StatusCode = statusCode; + RawContent = rawContent; + } + } +} diff --git a/Adyen/Core/Client/ApiFactory.cs b/Adyen/Core/Client/ApiFactory.cs new file mode 100644 index 000000000..2c073ba34 --- /dev/null +++ b/Adyen/Core/Client/ApiFactory.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Adyen.Core.Client +{ + /// + /// The factory interface for creating the services that can communicate with the Adyen APIs. + /// + public interface IApiFactory + { + /// + /// A method to create an IApi of type IResult + /// + /// + /// + IResult Create() where IResult : IAdyenApiService; + } + + /// + /// The implementation of . + /// + public class ApiFactory : IApiFactory + { + /// + /// The service provider + /// + public IServiceProvider Services { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + public ApiFactory(IServiceProvider services) + { + Services = services; + } + + /// + /// A method to create an IApi of type IResult + /// + /// + /// + public IResult Create() where IResult : IAdyenApiService + { + return Services.GetRequiredService(); + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Client/ApiResponse.cs b/Adyen/Core/Client/ApiResponse.cs new file mode 100644 index 000000000..1eb24e24b --- /dev/null +++ b/Adyen/Core/Client/ApiResponse.cs @@ -0,0 +1,371 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Adyen.Core.Client +{ + /// + /// Provides a non-generic contract for the ApiResponse wrapper. + /// + public partial interface IApiResponse + { + /// + /// The IsSuccessStatusCode from the API response. + /// + bool IsSuccessStatusCode { get; } + + /// + /// Gets the status code (). + /// + /// The status code. + HttpStatusCode StatusCode { get; } + + /// + /// The raw content of this response. + /// + string RawContent { get; } + + /// + /// The raw binary stream (only set for binary responses). + /// + System.IO.Stream? ContentStream { get; } + + /// + /// The DateTime when the request was retrieved. + /// + DateTime DownloadedAt { get; } + + /// + /// The headers contained in the API response. + /// + System.Net.Http.Headers.HttpResponseHeaders Headers { get; } + + /// + /// The path used when making the request. + /// + string Path { get; } + + /// + /// The reason phrase contained in the API response. + /// + string? ReasonPhrase { get; } + + /// + /// The DateTime when the request was sent. + /// + DateTime RequestedAt { get; } + + /// + /// The Uri used when making the request. + /// + Uri? RequestUri { get; } + } + + /// + /// API Response + /// + public partial class ApiResponse : IApiResponse + { + /// + /// Gets the status code (HTTP status code). + /// + /// The status code. + public HttpStatusCode StatusCode { get; } + + /// + /// The raw data. + /// + public string RawContent { get; protected set; } + + /// + /// The raw binary stream (only set for binary responses). + /// + public System.IO.Stream? ContentStream { get; protected set; } + + /// + /// The IsSuccessStatusCode from the API response. + /// + public bool IsSuccessStatusCode { get; } + + /// + /// The reason phrase contained in the API response. + /// + public string? ReasonPhrase { get; } + + /// + /// The headers contained in the API response. + /// + public System.Net.Http.Headers.HttpResponseHeaders Headers { get; } + + /// + /// The DateTime (default: UtcNow) when the request was retrieved. + /// + public DateTime DownloadedAt { get; } = DateTime.UtcNow; + + /// + /// The DateTime when the request was sent. + /// + public DateTime RequestedAt { get; } + + /// + /// The path used when making the request. + /// + public string Path { get; } + + /// + /// The used when making the request. + /// + public Uri? RequestUri { get; } + + /// + /// The . + /// + protected System.Text.Json.JsonSerializerOptions _jsonSerializerOptions; + + /// + /// Construct the response using an HttpResponseMessage. + /// + /// . + /// + /// The raw data. + /// The path used when making the request. + /// The when the request was sent. + /// The . + public ApiResponse(global::System.Net.Http.HttpRequestMessage httpRequestMessage, System.Net.Http.HttpResponseMessage httpResponseMessage, string rawContent, string path, DateTime requestedAt, System.Text.Json.JsonSerializerOptions jsonSerializerOptions) + { + StatusCode = httpResponseMessage.StatusCode; + Headers = httpResponseMessage.Headers; + IsSuccessStatusCode = httpResponseMessage.IsSuccessStatusCode; + ReasonPhrase = httpResponseMessage.ReasonPhrase; + RawContent = rawContent; + Path = path; + RequestUri = httpRequestMessage.RequestUri; + RequestedAt = requestedAt; + _jsonSerializerOptions = jsonSerializerOptions; + } + + /// + /// Construct the response using the . + /// + /// . + /// . + /// The raw binary stream (only set for binary responses). + /// The path used when making the request. + /// The DateTime.UtcNow when the request was sent. + /// The . + public ApiResponse(global::System.Net.Http.HttpRequestMessage httpRequestMessage, System.Net.Http.HttpResponseMessage httpResponseMessage, System.IO.Stream contentStream, string path, DateTime requestedAt, System.Text.Json.JsonSerializerOptions jsonSerializerOptions) + { + StatusCode = httpResponseMessage.StatusCode; + Headers = httpResponseMessage.Headers; + IsSuccessStatusCode = httpResponseMessage.IsSuccessStatusCode; + ReasonPhrase = httpResponseMessage.ReasonPhrase; + ContentStream = contentStream; + RawContent = string.Empty; + Path = path; + RequestUri = httpRequestMessage.RequestUri; + RequestedAt = requestedAt; + _jsonSerializerOptions = jsonSerializerOptions; + } + } + + /// + /// An interface for responses of type BadRequest. + /// + /// + public interface IBadRequest : IApiResponse + { + /// + /// Deserializes the response if the response is BadRequest. + /// + /// + TType BadRequest(); + + /// + /// Returns true if the response is BadRequest and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeBadRequestResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type TooManyRequests. + /// + /// + public interface ITooManyRequests : IApiResponse + { + /// + /// Deserializes the response if the response is TooManyRequests. + /// + /// + TType TooManyRequests(); + + /// + /// Returns true if the response is TooManyRequests and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeTooManyRequestsResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type Unauthorized. + /// + /// + public interface IUnauthorized : IApiResponse + { + /// + /// Deserializes the response if the response is Unauthorized. + /// + /// + TType Unauthorized(); + + /// + /// Returns true if the response is Unauthorized and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeUnauthorizedResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type Forbidden. + /// + /// + public interface IForbidden : IApiResponse + { + /// + /// Deserializes the response if the response is Forbidden. + /// + /// + TType Forbidden(); + + /// + /// Returns true if the response is Forbidden and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeForbiddenResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type Ok. + /// + /// + public interface IOk : IApiResponse + { + /// + /// Deserializes the response if the response is Ok. + /// + /// + TType Ok(); + + /// + /// Returns true if the response is Ok and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeOkResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type UnprocessableContent. + /// + /// + public interface IUnprocessableContent : IApiResponse + { + /// + /// Deserializes the response if the response is UnprocessableContent. + /// + /// + TType UnprocessableContent(); + + /// + /// Returns true if the response is UnprocessableContent and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeUnprocessableContentResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type InternalServerError. + /// + /// + public interface IInternalServerError : IApiResponse + { + /// + /// Deserializes the response if the response is InternalServerError. + /// + /// + TType InternalServerError(); + + /// + /// Returns true if the response is InternalServerError and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeInternalServerErrorResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type Created. + /// + /// + public interface ICreated : IApiResponse + { + /// + /// Deserializes the response if the response is Created. + /// + /// + TType Created(); + + /// + /// Returns true if the response is Created and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeCreatedResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type Accepted. + /// + /// + public interface IAccepted : IApiResponse + { + /// + /// Deserializes the response if the response is Accepted. + /// + /// + TType Accepted(); + + /// + /// Returns true if the response is Accepted and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeAcceptedResponse([NotNullWhen(true)]out TType? result); + } + + /// + /// An interface for responses of type NotFound. + /// + /// + public interface INotFound : IApiResponse + { + /// + /// Deserializes the response if the response is NotFound. + /// + /// + TType NotFound(); + + /// + /// Returns true if the response is NotFound and the deserialized response is not null. + /// + /// + /// + bool TryDeserializeNotFoundResponse([NotNullWhen(true)]out TType? result); + } +} diff --git a/Adyen/Core/Client/ApiResponseEventArgs.cs b/Adyen/Core/Client/ApiResponseEventArgs.cs new file mode 100644 index 000000000..7f6d48f23 --- /dev/null +++ b/Adyen/Core/Client/ApiResponseEventArgs.cs @@ -0,0 +1,24 @@ +using System; + +namespace Adyen.Core.Client +{ + /// + /// This class is used for wrapping the . + /// + public class ApiResponseEventArgs : EventArgs + { + /// + /// The . + /// + public ApiResponse ApiResponse { get; } + + /// + /// The constructed from the Adyen . + /// + /// . + public ApiResponseEventArgs(ApiResponse apiResponse) + { + ApiResponse = apiResponse; + } + } +} diff --git a/Adyen/Core/Client/ExceptionEventArgs.cs b/Adyen/Core/Client/ExceptionEventArgs.cs new file mode 100644 index 000000000..1cbfc25fa --- /dev/null +++ b/Adyen/Core/Client/ExceptionEventArgs.cs @@ -0,0 +1,24 @@ +using System; + +namespace Adyen.Core.Client +{ + /// + /// Useful for tracking server health + /// + public class ExceptionEventArgs : EventArgs + { + /// + /// The ApiResponse exception + /// + public Exception Exception { get; } + + /// + /// The ExceptionEventArgs + /// + /// + public ExceptionEventArgs(Exception exception) + { + Exception = exception; + } + } +} diff --git a/Adyen/Core/Client/Extensions/HttpClientBuilderExtensions.cs b/Adyen/Core/Client/Extensions/HttpClientBuilderExtensions.cs new file mode 100644 index 000000000..bc5187d30 --- /dev/null +++ b/Adyen/Core/Client/Extensions/HttpClientBuilderExtensions.cs @@ -0,0 +1,68 @@ +#nullable enable + +using System; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Polly.Timeout; +using Polly.Extensions.Http; +using Polly; + +namespace Adyen.Core.Client.Extensions +{ + /// + /// Extension methods for IHttpClientBuilder + /// + public static class HttpClientBuilderExtensions + { + /// + /// Adds a Polly retry policy to your clients. + /// + /// . + /// The number of retries. + /// . + public static IHttpClientBuilder AddRetryPolicy(this IHttpClientBuilder httpClient, int numberOfRetries) + { + httpClient.AddPolicyHandler(RetryPolicy(numberOfRetries)); + return httpClient; + } + + private static Polly.Retry.AsyncRetryPolicy RetryPolicy(int numberOfRetries) + => HttpPolicyExtensions + .HandleTransientHttpError() + .Or() + .RetryAsync(numberOfRetries); + + /// + /// Adds a Polly timeout policy to your clients. + /// Use this when you need resilient policies (using the ) and want to combine this with a retry & circuit breaker. + /// + /// . + /// . + /// . + public static IHttpClientBuilder AddTimeoutPolicy(this IHttpClientBuilder httpClient, TimeSpan timeout) + { + httpClient.AddPolicyHandler(TimeoutPolicy(timeout)); + return httpClient; + } + + private static AsyncTimeoutPolicy TimeoutPolicy(TimeSpan timeout) + => Policy.TimeoutAsync(timeout); + + /// + /// Adds a Polly circuit breaker to your clients. + /// + /// . + /// Example: if set to 3 - if 3 consecutive request fail, Polly will 'open' the circuit for the duration of and fail all incoming requests. After that, the circuit will be 'half-open'. + /// . + /// . + public static IHttpClientBuilder AddCircuitBreakerPolicy(this IHttpClientBuilder httpClient, int numberOfEventsAllowedBeforeBreaking, TimeSpan durationOfBreak) + { + httpClient.AddTransientHttpErrorPolicy(policyBuilder => CircuitBreakerPolicy(policyBuilder, numberOfEventsAllowedBeforeBreaking, durationOfBreak)); + return httpClient; + } + + private static Polly.CircuitBreaker.AsyncCircuitBreakerPolicy CircuitBreakerPolicy( + PolicyBuilder policyBuilder, int numberOfEventsAllowedBeforeBreaking, TimeSpan durationOfBreak) + => policyBuilder.CircuitBreakerAsync(numberOfEventsAllowedBeforeBreaking, durationOfBreak); + } +} diff --git a/Adyen/Core/Client/Extensions/HttpRequestMessageExtensions.cs b/Adyen/Core/Client/Extensions/HttpRequestMessageExtensions.cs new file mode 100644 index 000000000..815a00ac7 --- /dev/null +++ b/Adyen/Core/Client/Extensions/HttpRequestMessageExtensions.cs @@ -0,0 +1,63 @@ +namespace Adyen.Core.Client.Extensions +{ + /// + /// Extension function that adds custom headers and UserAgent to the . + /// + public static class HttpRequestMessageExtensions + { + /// + /// The name of the application. + /// + public static string ApplicationName { get; set; } + + /// + /// Name of this library. This will be sent as a part of the headers. + /// + public const string AdyenLibraryName = "adyen-dotnet-api-library"; + + /// + /// Version of this library. + /// + public const string AdyenLibraryVersion = "32.2.1"; // Updated by release-automation-action + + /// + /// Adds the UserAgent to the headers of the object. + /// + /// . + /// . + public static HttpRequestMessage AddUserAgentToHeaders(this HttpRequestMessage httpRequestMessage) + { + // Add application name if set. + if (!string.IsNullOrWhiteSpace(ApplicationName)) + { + httpRequestMessage.Headers.Add("UserAgent", $"{ApplicationName} {AdyenLibraryName}/{AdyenLibraryVersion}"); + return httpRequestMessage; + } + + httpRequestMessage.Headers.Add("UserAgent", $"{AdyenLibraryName}/{AdyenLibraryVersion}"); + return httpRequestMessage; + } + + /// + /// Adds the to the headers of the object. + /// + /// . + /// . + public static HttpRequestMessage AddLibraryNameToHeader(this HttpRequestMessage httpRequestMessage) + { + httpRequestMessage.Headers.Add("adyen-library-name", AdyenLibraryName); + return httpRequestMessage; + } + + /// + /// Adds the to the headers of the object. + /// + /// . + /// . + public static HttpRequestMessage AddLibraryVersionToHeader(this HttpRequestMessage httpRequestMessage) + { + httpRequestMessage.Headers.Add("adyen-library-version", AdyenLibraryVersion); + return httpRequestMessage; + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Client/IAdyenApiService.cs b/Adyen/Core/Client/IAdyenApiService.cs new file mode 100644 index 000000000..ca6e164d6 --- /dev/null +++ b/Adyen/Core/Client/IAdyenApiService.cs @@ -0,0 +1,13 @@ +namespace Adyen.Core.Client +{ + /// + /// Interface for interacting with any Adyen API using . + /// + public interface IAdyenApiService + { + /// + /// The object, best practice: instantiate and manage object using the . + /// + System.Net.Http.HttpClient HttpClient { get; } + } +} \ No newline at end of file diff --git a/Adyen/Core/Client/RequestOptions.cs b/Adyen/Core/Client/RequestOptions.cs new file mode 100644 index 000000000..8c61091cb --- /dev/null +++ b/Adyen/Core/Client/RequestOptions.cs @@ -0,0 +1,75 @@ +#nullable enable + +namespace Adyen.Core.Client +{ + public class RequestOptions + { + /// + /// Dictionary containing the optional header values. If set, these values will be sent in the HttpRequest when is called. + /// + public IDictionary Headers { get; private set; } = new Dictionary(); + + + #region Helper functions to append headers to the Headers dictionary. + + /// + /// Add the "IdempotencyKey" to the headers with the given value. + /// The Adyen API supports idempotency, allowing you to retry a request multiple times while only performing the action once. + /// This helps avoid unwanted duplication in case of failures and retries. + /// + /// The value of the IdempotencyKey. + /// . + public RequestOptions AddIdempotencyKey(string idempotencyKey) + { + this.Headers.Add("Idempotency-Key", idempotencyKey); + return this; + } + + /// + /// Adds additional custom headers with the given keys and values. + /// + /// The values. + /// . + public RequestOptions AddAdditionalHeaders(IDictionary additionalHeaders) + { + foreach (KeyValuePair kvp in additionalHeaders) + this.Headers.Add(kvp.Key, kvp.Value); + return this; + } + + /// + /// Adds the "WWW-Authenticate" to the headers with the given value. Used in the Configuration and Transfers API. + /// + /// The value of WWW-Authenticate. + /// . + public RequestOptions AddWWWAuthenticateHeader(string wwwAuthenticate) + { + this.Headers.Add("WWW-Authenticate", wwwAuthenticate); + return this; + } + + /// + /// Adds the "x-requested-verification-code" to the headers with the given value. Used in the LegalEntityManagement API. + /// + /// The value of x-requested-verification-code. + /// . + public RequestOptions AddxRequestedVerificationCodeHeader(string xRequestedVerificationCodeHeader) + { + this.Headers.Add("x-requested-verification-code", xRequestedVerificationCodeHeader); + return this; + } + + #endregion + + /// + /// Adds all key-value-pairs from to the header. + /// + /// + public System.Net.Http.HttpRequestMessage AddHeadersToHttpRequestMessage(System.Net.Http.HttpRequestMessage httpRequestMessage) + { + foreach (KeyValuePair header in this.Headers) + httpRequestMessage.Headers.Add(header.Key, header.Value); + return httpRequestMessage; + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Client/UrlBuilderExtensions.cs b/Adyen/Core/Client/UrlBuilderExtensions.cs new file mode 100644 index 000000000..37885f81a --- /dev/null +++ b/Adyen/Core/Client/UrlBuilderExtensions.cs @@ -0,0 +1,80 @@ +using Adyen.Core.Options; + +namespace Adyen.Core.Client +{ + /// + /// Helper utility functions to construct the Adyen live-urls for our APIs. + /// + public static class UrlBuilderExtensions + { + /// + /// Constructs the Host URL based on the selected , used to populate the `HttpClient.BaseAddress`. + /// + /// . + /// The base URL of the API. + /// String containing the Host URL. + /// + public static string ConstructHostUrl(AdyenOptions adyenOptions, string baseUrl) + { + if (adyenOptions.Environment == AdyenEnvironment.Live) + return ConstructLiveUrl(adyenOptions.LiveEndpointUrlPrefix, baseUrl); + + // Some Adyen OpenApi Specifications use the live-url, instead of the test-url, this line replaces "-live" with "-test". + // If you need to override this URL, you can do so by replacing the BASE_URL in ClientUtils.cs. + if (adyenOptions.Environment == AdyenEnvironment.Test) + return baseUrl.Replace("-live", "-test"); + + throw new ArgumentOutOfRangeException(adyenOptions.Environment.ToString()); + } + + /// + /// Construct LIVE BaseUrl, add the liveEndpointUrlPrefix it's the Checkout API or the Classic Payment API. + /// This helper function can be removed when all URLs (test & live) are included in the Adyen OpenApi Specifications: https://github.com/Adyen/adyen-openapi. + /// + /// The Live endpoint url prefix. + /// The base URL of the API. + /// String containing the LIVE URL. + /// + public static string ConstructLiveUrl(string liveEndpointUrlPrefix, string url) + { + // Change base url for Live environment + if (url.Contains("pal-")) // Payment API prefix + { + if (liveEndpointUrlPrefix == null) + { + throw new InvalidOperationException("LiveEndpointUrlPrefix is null - please configure your AdyenOptions.LiveEndpointUrlPrefix"); + } + + url = url.Replace("https://pal-test.adyen.com/pal/servlet/", + "https://" + liveEndpointUrlPrefix + "-pal-live.adyenpayments.com/pal/servlet/"); + } + else if (url.Contains("checkout-")) // Checkout API prefix + { + if (liveEndpointUrlPrefix == null) + { + throw new InvalidOperationException("LiveEndpointUrlPrefix is null - please configure your AdyenOptions.LiveEndpointUrlPrefix"); + } + + if (url.Contains("possdk")) + { + url = url.Replace("https://checkout-test.adyen.com/", + "https://" + liveEndpointUrlPrefix + "-checkout-live.adyenpayments.com/"); + } + else + { + url = url.Replace("https://checkout-test.adyen.com/", + "https://" + liveEndpointUrlPrefix + "-checkout-live.adyenpayments.com/checkout/"); + } + } + else if (url.Contains("https://test.adyen.com/authe/api/")) // SessionAuthentication + { + url = url.Replace("https://test.adyen.com/authe/api/", + "https://authe-live.adyen.com/authe/api/"); + } + + // If no prefix is required, we replace "test" -> "live" + url = url.Replace("-test", "-live"); + return url; + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Converters/ByteArrayConverter.cs b/Adyen/Core/Converters/ByteArrayConverter.cs new file mode 100644 index 000000000..e222288a8 --- /dev/null +++ b/Adyen/Core/Converters/ByteArrayConverter.cs @@ -0,0 +1,29 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Adyen.Core.Converters +{ + public class ByteArrayConverter : JsonConverter + { + public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + string value = reader.GetString(); + return Encoding.UTF8.GetBytes(value); + } + + public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(Encoding.UTF8.GetString(value)); + } + } +} \ No newline at end of file diff --git a/Adyen/Core/Converters/DateOnlyJsonConverter.cs b/Adyen/Core/Converters/DateOnlyJsonConverter.cs new file mode 100644 index 000000000..80c34bd8d --- /dev/null +++ b/Adyen/Core/Converters/DateOnlyJsonConverter.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Adyen.Core.Converters +{ + /// + /// Formatter for 'date' openapi formats ss defined by full-date - RFC3339 + /// see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + /// + public class DateOnlyJsonConverter : JsonConverter + { + /// + /// The formats used to deserialize the date + /// + public static string[] Formats { get; } = { + "yyyy'-'MM'-'dd", + "yyyyMMdd" + + }; + + /// + /// Returns a DateOnly from the Json object + /// + /// + /// + /// + /// + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + throw new NotSupportedException(); + + string value = reader.GetString()!; + + foreach(string format in Formats) + if (DateOnly.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly result)) + return result; + + throw new NotSupportedException(); + } + + /// + /// Writes the DateOnly to the json writer + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateOnly dateOnlyValue, JsonSerializerOptions options) => + writer.WriteStringValue(dateOnlyValue.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); + } +} diff --git a/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs new file mode 100644 index 000000000..c0e1f236a --- /dev/null +++ b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs @@ -0,0 +1,57 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Adyen.Core.Converters +{ + /// + /// Formatter for 'date' openapi formats ss defined by full-date - RFC3339 + /// see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + /// + public class DateOnlyNullableJsonConverter : JsonConverter + { + /// + /// The formats used to deserialize the date + /// + public static string[] Formats { get; } = { + "yyyy'-'MM'-'dd", + "yyyyMMdd" + + }; + + /// + /// Returns a DateOnly from the Json object + /// + /// + /// + /// + /// + public override DateOnly? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + return null; + + string value = reader.GetString()!; + + foreach(string format in Formats) + if (DateOnly.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly result)) + return result; + + throw new NotSupportedException(); + } + + /// + /// Writes the DateOnly to the json writer + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateOnly? dateOnlyValue, JsonSerializerOptions options) + { + if (dateOnlyValue == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(dateOnlyValue.Value.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); + } + } +} diff --git a/Adyen/Core/Converters/DateTimeJsonConverter.cs b/Adyen/Core/Converters/DateTimeJsonConverter.cs new file mode 100644 index 000000000..cbf1f4dde --- /dev/null +++ b/Adyen/Core/Converters/DateTimeJsonConverter.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Adyen.Core.Converters +{ + /// + /// Formatter for 'date-time' openapi formats ss defined by full-date - RFC3339 + /// see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + /// + public class DateTimeJsonConverter : JsonConverter + { + /// + /// The formats used to deserialize the date + /// + public static string[] Formats { get; } = { + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ssK", + "yyyyMMddTHHmmss.fffffffK", + "yyyyMMddTHHmmss.ffffffK", + "yyyyMMddTHHmmss.fffffK", + "yyyyMMddTHHmmss.ffffK", + "yyyyMMddTHHmmss.fffK", + "yyyyMMddTHHmmss.ffK", + "yyyyMMddTHHmmss.fK", + "yyyyMMddTHHmmssK", + + }; + + /// + /// Returns a DateTime from the Json object + /// + /// + /// + /// + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + throw new NotSupportedException(); + + string value = reader.GetString()!; + + foreach(string format in Formats) + if (DateTime.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime result)) + return result; + + throw new NotSupportedException(); + } + + /// + /// Writes the DateTime to the json writer + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime dateTimeValue, JsonSerializerOptions options) => + writer.WriteStringValue(dateTimeValue.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); + } +} diff --git a/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs b/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs new file mode 100644 index 000000000..f828a2ce0 --- /dev/null +++ b/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Adyen.Core.Converters +{ + /// + /// Formatter for 'date-time' openapi formats ss defined by full-date - RFC3339 + /// see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + /// + public class DateTimeNullableJsonConverter : JsonConverter + { + /// + /// The formats used to deserialize the date + /// + public static string[] Formats { get; } = { + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'ffK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fK", + "yyyy'-'MM'-'dd'T'HH':'mm':'ssK", + "yyyyMMddTHHmmss.fffffffK", + "yyyyMMddTHHmmss.ffffffK", + "yyyyMMddTHHmmss.fffffK", + "yyyyMMddTHHmmss.ffffK", + "yyyyMMddTHHmmss.fffK", + "yyyyMMddTHHmmss.ffK", + "yyyyMMddTHHmmss.fK", + "yyyyMMddTHHmmssK", + + }; + + /// + /// Returns a DateTime from the Json object + /// + /// + /// + /// + /// + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + return null; + + string value = reader.GetString()!; + + foreach(string format in Formats) + if (DateTime.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime result)) + return result; + + return null; + } + + /// + /// Writes the DateTime to the json writer + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime? dateTimeValue, JsonSerializerOptions options) + { + if (dateTimeValue == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(dateTimeValue.Value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); + } + } +} diff --git a/Adyen/Core/IEnum.cs b/Adyen/Core/IEnum.cs new file mode 100644 index 000000000..11055cb53 --- /dev/null +++ b/Adyen/Core/IEnum.cs @@ -0,0 +1,11 @@ +namespace Adyen.Core +{ + /// + /// Interface for defining enums. + /// This interface is used to make enums forward-compatible. + /// + public interface IEnum + { + string? Value { get; set; } + } +} \ No newline at end of file diff --git a/Adyen/Core/Option.cs b/Adyen/Core/Option.cs new file mode 100644 index 000000000..407cd4532 --- /dev/null +++ b/Adyen/Core/Option.cs @@ -0,0 +1,48 @@ +#nullable enable + + +namespace Adyen.Core +{ + /// + /// A wrapper for operation parameters which are not required. + /// + public struct Option + { + /// + /// The value to send to the server. + /// + public TType Value { get; } + + /// + /// When true the value will be sent to the server. + /// + internal bool IsSet { get; } + + /// + /// A wrapper for operation parameters which are not required. + /// + /// + public Option(TType value) + { + IsSet = true; + Value = value; + } + + /// + /// Implicitly converts this option to the contained type. + /// + /// + /// + /// . + public static implicit operator TType(Option option) => option.Value; + + /// + /// Implicitly converts the provided value to an Option. + /// + /// . + /// + /// Option of . + public static implicit operator Option(TType value) => new Option(value); + + } +} \ No newline at end of file diff --git a/Adyen/Core/Options/AdyenEnvironment.cs b/Adyen/Core/Options/AdyenEnvironment.cs new file mode 100644 index 000000000..6d98e4fe0 --- /dev/null +++ b/Adyen/Core/Options/AdyenEnvironment.cs @@ -0,0 +1,12 @@ +namespace Adyen.Core.Options +{ + /// + /// The Adyen Environment. + /// Changing this value affects the ClientUtils.BASE_URL where your API requests are sent. + /// + public enum AdyenEnvironment + { + Test, + Live + } +} \ No newline at end of file diff --git a/Adyen/Core/Options/AdyenOptions.cs b/Adyen/Core/Options/AdyenOptions.cs new file mode 100644 index 000000000..3c7e257cd --- /dev/null +++ b/Adyen/Core/Options/AdyenOptions.cs @@ -0,0 +1,36 @@ +namespace Adyen.Core.Options +{ + /// + /// Stores the variables used to communicate with the Adyen platforms. + /// + public class AdyenOptions + { + /// + /// The Adyen Environment. + /// + public AdyenEnvironment Environment { get; set; } = AdyenEnvironment.Test; + + /// + /// Used in the LIVE environment only. + /// This prefix is appended to HttpClient.BaseAddress when is set to `AdyenEnvironment.Live` + /// See: https://docs.adyen.com/development-resources/live-endpoints/ + /// + public string LiveEndpointUrlPrefix { get; set; } + + /// + /// The `ADYEN_API_KEY` is an Adyen API authentication token that must be included in the HTTP request header and allows your application to securely communicate with the Adyen APIs. + /// Guide on how to obtain the `ADYEN_API_KEY` + /// 1. For Digital/ECOM & In-Person Payments, visit: https://docs.adyen.com/development-resources/api-credentials/#generate-api-key to get your API Key. + /// 2. For Platforms & Financial Services, visit: https://docs.adyen.com/adyen-for-platforms-model to get started. + /// + public string AdyenApiKey { get; set; } + + /// + /// The `ADYEN_HMAC_KEY` is used to verify the incoming HMAC signature from incoming webhooks. + /// To protect your server from unauthorised webhook events, Adyen recommends that you use Hash-based message authentication code HMAC signatures for our webhooks. + /// Each webhook event will include a signature calculated using a secret `ADYEN_HMAC_KEY` key and the payload from the webhook. + /// By verifying this signature, you confirm that the webhook was sent by Adyen, and was not modified during transmission. + /// + public string AdyenHmacKey { get; set; } + } +} \ No newline at end of file diff --git a/Adyen/Core/Utilities/HmacValidatorUtility.cs b/Adyen/Core/Utilities/HmacValidatorUtility.cs new file mode 100644 index 000000000..41200e4ab --- /dev/null +++ b/Adyen/Core/Utilities/HmacValidatorUtility.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Adyen.Core.Utilities +{ + /// + /// Utility class to help verify hmac signatures from incoming webhooks. + /// + public static class HmacValidatorUtility + { + /// + /// Generates the Base64 encoded signature using the HMAC algorithm with the SHA256 hashing function. + /// + /// The JSON payload. + /// The secret ADYEN_HMAC_KEY, retrieved from the Adyen Customer Area. + /// The HMAC string for the payload. + /// + public static string GenerateBase64Sha256HmacSignature(string payload, string hmacKey) + { + byte[] key = ConvertHexadecimalToBytes(hmacKey); + byte[] data = Encoding.UTF8.GetBytes(payload); + + try + { + using (HMACSHA256 hmac = new HMACSHA256(key)) + { + // Compute the hmac on input data bytes + byte[] rawHmac = hmac.ComputeHash(data); + + // Base64-encode the hmac + return Convert.ToBase64String(rawHmac); + } + } + catch (Exception e) + { + throw new Exception("Failed to generate HMAC : " + e.Message); + } + } + + /// + /// Converts a hexadecimal into a byte array. + /// + /// The hexadecimal string. + /// An array of bytes that repesents the hexadecimalString. + private static byte[] ConvertHexadecimalToBytes(string hexadecimalString) + { + if ((hexadecimalString.Length % 2) == 1) + { + hexadecimalString += '0'; + } + + byte[] bytes = new byte[hexadecimalString.Length / 2]; + for (int i = 0; i < hexadecimalString.Length; i += 2) + { + bytes[i / 2] = Convert.ToByte(hexadecimalString.Substring(i, 2), 16); + } + + return bytes; + } + + /// + /// Validates a balance platform and management webhook payload with the given and . + /// + /// The HMAC signature, retrieved from the request header. + /// The HMAC key, retrieved from the Adyen Customer Area. + /// The webhook JSON payload. + /// A return value indicates the HMAC validation succeeded. + public static bool IsHmacSignatureValid(string hmacSignature, string hmacKey, string payload) + { + string signature = GenerateBase64Sha256HmacSignature(payload, hmacKey); + return TimeSafeEquals(Encoding.UTF8.GetBytes(hmacSignature), Encoding.UTF8.GetBytes(signature)); + } + + /// + /// This method compares two bytestrings in constant time based on length of shortest bytestring to prevent timing attacks. + /// + /// True if there's a difference. + private static bool TimeSafeEquals(byte[] a, byte[] b) + { + uint diff = (uint)a.Length ^ (uint)b.Length; + for (int i = 0; i < a.Length && i < b.Length; i++) { diff |= (uint)(a[i] ^ b[i]); } + return diff == 0; + } + } +} + From f7b89662975d78261e71974d7539544157906989 Mon Sep 17 00:00:00 2001 From: Kwok He <105217051+Kwok-he-Chu@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:59:29 +0100 Subject: [PATCH 2/5] Remove workflow.yml for .NET6.0 --- .github/workflows/build-net6.0.yml | 43 ------------------------------ 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/build-net6.0.yml diff --git a/.github/workflows/build-net6.0.yml b/.github/workflows/build-net6.0.yml deleted file mode 100644 index 983d463bd..000000000 --- a/.github/workflows/build-net6.0.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: .NET Core 6.0 - -on: - push: - branches: - - main - pull_request: - branches: - - main - - sdk-automation/models - - promote/main - workflow_dispatch: {} - -permissions: - contents: read - -jobs: - dotnet6-build-and-unit-test: - name: Build and Test on .NET 6.0 - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ ubuntu-latest, windows-latest ] - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET 6.0 and .NET 8.0 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 6.0.x - 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build (debug) in .NET 6.0 - run: dotnet build --configuration Debug --framework net6.0 --no-restore - - - name: Run unit tests on .NET 6.0 - run: dotnet test --no-build --configuration Debug --framework net6.0 --no-restore Adyen.Test/Adyen.Test.csproj \ No newline at end of file From 056f74a2b9270440287f665c76f0d071f7cd1a88 Mon Sep 17 00:00:00 2001 From: Kwok He <105217051+Kwok-he-Chu@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:24:26 +0100 Subject: [PATCH 3/5] Include updated core classes for review --- Adyen/Adyen.csproj | 4 --- Adyen/Core/Converters/ByteArrayConverter.cs | 16 +++++++++++ .../Core/Converters/DateOnlyJsonConverter.cs | 24 ++++++++-------- .../DateOnlyNullableJsonConverter.cs | 26 ++++++++--------- .../Core/Converters/DateTimeJsonConverter.cs | 24 ++++++++-------- .../DateTimeNullableJsonConverter.cs | 28 +++++++++---------- Adyen/Core/Utilities/HmacValidatorUtility.cs | 2 +- 7 files changed, 68 insertions(+), 56 deletions(-) diff --git a/Adyen/Adyen.csproj b/Adyen/Adyen.csproj index 3e50f1b5f..deeff46a8 100644 --- a/Adyen/Adyen.csproj +++ b/Adyen/Adyen.csproj @@ -57,8 +57,4 @@ - - - - diff --git a/Adyen/Core/Converters/ByteArrayConverter.cs b/Adyen/Core/Converters/ByteArrayConverter.cs index e222288a8..0b1ba4071 100644 --- a/Adyen/Core/Converters/ByteArrayConverter.cs +++ b/Adyen/Core/Converters/ByteArrayConverter.cs @@ -4,8 +4,18 @@ namespace Adyen.Core.Converters { + /// + /// JsonConverter for byte arrays. + /// public class ByteArrayConverter : JsonConverter { + /// + /// Reads a byte array during deserialization. + /// + /// . + /// . + /// . + /// Byte array. public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) @@ -15,6 +25,12 @@ public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS return Encoding.UTF8.GetBytes(value); } + /// + /// Writes a byte array during serialization. + /// + /// . + /// . + /// . public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options) { if (value == null) diff --git a/Adyen/Core/Converters/DateOnlyJsonConverter.cs b/Adyen/Core/Converters/DateOnlyJsonConverter.cs index 80c34bd8d..523644b08 100644 --- a/Adyen/Core/Converters/DateOnlyJsonConverter.cs +++ b/Adyen/Core/Converters/DateOnlyJsonConverter.cs @@ -12,7 +12,7 @@ namespace Adyen.Core.Converters public class DateOnlyJsonConverter : JsonConverter { /// - /// The formats used to deserialize the date + /// The formats used to deserialize the date. /// public static string[] Formats { get; } = { "yyyy'-'MM'-'dd", @@ -21,12 +21,12 @@ public class DateOnlyJsonConverter : JsonConverter }; /// - /// Returns a DateOnly from the Json object + /// Returns a from the Json object. /// - /// - /// - /// - /// + /// . + /// . + /// . + /// . public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) throw new NotSupportedException(); @@ -41,12 +41,12 @@ public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, Jso } /// - /// Writes the DateOnly to the json writer + /// Writes the to the . /// - /// - /// - /// - public override void Write(Utf8JsonWriter writer, DateOnly dateOnlyValue, JsonSerializerOptions options) => - writer.WriteStringValue(dateOnlyValue.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateOnly dateOnly, JsonSerializerOptions options) => + writer.WriteStringValue(dateOnly.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); } } diff --git a/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs index c0e1f236a..91ddb3b49 100644 --- a/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs +++ b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs @@ -12,7 +12,7 @@ namespace Adyen.Core.Converters public class DateOnlyNullableJsonConverter : JsonConverter { /// - /// The formats used to deserialize the date + /// The formats used to deserialize the date. /// public static string[] Formats { get; } = { "yyyy'-'MM'-'dd", @@ -21,12 +21,12 @@ public class DateOnlyNullableJsonConverter : JsonConverter }; /// - /// Returns a DateOnly from the Json object + /// Returns a nullable from the Json object. /// - /// - /// - /// - /// + /// . + /// . + /// . + /// . public override DateOnly? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; @@ -41,17 +41,17 @@ public class DateOnlyNullableJsonConverter : JsonConverter } /// - /// Writes the DateOnly to the json writer + /// Writes the to the . /// - /// - /// - /// - public override void Write(Utf8JsonWriter writer, DateOnly? dateOnlyValue, JsonSerializerOptions options) + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateOnly? dateOnly, JsonSerializerOptions options) { - if (dateOnlyValue == null) + if (dateOnly == null) writer.WriteNullValue(); else - writer.WriteStringValue(dateOnlyValue.Value.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); + writer.WriteStringValue(dateOnly.Value.ToString("yyyy'-'MM'-'dd", CultureInfo.InvariantCulture)); } } } diff --git a/Adyen/Core/Converters/DateTimeJsonConverter.cs b/Adyen/Core/Converters/DateTimeJsonConverter.cs index cbf1f4dde..43a758362 100644 --- a/Adyen/Core/Converters/DateTimeJsonConverter.cs +++ b/Adyen/Core/Converters/DateTimeJsonConverter.cs @@ -12,7 +12,7 @@ namespace Adyen.Core.Converters public class DateTimeJsonConverter : JsonConverter { /// - /// The formats used to deserialize the date + /// The formats used to deserialize the date. /// public static string[] Formats { get; } = { "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", @@ -35,12 +35,12 @@ public class DateTimeJsonConverter : JsonConverter }; /// - /// Returns a DateTime from the Json object + /// Returns a from the Json object. /// - /// - /// - /// - /// + /// . + /// . + /// . + /// . public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) throw new NotSupportedException(); @@ -55,12 +55,12 @@ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, Jso } /// - /// Writes the DateTime to the json writer + /// Writes the to the . /// - /// - /// - /// - public override void Write(Utf8JsonWriter writer, DateTime dateTimeValue, JsonSerializerOptions options) => - writer.WriteStringValue(dateTimeValue.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateTime dateTime, JsonSerializerOptions options) => + writer.WriteStringValue(dateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); } } diff --git a/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs b/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs index f828a2ce0..abfa0c099 100644 --- a/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs +++ b/Adyen/Core/Converters/DateTimeNullableJsonConverter.cs @@ -12,7 +12,7 @@ namespace Adyen.Core.Converters public class DateTimeNullableJsonConverter : JsonConverter { /// - /// The formats used to deserialize the date + /// The formats used to deserialize the date. /// public static string[] Formats { get; } = { "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", @@ -35,12 +35,12 @@ public class DateTimeNullableJsonConverter : JsonConverter }; /// - /// Returns a DateTime from the Json object + /// Returns a nullable from the Json object. /// - /// - /// - /// - /// + /// . + /// . + /// . + /// . public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; @@ -54,18 +54,18 @@ public class DateTimeNullableJsonConverter : JsonConverter return null; } - /// - /// Writes the DateTime to the json writer + /// + /// Writes the to the . /// - /// - /// - /// - public override void Write(Utf8JsonWriter writer, DateTime? dateTimeValue, JsonSerializerOptions options) + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateTime? dateTime, JsonSerializerOptions options) { - if (dateTimeValue == null) + if (dateTime == null) writer.WriteNullValue(); else - writer.WriteStringValue(dateTimeValue.Value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); + writer.WriteStringValue(dateTime.Value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK", CultureInfo.InvariantCulture)); } } } diff --git a/Adyen/Core/Utilities/HmacValidatorUtility.cs b/Adyen/Core/Utilities/HmacValidatorUtility.cs index 41200e4ab..0a44ed2b0 100644 --- a/Adyen/Core/Utilities/HmacValidatorUtility.cs +++ b/Adyen/Core/Utilities/HmacValidatorUtility.cs @@ -33,7 +33,7 @@ public static string GenerateBase64Sha256HmacSignature(string payload, string hm } catch (Exception e) { - throw new Exception("Failed to generate HMAC : " + e.Message); + throw new Exception("Failed to generate HMAC signature: " + e.Message, e); } } From c277c113f203d88aa2706622d0d15b0d8014a120 Mon Sep 17 00:00:00 2001 From: Kwok He <105217051+Kwok-he-Chu@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:03:48 +0100 Subject: [PATCH 4/5] Fix workflow file to target current HttpRequestMessageExtension --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfafc9753..9d564528d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,8 +34,8 @@ jobs: with: token: ${{ secrets.ADYEN_AUTOMATION_BOT_ACCESS_TOKEN }} develop-branch: main - version-files: Adyen/Adyen.csproj Adyen.Test/Adyen.Test.csproj Adyen/Constants/ClientConfig.cs - release-title: Adyen .net API Library + version-files: Adyen/Adyen.csproj Adyen.Test/Adyen.Test.csproj Adyen/Core/Client/Extensions/HttpRequestMessageExtensions.cs + release-title: Adyen .NET API Library pre-release: ${{ inputs.pre-release || false }} github-release: ${{ inputs.github-release || false }} separator: .pre.beta From 4276c32f4da024b062a764506095115b66a74055 Mon Sep 17 00:00:00 2001 From: Kwok He <105217051+Kwok-he-Chu@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:02:44 +0100 Subject: [PATCH 5/5] DateOnlyNullable returns null when the format is not supported Changed test to assert this behavior Add a underscore to Test method names for readability --- .../DateOnlyNullableJsonConverterTest.cs | 21 +++++++++---------- .../DateOnlyNullableJsonConverter.cs | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs b/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs index 8f75f6470..a3889cf77 100644 --- a/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs +++ b/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.cs @@ -10,7 +10,7 @@ public class DateOnlyNullableJsonConverterTest private readonly DateOnlyNullableJsonConverter _converter = new DateOnlyNullableJsonConverter(); [TestMethod] - public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_ReturnsCorrectDate() + public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_Returns_Correct_DateOnly() { // Arrange string json = "\"2025-12-25\""; @@ -25,7 +25,7 @@ public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_ReturnsCorrectDate() } [TestMethod] - public void Given_Date_yyyyMMdd_When_Read_Then_ReturnsCorrectDate() + public void Given_DateOnly_yyyyMMdd_When_Read_Then_Returns_Correct_DateOnly() { // Arrange string json = "\"20251225\""; @@ -40,23 +40,22 @@ public void Given_Date_yyyyMMdd_When_Read_Then_ReturnsCorrectDate() } [TestMethod] - public void Given_WrongFormatDateOnlyString_When_Read_Then_ThrowsNotSupportedException() + public void Given_InvalidFormat_DateOnlyString_When_Read_Then_Returns_Null() { // Arrange - string json = "\"25-12-2025\""; // Incorrect format dd-MM-yyyy + string json = "\"25-12-2025\""; // Invalid format "yyyy'-'MM'-'dd" or "yyyyMMdd" // Act + Utf8JsonReader reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); + reader.Read(); + var result = _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); + // Assert - Assert.ThrowsException(() => - { - var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(json)); - reader.Read(); - _converter.Read(ref reader, typeof(DateOnly), new JsonSerializerOptions()); - }); + Assert.IsNull(result); } [TestMethod] - public void Given_InvalidDateOnlyString_When_Read_Then_ThrowsJsonException() + public void Given_Invalid_DateOnlyString_When_Read_Then_ThrowsJsonException() { // Arrange string json = "invalid-date"; diff --git a/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs index 91ddb3b49..65627727c 100644 --- a/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs +++ b/Adyen/Core/Converters/DateOnlyNullableJsonConverter.cs @@ -37,7 +37,7 @@ public class DateOnlyNullableJsonConverter : JsonConverter if (DateOnly.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly result)) return result; - throw new NotSupportedException(); + return null; } ///