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 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 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..a3889cf77 --- /dev/null +++ b/Adyen.Test/Core/Converters/DateOnlyNullableJsonConverterTest.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 DateOnlyNullableJsonConverterTest + { + private readonly DateOnlyNullableJsonConverter _converter = new DateOnlyNullableJsonConverter(); + + [TestMethod] + public void Given_DateWithDashes_yyyy_MM_dd_When_Read_Then_Returns_Correct_DateOnly() + { + // 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_DateOnly_yyyyMMdd_When_Read_Then_Returns_Correct_DateOnly() + { + // 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_InvalidFormat_DateOnlyString_When_Read_Then_Returns_Null() + { + // Arrange + 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.IsNull(result); + } + + [TestMethod] + public void Given_Invalid_DateOnlyString_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..deeff46a8 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 @@ - + + + - + + 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..0b1ba4071 --- /dev/null +++ b/Adyen/Core/Converters/ByteArrayConverter.cs @@ -0,0 +1,45 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +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) + return null; + + string value = reader.GetString(); + return Encoding.UTF8.GetBytes(value); + } + + /// + /// Writes a byte array during serialization. + /// + /// . + /// . + /// . + 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..523644b08 --- /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 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 to the . + /// + /// . + /// . + /// . + 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 new file mode 100644 index 000000000..65627727c --- /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 nullable 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; + + return null; + } + + /// + /// Writes the to the . + /// + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateOnly? dateOnly, JsonSerializerOptions options) + { + if (dateOnly == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(dateOnly.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..43a758362 --- /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 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 to the . + /// + /// . + /// . + /// . + 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 new file mode 100644 index 000000000..abfa0c099 --- /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 nullable 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 to the . + /// + /// . + /// . + /// . + public override void Write(Utf8JsonWriter writer, DateTime? dateTime, JsonSerializerOptions options) + { + if (dateTime == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(dateTime.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..0a44ed2b0 --- /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 signature: " + e.Message, e); + } + } + + /// + /// 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; + } + } +} +