diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efe61a5..9fc2adbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Represents the **NuGet** versions. +## v3.14.0 +- *Enhancement*: Planned feature obsoletion. The `TypedHttpClientBase` methods `WithRetry`, `WithTimeout`, `WithCustomRetryPolicy` and `WithMaxRetryDelay` are now marked as obsolete and will result in a compile-time warning. Related `TypedHttpClientOptions`, `HttpRequestLogger` and `SettingsBase` capabilities have also been obsoleted. + - Why? Primarily based on Microsoft guidance around [`IHttpClientFactory`](https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory) usage. Specifically advances in native HTTP [resilency](https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience) support, and the [.NET 8 networking improvements](https://devblogs.microsoft.com/dotnet/dotnet-8-networking-improvements/). + - When? Soon, planned within the next minor release (`v3.15.0`). This will simplify the underlying `TypedHttpClientBase` logic and remove the internal dependency on an older version of the [_Polly_](https://www.nuget.org/packages/Polly/7.2.4) package. + - How? Review the compile-time warnings, and [update the codebase](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly) to use the native `IHttpClientFactory` resiliency capabilities. +- *Enhancement*: Updated `CoreEx.UnitTesting` to leverage the latest `UnitTestEx` (`v4.2.0`) which has added support for testing `HttpMessageHandler` and `HttpClient` configurations. This will enable improved mocked testing as a result of the above changes where applicable. +- *Enhancement*: Added `CustomSerializers` property to `IEventSerializer` of type `CustomEventSerializers`. This allows for the add (registration) of custom JSON serialization logic for a specified `EventData.Value` type. This is intended to allow an opportunity to serialize a specific type in a different manner to the default JSON serialization; for example, exclude certain properties, or use a different serialization format. +- *Enhancement*: Updated the unit testing `ExpectedEventPublisher` so that it now executes the configured `IEventSerializer` during publishing. A new `UnitTestBase.GetExpectedEventPublisher` extension method added to simplify access to the `ExpectedEventPublisher` instance and corresponding `GetPublishedEvents` property to enable further assert where required. + ## v3.13.0 - *Enhancement*: Added `DatabaseMapperEx` enabling extended/explicit mapping where performance is critical versus existing that uses reflection and compiled expressions; can offer up to 40%+ improvement in some scenarios. - *Enhancement*: The `AddMappers()` and `AddValidators()` extension methods now also support two or three assembly specification overloads. diff --git a/Common.targets b/Common.targets index a0a3db9a..00646e62 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.13.0 + 3.14.0 preview Avanade Avanade @@ -27,6 +27,7 @@ true true true + true @@ -35,4 +36,4 @@ true - + \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs index 57c61827..4812eeff 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs @@ -389,7 +389,7 @@ public void G100_Verify_Publish() Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); var e = imp.GetEvents("pendingVerifications"); Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" }, e[0].Value); + ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); } [Test] @@ -398,7 +398,7 @@ public void G100_Verify_Publish_WithExpectations() using var test = ApiTester.Create(); test.UseExpectedEvents() .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" } }) + .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) .Run(c => c.VerifyAsync(1.ToGuid())); } diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs index 65a92e59..f831fffb 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest2.cs @@ -379,7 +379,7 @@ public void G100_Verify_Publish() Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); var e = imp.GetEvents("pendingVerifications"); Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" }, e[0].Value); + ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); } [Test] @@ -388,7 +388,7 @@ public void G100_Verify_Publish_WithExpectations() using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped()); test.UseExpectedEvents() .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" } }) + .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) .Run(c => c.VerifyAsync(1.ToGuid())); } diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs index ee0e690e..04c52443 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeResultControllerTest.cs @@ -392,7 +392,7 @@ public void G100_Verify_Publish() Assert.That(imp.GetNames(), Has.Length.EqualTo(1)); var e = imp.GetEvents("pendingVerifications"); Assert.That(e, Has.Length.EqualTo(1)); - ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" }, e[0].Value); + ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" }, e[0].Value); } [Test] @@ -401,7 +401,7 @@ public void G100_Verify_Publish_WithExpectations() using var test = ApiTester.Create(); test.UseExpectedEvents() .Controller() - .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 38, Gender = "F" } }) + .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 39, Gender = "F" } }) .ExpectStatusCode(System.Net.HttpStatusCode.Accepted) .Run(c => c.VerifyAsync(1.ToGuid())); } diff --git a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj index d9f2a582..9f191100 100644 --- a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj +++ b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj @@ -21,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs b/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs index 677b346b..08df6e45 100644 --- a/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs +++ b/src/CoreEx.Newtonsoft/Json/CloudEventSerializer.cs @@ -50,6 +50,21 @@ protected override Task DecodeAsync(BinaryData eventData, Cancell /// protected override Task EncodeAsync(CloudEvent cloudEvent, CancellationToken cancellationToken = default) - => Task.FromResult(new BinaryData(new JsonEventFormatter(JsonSerializer).EncodeStructuredModeMessage(cloudEvent, out var _))); + => Task.FromResult(new BinaryData(new InternalFormatter(JsonSerializer).EncodeStructuredModeMessage(cloudEvent, out var _))); + + private class InternalFormatter(Nsj.JsonSerializer jsonSerializer) : JsonEventFormatter(jsonSerializer) + { + /// + protected override void EncodeStructuredModeData(CloudEvent cloudEvent, JsonWriter writer) + { + if (cloudEvent.Data is BinaryData bd && cloudEvent.DataContentType == MediaTypeNames.Application.Json) + { + writer.WritePropertyName(DataPropertyName); + writer.WriteRawValue(bd.ToString()); + } + else + base.EncodeStructuredModeData(cloudEvent, writer); + } + } } } \ No newline at end of file diff --git a/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj b/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj index 1ad5f106..381e7eba 100644 --- a/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj +++ b/src/CoreEx.UnitTesting.NUnit/CoreEx.UnitTesting.NUnit.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj index 3f87ad45..99a2e997 100644 --- a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj +++ b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs b/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs index 853e3640..83571e23 100644 --- a/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs +++ b/src/CoreEx.UnitTesting/Expectations/EventExpectations.cs @@ -122,7 +122,7 @@ protected override Task OnAssertAsync(AssertArgs args) if (!expectedEventPublisher.IsEmpty) args.Tester.Implementor.AssertFail("Expected Event Publish/Send mismatch; there are one or more published events that have not been sent."); - var names = expectedEventPublisher.SentEvents.Keys.ToArray(); + var names = expectedEventPublisher.PublishedEvents.Keys.ToArray(); if (_expectNoEvents && !_expectEvents && _expectedEvents.Count == 0 && names.Length > 0) args.Tester.Implementor.AssertFail($"Expected no Event(s); one or more were published."); @@ -158,9 +158,10 @@ protected override Task OnAssertAsync(AssertArgs args) } /// - /// Gets the event from the event storage. + /// Gets the event JSON from the event storage. /// - private static List GetEvents(ExpectedEventPublisher expectedEventPublisher, string? name) => expectedEventPublisher!.SentEvents.TryGetValue(name ?? ExpectedEventPublisher.NullKeyName, out var queue) ? [.. queue] : new(); + private static List GetEvents(ExpectedEventPublisher expectedEventPublisher, string? name) + => expectedEventPublisher!.PublishedEvents.TryGetValue(name ?? ExpectedEventPublisher.NullKeyName, out var queue) ? [.. queue.Select(x => x.Json)] : new(); /// /// Asserts the events for the destination. diff --git a/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs b/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs index 53e63be8..6c98b835 100644 --- a/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs +++ b/src/CoreEx.UnitTesting/Expectations/ExpectedEventPublisher.cs @@ -4,8 +4,8 @@ using CoreEx.Events; using CoreEx.Json; using Microsoft.Extensions.Logging; -using System; using System.Collections.Concurrent; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -16,7 +16,8 @@ namespace UnitTestEx.Expectations /// /// Provides an expected event publisher to support . /// - /// Where an is provided then each will also be logged during Send. + /// Where an is provided then each will also be logged during Send. + /// public sealed class ExpectedEventPublisher : EventPublisher { private readonly TestSharedState _sharedState; @@ -33,7 +34,7 @@ public sealed class ExpectedEventPublisher : EventPublisher /// /// The . /// The where found; otherwise, null. - public static ExpectedEventPublisher? GetFromSharedState(TestSharedState sharedState) + internal static ExpectedEventPublisher? GetFromSharedState(TestSharedState sharedState) => sharedState.ThrowIfNull(nameof(sharedState)).StateData.TryGetValue(nameof(ExpectedEventPublisher), out var eep) ? eep as ExpectedEventPublisher : null; /// @@ -41,7 +42,7 @@ public sealed class ExpectedEventPublisher : EventPublisher /// /// The . /// The . - public static void SetToSharedState(TestSharedState sharedState, ExpectedEventPublisher? expectedEventPublisher) + internal static void SetToSharedState(TestSharedState sharedState, ExpectedEventPublisher? expectedEventPublisher) => sharedState.ThrowIfNull(nameof(sharedState)).StateData[nameof(ExpectedEventPublisher)] = expectedEventPublisher.ThrowIfNull(nameof(expectedEventPublisher)); /// @@ -51,8 +52,9 @@ public static void SetToSharedState(TestSharedState sharedState, ExpectedEventPu /// The optional for logging the events (each ). /// The optional for the logging. Defaults to /// The ; defaults where not specified. - public ExpectedEventPublisher(TestSharedState sharedState, ILogger? logger = null, IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null) - : base(eventDataFormatter, new CoreEx.Text.Json.EventDataSerializer(), new NullEventSender()) + /// The ; defaults where not specified. + public ExpectedEventPublisher(TestSharedState sharedState, ILogger? logger = null, IJsonSerializer? jsonSerializer = null, EventDataFormatter? eventDataFormatter = null, IEventSerializer? eventSerializer = null) + : base(eventDataFormatter, eventSerializer ?? new CoreEx.Text.Json.EventDataSerializer(), new NullEventSender()) { _sharedState = sharedState.ThrowIfNull(nameof(sharedState)); SetToSharedState(_sharedState, this); @@ -61,17 +63,27 @@ public ExpectedEventPublisher(TestSharedState sharedState, ILogger - /// Gets the dictionary that contains the sent events by destination. + /// Gets the dictionary that contains the actual published and sent events by destination. /// - /// The sent events are queued as the JSON-serialized representation of the . - public ConcurrentDictionary> SentEvents { get; } = new(); + /// The actual published events are queued as the JSON-serialized (indented) representation of the , the itself, and the corresponding . + public ConcurrentDictionary> PublishedEvents { get; } = new(); + + /// + /// Indicates whether any events have been published. + /// + public bool HasPublishedEvents => !PublishedEvents.IsEmpty; + + /// + /// Gets the total count of published events (across all destinations). + /// + public int PublishedEventCount => PublishedEvents.Select(x => x.Value.Count).Sum(); /// protected override Task OnEventSendAsync(string? name, EventData eventData, EventSendData eventSendData, CancellationToken cancellationToken) { - var queue = SentEvents.GetOrAdd(name ?? NullKeyName, _ => new ConcurrentQueue()); + var queue = PublishedEvents.GetOrAdd(name ?? NullKeyName, _ => new ConcurrentQueue<(string Json, EventData Event, EventSendData SentEvent)>()); var json = _jsonSerializer.Serialize(eventData, JsonWriteFormat.Indented); - queue.Enqueue(json); + queue.Enqueue((json, eventData, eventSendData)); if (_logger != null) { diff --git a/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs b/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs index 790c592e..f1fc75b8 100644 --- a/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs +++ b/src/CoreEx.UnitTesting/Expectations/UnitTestExExtensions.cs @@ -5,6 +5,7 @@ using CoreEx.Events; using CoreEx.Http; using CoreEx.Wildcards; +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.Linq; @@ -56,9 +57,23 @@ public static TSelf UseExpectedEvents(this TesterBase tester) wher /// /// Gets whether the has been invoked /// - /// - /// - public static bool IsExpectedEventPublisherConfigured(this TesterBase owner) => owner.SetUp.Properties.TryGetValue(TesterBaseIsExpectedEventPublisherConfiguredKey, out var val) && (bool)val!; + /// The . + /// Indicates whether the is configured. + public static bool IsExpectedEventPublisherConfigured(this TesterBase tester) => tester.SetUp.Properties.TryGetValue(TesterBaseIsExpectedEventPublisherConfiguredKey, out var val) && (bool)val!; + + /// + /// Gets the from the . + /// + /// The . + /// The . + /// Thrown where the has not been configured (). + public static ExpectedEventPublisher GetExpectedEventPublisher(this TesterBase tester) + { + if (!IsExpectedEventPublisherConfigured(tester)) + throw new InvalidOperationException($"The {nameof(ExpectedEventPublisher)} has not been configured. Please ensure that the {nameof(UseExpectedEvents)} method has been invoked."); + + return (ExpectedEventPublisher)tester.Services.GetRequiredService(); + } #endregion diff --git a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs index 64fc21f4..4b479a02 100644 --- a/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs +++ b/src/CoreEx/Abstractions/IServiceCollectionExtensions.cs @@ -164,26 +164,26 @@ public static IServiceCollection AddSettings(this IServiceCollection public static IServiceCollection AddEventPublisher(this IServiceCollection services) where TEventPublisher : class, IEventPublisher => CheckServices(services).AddScoped(); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The . /// The . public static IServiceCollection AddLoggerEventSender(this IServiceCollection services) => CheckServices(services).AddEventSender(); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The . /// The . public static IServiceCollection AddNullEventSender(this IServiceCollection services) => CheckServices(services).AddEventSender(); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The . /// The . /// The . - public static IServiceCollection AddEventSender(this IServiceCollection services) where TEventSender : class, IEventSender => CheckServices(services).AddScoped(); + public static IServiceCollection AddEventSender(this IServiceCollection services) where TEventSender : class, IEventSender => CheckServices(services).AddSingleton(); /// /// Adds the as a singleton service. @@ -238,12 +238,12 @@ public static IServiceCollection AddJsonMergePatch(this IServiceCollection servi }); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The . /// The action to enable the to be further configured. /// The . - public static IServiceCollection AddCloudEventSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddScoped(sp => + public static IServiceCollection AddCloudEventSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => { var ces = new CoreEx.Text.Json.CloudEventSerializer(sp.GetService()); configure?.Invoke(sp, ces); @@ -251,12 +251,12 @@ public static IServiceCollection AddCloudEventSerializer(this IServiceCollection }); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The action to enable the to be further configured. /// The . /// The . - public static IServiceCollection AddEventDataSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddScoped(sp => + public static IServiceCollection AddEventDataSerializer(this IServiceCollection services, Action? configure = null) => CheckServices(services).AddSingleton(sp => { var eds = new CoreEx.Text.Json.EventDataSerializer(sp.GetService(), sp.GetService()); configure?.Invoke(sp, eds); @@ -264,12 +264,12 @@ public static IServiceCollection AddEventDataSerializer(this IServiceCollection }); /// - /// Adds the as the scoped service. + /// Adds the as the singleton service. /// /// The . /// The optional ; will default where not specified. /// The . - public static IServiceCollection AddEventDataFormatter(this IServiceCollection services, EventDataFormatter? formatter = null) => CheckServices(services).AddScoped(_ => formatter ?? new EventDataFormatter()); + public static IServiceCollection AddEventDataFormatter(this IServiceCollection services, EventDataFormatter? formatter = null) => CheckServices(services).AddSingleton(_ => formatter ?? new EventDataFormatter()); /// /// Adds the as the singleton service. diff --git a/src/CoreEx/Configuration/SettingsBase.cs b/src/CoreEx/Configuration/SettingsBase.cs index d56be3e8..e73a67a2 100644 --- a/src/CoreEx/Configuration/SettingsBase.cs +++ b/src/CoreEx/Configuration/SettingsBase.cs @@ -121,26 +121,31 @@ public T GetRequiredValue([CallerMemberName] string key = "") /// /// Indicates whether the logs the request and response . It is recommended that this is only used for development/debugging purposes. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-logging/?view=aspnetcore-8.0 on how to implement.")] public bool HttpLogContent => GetValue(nameof(HttpLogContent), false); /// /// Gets the default retry count. Defaults to 3. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public int HttpRetryCount => GetValue(nameof(HttpRetryCount), 3); /// /// Gets the default retry delay in seconds. Defaults to 1.8. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public double HttpRetrySeconds => GetValue(nameof(HttpRetrySeconds), 1.8d); /// /// Gets the default timeout. Defaults to 90 seconds. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public int HttpTimeoutSeconds => GetValue(defaultValue: 90); /// /// Gets the default maximum retry delay. Defaults to 2 minutes. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TimeSpan HttpMaxRetryDelay => TimeSpan.FromSeconds(GetValue(defaultValue: 120)); /// diff --git a/src/CoreEx/Events/CloudEventSerializerBase.cs b/src/CoreEx/Events/CloudEventSerializerBase.cs index bdc2b72e..9e085003 100644 --- a/src/CoreEx/Events/CloudEventSerializerBase.cs +++ b/src/CoreEx/Events/CloudEventSerializerBase.cs @@ -44,6 +44,9 @@ public abstract class CloudEventSerializerBase : IEventSerializer /// public IAttachmentStorage? AttachmentStorage { get; set; } + /// + public CustomEventSerializers CustomSerializers { get; } = new(); + /// public async Task DeserializeAsync(BinaryData eventData, CancellationToken cancellationToken = default) { @@ -190,14 +193,12 @@ public Task SerializeAsync(EventData @event, CancellationToken /// private async Task SerializeToCloudEventAsync(EventData @event, CancellationToken cancellationToken) { - EventDataFormatter.Format(@event); - var ce = new CloudEvent { Id = @event.Id, Time = @event.Timestamp, - Type = @event.Type ?? throw new InvalidOperationException("CloudEvents must have a Type; the EventDataFormatter should be updated to set."), - Source = @event.Source ?? throw new InvalidOperationException("CloudEvents must have a Source; the EventDataFormatter should be updated to set.") + Type = @event.Type ?? throw new InvalidOperationException($"CloudEvents must have a Type; the {nameof(EventDataFormatter)} should be updated to set."), + Source = @event.Source ?? throw new InvalidOperationException($"CloudEvents must have a Source; the {nameof(EventDataFormatter)} should be updated to set.") }; SetExtensionAttribute(ce, SubjectName, @event.Subject); @@ -221,12 +222,12 @@ private async Task SerializeToCloudEventAsync(EventData @event, Canc if (@event.Value is not null) { ce.DataContentType = MediaTypeNames.Application.Json; - ce.Data = @event.Value; + ce.Data = CustomSerializers.SerializeToBinaryData(@event, EventDataFormatter.JsonSerializer!, true); // Where attachments are supported, check the size of the data and write to the attachment storage if required. - if (AttachmentStorage != null) + if (AttachmentStorage is not null) { - var data = EventDataFormatter.JsonSerializer!.SerializeToBinaryData(@event.Value); + var data = CustomSerializers.SerializeToBinaryData(@event, EventDataFormatter.JsonSerializer!, true); if (data.ToMemory().Length >= AttachmentStorage!.MaxDataSize) ce.Data = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); } diff --git a/src/CoreEx/Events/CustomEventSerializers.cs b/src/CoreEx/Events/CustomEventSerializers.cs new file mode 100644 index 00000000..7cc3c1a2 --- /dev/null +++ b/src/CoreEx/Events/CustomEventSerializers.cs @@ -0,0 +1,67 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Json; +using System; +using System.Collections.Generic; + +namespace CoreEx.Events +{ + /// + /// Enables the adding of for specific types to customize the serialization. + /// + /// This allows the JSON to be manipulated before it is sent; for example, to remove properties and/or mask content where applicable. + public class CustomEventSerializers + { + private readonly Dictionary _serializers = []; + + /// + /// Adds a serializer for the specified type. + /// + /// The . + /// The to be used to perform the serialization. + /// The to enable fluent-style method-chaining. + public CustomEventSerializers Add(CustomEventSerializer serializer) + { + _serializers.Add(typeof(T), serializer); + return this; + } + + /// + /// Serialize the () to JSON . + /// + /// The for serialization. + /// The to be used. + /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). + /// The JSON . + public virtual BinaryData SerializeToBinaryData(EventData @event, IJsonSerializer jsonSerializer, bool serializeValueOnly) + { + @event.ThrowIfNull(nameof(@event)); + jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); + + if (@event.Value is not null && _serializers.TryGetValue(@event.Value.GetType(), out var serializer)) + return serializer(@event, jsonSerializer, serializeValueOnly); + else + return DefaultEventSerializer(@event, jsonSerializer, serializeValueOnly); + } + + /// + /// Provides the default event serialization. + /// + public static CustomEventSerializer DefaultEventSerializer { get; } = (@event, jsonSerializer, serializeValueOnly) => + { + @event.ThrowIfNull(nameof(@event)); + jsonSerializer.ThrowIfNull(nameof(jsonSerializer)); + + return serializeValueOnly ? jsonSerializer.SerializeToBinaryData(@event.Value) : jsonSerializer.SerializeToBinaryData(@event); + }; + } + + /// + /// Represents the method that provides the custom event serialization. + /// + /// The for serialization. + /// The to be used. + /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). + /// + public delegate BinaryData CustomEventSerializer(EventData @event, IJsonSerializer jsonSerializer, bool serializeValueOnly); +} \ No newline at end of file diff --git a/src/CoreEx/Events/EventDataSerializerBase.cs b/src/CoreEx/Events/EventDataSerializerBase.cs index 7ca5321c..21b7fbc5 100644 --- a/src/CoreEx/Events/EventDataSerializerBase.cs +++ b/src/CoreEx/Events/EventDataSerializerBase.cs @@ -36,6 +36,9 @@ protected EventDataSerializerBase(IJsonSerializer jsonSerializer, EventDataForma /// public IAttachmentStorage? AttachmentStorage { get; set; } + /// + public CustomEventSerializers CustomSerializers { get; } = new(); + /// /// Indicates whether the is serialized only (true); or alternatively, the complete including all metadata (false). /// @@ -133,12 +136,12 @@ private async Task SerializeInternalAsync(EventData @event, Cancella BinaryData data; EventAttachment attachment; - // Only serializes the value + // Only serializes the value. if (SerializeValueOnly) { - data = JsonSerializer.SerializeToBinaryData(@event.Value); + data = CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, SerializeValueOnly); if (AttachmentStorage is null || data.ToMemory().Length <= AttachmentStorage!.MaxDataSize) - return JsonSerializer.SerializeToBinaryData(@event.Value); + return data; // Create the attachment and serialize the event with the attachment reference. attachment = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); @@ -146,19 +149,17 @@ private async Task SerializeInternalAsync(EventData @event, Cancella } // Serializes the complete event including metadata. - var e = @event.Copy(); - EventDataFormatter.Format(e); if (AttachmentStorage is null) - return JsonSerializer.SerializeToBinaryData(e); + return CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, SerializeValueOnly); // Serialize the value and check if needs to be an attachment. - data = JsonSerializer.SerializeToBinaryData(e.Value); + data = CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, true); if (data.ToMemory().Length < AttachmentStorage!.MaxDataSize) - return JsonSerializer.SerializeToBinaryData(e); + return CustomSerializers.SerializeToBinaryData(@event, JsonSerializer, false); // Create the attachment and re-serialize the event with the attachment reference. attachment = await AttachmentStorage.WriteAsync(@event, data, cancellationToken).ConfigureAwait(false); - return JsonSerializer.SerializeToBinaryData(new EventData(e) { Value = attachment }); + return JsonSerializer.SerializeToBinaryData(new EventData(@event) { Value = attachment }); } } } \ No newline at end of file diff --git a/src/CoreEx/Events/IEventSerializer.cs b/src/CoreEx/Events/IEventSerializer.cs index 4f54e816..9a760978 100644 --- a/src/CoreEx/Events/IEventSerializer.cs +++ b/src/CoreEx/Events/IEventSerializer.cs @@ -22,6 +22,11 @@ public interface IEventSerializer /// public IAttachmentStorage? AttachmentStorage { get; set; } + /// + /// Gets the which enables the or serialization to be customized per . + /// + public CustomEventSerializers CustomSerializers { get; } + /// /// Serializes the to a . /// diff --git a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs b/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs index 746588d8..5053068a 100644 --- a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs +++ b/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs @@ -53,11 +53,13 @@ internal TypedHttpClientOptions(ITypedHttpClientOptions owner, SettingsBase sett /// /// Gets the retry count; see . /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public int? RetryCount { get; private set; } /// /// Gets the retry seconds; see . /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public double? RetrySeconds { get; private set; } /// @@ -93,6 +95,7 @@ internal TypedHttpClientOptions(ITypedHttpClientOptions owner, SettingsBase sett /// /// Gets the custom retry policy; see . /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public PolicyBuilder? CustomRetryPolicy { get; private set; } /// @@ -103,6 +106,7 @@ internal TypedHttpClientOptions(ITypedHttpClientOptions owner, SettingsBase sett /// /// Gets the maximum retry delay; see . /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TimeSpan? MaxRetryDelay { get; private set; } /// @@ -130,6 +134,7 @@ private void CheckDefaultNotBeingUpdatedInSendMode() /// The custom retry policy. /// Defaults to with additional handling of and . /// This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TypedHttpClientOptions WithCustomRetryPolicy(PolicyBuilder retryPolicy) { CheckDefaultNotBeingUpdatedInSendMode(); @@ -172,6 +177,7 @@ public TypedHttpClientOptions ThrowKnownException(bool useContentAsErrorMessage /// The base number of seconds to delay between retries. Defaults to . Delay will be exponential with each retry. /// This is after each invocation; see . /// The is the number of additional retries that should be performed in addition to the initial request. + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TypedHttpClientOptions WithRetry(int? count = null, double? seconds = null) { CheckDefaultNotBeingUpdatedInSendMode(); @@ -267,6 +273,7 @@ public TypedHttpClientOptions Ensure(params HttpStatusCode[] statusCodes) /// /// This instance to support fluent-style method-chaining. /// This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TypedHttpClientOptions WithTimeout(TimeSpan timeout) { CheckDefaultNotBeingUpdatedInSendMode(); @@ -280,6 +287,7 @@ public TypedHttpClientOptions WithTimeout(TimeSpan timeout) /// /// This instance to support fluent-style method-chaining. /// This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TypedHttpClientOptions WithMaxRetryDelay(TimeSpan maxRetryDelay) { CheckDefaultNotBeingUpdatedInSendMode(); @@ -317,6 +325,7 @@ public TypedHttpClientOptions OnBeforeRequest(Func public void Reset() { +#pragma warning disable CS0618 // Type or member is obsolete CheckDefaultNotBeingUpdatedInSendMode(); if (_defaultOptions is null) { @@ -350,6 +359,7 @@ public void Reset() ShouldNullOnNotFound = _defaultOptions.ShouldNullOnNotFound; BeforeRequest = _defaultOptions.BeforeRequest; } +#pragma warning restore CS0618 // Type or member is obsolete } } } \ No newline at end of file diff --git a/src/CoreEx/Http/HttpRequestLogger.cs b/src/CoreEx/Http/HttpRequestLogger.cs index b491b754..588ae723 100644 --- a/src/CoreEx/Http/HttpRequestLogger.cs +++ b/src/CoreEx/Http/HttpRequestLogger.cs @@ -12,6 +12,7 @@ namespace CoreEx.Http /// /// Provides the HTTP request/response logging. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-logging/?view=aspnetcore-8.0 on how to implement.")] public class HttpRequestLogger { private readonly SettingsBase _settings; diff --git a/src/CoreEx/Http/TypedHttpClientBaseT.cs b/src/CoreEx/Http/TypedHttpClientBaseT.cs index 428c9722..56100052 100644 --- a/src/CoreEx/Http/TypedHttpClientBaseT.cs +++ b/src/CoreEx/Http/TypedHttpClientBaseT.cs @@ -52,7 +52,9 @@ public TypedHttpClientBase(HttpClient client, IJsonSerializer? jsonSerializer = ExecutionContext = executionContext ?? (ExecutionContext.HasCurrent ? ExecutionContext.Current : new ExecutionContext()); Settings = settings ?? ExecutionContext.GetService() ?? new DefaultSettings(); Logger = logger ?? ExecutionContext.GetService>>() ?? NullLoggerFactory.Instance.CreateLogger>(); +#pragma warning disable CS0618 // Type or member is obsolete RequestLogger = HttpRequestLogger.Create(Settings, Logger); +#pragma warning restore CS0618 // Type or member is obsolete OnDefaultOptionsConfiguration?.Invoke(DefaultOptions); } @@ -74,6 +76,7 @@ public TypedHttpClientBase(HttpClient client, IJsonSerializer? jsonSerializer = /// /// Gets the . /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-logging/?view=aspnetcore-8.0 on how to implement.")] protected HttpRequestLogger RequestLogger { get; } /// @@ -103,6 +106,7 @@ public TypedHttpClientBase(HttpClient client, IJsonSerializer? jsonSerializer = /// The custom retry policy. /// Defaults to with additional handling of and . /// This references the equivalent method within the . This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TSelf WithCustomRetryPolicy(PolicyBuilder retryPolicy) { SendOptions.WithCustomRetryPolicy(retryPolicy); @@ -142,6 +146,7 @@ public TSelf ThrowKnownException(bool useContentAsErrorMessage = false) /// This instance to support fluent-style method-chaining. /// The is the number of additional retries that should be performed in addition to the initial request. /// This references the equivalent method within the . This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TSelf WithRetry(int? count = null, double? seconds = null) { SendOptions.WithRetry(count, seconds); @@ -217,6 +222,7 @@ public TSelf Ensure(params HttpStatusCode[] statusCodes) /// /// This instance to support fluent-style method-chaining. /// This references the equivalent method within the . This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TSelf WithTimeout(TimeSpan timeout) { SendOptions.WithTimeout(timeout); @@ -229,6 +235,7 @@ public TSelf WithTimeout(TimeSpan timeout) /// This instance to support fluent-style method-chaining. /// Default is 30 seconds but it can be overridden for async calls (e.g. when using Azure Service Bus trigger). /// This references the equivalent method within the . This is after each invocation; see . + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] public TSelf WithMaxRetryDelay(TimeSpan maxRetryDelay) { SendOptions.WithMaxRetryDelay(maxRetryDelay); @@ -298,6 +305,7 @@ private async Task SendInternalAsync(HttpRequestMessage req CancellationTokenSource? cts = null; var options = SendOptions; +#pragma warning disable CS0618 // Type or member is obsolete try { var sw = Stopwatch.StartNew(); @@ -394,11 +402,13 @@ private async Task SendInternalAsync(HttpRequestMessage req throw new HttpRequestException($"Response status code {response.StatusCode}; expected one of the following: {string.Join(", ", options.ExpectedStatusCodes)}."); return response; +#pragma warning restore CS0618 // Type or member is obsolete } /// /// Sets the cancellation based on the timeout. /// + [Obsolete("This feature will soon be deprecated; please leverage IHttpClientFactory capabilies. See https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests on how to implement.")] private CancellationToken SetCancellationBasedOnTimeout(CancellationToken cancellationToken, out CancellationTokenSource? cts) { var timeout = SendOptions.Timeout ?? TimeSpan.FromSeconds(Settings.GetValue($"{GetType().Name}__{nameof(SettingsBase.HttpTimeoutSeconds)}") ?? Settings.HttpTimeoutSeconds); diff --git a/src/CoreEx/Json/IJsonSerializer.cs b/src/CoreEx/Json/IJsonSerializer.cs index 6acdc609..c0d05817 100644 --- a/src/CoreEx/Json/IJsonSerializer.cs +++ b/src/CoreEx/Json/IJsonSerializer.cs @@ -114,5 +114,33 @@ public interface IJsonSerializer /// The JSON name where underlying JSON attribute is defined or not; null where not serializable. /// true indicates that the property is considered serializable; otherwise, false. bool TryGetJsonName(MemberInfo memberInfo, [NotNullWhen(true)] out string? jsonName); + + /// + /// Serialize the to a JSON using the specified property filter (). + /// + /// The . + /// The value to serialize. + /// The list of JSON property names to . + /// The JSON . + /// This is a wrapper for . + public string SerializeWithIncludeFilter(T value, params string[] names) + { + TryApplyFilter(value, names, out string json, JsonPropertyFilter.Include, StringComparison.OrdinalIgnoreCase); + return json; + } + + /// + /// Serialize the to a JSON using the specified property filter (). + /// + /// The . + /// The value to serialize. + /// The list of JSON property names to . + /// The JSON . + /// This is a wrapper for . + public string SerializeWithExcludeFilter(T value, params string[] names) + { + TryApplyFilter(value, names, out string json, JsonPropertyFilter.Exclude, StringComparison.OrdinalIgnoreCase); + return json; + } } } \ No newline at end of file diff --git a/src/CoreEx/Text/Json/CloudEventSerializer.cs b/src/CoreEx/Text/Json/CloudEventSerializer.cs index 5861c228..ea6ef23a 100644 --- a/src/CoreEx/Text/Json/CloudEventSerializer.cs +++ b/src/CoreEx/Text/Json/CloudEventSerializer.cs @@ -3,6 +3,7 @@ using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using CoreEx.Events; +using Microsoft.Extensions.Options; using System; using System.Net.Mime; using System.Threading; @@ -42,6 +43,25 @@ protected override Task DecodeAsync(BinaryData eventData, Cancell /// protected override Task EncodeAsync(CloudEvent cloudEvent, CancellationToken cancellation = default) - => Task.FromResult(new BinaryData(new JsonEventFormatter(Options, new Stj.JsonDocumentOptions()).EncodeStructuredModeMessage(cloudEvent, out var _))); + //=> Task.FromResult(new BinaryData(new JsonEventFormatter(Options, new Stj.JsonDocumentOptions()).EncodeStructuredModeMessage(cloudEvent, out var _))); + => Task.FromResult(new BinaryData(new InternalFormatter(Options, new Stj.JsonDocumentOptions()).EncodeStructuredModeMessage(cloudEvent, out var _))); + + /// + /// Override the formatting where the is a and the is by assuming already serialized. + /// + private class InternalFormatter(Stj.JsonSerializerOptions options, Stj.JsonDocumentOptions jsonDocumentOptions) : JsonEventFormatter(options, jsonDocumentOptions) + { + /// + protected override void EncodeStructuredModeData(CloudEvent cloudEvent, Stj.Utf8JsonWriter writer) + { + if (cloudEvent.Data is BinaryData bd && cloudEvent.DataContentType == MediaTypeNames.Application.Json) + { + writer.WritePropertyName(DataPropertyName); + writer.WriteRawValue(bd, true); + } + else + base.EncodeStructuredModeData(cloudEvent, writer); + } + } } } \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonFilterer.cs b/src/CoreEx/Text/Json/JsonFilterer.cs index d6727705..25187565 100644 --- a/src/CoreEx/Text/Json/JsonFilterer.cs +++ b/src/CoreEx/Text/Json/JsonFilterer.cs @@ -67,7 +67,7 @@ public static bool TryRemovePathIndexes(string input, out string path) public static bool TryApply(T value, IEnumerable? paths, out string json, JsonPropertyFilter filter = JsonPropertyFilter.Include, JsonSerializerOptions? options = null, StringComparison comparison = StringComparison.OrdinalIgnoreCase, Action? preFilterInspector = null) { var r = TryApply(value, paths, out JsonNode node, filter, options, comparison, preFilterInspector); - json = node.ToJsonString(); + json = node.ToJsonString(options); return r; } diff --git a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj index e8cea940..e48f466b 100644 --- a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj +++ b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj @@ -19,22 +19,22 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/CoreEx.Test.csproj b/tests/CoreEx.Test/CoreEx.Test.csproj index 49f19bc9..c0d14be5 100644 --- a/tests/CoreEx.Test/CoreEx.Test.csproj +++ b/tests/CoreEx.Test/CoreEx.Test.csproj @@ -22,7 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs index 51bd9471..545ad541 100644 --- a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs +++ b/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs @@ -35,6 +35,7 @@ public async Task SystemTextJson_Serialize_Deserialize2() var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; var es = new CoreEx.Text.Json.CloudEventSerializer(ef) as IEventSerializer; var ed = CreateProductEvent2(); + ef.Format(ed); var bd = await es.SerializeAsync(ed).ConfigureAwait(false); Assert.That(bd, Is.Not.Null); Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); @@ -107,6 +108,7 @@ public async Task NewtonsoftJson_Serialize_Deserialize2() var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; var es = new CoreEx.Newtonsoft.Json.CloudEventSerializer(ef) as IEventSerializer; var ed = CreateProductEvent2(); + ef.Format(ed); var bd = await es.SerializeAsync(ed).ConfigureAwait(false); Assert.That(bd, Is.Not.Null); Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); diff --git a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs index 51294c56..bd1d77fc 100644 --- a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs +++ b/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs @@ -30,6 +30,7 @@ public async Task SystemTextJson_Serialize_Deserialize_EventData2() var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer(), ef) { SerializeValueOnly = false } as IEventSerializer; var ed = CloudEventSerializerTest.CreateProductEvent2(); + ef.Format(ed); var bd = await es.SerializeAsync(ed).ConfigureAwait(false); Assert.That(bd, Is.Not.Null); Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); @@ -176,6 +177,25 @@ public async Task SystemTextJson_Serialize_Deserialize_EventData3_WithNoAttachme Assert.That(ed3.Value?.ToString(), Is.EqualTo("{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}")); } + [Test] + public async Task SystemTextJson_Serialize_Deserialize_Custom_EventData1() + { + var es = new CoreEx.Text.Json.EventDataSerializer(new Text.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; + es.CustomSerializers.Add((ed, js, _) => new BinaryData(js.SerializeWithExcludeFilter(ed, "value.price"))); + var ed = CloudEventSerializerTest.CreateProductEvent1(); + var bd = await es.SerializeAsync(ed).ConfigureAwait(false); + Assert.That(bd, Is.Not.Null); + + var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); + ObjectComparer.Assert(ed, ed2, "value.price"); + + Assert.Multiple(() => + { + Assert.That(ed.Value.Price, Is.Not.Zero); + Assert.That(ed2.Value.Price, Is.Zero); // Price should be scrubbed. + }); + } + [Test] public async Task NewtonsoftJson_Serialize_Deserialize_EventData1() { @@ -195,6 +215,7 @@ public async Task NewtonsoftJson_Serialize_Deserialize_EventData2() var ef = new EventDataFormatter { SourceDefault = _ => new Uri("null", UriKind.RelativeOrAbsolute) }; var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer(), ef) { SerializeValueOnly = false } as IEventSerializer; var ed = CloudEventSerializerTest.CreateProductEvent2(); + ef.Format(ed); var bd = await es.SerializeAsync(ed).ConfigureAwait(false); Assert.That(bd, Is.Not.Null); Assert.That(bd.ToString(), Is.EqualTo(CloudEvent2)); @@ -268,6 +289,25 @@ public async Task NewtonsoftText_Serialize_Deserialize_ValueOnly2() ObjectComparer.Assert(new EventData(), ed2); } + [Test] + public async Task NewtonsoftText_Serialize_Deserialize_Custom_EventData1() + { + var es = new CoreEx.Newtonsoft.Json.EventDataSerializer(new Newtonsoft.Json.JsonSerializer()) { SerializeValueOnly = false } as IEventSerializer; + es.CustomSerializers.Add((ed, js, _) => new BinaryData(js.SerializeWithExcludeFilter(ed, "value.price"))); + var ed = CloudEventSerializerTest.CreateProductEvent1(); + var bd = await es.SerializeAsync(ed).ConfigureAwait(false); + Assert.That(bd, Is.Not.Null); + + var ed2 = await es.DeserializeAsync(bd).ConfigureAwait(false); + ObjectComparer.Assert(ed, ed2, "value.price"); + + Assert.Multiple(() => + { + Assert.That(ed.Value.Price, Is.Not.Zero); + Assert.That(ed2.Value.Price, Is.Zero); // Price should be scrubbed. + }); + } + private const string CloudEvent1 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; private const string CloudEvent2 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\"}"; diff --git a/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs b/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs index 3b360d0f..147be650 100644 --- a/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs +++ b/tests/CoreEx.Test/Framework/UnitTesting/ExpectationsTest.cs @@ -17,13 +17,13 @@ public void ExpectIdentifier() { var gt = GenericTester.CreateFor>().ExpectIdentifier(); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message.Contains("Expected IIdentifier.Id to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IIdentifier.Id to have a non-null value.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "x" })); gt = GenericTester.CreateFor>().ExpectIdentifier("y"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "x" })))!; - Assert.That(ex.Message.Contains("Expected IIdentifier.Id value of 'y'; actual 'x'."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IIdentifier.Id value of 'y'; actual 'x'.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { Id = "y" })); } @@ -33,13 +33,13 @@ public void ExpectPrimaryKey() { var gt = GenericTester.CreateFor>().ExpectPrimaryKey(); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2())))!; - Assert.That(ex.Message.Contains("Expected IPrimaryKey.PrimaryKey.Args to have one or more non-default values."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IPrimaryKey.PrimaryKey.Args to have one or more non-default values.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "x" })); gt = GenericTester.CreateFor>().ExpectPrimaryKey("y"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "x" })))!; - Assert.That(ex.Message.Contains("Expected IPrimaryKey.PrimaryKey value of 'y'; actual 'x'."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IPrimaryKey.PrimaryKey value of 'y'; actual 'x'.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity2 { Id = "y" })); } @@ -49,13 +49,13 @@ public void ExpectETag() { var gt = GenericTester.CreateFor>().ExpectETag(); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message.Contains("Expected IETag.ETag to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IETag.ETag to have a non-null value.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "xxx" })); gt = GenericTester.CreateFor>().ExpectETag("yyy"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "yyy" })))!; - Assert.That(ex.Message.Contains("Expected IETag.ETag value of 'yyy' to be different to actual."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected IETag.ETag value of 'yyy' to be different to actual.")); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ETag = "xxx" })); } @@ -67,13 +67,13 @@ public void ExpectChangeLogCreated() ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous", CreatedDate = DateTime.UtcNow } })); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog() })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedBy value of 'Anonymous'; actual was null."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedBy value of 'Anonymous'; actual was null.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous" } })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedDate to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedDate to have a non-null value.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { CreatedBy = "Anonymous", CreatedDate = DateTime.UtcNow.AddMinutes(-1) } })))!; Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).CreatedDate value of '") && ex.Message.Contains("' must be greater than or equal to expected."), Is.True); @@ -95,13 +95,13 @@ public void ExpectChangeLogUpdated() ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous", UpdatedDate = DateTime.UtcNow } })); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity())))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit) to have a non-null value.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog() })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedBy value of 'Anonymous'; actual was null."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedBy value of 'Anonymous'; actual was null.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous" } })))!; - Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedDate to have a non-null value."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedDate to have a non-null value.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertValueAsync(null, new Entity { ChangeLog = new ChangeLog { UpdatedBy = "Anonymous", UpdatedDate = DateTime.UtcNow.AddMinutes(-1) } })))!; Assert.That(ex.Message.Contains("Expected Change Log (IChangeLogAuditLog.ChangeLogAudit).UpdatedDate value of '") && ex.Message.Contains("' must be greater than or equal to expected."), Is.True); @@ -123,18 +123,18 @@ public void ExpectErrorType() ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new ValidationException())); var ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new BusinessException())))!; - Assert.That(ex.Message.Contains("Expected error type of 'ValidationError' but actual was 'BusinessError'."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but actual was 'BusinessError'.")); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(null, new Exception())))!; - Assert.That(ex.Message.Contains("Expected error type of 'ValidationError' but none was returned."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but none was returned.")); var hr = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr))))!; - Assert.That(ex.Message.Contains("Expected error type of 'ValidationError' but none was returned."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but none was returned.")); hr.Headers.Add(HttpConsts.ErrorTypeHeaderName, "BusinessError"); ex = Assert.Throws(() => ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr))))!; - Assert.That(ex.Message.Contains("Expected error type of 'ValidationError' but actual was 'BusinessError'."), Is.True); + Assert.That(ex.Message, Does.Contain("Expected error type of 'ValidationError' but actual was 'BusinessError'.")); hr.Headers.Add(HttpConsts.ErrorTypeHeaderName, "ValidationError"); ArrangerAssert(async () => await gt.ExpectationsArranger.AssertAsync(gt.ExpectationsArranger.CreateArgs(null, null).AddExtra(hr)));