diff --git a/.autover/changes/81dcfb46-b496-46d0-9a50-1318e39541f6.json b/.autover/changes/81dcfb46-b496-46d0-9a50-1318e39541f6.json new file mode 100644 index 00000000..c9d5e790 --- /dev/null +++ b/.autover/changes/81dcfb46-b496-46d0-9a50-1318e39541f6.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "AWS.Messaging", + "Type": "Minor", + "ChangelogMessages": [ + "Implement Utf8JsonWriter based serializer" + ] + } + ] +} \ No newline at end of file diff --git a/AWS.Messaging.sln b/AWS.Messaging.sln index cefae853..84aa9a83 100644 --- a/AWS.Messaging.sln +++ b/AWS.Messaging.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PollyIntegration", "samplea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "sampleapps\AppHost\AppHost.csproj", "{C028BF43-16C6-42FB-B422-5AD4F15B492C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Messaging.Benchmarks.Serialization", "test\AWS.Messaging.Benchmarks.Serialization\AWS.Messaging.Benchmarks.Serialization.csproj", "{393E7D65-0D04-A989-A4BC-85E36A16530E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,6 +101,10 @@ Global {C028BF43-16C6-42FB-B422-5AD4F15B492C}.Debug|Any CPU.Build.0 = Debug|Any CPU {C028BF43-16C6-42FB-B422-5AD4F15B492C}.Release|Any CPU.ActiveCfg = Release|Any CPU {C028BF43-16C6-42FB-B422-5AD4F15B492C}.Release|Any CPU.Build.0 = Release|Any CPU + {393E7D65-0D04-A989-A4BC-85E36A16530E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {393E7D65-0D04-A989-A4BC-85E36A16530E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {393E7D65-0D04-A989-A4BC-85E36A16530E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {393E7D65-0D04-A989-A4BC-85E36A16530E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -117,6 +123,7 @@ Global {143DC3E0-A1C6-4670-86F4-E7CD4C8F52CB} = {80DB2C77-6ADD-4A60-B27D-763BDF9659D3} {86896246-B032-4D34-82BE-CD5ACB6E43F9} = {1AA8985B-897C-4BD5-9735-FD8B33FEBFFB} {C028BF43-16C6-42FB-B422-5AD4F15B492C} = {1AA8985B-897C-4BD5-9735-FD8B33FEBFFB} + {393E7D65-0D04-A989-A4BC-85E36A16530E} = {80DB2C77-6ADD-4A60-B27D-763BDF9659D3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7B2B759D-6455-4089-8173-3F1619567B36} diff --git a/src/AWS.Messaging/Configuration/MessageBusBuilder.cs b/src/AWS.Messaging/Configuration/MessageBusBuilder.cs index a87a6cca..6d8f4198 100644 --- a/src/AWS.Messaging/Configuration/MessageBusBuilder.cs +++ b/src/AWS.Messaging/Configuration/MessageBusBuilder.cs @@ -32,6 +32,8 @@ public class MessageBusBuilder : IMessageBusBuilder private readonly IList _additionalServices = new List(); private readonly IServiceCollection _serviceCollection; + private bool _experimentalFeaturesEnabled; + /// /// Creates an instance of . /// @@ -321,6 +323,12 @@ public IMessageBusBuilder ConfigureBackoffPolicy(Action co return this; } + public IMessageBusBuilder EnableExperimentalFeatures() + { + _experimentalFeaturesEnabled = true; + return this; + } + internal void Build() { LoadConfigurationFromEnvironment(); @@ -332,8 +340,19 @@ internal void Build() _serviceCollection.TryAddSingleton(_messageConfiguration.PollingControlToken); _serviceCollection.TryAddSingleton(_messageConfiguration); - _serviceCollection.TryAddSingleton(); - _serviceCollection.TryAddSingleton(); + + if (_experimentalFeaturesEnabled) + { + _serviceCollection.AddSingleton(); + _serviceCollection.TryAddSingleton(); + + } + else + { + _serviceCollection.AddSingleton(); + _serviceCollection.TryAddSingleton(); + } + _serviceCollection.TryAddSingleton(); _serviceCollection.TryAddSingleton(); _serviceCollection.TryAddSingleton(); diff --git a/src/AWS.Messaging/Configuration/SerializerOptions.cs b/src/AWS.Messaging/Configuration/SerializerOptions.cs index c207d02a..07f7efc4 100644 --- a/src/AWS.Messaging/Configuration/SerializerOptions.cs +++ b/src/AWS.Messaging/Configuration/SerializerOptions.cs @@ -22,6 +22,14 @@ public class SerializationOptions Converters = { new JsonStringEnumConverter() } }; + /// + /// When set to true, it will clean the rented buffers after each use. + /// + /// + /// Setting this to false can improve performance in high-throughput scenarios at cost of potential security issues + /// + public bool CleanRentedBuffers { get; set; } = true; + /// /// Default constructor /// diff --git a/src/AWS.Messaging/Serialization/EnvelopeSerializerUtf8JsonWriter.cs b/src/AWS.Messaging/Serialization/EnvelopeSerializerUtf8JsonWriter.cs new file mode 100644 index 00000000..9e7ae83d --- /dev/null +++ b/src/AWS.Messaging/Serialization/EnvelopeSerializerUtf8JsonWriter.cs @@ -0,0 +1,473 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Configuration; +using AWS.Messaging.Serialization.Helpers; +using AWS.Messaging.Serialization.Parsers; +using AWS.Messaging.Services; +using Microsoft.Extensions.Logging; + +namespace AWS.Messaging.Serialization; + +/// +/// The performance based implementation of used by the framework. +/// +internal class EnvelopeSerializerUtf8JsonWriter : IEnvelopeSerializer +{ + private Uri? MessageSource { get; set; } + private const string CLOUD_EVENT_SPEC_VERSION = "1.0"; + + // Pre-encoded property names to avoid repeated encoding and allocations + private static readonly JsonEncodedText s_idProp = JsonEncodedText.Encode("id"); + private static readonly JsonEncodedText s_sourceProp = JsonEncodedText.Encode("source"); + private static readonly JsonEncodedText s_specVersionProp = JsonEncodedText.Encode("specversion"); + private static readonly JsonEncodedText s_typeProp = JsonEncodedText.Encode("type"); + private static readonly JsonEncodedText s_timeProp = JsonEncodedText.Encode("time"); + private static readonly JsonEncodedText s_dataContentTypeProp = JsonEncodedText.Encode("datacontenttype"); + private static readonly JsonEncodedText s_dataProp = JsonEncodedText.Encode("data"); + + private readonly IMessageConfiguration _messageConfiguration; + private readonly IMessageSerializer _messageSerializer; + private readonly IDateTimeHandler _dateTimeHandler; + private readonly IMessageIdGenerator _messageIdGenerator; + private readonly IMessageSourceHandler _messageSourceHandler; + private readonly ILogger _logger; + + private readonly IMessageSerializerUtf8JsonWriter? _messageSerializerUtf8Json; + + // Order matters for the SQS parser (must be last), but SNS and EventBridge parsers + // can be in any order since they check for different, mutually exclusive properties + private static readonly IMessageParser[] _parsers = new IMessageParser[] + { + new SNSMessageParser(), // Checks for SNS-specific properties (Type, TopicArn) + new EventBridgeMessageParser(), // Checks for EventBridge properties (detail-type, detail) + new SQSMessageParser() // Fallback parser - must be last + }; + + public EnvelopeSerializerUtf8JsonWriter( + ILogger logger, + IMessageConfiguration messageConfiguration, + IMessageSerializer messageSerializer, + IDateTimeHandler dateTimeHandler, + IMessageIdGenerator messageIdGenerator, + IMessageSourceHandler messageSourceHandler) + { + _logger = logger; + _messageConfiguration = messageConfiguration; + _messageSerializer = messageSerializer; + _dateTimeHandler = dateTimeHandler; + _messageIdGenerator = messageIdGenerator; + _messageSourceHandler = messageSourceHandler; + + _messageSerializerUtf8Json = messageSerializer as IMessageSerializerUtf8JsonWriter; + } + + /// + public async ValueTask> CreateEnvelopeAsync(T message) + { + var messageId = await _messageIdGenerator.GenerateIdAsync(); + var timeStamp = _dateTimeHandler.GetUtcNow(); + + var publisherMapping = _messageConfiguration.GetPublisherMapping(typeof(T)); + if (publisherMapping is null) + { + _logger.LogError("Failed to create a message envelope because a valid publisher mapping for message type '{MessageType}' does not exist.", typeof(T)); + throw new FailedToCreateMessageEnvelopeException($"Failed to create a message envelope because a valid publisher mapping for message type '{typeof(T)}' does not exist."); + } + + if (MessageSource is null) + { + MessageSource = await _messageSourceHandler.ComputeMessageSource(); + } + + return new MessageEnvelope + { + Id = messageId, + Source = MessageSource, + Version = CLOUD_EVENT_SPEC_VERSION, + MessageTypeIdentifier = publisherMapping.MessageTypeIdentifier, + TimeStamp = timeStamp, + Message = message + }; + } + + private static readonly JsonWriterOptions s_serializerWriterOptions = new() + { + // We control the JSON shape here, so skip validation for performance + SkipValidation = true, + }; + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + public async ValueTask SerializeAsync(MessageEnvelope envelope) + { + try + { + await InvokePreSerializationCallback(envelope); + T message = envelope.Message ?? throw new ArgumentNullException("The underlying application message cannot be null"); + + using var buffer = new RentArrayBufferWriter(cleanRentedBuffers: _messageConfiguration.SerializationOptions.CleanRentedBuffers); + using var writer = new Utf8JsonWriter(buffer, s_serializerWriterOptions); + + writer.WriteStartObject(); + + writer.WriteString(s_idProp, envelope.Id); + writer.WriteString(s_sourceProp, envelope.Source?.OriginalString); + writer.WriteString(s_specVersionProp, envelope.Version); + writer.WriteString(s_typeProp, envelope.MessageTypeIdentifier); + writer.WriteString(s_timeProp, envelope.TimeStamp); + + if (_messageSerializerUtf8Json is not null) + { + writer.WriteString(s_dataContentTypeProp, _messageSerializerUtf8Json.ContentType); + writer.WritePropertyName(s_dataProp); + _messageSerializerUtf8Json.SerializeToBuffer(writer, message); + } + else + { + var response = _messageSerializer.Serialize(message); + writer.WriteString(s_dataContentTypeProp, response.ContentType); + writer.WritePropertyName(s_dataProp); + if (IsJsonContentType(response.ContentType)) + { + writer.WriteRawValue(response.Data, skipInputValidation: true); + } + else + { + writer.WriteStringValue(response.Data); + } + } + + // Write metadata as top-level properties + foreach (var kvp in envelope.Metadata) + { + if (kvp.Key is not null && + kvp.Value.ValueKind != JsonValueKind.Undefined && + kvp.Value.ValueKind != JsonValueKind.Null && + !s_knownEnvelopeProperties.Contains(kvp.Key)) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + } + + writer.WriteEndObject(); + writer.Flush(); + + var jsonString = System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan); + var serializedMessage = await InvokePostSerializationCallback(jsonString); + + if (_messageConfiguration.LogMessageContent) + { + _logger.LogTrace("Serialized the MessageEnvelope object as the following raw string:\n{SerializedMessage}", serializedMessage); + } + else + { + _logger.LogTrace("Serialized the MessageEnvelope object to a raw string"); + } + return serializedMessage; + } + catch (JsonException) when (!_messageConfiguration.LogMessageContent) + { + _logger.LogError("Failed to serialize the MessageEnvelope into a raw string"); + throw new FailedToSerializeMessageEnvelopeException("Failed to serialize the MessageEnvelope into a raw string"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to serialize the MessageEnvelope into a raw string"); + throw new FailedToSerializeMessageEnvelopeException("Failed to serialize the MessageEnvelope into a raw string", ex); + } + } + + /// + public async ValueTask ConvertToEnvelopeAsync(Message sqsMessage) + { + try + { + // Get the raw envelope JSON and metadata from the appropriate wrapper (SNS/EventBridge/SQS) + var (envelopeJson, metadata) = await ParseOuterWrapper(sqsMessage); + + // Create and populate the envelope with the correct type + var (envelope, subscriberMapping) = DeserializeEnvelope(envelopeJson); + + // Add metadata from outer wrapper + envelope.SQSMetadata = metadata.SQSMetadata; + envelope.SNSMetadata = metadata.SNSMetadata; + envelope.EventBridgeMetadata = metadata.EventBridgeMetadata; + + await InvokePostDeserializationCallback(envelope); + return new ConvertToEnvelopeResult(envelope, subscriberMapping); + } + catch (JsonException) when (!_messageConfiguration.LogMessageContent) + { + _logger.LogError("Failed to create a {MessageEnvelopeName}", nameof(MessageEnvelope)); + throw new FailedToCreateMessageEnvelopeException($"Failed to create {nameof(MessageEnvelope)}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create a {MessageEnvelopeName}", nameof(MessageEnvelope)); + throw new FailedToCreateMessageEnvelopeException($"Failed to create {nameof(MessageEnvelope)}", ex); + } + } + + private bool IsJsonContentType(string? dataContentType) + { + if (string.IsNullOrWhiteSpace(dataContentType)) + { + // If dataContentType is not specified, it should be treated as "application/json" + return true; + } + + ReadOnlySpan contentType = dataContentType.AsSpan().Trim(); + + // Remove parameters (anything after ';') + int semicolonIndex = contentType.IndexOf(';'); + if (semicolonIndex >= 0) + contentType = contentType.Slice(0, semicolonIndex).Trim(); + + // Check "application/json" (case-insensitive) + if (contentType.Equals("application/json", StringComparison.OrdinalIgnoreCase)) + return true; + + // Find the '/' separator + int slashIndex = contentType.IndexOf('/'); + if (slashIndex < 0 + || slashIndex == contentType.Length - 1 + || slashIndex != contentType.LastIndexOf('/')) + { + // If there are multiple slashes, ends with a slash or there are no slashes at all, it's not a valid content type + return false; + } + + ReadOnlySpan subtype = contentType.Slice(slashIndex + 1); + + // Check if the media subtype is "json" or ends with "+json" + return subtype.Equals("json", StringComparison.OrdinalIgnoreCase) + || subtype.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + private static readonly FrozenSet s_knownEnvelopeProperties = new HashSet { + "id", + "source", + "specversion", + "type", + "time", + "datacontenttype", + "data" + }.ToFrozenSet(); + + private (MessageEnvelope Envelope, SubscriberMapping Mapping) DeserializeEnvelope(string envelopeString) + { + using var document = JsonDocument.Parse(envelopeString); + var root = document.RootElement; + + // Get the message type and lookup mapping first + var messageType = root.GetProperty("type").GetString() ?? throw new InvalidDataException("Message type identifier not found in envelope"); + var subscriberMapping = GetAndValidateSubscriberMapping(messageType); + + var envelope = subscriberMapping.MessageEnvelopeFactory.Invoke(); + + try + { + // Set envelope properties + envelope.Id = JsonPropertyHelper.GetRequiredProperty(root, "id", element => element.GetString()!); + envelope.Source = JsonPropertyHelper.GetRequiredProperty(root, "source", element => new Uri(element.GetString()!, UriKind.RelativeOrAbsolute)); + envelope.Version = JsonPropertyHelper.GetRequiredProperty(root, "specversion", element => element.GetString()!); + envelope.MessageTypeIdentifier = JsonPropertyHelper.GetRequiredProperty(root, "type", element => element.GetString()!); + envelope.TimeStamp = JsonPropertyHelper.GetRequiredProperty(root, "time", element => element.GetDateTimeOffset()); + envelope.DataContentType = JsonPropertyHelper.GetStringProperty(root, "datacontenttype"); + + // Handle metadata - copy any properties that aren't standard envelope properties + foreach (var property in root.EnumerateObject()) + { + if (!s_knownEnvelopeProperties.Contains(property.Name)) + { + envelope.Metadata[property.Name] = property.Value.Clone(); + } + } + + // Deserialize the message content using the custom serializer + var dataContent = JsonPropertyHelper.GetRequiredProperty(root, "data", element => + IsJsonContentType(envelope.DataContentType) + ? element.GetRawText() + : element.GetString()!); + var message = _messageSerializer.Deserialize(dataContent, subscriberMapping.MessageType); + envelope.SetMessage(message); + + return (envelope, subscriberMapping); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize or validate MessageEnvelope"); + throw new InvalidDataException("MessageEnvelope instance is not valid", ex); + } + } + + private async Task<(string MessageBody, MessageMetadata Metadata)> ParseOuterWrapper(Message sqsMessage) + { + sqsMessage.Body = await InvokePreDeserializationCallback(sqsMessage.Body); + + // Example 1: SNS-wrapped message in SQS + /* + sqsMessage.Body = { + "Type": "Notification", + "MessageId": "abc-123", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:MyTopic", + "Message": { + "id": "order-123", + "source": "com.myapp.orders", + "type": "OrderCreated", + "time": "2024-03-21T10:00:00Z", + "data": { + "orderId": "12345", + "amount": 99.99 + } + } + } + */ + + // Example 2: Raw SQS message + /* + sqsMessage.Body = { + "id": "order-123", + "source": "com.myapp.orders", + "type": "OrderCreated", + "time": "2024-03-21T10:00:00Z", + "data": { + "orderId": "12345", + "amount": 99.99 + } + } + */ + + var document = JsonDocument.Parse(sqsMessage.Body); + + try + { + string currentMessageBody = sqsMessage.Body; + var combinedMetadata = new MessageMetadata(); + + // Try each parser in order + foreach (var parser in _parsers.Where(p => p.CanParse(document.RootElement))) + { + // Example 1 (SNS message) flow: + // 1. SNSMessageParser.CanParse = true (finds "Type": "Notification") + // 2. parser.Parse extracts inner message and SNS metadata + // 3. messageBody = contents of "Message" field + // 4. metadata contains SNS information (TopicArn, MessageId, etc.) + + // Example 2 (Raw SQS) flow: + // 1. SNSMessageParser.CanParse = false (no SNS properties) + // 2. EventBridgeMessageParser.CanParse = false (no EventBridge properties) + // 3. SQSMessageParser.CanParse = true (fallback) + // 4. messageBody = original message + // 5. metadata contains just SQS information + var (messageBody, metadata) = parser.Parse(document.RootElement, sqsMessage); + + // Update the message body if this parser extracted an inner message + if (!string.IsNullOrEmpty(messageBody)) + { + // For Example 1: + // - Updates currentMessageBody to inner message + // - Creates new JsonElement for next parser to check + + // For Example 2: + // - This block runs but messageBody is same as original + currentMessageBody = messageBody; + document.Dispose(); + document = JsonDocument.Parse(messageBody); + } + + // Combine metadata + if (metadata.SQSMetadata != null) combinedMetadata.SQSMetadata = metadata.SQSMetadata; + if (metadata.SNSMetadata != null) combinedMetadata.SNSMetadata = metadata.SNSMetadata; + if (metadata.EventBridgeMetadata != null) combinedMetadata.EventBridgeMetadata = metadata.EventBridgeMetadata; + } + + // Example 1 final return: + // MessageBody = { + // "id": "order-123", + // "source": "com.myapp.orders", + // "type": "OrderCreated", + // "time": "2024-03-21T10:00:00Z", + // "data": { ... } + // } + // Metadata = { + // SNSMetadata: { TopicArn: "arn:aws...", MessageId: "abc-123" } + // } + + // Example 2 final return: + // MessageBody = { + // "id": "order-123", + // "source": "com.myapp.orders", + // "type": "OrderCreated", + // "time": "2024-03-21T10:00:00Z", + // "data": { ... } + // } + // Metadata = { } // Just basic SQS metadata + + return (currentMessageBody, combinedMetadata); + } + finally + { + document.Dispose(); + } + } + + private SubscriberMapping GetAndValidateSubscriberMapping(string messageTypeIdentifier) + { + var subscriberMapping = _messageConfiguration.GetSubscriberMapping(messageTypeIdentifier); + if (subscriberMapping is null) + { + var availableMappings = string.Join(", ", + _messageConfiguration.SubscriberMappings.Select(m => m.MessageTypeIdentifier)); + + _logger.LogError( + "'{MessageTypeIdentifier}' is not a valid subscriber mapping. Available mappings: {AvailableMappings}", + messageTypeIdentifier, + string.IsNullOrEmpty(availableMappings) ? "none" : availableMappings); + + throw new InvalidDataException( + $"'{messageTypeIdentifier}' is not a valid subscriber mapping. " + + $"Available mappings: {(string.IsNullOrEmpty(availableMappings) ? "none" : availableMappings)}"); + } + return subscriberMapping; + } + + private async ValueTask InvokePreSerializationCallback(MessageEnvelope messageEnvelope) + { + foreach (var serializationCallback in _messageConfiguration.SerializationCallbacks) + { + await serializationCallback.PreSerializationAsync(messageEnvelope); + } + } + + private async ValueTask InvokePostSerializationCallback(string message) + { + foreach (var serializationCallback in _messageConfiguration.SerializationCallbacks) + { + message = await serializationCallback.PostSerializationAsync(message); + } + return message; + } + + private async ValueTask InvokePreDeserializationCallback(string message) + { + foreach (var serializationCallback in _messageConfiguration.SerializationCallbacks) + { + message = await serializationCallback.PreDeserializationAsync(message); + } + return message; + } + + private async ValueTask InvokePostDeserializationCallback(MessageEnvelope messageEnvelope) + { + foreach (var serializationCallback in _messageConfiguration.SerializationCallbacks) + { + await serializationCallback.PostDeserializationAsync(messageEnvelope); + } + } +} diff --git a/src/AWS.Messaging/Serialization/Helpers/RentArrayBufferWriter.cs b/src/AWS.Messaging/Serialization/Helpers/RentArrayBufferWriter.cs new file mode 100644 index 00000000..a8261ae3 --- /dev/null +++ b/src/AWS.Messaging/Serialization/Helpers/RentArrayBufferWriter.cs @@ -0,0 +1,208 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Buffers; +using System.Diagnostics; + +/// +/// https://gist.github.com/ahsonkhan/c76a1cc4dc7107537c3fdc0079a68b35 +/// Standard ArrayBufferWriter is not using pooled memory +/// +internal class RentArrayBufferWriter : IBufferWriter, IDisposable +{ + private const int MINIMUM_BUFFER_SIZE = 256; + + private byte[]? _rentedBuffer; + private int _written; + private long _committed; + + private readonly bool _cleanRentedBuffers; + + public RentArrayBufferWriter(int initialCapacity = MINIMUM_BUFFER_SIZE, bool cleanRentedBuffers = true) + { + if (initialCapacity <= 0) + { + throw new ArgumentException(null, nameof(initialCapacity)); + } + _cleanRentedBuffers = cleanRentedBuffers; + + _rentedBuffer = ArrayPool.Shared.Rent(initialCapacity); + _written = 0; + _committed = 0; + } + + public (byte[], int) WrittenBuffer + { + get + { + CheckIfDisposed(); + + return (_rentedBuffer!, _written); + } + } + + public Memory WrittenMemory + { + get + { + CheckIfDisposed(); + + return _rentedBuffer.AsMemory(0, _written); + } + } + + public Span WrittenSpan + { + get + { + CheckIfDisposed(); + + return _rentedBuffer.AsSpan(0, _written); + } + } + + public int BytesWritten + { + get + { + CheckIfDisposed(); + + return _written; + } + } + + public long BytesCommitted + { + get + { + CheckIfDisposed(); + + return _committed; + } + } + + public void Clear() + { + CheckIfDisposed(); + + ClearHelper(); + } + + private void ClearHelper() + { + if (_cleanRentedBuffers) + { + _rentedBuffer.AsSpan(0, _written).Clear(); + + } + _written = 0; + } + + public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken = default) + { + CheckIfDisposed(); + + ArgumentNullException.ThrowIfNull(stream); + + await stream.WriteAsync(new Memory(_rentedBuffer, 0, _written), cancellationToken).ConfigureAwait(false); + _committed += _written; + + ClearHelper(); + } + + public void CopyTo(Stream stream) + { + CheckIfDisposed(); + + ArgumentNullException.ThrowIfNull(stream); + + stream.Write(_rentedBuffer!, 0, _written); + _committed += _written; + + ClearHelper(); + } + + public void Advance(int count) + { + CheckIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfLessThan(count, 0); + + if (_written > _rentedBuffer!.Length - count) + { + throw new InvalidOperationException("Cannot advance past the end of the buffer."); + } + + _written += count; + } + + // Returns the rented buffer back to the pool + public void Dispose() + { + if (_rentedBuffer == null) + { + return; + } + + ArrayPool.Shared.Return(_rentedBuffer, clearArray: _cleanRentedBuffers); + _rentedBuffer = null; + _written = 0; + } + + private void CheckIfDisposed() + { + ObjectDisposedException.ThrowIf(_rentedBuffer == null, this); + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfLessThan(sizeHint, 0); + + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsMemory(_written); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfLessThan(sizeHint, 0); + + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsSpan(_written); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + Debug.Assert(sizeHint >= 0); + + if (sizeHint == 0) + { + sizeHint = MINIMUM_BUFFER_SIZE; + } + + var availableSpace = _rentedBuffer!.Length - _written; + + if (sizeHint > availableSpace) + { + var growBy = sizeHint > _rentedBuffer.Length ? sizeHint : _rentedBuffer.Length; + + var newSize = checked(_rentedBuffer.Length + growBy); + + var oldBuffer = _rentedBuffer; + + _rentedBuffer = ArrayPool.Shared.Rent(newSize); + + Debug.Assert(oldBuffer.Length >= _written); + Debug.Assert(_rentedBuffer.Length >= _written); + + oldBuffer.AsSpan(0, _written).CopyTo(_rentedBuffer); + ArrayPool.Shared.Return(oldBuffer, clearArray: _cleanRentedBuffers); + } + + Debug.Assert(_rentedBuffer.Length - _written > 0); + Debug.Assert(_rentedBuffer.Length - _written >= sizeHint); + } +} diff --git a/src/AWS.Messaging/Serialization/IMessageSerializerUtf8JsonWriter.cs b/src/AWS.Messaging/Serialization/IMessageSerializerUtf8JsonWriter.cs new file mode 100644 index 00000000..17560190 --- /dev/null +++ b/src/AWS.Messaging/Serialization/IMessageSerializerUtf8JsonWriter.cs @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; + +namespace AWS.Messaging.Serialization; + +/// +/// Supports serialization and deserialization of domain-specific application messages. +/// This interface extends to provide a methods for allocation-free serialization/deserialization. +/// +public interface IMessageSerializerUtf8JsonWriter +{ + /// + /// Serializes the .NET message object into a UTF-8 JSON string using a Utf8JsonWriter. + /// + /// Utf8JsonWriter to write the serialized data. + /// The .NET object that will be serialized. + void SerializeToBuffer(Utf8JsonWriter writer, T value); + + /// + /// Gets the MIME type of the content. + /// + string ContentType { get; } +} diff --git a/src/AWS.Messaging/Serialization/MessageSerializerUtf8JsonWriter.cs b/src/AWS.Messaging/Serialization/MessageSerializerUtf8JsonWriter.cs new file mode 100644 index 00000000..660154af --- /dev/null +++ b/src/AWS.Messaging/Serialization/MessageSerializerUtf8JsonWriter.cs @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using System.Text.Json.Serialization; +using AWS.Messaging.Configuration; +using AWS.Messaging.Services; +using Microsoft.Extensions.Logging; + +namespace AWS.Messaging.Serialization; + +/// +/// This is the performance based implementation of used by the framework. +/// It uses System.Text.Json to serialize and deserialize messages. +/// +internal sealed partial class MessageSerializerUtf8JsonWriter : IMessageSerializer, IMessageSerializerUtf8JsonWriter +{ + private readonly ILogger _logger; + private readonly IMessageConfiguration _messageConfiguration; + private readonly JsonSerializerContext? _jsonSerializerContext; + + public MessageSerializerUtf8JsonWriter(ILogger logger, IMessageConfiguration messageConfiguration, IMessageJsonSerializerContextContainer jsonContextContainer) + { + _logger = logger; + _messageConfiguration = messageConfiguration; + _jsonSerializerContext = jsonContextContainer.GetJsonSerializerContext(); + } + + public string ContentType => "application/json"; + + /// + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "Consumers relying on trimming would have been required to call the AddAWSMessageBus overload that takes in JsonSerializerContext that will be used here to avoid the call that requires unreferenced code.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", + Justification = "Consumers relying on trimming would have been required to call the AddAWSMessageBus overload that takes in JsonSerializerContext that will be used here to avoid the call that requires unreferenced code.")] + public object Deserialize(string message, Type deserializedType) + { + try + { + var jsonSerializerOptions = _messageConfiguration.SerializationOptions.SystemTextJsonOptions; + if (_messageConfiguration.LogMessageContent) + { + Logs.DeserializingMessageWithContent(_logger, deserializedType, message); + } + else + { + Logs.DeserializingMessage(_logger, deserializedType); + } + + if (_jsonSerializerContext != null) + { + return JsonSerializer.Deserialize(message, deserializedType, _jsonSerializerContext) ?? throw new JsonException("The deserialized application message is null."); + } + else + { + return JsonSerializer.Deserialize(message, deserializedType, jsonSerializerOptions) ?? throw new JsonException("The deserialized application message is null."); + } + } + catch (JsonException) when (!_messageConfiguration.LogMessageContent) + { + Logs.FailedToDeserializeMessage(_logger, deserializedType); + throw new FailedToDeserializeApplicationMessageException($"Failed to deserialize application message into an instance of {deserializedType}."); + } + catch (Exception ex) + { + Logs.FailedToDeserializeMessageException(_logger, ex, deserializedType); + throw new FailedToDeserializeApplicationMessageException($"Failed to deserialize application message into an instance of {deserializedType}.", ex); + } + } + + /// + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "Consumers relying on trimming would have been required to call the AddAWSMessageBus overload that takes in JsonSerializerContext that will be used here to avoid the call that requires unreferenced code.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", + Justification = "Consumers relying on trimming would have been required to call the AddAWSMessageBus overload that takes in JsonSerializerContext that will be used here to avoid the call that requires unreferenced code.")] + public MessageSerializerResults Serialize(object message) + { + try + { + var jsonSerializerOptions = _messageConfiguration.SerializationOptions.SystemTextJsonOptions; + + string jsonString; + Type messageType = message.GetType(); + + if (_jsonSerializerContext != null) + { + jsonString = JsonSerializer.Serialize(message, messageType, _jsonSerializerContext); + } + else + { + jsonString = JsonSerializer.Serialize(message, jsonSerializerOptions); + } + + if (_messageConfiguration.LogMessageContent) + { + Logs.SerializedMessageWithContent(_logger, jsonString); + } + else + { + Logs.SerializedMessage(_logger, jsonString.Length); + } + + return new MessageSerializerResults(jsonString, ContentType); + } + catch (JsonException) when (!_messageConfiguration.LogMessageContent) + { + Logs.FailedToSerializeMessage(_logger); + throw new FailedToSerializeApplicationMessageException("Failed to serialize application message into a string"); + } + catch (Exception ex) + { + Logs.FailedToSerializeMessageException(_logger, ex); + throw new FailedToSerializeApplicationMessageException("Failed to serialize application message into a string", ex); + } + } + + /// + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "Consumers relying on trimming would have been required to call the AddAWSMessageBus overload that takes in JsonSerializerContext that will be used here to avoid the call that requires unreferenced code.")] + public void SerializeToBuffer(Utf8JsonWriter writer, T value) + { + try + { + var typeInfo = _jsonSerializerContext?.GetTypeInfo(typeof(T)); + + writer.Flush(); + var startPosition = writer.BytesCommitted; + + if (typeInfo is not null) + { + JsonSerializer.Serialize(writer, value, typeInfo); + } + else + { + // This is not AOT-friendly fallback, but it is necessary for scenarios where the JsonSerializerContext is not provided. + JsonSerializer.Serialize(writer, value, _messageConfiguration.SerializationOptions.SystemTextJsonOptions); + } + + writer.Flush(); + Logs.SerializedMessage(_logger, writer.BytesCommitted - startPosition); + } + catch (JsonException) when (!_messageConfiguration.LogMessageContent) + { + Logs.FailedToSerializeMessage(_logger); + throw new FailedToSerializeApplicationMessageException("Failed to serialize application message into a string"); + } + catch (Exception ex) + { + Logs.FailedToSerializeMessageException(_logger, ex); + throw new FailedToSerializeApplicationMessageException("Failed to serialize application message into a string", ex); + } + } + + internal static partial class Logs + { + [LoggerMessage(EventId = 0, Level = LogLevel.Trace, Message = "Deserializing the following message into type '{DeserializedType}':\n{Message}")] + public static partial void DeserializingMessageWithContent(ILogger logger, Type deserializedType, string message); + + [LoggerMessage(EventId = 0, Level = LogLevel.Trace, Message = "Deserializing the following message into type '{DeserializedType}'")] + public static partial void DeserializingMessage(ILogger logger, Type deserializedType); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "Failed to deserialize application message into an instance of {DeserializedType}.")] + public static partial void FailedToDeserializeMessage(ILogger logger, Type deserializedType); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "Failed to deserialize application message into an instance of {DeserializedType}.")] + public static partial void FailedToDeserializeMessageException(ILogger logger, Exception ex, Type deserializedType); + + [LoggerMessage(EventId = 0, Level = LogLevel.Trace, Message = "Serialized the message object as the following raw string:\n{JsonString}")] + public static partial void SerializedMessageWithContent(ILogger logger, string jsonString); + + [LoggerMessage(EventId = 0, Level = LogLevel.Trace, Message = "Serialized the message object to a raw string with a content length of {ContentLength}.")] + public static partial void SerializedMessage(ILogger logger, long contentLength); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "Failed to serialize application message into a string")] + public static partial void FailedToSerializeMessage(ILogger logger); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "Failed to serialize application message into a string")] + public static partial void FailedToSerializeMessageException(ILogger logger, Exception ex); + } +} diff --git a/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.SerializationBenchmarks-report-github.md b/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.SerializationBenchmarks-report-github.md new file mode 100644 index 00000000..83332519 --- /dev/null +++ b/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.SerializationBenchmarks-report-github.md @@ -0,0 +1,35 @@ +``` + +BenchmarkDotNet v0.13.10, Windows 11 (10.0.26100.4652) +Unknown processor +.NET SDK 10.0.100-preview.2.25164.34 + [Host] : .NET 8.0.17 (8.0.1725.26602), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.17 (8.0.1725.26602), X64 RyuJIT AVX2 + + +``` +| Method | ItemCount | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|------------------------------------------ |---------- |-------------:|------------:|------------:|------:|--------:|--------:|--------:|----------:|------------:| +| **StandardSerializer** | **1** | **1,046.8 ns** | **2.09 ns** | **1.85 ns** | **1.00** | **0.1240** | **-** | **-** | **2368 B** | **1.00** | +| StandardSerializerWithJsonContext | 1 | 931.3 ns | 1.58 ns | 1.32 ns | 0.89 | 0.1087 | - | - | 2056 B | 0.87 | +| JsonWriterSerializer | 1 | 480.6 ns | 2.31 ns | 2.16 ns | 0.46 | 0.0544 | - | - | 1040 B | 0.44 | +| JsonWriterSerializerWithJsonContext | 1 | 376.9 ns | 0.68 ns | 0.64 ns | 0.36 | 0.0386 | - | - | 728 B | 0.31 | +| JsonWriterSerializerWithJsonContextUnsafe | 1 | 279.2 ns | 0.75 ns | 0.67 ns | 0.27 | 0.0386 | - | - | 728 B | 0.31 | +| | | | | | | | | | | | +| **StandardSerializer** | **10** | **3,178.0 ns** | **6.32 ns** | **5.91 ns** | **1.00** | **0.2861** | **-** | **-** | **5432 B** | **1.00** | +| StandardSerializerWithJsonContext | 10 | 2,640.6 ns | 7.66 ns | 7.16 ns | 0.83 | 0.2708 | - | - | 5120 B | 0.94 | +| JsonWriterSerializer | 10 | 1,274.2 ns | 4.00 ns | 3.74 ns | 0.40 | 0.1011 | - | - | 1920 B | 0.35 | +| JsonWriterSerializerWithJsonContext | 10 | 817.5 ns | 2.35 ns | 2.20 ns | 0.26 | 0.0849 | - | - | 1608 B | 0.30 | +| JsonWriterSerializerWithJsonContextUnsafe | 10 | 618.9 ns | 1.66 ns | 1.55 ns | 0.19 | 0.0849 | - | - | 1608 B | 0.30 | +| | | | | | | | | | | | +| **StandardSerializer** | **100** | **26,593.8 ns** | **144.59 ns** | **135.25 ns** | **1.00** | **1.9531** | **0.1526** | **-** | **37032 B** | **1.00** | +| StandardSerializerWithJsonContext | 100 | 22,249.2 ns | 66.56 ns | 59.01 ns | 0.84 | 1.9226 | 0.1526 | - | 36720 B | 0.99 | +| JsonWriterSerializer | 100 | 7,252.0 ns | 39.25 ns | 36.72 ns | 0.27 | 0.5875 | - | - | 11104 B | 0.30 | +| JsonWriterSerializerWithJsonContext | 100 | 3,822.7 ns | 17.19 ns | 16.08 ns | 0.14 | 0.5722 | - | - | 10792 B | 0.29 | +| JsonWriterSerializerWithJsonContextUnsafe | 100 | 3,530.0 ns | 8.45 ns | 7.05 ns | 0.13 | 0.5722 | - | - | 10792 B | 0.29 | +| | | | | | | | | | | | +| **StandardSerializer** | **1000** | **311,337.5 ns** | **1,470.11 ns** | **1,303.22 ns** | **1.00** | **96.6797** | **96.6797** | **96.6797** | **361993 B** | **1.00** | +| StandardSerializerWithJsonContext | 1000 | 277,978.3 ns | 653.26 ns | 579.10 ns | 0.89 | 96.6797 | 96.6797 | 96.6797 | 361681 B | 1.00 | +| JsonWriterSerializer | 1000 | 103,399.2 ns | 234.08 ns | 207.51 ns | 0.33 | 33.3252 | 33.3252 | 33.3252 | 106538 B | 0.29 | +| JsonWriterSerializerWithJsonContext | 1000 | 69,779.9 ns | 146.32 ns | 136.87 ns | 0.22 | 33.3252 | 33.3252 | 33.3252 | 106226 B | 0.29 | +| JsonWriterSerializerWithJsonContextUnsafe | 1000 | 70,258.7 ns | 208.98 ns | 195.48 ns | 0.23 | 33.3252 | 33.3252 | 33.3252 | 106226 B | 0.29 | diff --git a/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.csproj b/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.csproj new file mode 100644 index 00000000..3609e7e0 --- /dev/null +++ b/test/AWS.Messaging.Benchmarks.Serialization/AWS.Messaging.Benchmarks.Serialization.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + false + Exe + + + + + + + + + + diff --git a/test/AWS.Messaging.Benchmarks.Serialization/AddressInfoListEnvelopeSerializerContext.cs b/test/AWS.Messaging.Benchmarks.Serialization/AddressInfoListEnvelopeSerializerContext.cs new file mode 100644 index 00000000..c1d61611 --- /dev/null +++ b/test/AWS.Messaging.Benchmarks.Serialization/AddressInfoListEnvelopeSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using AWS.Messaging.Benchmarks.Serialization; + +[JsonSerializable(typeof(AddressInfoListEnvelope))] +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +public partial class AddressInfoListEnvelopeSerializerContext : JsonSerializerContext +{ +} diff --git a/test/AWS.Messaging.Benchmarks.Serialization/Program.cs b/test/AWS.Messaging.Benchmarks.Serialization/Program.cs new file mode 100644 index 00000000..420e940b --- /dev/null +++ b/test/AWS.Messaging.Benchmarks.Serialization/Program.cs @@ -0,0 +1,11 @@ +using BenchmarkDotNet.Running; + +namespace AWS.Messaging.Benchmarks.Serialization; + +public class Program +{ + public static void Main(string[] args) + { + BenchmarkRunner.Run(); + } +} diff --git a/test/AWS.Messaging.Benchmarks.Serialization/SerializationBenchmarks.cs b/test/AWS.Messaging.Benchmarks.Serialization/SerializationBenchmarks.cs new file mode 100644 index 00000000..83455169 --- /dev/null +++ b/test/AWS.Messaging.Benchmarks.Serialization/SerializationBenchmarks.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AWS.Messaging.Serialization; +using AWS.Messaging.Services; +using AWS.Messaging.UnitTests.Models; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; + +namespace AWS.Messaging.Benchmarks.Serialization; + +[MemoryDiagnoser] +public class SerializationBenchmarks +{ + private IEnvelopeSerializer _jsonWriterSerializer; + private IEnvelopeSerializer _standardSerializer; + private IEnvelopeSerializer _standardSerializerWithJsonContext; + private IEnvelopeSerializer _jsonWriterSerializerWithJsonContext; + private IEnvelopeSerializer _jsonWriterSerializerWithJsonContextUnsafe; + private MessageEnvelope _envelope; + private Mock _mockDateTimeHandler; + + [Params(1, 10, 100, 1000)] + public int ItemCount; + + [GlobalSetup] + public void Setup() + { + _mockDateTimeHandler = new Mock(); + var testDate = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc); + _mockDateTimeHandler.Setup(x => x.GetUtcNow()).Returns(testDate); + + CreateStandardSerializer(); + CreateStandardSerializerWithJsonContext(); + CreateJsonWriterSerializerWithJsonContext(); + CreateJsonWriterSerializerWithJsonContextUnsafe(); + CreateJsonWriterSerializer(); + + var items = new List(ItemCount); + for (var i = 0; i < ItemCount; i++) + { + items.Add(new AddressInfo + { + Street = $"Street {i}", + Unit = i, + ZipCode = $"{10000 + i}" + }); + } + + var message = new AddressInfoListEnvelope + { + Items = items + }; + + _envelope = new MessageEnvelope + { + Id = "id-123", + Source = new Uri("/backend/service", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfoList", + TimeStamp = DateTimeOffset.UtcNow, + Message = message + }; + } + + private void CreateStandardSerializer() + { + var _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfoList"); + builder.AddMessageHandler("addressInfoList"); + builder.AddMessageSource("/aws/messaging"); + }); + + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), _mockDateTimeHandler.Object)); + + _standardSerializer = _serviceCollection.BuildServiceProvider().GetRequiredService(); + } + + private void CreateStandardSerializerWithJsonContext() + { + var _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(new AddressInfoListEnvelopeSerializerContext(), builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfoList"); + builder.AddMessageHandler("addressInfoList"); + builder.AddMessageSource("/aws/messaging"); + }); + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), _mockDateTimeHandler.Object)); + _standardSerializerWithJsonContext = _serviceCollection.BuildServiceProvider().GetRequiredService(); + } + + private void CreateJsonWriterSerializerWithJsonContext() + { + var _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(new AddressInfoListEnvelopeSerializerContext(), builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfoList"); + builder.AddMessageHandler("addressInfoList"); + builder.AddMessageSource("/aws/messaging"); + builder.EnableExperimentalFeatures(); + }); + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), _mockDateTimeHandler.Object)); + _jsonWriterSerializerWithJsonContext = _serviceCollection.BuildServiceProvider().GetRequiredService(); + } + + private void CreateJsonWriterSerializerWithJsonContextUnsafe() + { + var _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(new AddressInfoListEnvelopeSerializerContext(), builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfoList"); + builder.AddMessageHandler("addressInfoList"); + builder.AddMessageSource("/aws/messaging"); + builder.EnableExperimentalFeatures(); + builder.ConfigureSerializationOptions(options => + { + options.CleanRentedBuffers = false; // Disable cleaning rented buffers for performance + }); + }); + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), _mockDateTimeHandler.Object)); + _jsonWriterSerializerWithJsonContextUnsafe = _serviceCollection.BuildServiceProvider().GetRequiredService(); + } + + private void CreateJsonWriterSerializer() + { + var _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfoList"); + builder.AddMessageHandler("addressInfoList"); + builder.AddMessageSource("/aws/messaging"); + builder.EnableExperimentalFeatures(); + }); + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), _mockDateTimeHandler.Object)); + _jsonWriterSerializer = _serviceCollection.BuildServiceProvider().GetRequiredService(); + } + + [Benchmark(Baseline = true)] + public async Task StandardSerializer() + { + return await _standardSerializer.SerializeAsync(_envelope); + } + + [Benchmark] + public async Task StandardSerializerWithJsonContext() + { + return await _standardSerializerWithJsonContext.SerializeAsync(_envelope); + } + + [Benchmark] + public async Task JsonWriterSerializer() + { + return await _jsonWriterSerializer.SerializeAsync(_envelope); + } + + [Benchmark] + public async Task JsonWriterSerializerWithJsonContext() + { + return await _jsonWriterSerializerWithJsonContext.SerializeAsync(_envelope); + } + + [Benchmark] + public async Task JsonWriterSerializerWithJsonContextUnsafe() + { + return await _jsonWriterSerializerWithJsonContextUnsafe.SerializeAsync(_envelope); + } +} + +public class AddressInfoListEnvelope +{ + public List Items { get; set; } = []; +} + +public class AddressInfoListHandler : IMessageHandler +{ + public Task HandleAsync(MessageEnvelope messageEnvelope, CancellationToken token = default) + => Task.FromResult(MessageProcessStatus.Success()); +} diff --git a/test/AWS.Messaging.UnitTests/AWS.Messaging.UnitTests.csproj b/test/AWS.Messaging.UnitTests/AWS.Messaging.UnitTests.csproj index 8de1f1f5..ce7f0b53 100644 --- a/test/AWS.Messaging.UnitTests/AWS.Messaging.UnitTests.csproj +++ b/test/AWS.Messaging.UnitTests/AWS.Messaging.UnitTests.csproj @@ -21,6 +21,7 @@ + diff --git a/test/AWS.Messaging.UnitTests/MessageBusBuilderTests.cs b/test/AWS.Messaging.UnitTests/MessageBusBuilderTests.cs index ffb87c96..b7bcb831 100644 --- a/test/AWS.Messaging.UnitTests/MessageBusBuilderTests.cs +++ b/test/AWS.Messaging.UnitTests/MessageBusBuilderTests.cs @@ -666,6 +666,40 @@ public void lambdaMessageProcessorConfiguration_LambdaMessagingOptions_Invalid() })); } + [Fact] + public void MessageBus_Default_RegistersStandardImplementations() + { + _serviceCollection.AddAWSMessageBus(builder => + { + // No experimental features enabled + }); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageSerializer = serviceProvider.GetRequiredService(); + + Assert.IsType(envelopeSerializer); + Assert.IsType(messageSerializer); + } + + [Fact] + public void MessageBus_EnableExperimentalFeatures_RegistersExperimentalImplementations() + { + _serviceCollection.AddAWSMessageBus(builder => + { + builder.EnableExperimentalFeatures(); + }); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageSerializer = serviceProvider.GetRequiredService(); + + Assert.IsType(envelopeSerializer); + Assert.IsType(messageSerializer); + } + // These services must be present irrespective of whether publishers or subscribers are configured. private void CheckRequiredServices(ServiceProvider serviceProvider) { diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs index 2ccb8944..473dbc44 100644 --- a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs +++ b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs @@ -1,912 +1,28 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Amazon.SQS.Model; -using AWS.Messaging.Configuration; using AWS.Messaging.Serialization; -using AWS.Messaging.Services; -using AWS.Messaging.UnitTests.MessageHandlers; -using AWS.Messaging.UnitTests.Models; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Moq; using Xunit; - namespace AWS.Messaging.UnitTests.SerializationTests; -public class EnvelopeSerializerTests +public class EnvelopeSerializerTests : EnvelopeSerializerTestsBase { - private readonly IServiceCollection _serviceCollection; - private readonly DateTimeOffset _testdate = new DateTime(year: 2000, month: 12, day: 5, hour: 10, minute: 30, second: 55, DateTimeKind.Utc); - - public EnvelopeSerializerTests() - { - _serviceCollection = new ServiceCollection(); - _serviceCollection.AddLogging(); - _serviceCollection.AddAWSMessageBus(builder => - { - builder.AddSQSPublisher("sqsQueueUrl", "addressInfo"); - builder.AddMessageHandler("addressInfo"); - builder.AddMessageHandler("plaintext"); - builder.AddMessageSource("/aws/messaging"); - }); - - var mockDateTimeHandler = new Mock(); - mockDateTimeHandler.Setup(x => x.GetUtcNow()).Returns(_testdate); - _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), mockDateTimeHandler.Object)); - } - - [Fact] - public async Task CreateEnvelope() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - var message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - }; - - // ACT - var envelope = await envelopeSerializer.CreateEnvelopeAsync(message); - - // ASSERT - Assert.NotNull(envelope); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); - - var addressInfo = envelope.Message; - Assert.Equal("Prince St", addressInfo?.Street); - Assert.Equal(123, addressInfo?.Unit); - Assert.Equal("00001", addressInfo?.ZipCode); - } - - [Fact] - public async Task CreateEnvelope_MissingPublisherMapping_ThrowsException() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - var message = new ChatMessage - { - MessageDescription = "This is a test message" - }; - - // ACT and ASSERT - // This throws an exception since no publisher is configured against the ChatMessage type. - await Assert.ThrowsAsync(async () => await envelopeSerializer.CreateEnvelopeAsync(message)); - } - - - [Fact] - public async Task SerializeEnvelope() - { - // ARRANGE - var message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - }; - - var envelope = new MessageEnvelope - { - Id = "id-123", - Source = new Uri("/backend/service", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = message - }; - - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - // ACT - var jsonBlob = await envelopeSerializer.SerializeAsync(envelope); - - // ASSERT - // The \u0022 corresponds to quotation mark (") - var expectedBlob = "{\"id\":\"id-123\",\"source\":\"/backend/service\",\"specversion\":\"1.0\",\"type\":\"addressInfo\",\"time\":\"2000-12-05T10:30:55+00:00\",\"datacontenttype\":\"application/json\",\"data\":{\"Unit\":123,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"; - Assert.Equal(expectedBlob, jsonBlob); - } - - [Fact] - public async Task ConvertToEnvelope_NoOuterEnvelope_In_SQSMessageBody() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - var messageEnvelope = new MessageEnvelope - { - Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - - }; - var sqsMessage = new Message - { - Body = await envelopeSerializer.SerializeAsync(messageEnvelope), - ReceiptHandle = "receipt-handle", - MessageAttributes = new Dictionary(), - Attributes = new Dictionary() - }; - sqsMessage.MessageAttributes.Add("attr1", new MessageAttributeValue { DataType = "String", StringValue = "val1" }); - sqsMessage.Attributes.Add("MessageGroupId", "group-123"); - sqsMessage.Attributes.Add("MessageDeduplicationId", "dedup-123"); - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var envelope = (MessageEnvelope)result.Envelope; - Assert.NotNull(envelope); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); - - var addressInfo = envelope.Message; - Assert.Equal("Prince St", addressInfo?.Street); - Assert.Equal(123, addressInfo?.Unit); - Assert.Equal("00001", addressInfo?.ZipCode); - - var subscribeMapping = result.Mapping; - Assert.NotNull(subscribeMapping); - Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); - Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); - Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); - - var sqsMetadata = envelope.SQSMetadata!; - Assert.Equal("receipt-handle", sqsMetadata.ReceiptHandle); - Assert.Equal("group-123", sqsMetadata.MessageGroupId); - Assert.Equal("dedup-123", sqsMetadata.MessageDeduplicationId); - Assert.Equal("String", sqsMetadata.MessageAttributes!["attr1"].DataType); - Assert.Equal("val1", sqsMetadata.MessageAttributes["attr1"].StringValue); - } - - [Fact] - public async Task ConvertToEnvelope_With_SNSOuterEnvelope_In_SQSMessageBody() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - var innerMessageEnvelope = new MessageEnvelope - { - Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - var outerMessageEnvelope = new Dictionary - { - { "Type", "Notification" }, - { "MessageId", "abcd-123" }, - { "TopicArn", "arn:aws:sns:us-east-2:111122223333:ExampleTopic1" }, - { "Subject", "TestSubject" }, - { "Timestamp", _testdate }, - { "SignatureVersion", "1" }, - { "Signature", "abcdef33242" }, - { "SigningCertURL", "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem" }, - { "UnsubscribeURL", "https://www.click-here.com" }, - { "Message", await envelopeSerializer.SerializeAsync(innerMessageEnvelope) }, - { "MessageAttributes", new Dictionary - { - { "attr1", new Amazon.SimpleNotificationService.Model.MessageAttributeValue{ DataType = "String", StringValue = "val1"} }, - { "attr2", new Amazon.SimpleNotificationService.Model.MessageAttributeValue{ DataType = "Number", StringValue = "3"} } - } } - }; - - var sqsMessage = new Message - { - Body = JsonSerializer.Serialize(outerMessageEnvelope), - }; - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var envelope = (MessageEnvelope)result.Envelope; - Assert.NotNull(envelope); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); - - var addressInfo = envelope.Message; - Assert.Equal("Prince St", addressInfo?.Street); - Assert.Equal(123, addressInfo?.Unit); - Assert.Equal("00001", addressInfo?.ZipCode); - - var subscribeMapping = result.Mapping; - Assert.NotNull(subscribeMapping); - Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); - Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); - Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); - - var snsMetadata = envelope.SNSMetadata!; - Assert.Equal("arn:aws:sns:us-east-2:111122223333:ExampleTopic1", snsMetadata.TopicArn); - Assert.Equal("https://www.click-here.com", snsMetadata.UnsubscribeURL); - Assert.Equal("abcd-123", snsMetadata.MessageId); - Assert.Equal("TestSubject", snsMetadata.Subject); - Assert.Equal(_testdate, snsMetadata.Timestamp); - Assert.Equal("String", snsMetadata.MessageAttributes!["attr1"].DataType); - Assert.Equal("val1", snsMetadata.MessageAttributes["attr1"].StringValue); - Assert.Equal("Number", snsMetadata.MessageAttributes["attr2"].DataType); - Assert.Equal("3", snsMetadata.MessageAttributes["attr2"].StringValue); - } - - [Fact] - public async Task ConvertToEnvelope_With_EventBridgeOuterEnvelope_In_SQSMessageBody() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - var innerMessageEnvelope = new MessageEnvelope - { - Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - var outerMessageEnvelope = new Dictionary - { - { "version", "0" }, - { "id", "abcd-123" }, - { "source", "some-source" }, - { "detail-type", "address" }, - { "time", _testdate }, - { "account", "123456789123" }, - { "region", "us-west-2" }, - { "resources", new List{ "arn1", "arn2" } }, - { "detail", innerMessageEnvelope }, // The "detail" property is set as a JSON object and not a string. - }; - - var sqsMessage = new Message - { - Body = JsonSerializer.Serialize(outerMessageEnvelope) - }; - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var envelope = (MessageEnvelope)result.Envelope; - Assert.NotNull(envelope); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); - - var addressInfo = envelope.Message; - Assert.Equal("Prince St", addressInfo?.Street); - Assert.Equal(123, addressInfo?.Unit); - Assert.Equal("00001", addressInfo?.ZipCode); - - var subscribeMapping = result.Mapping; - Assert.NotNull(subscribeMapping); - Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); - Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); - Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); - - var eventBridgeMetadata = envelope.EventBridgeMetadata!; - Assert.Equal("abcd-123", eventBridgeMetadata.EventId); - Assert.Equal("some-source", eventBridgeMetadata.Source); - Assert.Equal("address", eventBridgeMetadata.DetailType); - Assert.Equal(_testdate, eventBridgeMetadata.Time); - Assert.Equal("123456789123", eventBridgeMetadata.AWSAccount); - Assert.Equal("us-west-2", eventBridgeMetadata.AWSRegion); - Assert.Equal(new List { "arn1", "arn2" }, eventBridgeMetadata.Resources); - } - - [Fact] - public async Task ConvertToEnvelope_MissingSubscriberMapping_ThrowsException() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - var messageEnvelope = new MessageEnvelope - { - Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "chatmessage", - TimeStamp = _testdate, - Message = new ChatMessage - { - MessageDescription = "This is a test message" - } - - }; - var sqsMessage = new Message - { - Body = await envelopeSerializer.SerializeAsync(messageEnvelope), - ReceiptHandle = "receipt-handle" - }; - - // ACT and ASSERT - // This throws an exception because no subscriber is configured against the ChatMessage type. - await Assert.ThrowsAsync(async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage)); - } + protected override bool EnableExperimentalFeatures => false; [Fact] - public async Task SerializationCallbacks_AreCorrectlyInvoked() - { - // ARRANGE - _serviceCollection.AddAWSMessageBus(builder => - { - builder.AddMessageHandler("addressInfo"); - builder.AddSerializationCallback(new MockSerializationCallback()); - }); - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - var messageEnvelope = new MessageEnvelope - { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - // ACT - Serialize Envelope - var serializedMessage = await envelopeSerializer.SerializeAsync(messageEnvelope); - - // ASSERT - Check expected base 64 encoded string - var expectedserializedMessage = "eyJpZCI6IjEyMyIsInNvdXJjZSI6Ii9hd3MvbWVzc2FnaW5nIiwic3BlY3ZlcnNpb24iOiIxLjAiLCJ0eXBlIjoiYWRkcmVzc0luZm8iLCJ0aW1lIjoiMjAwMC0xMi0wNVQxMDozMDo1NSswMDowMCIsImRhdGFjb250ZW50dHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJkYXRhIjp7IlVuaXQiOjEyMywiU3RyZWV0IjoiUHJpbmNlIFN0IiwiWmlwQ29kZSI6IjAwMDAxIn0sIklzLURlbGl2ZXJlZCI6ZmFsc2V9"; - Assert.Equal(expectedserializedMessage, serializedMessage); - - // ACT - Convert To Envelope from base 64 Encoded Message - var sqsMessage = new Message - { - Body = serializedMessage - }; - - var conversionResult = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var envelope = (MessageEnvelope)conversionResult.Envelope; - Assert.NotNull(envelope); - Assert.Equal("123", envelope.Id); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); - Assert.True(envelope.Metadata["Is-Delivered"].GetBoolean()); - - var subscribeMapping = conversionResult.Mapping; - Assert.NotNull(subscribeMapping); - Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); - Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); - Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task SerializeAsync_DataMessageLogging_NoError(bool dataMessageLogging) + public override void EnvelopeSerializer_RegistersCorrectly() { - var logger = new Mock>(); - var messageConfiguration = new MessageConfiguration { LogMessageContent = dataMessageLogging }; - var messageSerializer = new Mock(); - var dateTimeHandler = new Mock(); - var messageIdGenerator = new Mock(); - var messageSourceHandler = new Mock(); - var envelopeSerializer = new EnvelopeSerializer(logger.Object, messageConfiguration, messageSerializer.Object, dateTimeHandler.Object, messageIdGenerator.Object, messageSourceHandler.Object); - var messageEnvelope = new MessageEnvelope { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - var serializedContent = JsonSerializer.Serialize(messageEnvelope.Message); - var messageSerializeResults = new MessageSerializerResults(serializedContent, "application/json"); + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + // ACT + var envelopeSerializer = serviceProvider.GetService(); + // ASSERT + Assert.NotNull(envelopeSerializer); - // Mock the serializer to return a specific string - messageSerializer - .Setup(x => x.Serialize(It.IsAny())) - .Returns(messageSerializeResults); - - await envelopeSerializer.SerializeAsync(messageEnvelope); - - if (dataMessageLogging) - { - logger.Verify(log => log.Log( - It.Is(logLevel => logLevel == LogLevel.Trace), - It.Is(eventId => eventId.Id == 0), - It.Is((@object, @type) => @object.ToString() == "Serialized the MessageEnvelope object as the following raw string:\n{\"id\":\"123\",\"source\":\"/aws/messaging\",\"specversion\":\"1.0\",\"type\":\"addressInfo\",\"time\":\"2000-12-05T10:30:55+00:00\",\"datacontenttype\":\"application/json\",\"data\":{\"Unit\":123,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"), - null, - It.IsAny>()), - Times.Once); + Assert.IsType(envelopeSerializer); } - else - { - logger.Verify(log => log.Log( - It.Is(logLevel => logLevel == LogLevel.Trace), - It.Is(eventId => eventId.Id == 0), - It.Is((@object, @type) => @object.ToString() == "Serialized the MessageEnvelope object to a raw string"), - null, - It.IsAny>()), - Times.Once); - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task SerializeAsync_DataMessageLogging_WithError(bool dataMessageLogging) - { - // ARRANGE - var logger = new Mock>(); - var services = new ServiceCollection(); - services.AddAWSMessageBus(builder => - { - builder.AddSQSPublisher("sqsQueueUrl", "addressInfo"); - }); - var serviceProvider = services.BuildServiceProvider(); - var messageConfiguration = serviceProvider.GetRequiredService(); - messageConfiguration.LogMessageContent = dataMessageLogging; - - var messageSerializer = new Mock(); - var dateTimeHandler = new Mock(); - var messageIdGenerator = new Mock(); - var messageSourceHandler = new Mock(); - var envelopeSerializer = new EnvelopeSerializer( - logger.Object, - messageConfiguration, - messageSerializer.Object, - dateTimeHandler.Object, - messageIdGenerator.Object, - messageSourceHandler.Object); - - var messageEnvelope = new MessageEnvelope - { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - // Setup the serializer to throw when trying to serialize the message - messageSerializer.Setup(x => x.Serialize(It.IsAny())) - .Throws(new JsonException("Test exception")); - - // ACT & ASSERT - var exception = await Assert.ThrowsAsync( - async () => await envelopeSerializer.SerializeAsync(messageEnvelope)); - - Assert.Equal("Failed to serialize the MessageEnvelope into a raw string", exception.Message); - - if (dataMessageLogging) - { - Assert.NotNull(exception.InnerException); - Assert.IsType(exception.InnerException); - Assert.Equal("Test exception", exception.InnerException.Message); - } - else - { - Assert.Null(exception.InnerException); - } - - // Verify logging behavior - logger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task ConvertToEnvelopeAsync_DataMessageLogging_WithError(bool dataMessageLogging) - { - // ARRANGE - var logger = new Mock>(); - var messageConfiguration = new MessageConfiguration { LogMessageContent = dataMessageLogging }; - var messageSerializer = new Mock(); - var dateTimeHandler = new Mock(); - var messageIdGenerator = new Mock(); - var messageSourceHandler = new Mock(); - var envelopeSerializer = new EnvelopeSerializer( - logger.Object, - messageConfiguration, - messageSerializer.Object, - dateTimeHandler.Object, - messageIdGenerator.Object, - messageSourceHandler.Object); - - // Create an SQS message with invalid JSON that will cause JsonDocument.Parse to fail - var sqsMessage = new Message - { - Body = "invalid json {", - ReceiptHandle = "receipt-handle" - }; - - // ACT & ASSERT - var exception = await Assert.ThrowsAsync( - async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage)); - - Assert.Equal("Failed to create MessageEnvelope", exception.Message); - - if (dataMessageLogging) - { - Assert.NotNull(exception.InnerException); - Assert.IsAssignableFrom(exception.InnerException); // JsonReaderException is not directly usable so just verify that its a generic json exception for now. - } - else - { - Assert.Null(exception.InnerException); - } - - logger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task ConvertToEnvelope_WithMetadata_PreservesOnlyExpectedMetadataProperties() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - // Create a JSON string with both standard envelope properties and custom metadata - var testData = new - { - id = "test-id-123", - source = "/aws/messaging", - specversion = "1.0", - type = "addressInfo", - time = "2000-12-05T10:30:55+00:00", - data = new AddressInfo - { - Unit = 123, - Street = "Prince St", - ZipCode = "00001" - }, - customString = "test-value", - customNumber = 42, - customBoolean = true, - customObject = new { nestedKey = "nestedValue" } - }; - - var json = JsonSerializer.Serialize(testData); - - var sqsMessage = new Message - { - Body = json - }; - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - var deserializedEnvelope = (MessageEnvelope)result.Envelope; - - // ASSERT - Assert.NotNull(deserializedEnvelope); - Assert.NotNull(deserializedEnvelope.Metadata); - - // Verify standard envelope properties - Assert.Equal("test-id-123", deserializedEnvelope.Id); - Assert.Equal("/aws/messaging", deserializedEnvelope.Source.ToString()); - Assert.Equal("1.0", deserializedEnvelope.Version); - Assert.Equal("addressInfo", deserializedEnvelope.MessageTypeIdentifier); - - // Define expected metadata properties - var expectedMetadataKeys = new HashSet - { - "customString", - "customNumber", - "customBoolean", - "customObject" - }; - - // Verify metadata contains exactly the expected keys - Assert.Equal(expectedMetadataKeys, deserializedEnvelope.Metadata.Keys.ToHashSet()); - - // Verify each metadata property has the correct value - Assert.Equal("test-value", deserializedEnvelope.Metadata["customString"].GetString()); - Assert.Equal(42, deserializedEnvelope.Metadata["customNumber"].GetInt32()); - Assert.True(deserializedEnvelope.Metadata["customBoolean"].GetBoolean()); - Assert.Equal("nestedValue", deserializedEnvelope.Metadata["customObject"].GetProperty("nestedKey").GetString()); - - // Verify standard envelope properties are not in metadata - Assert.False(deserializedEnvelope.Metadata.ContainsKey("id")); - Assert.False(deserializedEnvelope.Metadata.ContainsKey("source")); - Assert.False(deserializedEnvelope.Metadata.ContainsKey("specversion")); - Assert.False(deserializedEnvelope.Metadata.ContainsKey("type")); - Assert.False(deserializedEnvelope.Metadata.ContainsKey("time")); - Assert.False(deserializedEnvelope.Metadata.ContainsKey("data")); - - // Verify message content - Assert.NotNull(deserializedEnvelope.Message); - Assert.Equal("Prince St", deserializedEnvelope.Message.Street); - Assert.Equal(123, deserializedEnvelope.Message.Unit); - Assert.Equal("00001", deserializedEnvelope.Message.ZipCode); - } - - [Fact] - public async Task ConvertToEnvelope_NullSubscriberMapping_ThrowsException() - { - // ARRANGE - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - var messageEnvelope = new MessageEnvelope - { - Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "unknownMessageType", // Using an unknown message type - TimeStamp = _testdate, - Message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - } - }; - - var sqsMessage = new Message - { - Body = await envelopeSerializer.SerializeAsync(messageEnvelope), - ReceiptHandle = "receipt-handle" - }; - - // ACT & ASSERT - var exception = await Assert.ThrowsAsync( - async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage) - ); - - // Verify the exception message - Assert.Equal("Failed to create MessageEnvelope", exception.Message); - - // Verify the inner exception type and message - Assert.IsType(exception.InnerException); - var innerException = exception.InnerException as InvalidDataException; - Assert.NotNull(innerException); - Assert.Contains("'unknownMessageType' is not a valid subscriber mapping.", innerException.Message); - Assert.Contains("Available mappings:", innerException.Message); - Assert.Contains("addressInfo", innerException.Message); - } - - [Fact] - public async Task ConvertToEnvelope_WithNonJsonContentType() - { - // ARRANGE - var logger = new Mock>(); - var services = new ServiceCollection(); - services.AddAWSMessageBus(builder => - { - builder.AddMessageHandler("plaintext"); - }); - var serviceProvider = services.BuildServiceProvider(); - var messageConfiguration = serviceProvider.GetRequiredService(); - var messageSerializer = new Mock(); - var dateTimeHandler = new Mock(); - var messageIdGenerator = new Mock(); - var messageSourceHandler = new Mock(); - var envelopeSerializer = new EnvelopeSerializer(logger.Object, messageConfiguration, messageSerializer.Object, dateTimeHandler.Object, messageIdGenerator.Object, messageSourceHandler.Object); - var plainTextContent = "Hello, this is plain text content"; - var messageEnvelope = new MessageEnvelope - { - Id = "id-123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "plaintext", - TimeStamp = _testdate, - Message = plainTextContent - }; - - var serializedContent = JsonSerializer.Serialize(messageEnvelope.Message); - var messageSerializeResults = new MessageSerializerResults(serializedContent, "text/plain"); - - // Mock the serializer to return a specific string - messageSerializer - .Setup(x => x.Serialize(It.IsAny())) - .Returns(messageSerializeResults); - - messageSerializer - .Setup(x => x.Deserialize(It.IsAny(), typeof(string))) - .Returns("Hello, this is plain text content"); - - var sqsMessage = new Message - { - Body = await envelopeSerializer.SerializeAsync(messageEnvelope), - ReceiptHandle = "receipt-handle" - }; - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var envelope = (MessageEnvelope)result.Envelope; - Assert.NotNull(envelope); - Assert.Equal(_testdate, envelope.TimeStamp); - Assert.Equal("1.0", envelope.Version); - Assert.Equal("/aws/messaging", envelope.Source?.ToString()); - Assert.Equal("plaintext", envelope.MessageTypeIdentifier); - Assert.Equal("text/plain", envelope.DataContentType); - Assert.Equal(plainTextContent, envelope.Message); - } - - [Fact] - public async Task ConvertToEnvelope_WithCustomJsonContentType() - { - // ARRANGE - var mockMessageSerializer = new Mock(); - _serviceCollection.RemoveAll(); - _serviceCollection.AddSingleton(mockMessageSerializer.Object); - - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var envelopeSerializer = serviceProvider.GetRequiredService(); - - var message = new AddressInfo - { - Street = "Prince St", - Unit = 123, - ZipCode = "00001" - }; - - // Mock serializer behavior - var serializedMessage = JsonSerializer.Serialize(message); - mockMessageSerializer - .Setup(x => x.Serialize(It.IsAny())) - .Returns(new MessageSerializerResults(serializedMessage, "application/ld+json")); - - mockMessageSerializer - .Setup(x => x.Deserialize(It.IsAny(), typeof(AddressInfo))) - .Returns(message); - - // Create the envelope - var envelope = new MessageEnvelope - { - Id = "test-id-123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = message - }; - - // Serialize the envelope to SQS message - var sqsMessage = new Message - { - Body = await envelopeSerializer.SerializeAsync(envelope), - ReceiptHandle = "receipt-handle" - }; - - // ACT - var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); - - // ASSERT - var deserializedEnvelope = (MessageEnvelope)result.Envelope; - Assert.NotNull(deserializedEnvelope); - - // Verify the content type was preserved - Assert.Equal("application/ld+json", deserializedEnvelope.DataContentType); - - // Verify the message was correctly deserialized - Assert.NotNull(deserializedEnvelope.Message); - Assert.Equal("Prince St", deserializedEnvelope.Message.Street); - Assert.Equal(123, deserializedEnvelope.Message.Unit); - Assert.Equal("00001", deserializedEnvelope.Message.ZipCode); - - // Verify other envelope properties - Assert.Equal("test-id-123", deserializedEnvelope.Id); - Assert.Equal("/aws/messaging", deserializedEnvelope.Source?.ToString()); - Assert.Equal("1.0", deserializedEnvelope.Version); - Assert.Equal("addressInfo", deserializedEnvelope.MessageTypeIdentifier); - Assert.Equal(_testdate, deserializedEnvelope.TimeStamp); - - // Verify the serializer was called with correct parameters - mockMessageSerializer.Verify( - x => x.Serialize(It.IsAny()), - Times.Once); - - mockMessageSerializer.Verify( - x => x.Deserialize(It.IsAny(), typeof(AddressInfo)), - Times.Once); - } - - - -} - -public class MockSerializationCallback : ISerializationCallback -{ - public ValueTask PreSerializationAsync(MessageEnvelope messageEnvelope) - { - messageEnvelope.Metadata["Is-Delivered"] = JsonSerializer.SerializeToElement(false); - return ValueTask.CompletedTask; - } - - public ValueTask PostSerializationAsync(string message) - { - var bytes = Encoding.UTF8.GetBytes(message); - var encodedString = Convert.ToBase64String(bytes); - return new ValueTask(encodedString); - } - - public ValueTask PreDeserializationAsync(string message) - { - var bytes = Convert.FromBase64String(message); - var decodedString = Encoding.UTF8.GetString(bytes); - return new ValueTask(decodedString); - } - - public ValueTask PostDeserializationAsync(MessageEnvelope messageEnvelope) - { - messageEnvelope.Metadata["Is-Delivered"] = JsonSerializer.SerializeToElement(true); - return ValueTask.CompletedTask; } } diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTestsBase.cs b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTestsBase.cs new file mode 100644 index 00000000..e379c5e2 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTestsBase.cs @@ -0,0 +1,920 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Amazon.SQS.Model; +using AWS.Messaging.Configuration; +using AWS.Messaging.Serialization; +using AWS.Messaging.Services; +using AWS.Messaging.UnitTests.MessageHandlers; +using AWS.Messaging.UnitTests.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests; + +public abstract class EnvelopeSerializerTestsBase +{ + protected readonly IServiceCollection _serviceCollection; + private readonly DateTimeOffset _testdate = new DateTime(year: 2000, month: 12, day: 5, hour: 10, minute: 30, second: 55, DateTimeKind.Utc); + + protected abstract bool EnableExperimentalFeatures { get; } + + public EnvelopeSerializerTestsBase() + { + _serviceCollection = new ServiceCollection(); + _serviceCollection.AddLogging(); + _serviceCollection.AddAWSMessageBus(builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfo"); + builder.AddMessageHandler("addressInfo"); + builder.AddMessageHandler("plaintext"); + builder.AddMessageSource("/aws/messaging"); + if (EnableExperimentalFeatures) + { + builder.EnableExperimentalFeatures(); + } + }); + + var mockDateTimeHandler = new Mock(); + mockDateTimeHandler.Setup(x => x.GetUtcNow()).Returns(_testdate); + _serviceCollection.Replace(new ServiceDescriptor(typeof(IDateTimeHandler), mockDateTimeHandler.Object)); + } + + public abstract void EnvelopeSerializer_RegistersCorrectly(); + + [Fact] + public async Task CreateEnvelope() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + var message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + }; + + // ACT + var envelope = await envelopeSerializer.CreateEnvelopeAsync(message); + + // ASSERT + Assert.NotNull(envelope); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); + + var addressInfo = envelope.Message; + Assert.Equal("Prince St", addressInfo?.Street); + Assert.Equal(123, addressInfo?.Unit); + Assert.Equal("00001", addressInfo?.ZipCode); + } + + [Fact] + public async Task CreateEnvelope_MissingPublisherMapping_ThrowsException() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + var message = new ChatMessage + { + MessageDescription = "This is a test message" + }; + + // ACT and ASSERT + // This throws an exception since no publisher is configured against the ChatMessage type. + await Assert.ThrowsAsync(async () => await envelopeSerializer.CreateEnvelopeAsync(message)); + } + + + [Fact] + public async Task SerializeEnvelope() + { + // ARRANGE + var message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + }; + + var envelope = new MessageEnvelope + { + Id = "id-123", + Source = new Uri("/backend/service", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = message + }; + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + // ACT + var jsonBlob = await envelopeSerializer.SerializeAsync(envelope); + + // ASSERT + // The \u0022 corresponds to quotation mark (") + var expectedBlob = "{\"id\":\"id-123\",\"source\":\"/backend/service\",\"specversion\":\"1.0\",\"type\":\"addressInfo\",\"time\":\"2000-12-05T10:30:55+00:00\",\"datacontenttype\":\"application/json\",\"data\":{\"Unit\":123,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"; + Assert.Equal(expectedBlob, jsonBlob); + } + + [Fact] + public async Task ConvertToEnvelope_NoOuterEnvelope_In_SQSMessageBody() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageEnvelope = new MessageEnvelope + { + Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + + }; + var sqsMessage = new Message + { + Body = await envelopeSerializer.SerializeAsync(messageEnvelope), + ReceiptHandle = "receipt-handle", + MessageAttributes = new Dictionary(), + Attributes = new Dictionary() + }; + sqsMessage.MessageAttributes.Add("attr1", new MessageAttributeValue { DataType = "String", StringValue = "val1" }); + sqsMessage.Attributes.Add("MessageGroupId", "group-123"); + sqsMessage.Attributes.Add("MessageDeduplicationId", "dedup-123"); + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var envelope = (MessageEnvelope)result.Envelope; + Assert.NotNull(envelope); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); + + var addressInfo = envelope.Message; + Assert.Equal("Prince St", addressInfo?.Street); + Assert.Equal(123, addressInfo?.Unit); + Assert.Equal("00001", addressInfo?.ZipCode); + + var subscribeMapping = result.Mapping; + Assert.NotNull(subscribeMapping); + Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); + Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); + Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); + + var sqsMetadata = envelope.SQSMetadata!; + Assert.Equal("receipt-handle", sqsMetadata.ReceiptHandle); + Assert.Equal("group-123", sqsMetadata.MessageGroupId); + Assert.Equal("dedup-123", sqsMetadata.MessageDeduplicationId); + Assert.Equal("String", sqsMetadata.MessageAttributes!["attr1"].DataType); + Assert.Equal("val1", sqsMetadata.MessageAttributes["attr1"].StringValue); + } + + [Fact] + public async Task ConvertToEnvelope_With_SNSOuterEnvelope_In_SQSMessageBody() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + var innerMessageEnvelope = new MessageEnvelope + { + Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + var outerMessageEnvelope = new Dictionary + { + { "Type", "Notification" }, + { "MessageId", "abcd-123" }, + { "TopicArn", "arn:aws:sns:us-east-2:111122223333:ExampleTopic1" }, + { "Subject", "TestSubject" }, + { "Timestamp", _testdate }, + { "SignatureVersion", "1" }, + { "Signature", "abcdef33242" }, + { "SigningCertURL", "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem" }, + { "UnsubscribeURL", "https://www.click-here.com" }, + { "Message", await envelopeSerializer.SerializeAsync(innerMessageEnvelope) }, + { "MessageAttributes", new Dictionary + { + { "attr1", new Amazon.SimpleNotificationService.Model.MessageAttributeValue{ DataType = "String", StringValue = "val1"} }, + { "attr2", new Amazon.SimpleNotificationService.Model.MessageAttributeValue{ DataType = "Number", StringValue = "3"} } + } } + }; + + var sqsMessage = new Message + { + Body = JsonSerializer.Serialize(outerMessageEnvelope), + }; + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var envelope = (MessageEnvelope)result.Envelope; + Assert.NotNull(envelope); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); + + var addressInfo = envelope.Message; + Assert.Equal("Prince St", addressInfo?.Street); + Assert.Equal(123, addressInfo?.Unit); + Assert.Equal("00001", addressInfo?.ZipCode); + + var subscribeMapping = result.Mapping; + Assert.NotNull(subscribeMapping); + Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); + Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); + Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); + + var snsMetadata = envelope.SNSMetadata!; + Assert.Equal("arn:aws:sns:us-east-2:111122223333:ExampleTopic1", snsMetadata.TopicArn); + Assert.Equal("https://www.click-here.com", snsMetadata.UnsubscribeURL); + Assert.Equal("abcd-123", snsMetadata.MessageId); + Assert.Equal("TestSubject", snsMetadata.Subject); + Assert.Equal(_testdate, snsMetadata.Timestamp); + Assert.Equal("String", snsMetadata.MessageAttributes!["attr1"].DataType); + Assert.Equal("val1", snsMetadata.MessageAttributes["attr1"].StringValue); + Assert.Equal("Number", snsMetadata.MessageAttributes["attr2"].DataType); + Assert.Equal("3", snsMetadata.MessageAttributes["attr2"].StringValue); + } + + [Fact] + public async Task ConvertToEnvelope_With_EventBridgeOuterEnvelope_In_SQSMessageBody() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + var innerMessageEnvelope = new MessageEnvelope + { + Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + var outerMessageEnvelope = new Dictionary + { + { "version", "0" }, + { "id", "abcd-123" }, + { "source", "some-source" }, + { "detail-type", "address" }, + { "time", _testdate }, + { "account", "123456789123" }, + { "region", "us-west-2" }, + { "resources", new List{ "arn1", "arn2" } }, + { "detail", innerMessageEnvelope }, // The "detail" property is set as a JSON object and not a string. + }; + + var sqsMessage = new Message + { + Body = JsonSerializer.Serialize(outerMessageEnvelope) + }; + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var envelope = (MessageEnvelope)result.Envelope; + Assert.NotNull(envelope); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); + + var addressInfo = envelope.Message; + Assert.Equal("Prince St", addressInfo?.Street); + Assert.Equal(123, addressInfo?.Unit); + Assert.Equal("00001", addressInfo?.ZipCode); + + var subscribeMapping = result.Mapping; + Assert.NotNull(subscribeMapping); + Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); + Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); + Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); + + var eventBridgeMetadata = envelope.EventBridgeMetadata!; + Assert.Equal("abcd-123", eventBridgeMetadata.EventId); + Assert.Equal("some-source", eventBridgeMetadata.Source); + Assert.Equal("address", eventBridgeMetadata.DetailType); + Assert.Equal(_testdate, eventBridgeMetadata.Time); + Assert.Equal("123456789123", eventBridgeMetadata.AWSAccount); + Assert.Equal("us-west-2", eventBridgeMetadata.AWSRegion); + Assert.Equal(new List { "arn1", "arn2" }, eventBridgeMetadata.Resources); + } + + [Fact] + public async Task ConvertToEnvelope_MissingSubscriberMapping_ThrowsException() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageEnvelope = new MessageEnvelope + { + Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "chatmessage", + TimeStamp = _testdate, + Message = new ChatMessage + { + MessageDescription = "This is a test message" + } + + }; + var sqsMessage = new Message + { + Body = await envelopeSerializer.SerializeAsync(messageEnvelope), + ReceiptHandle = "receipt-handle" + }; + + // ACT and ASSERT + // This throws an exception because no subscriber is configured against the ChatMessage type. + await Assert.ThrowsAsync(async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage)); + } + + [Fact] + public async Task SerializationCallbacks_AreCorrectlyInvoked() + { + // ARRANGE + _serviceCollection.AddAWSMessageBus(builder => + { + builder.AddMessageHandler("addressInfo"); + builder.AddSerializationCallback(new MockSerializationCallback()); + }); + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageEnvelope = new MessageEnvelope + { + Id = "123", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + // ACT - Serialize Envelope + var serializedMessage = await envelopeSerializer.SerializeAsync(messageEnvelope); + + // ASSERT - Check expected base 64 encoded string + var expectedserializedMessage = "eyJpZCI6IjEyMyIsInNvdXJjZSI6Ii9hd3MvbWVzc2FnaW5nIiwic3BlY3ZlcnNpb24iOiIxLjAiLCJ0eXBlIjoiYWRkcmVzc0luZm8iLCJ0aW1lIjoiMjAwMC0xMi0wNVQxMDozMDo1NSswMDowMCIsImRhdGFjb250ZW50dHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJkYXRhIjp7IlVuaXQiOjEyMywiU3RyZWV0IjoiUHJpbmNlIFN0IiwiWmlwQ29kZSI6IjAwMDAxIn0sIklzLURlbGl2ZXJlZCI6ZmFsc2V9"; + Assert.Equal(expectedserializedMessage, serializedMessage); + + // ACT - Convert To Envelope from base 64 Encoded Message + var sqsMessage = new Message + { + Body = serializedMessage + }; + + var conversionResult = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var envelope = (MessageEnvelope)conversionResult.Envelope; + Assert.NotNull(envelope); + Assert.Equal("123", envelope.Id); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("addressInfo", envelope.MessageTypeIdentifier); + Assert.True(envelope.Metadata["Is-Delivered"].GetBoolean()); + + var subscribeMapping = conversionResult.Mapping; + Assert.NotNull(subscribeMapping); + Assert.Equal("addressInfo", subscribeMapping.MessageTypeIdentifier); + Assert.Equal(typeof(AddressInfo), subscribeMapping.MessageType); + Assert.Equal(typeof(AddressInfoHandler), subscribeMapping.HandlerType); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SerializeAsync_DataMessageLogging_NoError(bool dataMessageLogging) + { + var logger = new Mock>(); + var messageConfiguration = new MessageConfiguration { LogMessageContent = dataMessageLogging }; + var messageSerializer = new Mock(); + var dateTimeHandler = new Mock(); + var messageIdGenerator = new Mock(); + var messageSourceHandler = new Mock(); + var envelopeSerializer = new EnvelopeSerializer(logger.Object, messageConfiguration, messageSerializer.Object, dateTimeHandler.Object, messageIdGenerator.Object, messageSourceHandler.Object); + var messageEnvelope = new MessageEnvelope + { + Id = "123", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + var serializedContent = JsonSerializer.Serialize(messageEnvelope.Message); + var messageSerializeResults = new MessageSerializerResults(serializedContent, "application/json"); + + + // Mock the serializer to return a specific string + messageSerializer + .Setup(x => x.Serialize(It.IsAny())) + .Returns(messageSerializeResults); + + await envelopeSerializer.SerializeAsync(messageEnvelope); + + if (dataMessageLogging) + { + logger.Verify(log => log.Log( + It.Is(logLevel => logLevel == LogLevel.Trace), + It.Is(eventId => eventId.Id == 0), + It.Is((@object, @type) => @object.ToString() == "Serialized the MessageEnvelope object as the following raw string:\n{\"id\":\"123\",\"source\":\"/aws/messaging\",\"specversion\":\"1.0\",\"type\":\"addressInfo\",\"time\":\"2000-12-05T10:30:55+00:00\",\"datacontenttype\":\"application/json\",\"data\":{\"Unit\":123,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"), + null, + It.IsAny>()), + Times.Once); + } + else + { + logger.Verify(log => log.Log( + It.Is(logLevel => logLevel == LogLevel.Trace), + It.Is(eventId => eventId.Id == 0), + It.Is((@object, @type) => @object.ToString() == "Serialized the MessageEnvelope object to a raw string"), + null, + It.IsAny>()), + Times.Once); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SerializeAsync_DataMessageLogging_WithError(bool dataMessageLogging) + { + // ARRANGE + var logger = new Mock>(); + var services = new ServiceCollection(); + services.AddAWSMessageBus(builder => + { + builder.AddSQSPublisher("sqsQueueUrl", "addressInfo"); + }); + var serviceProvider = services.BuildServiceProvider(); + var messageConfiguration = serviceProvider.GetRequiredService(); + messageConfiguration.LogMessageContent = dataMessageLogging; + + var messageSerializer = new Mock(); + var dateTimeHandler = new Mock(); + var messageIdGenerator = new Mock(); + var messageSourceHandler = new Mock(); + var envelopeSerializer = new EnvelopeSerializer( + logger.Object, + messageConfiguration, + messageSerializer.Object, + dateTimeHandler.Object, + messageIdGenerator.Object, + messageSourceHandler.Object); + + var messageEnvelope = new MessageEnvelope + { + Id = "123", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + // Setup the serializer to throw when trying to serialize the message + messageSerializer.Setup(x => x.Serialize(It.IsAny())) + .Throws(new JsonException("Test exception")); + + // ACT & ASSERT + var exception = await Assert.ThrowsAsync( + async () => await envelopeSerializer.SerializeAsync(messageEnvelope)); + + Assert.Equal("Failed to serialize the MessageEnvelope into a raw string", exception.Message); + + if (dataMessageLogging) + { + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + Assert.Equal("Test exception", exception.InnerException.Message); + } + else + { + Assert.Null(exception.InnerException); + } + + // Verify logging behavior + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => true), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConvertToEnvelopeAsync_DataMessageLogging_WithError(bool dataMessageLogging) + { + // ARRANGE + var logger = new Mock>(); + var messageConfiguration = new MessageConfiguration { LogMessageContent = dataMessageLogging }; + var messageSerializer = new Mock(); + var dateTimeHandler = new Mock(); + var messageIdGenerator = new Mock(); + var messageSourceHandler = new Mock(); + var envelopeSerializer = new EnvelopeSerializer( + logger.Object, + messageConfiguration, + messageSerializer.Object, + dateTimeHandler.Object, + messageIdGenerator.Object, + messageSourceHandler.Object); + + // Create an SQS message with invalid JSON that will cause JsonDocument.Parse to fail + var sqsMessage = new Message + { + Body = "invalid json {", + ReceiptHandle = "receipt-handle" + }; + + // ACT & ASSERT + var exception = await Assert.ThrowsAsync( + async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage)); + + Assert.Equal("Failed to create MessageEnvelope", exception.Message); + + if (dataMessageLogging) + { + Assert.NotNull(exception.InnerException); + Assert.IsAssignableFrom(exception.InnerException); // JsonReaderException is not directly usable so just verify that its a generic json exception for now. + } + else + { + Assert.Null(exception.InnerException); + } + + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => true), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ConvertToEnvelope_WithMetadata_PreservesOnlyExpectedMetadataProperties() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + // Create a JSON string with both standard envelope properties and custom metadata + var testData = new + { + id = "test-id-123", + source = "/aws/messaging", + specversion = "1.0", + type = "addressInfo", + time = "2000-12-05T10:30:55+00:00", + data = new AddressInfo + { + Unit = 123, + Street = "Prince St", + ZipCode = "00001" + }, + customString = "test-value", + customNumber = 42, + customBoolean = true, + customObject = new { nestedKey = "nestedValue" } + }; + + var json = JsonSerializer.Serialize(testData); + + var sqsMessage = new Message + { + Body = json + }; + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + var deserializedEnvelope = (MessageEnvelope)result.Envelope; + + // ASSERT + Assert.NotNull(deserializedEnvelope); + Assert.NotNull(deserializedEnvelope.Metadata); + + // Verify standard envelope properties + Assert.Equal("test-id-123", deserializedEnvelope.Id); + Assert.Equal("/aws/messaging", deserializedEnvelope.Source.ToString()); + Assert.Equal("1.0", deserializedEnvelope.Version); + Assert.Equal("addressInfo", deserializedEnvelope.MessageTypeIdentifier); + + // Define expected metadata properties + var expectedMetadataKeys = new HashSet + { + "customString", + "customNumber", + "customBoolean", + "customObject" + }; + + // Verify metadata contains exactly the expected keys + Assert.Equal(expectedMetadataKeys, deserializedEnvelope.Metadata.Keys.ToHashSet()); + + // Verify each metadata property has the correct value + Assert.Equal("test-value", deserializedEnvelope.Metadata["customString"].GetString()); + Assert.Equal(42, deserializedEnvelope.Metadata["customNumber"].GetInt32()); + Assert.True(deserializedEnvelope.Metadata["customBoolean"].GetBoolean()); + Assert.Equal("nestedValue", deserializedEnvelope.Metadata["customObject"].GetProperty("nestedKey").GetString()); + + // Verify standard envelope properties are not in metadata + Assert.False(deserializedEnvelope.Metadata.ContainsKey("id")); + Assert.False(deserializedEnvelope.Metadata.ContainsKey("source")); + Assert.False(deserializedEnvelope.Metadata.ContainsKey("specversion")); + Assert.False(deserializedEnvelope.Metadata.ContainsKey("type")); + Assert.False(deserializedEnvelope.Metadata.ContainsKey("time")); + Assert.False(deserializedEnvelope.Metadata.ContainsKey("data")); + + // Verify message content + Assert.NotNull(deserializedEnvelope.Message); + Assert.Equal("Prince St", deserializedEnvelope.Message.Street); + Assert.Equal(123, deserializedEnvelope.Message.Unit); + Assert.Equal("00001", deserializedEnvelope.Message.ZipCode); + } + + [Fact] + public async Task ConvertToEnvelope_NullSubscriberMapping_ThrowsException() + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + var messageEnvelope = new MessageEnvelope + { + Id = "66659d05-e4ff-462f-81c4-09e560e66a5c", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "unknownMessageType", // Using an unknown message type + TimeStamp = _testdate, + Message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + } + }; + + var sqsMessage = new Message + { + Body = await envelopeSerializer.SerializeAsync(messageEnvelope), + ReceiptHandle = "receipt-handle" + }; + + // ACT & ASSERT + var exception = await Assert.ThrowsAsync( + async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage) + ); + + // Verify the exception message + Assert.Equal("Failed to create MessageEnvelope", exception.Message); + + // Verify the inner exception type and message + Assert.IsType(exception.InnerException); + var innerException = exception.InnerException as InvalidDataException; + Assert.NotNull(innerException); + Assert.Contains("'unknownMessageType' is not a valid subscriber mapping.", innerException.Message); + Assert.Contains("Available mappings:", innerException.Message); + Assert.Contains("addressInfo", innerException.Message); + } + + [Fact] + public async Task ConvertToEnvelope_WithNonJsonContentType() + { + // ARRANGE + var logger = new Mock>(); + var services = new ServiceCollection(); + services.AddAWSMessageBus(builder => + { + builder.AddMessageHandler("plaintext"); + }); + var serviceProvider = services.BuildServiceProvider(); + var messageConfiguration = serviceProvider.GetRequiredService(); + var messageSerializer = new Mock(); + var dateTimeHandler = new Mock(); + var messageIdGenerator = new Mock(); + var messageSourceHandler = new Mock(); + var envelopeSerializer = new EnvelopeSerializer(logger.Object, messageConfiguration, messageSerializer.Object, dateTimeHandler.Object, messageIdGenerator.Object, messageSourceHandler.Object); + var plainTextContent = "Hello, this is plain text content"; + var messageEnvelope = new MessageEnvelope + { + Id = "id-123", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "plaintext", + TimeStamp = _testdate, + Message = plainTextContent + }; + + var serializedContent = JsonSerializer.Serialize(messageEnvelope.Message); + var messageSerializeResults = new MessageSerializerResults(serializedContent, "text/plain"); + + // Mock the serializer to return a specific string + messageSerializer + .Setup(x => x.Serialize(It.IsAny())) + .Returns(messageSerializeResults); + + messageSerializer + .Setup(x => x.Deserialize(It.IsAny(), typeof(string))) + .Returns("Hello, this is plain text content"); + + var sqsMessage = new Message + { + Body = await envelopeSerializer.SerializeAsync(messageEnvelope), + ReceiptHandle = "receipt-handle" + }; + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var envelope = (MessageEnvelope)result.Envelope; + Assert.NotNull(envelope); + Assert.Equal(_testdate, envelope.TimeStamp); + Assert.Equal("1.0", envelope.Version); + Assert.Equal("/aws/messaging", envelope.Source?.ToString()); + Assert.Equal("plaintext", envelope.MessageTypeIdentifier); + Assert.Equal("text/plain", envelope.DataContentType); + Assert.Equal(plainTextContent, envelope.Message); + } + + [Fact] + public async Task ConvertToEnvelope_WithCustomJsonContentType() + { + // ARRANGE + var mockMessageSerializer = new Mock(); + _serviceCollection.RemoveAll(); + _serviceCollection.AddSingleton(mockMessageSerializer.Object); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var envelopeSerializer = serviceProvider.GetRequiredService(); + + var message = new AddressInfo + { + Street = "Prince St", + Unit = 123, + ZipCode = "00001" + }; + + // Mock serializer behavior + var serializedMessage = JsonSerializer.Serialize(message); + mockMessageSerializer + .Setup(x => x.Serialize(It.IsAny())) + .Returns(new MessageSerializerResults(serializedMessage, "application/ld+json")); + + mockMessageSerializer + .Setup(x => x.Deserialize(It.IsAny(), typeof(AddressInfo))) + .Returns(message); + + // Create the envelope + var envelope = new MessageEnvelope + { + Id = "test-id-123", + Source = new Uri("/aws/messaging", UriKind.Relative), + Version = "1.0", + MessageTypeIdentifier = "addressInfo", + TimeStamp = _testdate, + Message = message + }; + + // Serialize the envelope to SQS message + var sqsMessage = new Message + { + Body = await envelopeSerializer.SerializeAsync(envelope), + ReceiptHandle = "receipt-handle" + }; + + // ACT + var result = await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage); + + // ASSERT + var deserializedEnvelope = (MessageEnvelope)result.Envelope; + Assert.NotNull(deserializedEnvelope); + + // Verify the content type was preserved + Assert.Equal("application/ld+json", deserializedEnvelope.DataContentType); + + // Verify the message was correctly deserialized + Assert.NotNull(deserializedEnvelope.Message); + Assert.Equal("Prince St", deserializedEnvelope.Message.Street); + Assert.Equal(123, deserializedEnvelope.Message.Unit); + Assert.Equal("00001", deserializedEnvelope.Message.ZipCode); + + // Verify other envelope properties + Assert.Equal("test-id-123", deserializedEnvelope.Id); + Assert.Equal("/aws/messaging", deserializedEnvelope.Source?.ToString()); + Assert.Equal("1.0", deserializedEnvelope.Version); + Assert.Equal("addressInfo", deserializedEnvelope.MessageTypeIdentifier); + Assert.Equal(_testdate, deserializedEnvelope.TimeStamp); + + // Verify the serializer was called with correct parameters + mockMessageSerializer.Verify( + x => x.Serialize(It.IsAny()), + Times.Once); + + mockMessageSerializer.Verify( + x => x.Deserialize(It.IsAny(), typeof(AddressInfo)), + Times.Once); + } + + + +} + +public class MockSerializationCallback : ISerializationCallback +{ + public ValueTask PreSerializationAsync(MessageEnvelope messageEnvelope) + { + messageEnvelope.Metadata["Is-Delivered"] = JsonSerializer.SerializeToElement(false); + return ValueTask.CompletedTask; + } + + public ValueTask PostSerializationAsync(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + var encodedString = Convert.ToBase64String(bytes); + return new ValueTask(encodedString); + } + + public ValueTask PreDeserializationAsync(string message) + { + var bytes = Convert.FromBase64String(message); + var decodedString = Encoding.UTF8.GetString(bytes); + return new ValueTask(decodedString); + } + + public ValueTask PostDeserializationAsync(MessageEnvelope messageEnvelope) + { + messageEnvelope.Metadata["Is-Delivered"] = JsonSerializer.SerializeToElement(true); + return ValueTask.CompletedTask; + } +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerUtf8JsonWriterTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerUtf8JsonWriterTests.cs new file mode 100644 index 00000000..ac770e5d --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerUtf8JsonWriterTests.cs @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Messaging.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests; + +public class EnvelopeSerializerUtf8JsonWriterTests : EnvelopeSerializerTestsBase +{ + protected override bool EnableExperimentalFeatures => true; + + [Fact] + public override void EnvelopeSerializer_RegistersCorrectly() + { + { + // ARRANGE + var serviceProvider = _serviceCollection.BuildServiceProvider(); + // ACT + var envelopeSerializer = serviceProvider.GetService(); + // ASSERT + Assert.NotNull(envelopeSerializer); + + Assert.IsType(envelopeSerializer); + } + } +} + diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTests.cs index 194f2939..8c84ab7f 100644 --- a/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTests.cs +++ b/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTests.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using Newtonsoft.Json; using Xunit; namespace AWS.Messaging.UnitTests.SerializationTests; diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTestsUtf8JsonWriterTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTestsUtf8JsonWriterTests.cs new file mode 100644 index 00000000..7d78d291 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/MessageSerializerTestsUtf8JsonWriterTests.cs @@ -0,0 +1,432 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; +using AWS.Messaging.Configuration; +using AWS.Messaging.Serialization; +using AWS.Messaging.Services; +using AWS.Messaging.UnitTests.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Moq; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests; + +public class MessageSerializerUtf8JsonWriterTests +{ + private readonly FakeLogger _logger; + + public MessageSerializerUtf8JsonWriterTests() + { + _logger = new FakeLogger(); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Serialize(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + // ARRANGE + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(new NullLogger(), new MessageConfiguration(), messageJsonSerializerContextFactory); + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + // ACT + var result = serializer.Serialize(person); + + // ASSERT + var expectedString = "{\"FirstName\":\"Bob\",\"LastName\":\"Stone\",\"Age\":30,\"Gender\":\"Male\",\"Address\":{\"Unit\":12,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"; + Assert.Equal(expectedString, result.Data); + Assert.Equal("application/json", result.ContentType); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Serialize_NoDataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration(); + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + var jsonString = serializer.Serialize(person).Data; + + Assert.Equal(1, _logger.Collector.Count); + + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal("Serialized the message object to a raw string with a content length of " + jsonString.Length + ".", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Serialize_DataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + serializer.Serialize(person); + + Assert.Equal(1, _logger.Collector.Count); + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal("Serialized the message object as the following raw string:\n{\"FirstName\":\"Bob\",\"LastName\":\"Stone\",\"Age\":30,\"Gender\":\"Male\",\"Address\":{\"Unit\":12,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + public class UnsupportedType + { + public string? Name { get; set; } + public UnsupportedType? Type { get; set; } + } + + [Fact] + public void Serialize_NoDataMessageLogging_WithError() + { + var messageConfiguration = new MessageConfiguration(); + + // This test doesn't use the JsonSerializationContext version because System.Text.Json + // doesn't detect circular references like the reflection version. + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, new NullMessageJsonSerializerContextContainer()); + + // Creating an object with circular dependency to force an exception in the JsonSerializer.Serialize method. + var unsupportedType1 = new UnsupportedType { Name = "type1" }; + var unsupportedType2 = new UnsupportedType { Name = "type2" }; + unsupportedType1.Type = unsupportedType2; + unsupportedType2.Type = unsupportedType1; + + var exception = Assert.Throws(() => serializer.Serialize(unsupportedType1)); + + Assert.Equal("Failed to serialize application message into a string", exception.Message); + Assert.Null(exception.InnerException); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Serialize_DataMessageLogging_WithError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + // Creating an object with circular dependency to force an exception in the JsonSerializer.Serialize method. + var unsupportedType1 = new UnsupportedType { Name = "type1" }; + var unsupportedType2 = new UnsupportedType { Name = "type2" }; + unsupportedType1.Type = unsupportedType2; + unsupportedType2.Type = unsupportedType1; + + var exception = Assert.Throws(() => serializer.Serialize(unsupportedType1)); + + Assert.Equal("Failed to serialize application message into a string", exception.Message); + Assert.NotNull(exception.InnerException); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Deserialize(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + // ARRANGE + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(new NullLogger(), new MessageConfiguration(), messageJsonSerializerContextFactory); + var jsonString = + @"{ + ""FirstName"":""Bob"", + ""LastName"":""Stone"", + ""Age"":30, + ""Gender"":""Male"", + ""Address"":{ + ""Unit"":12, + ""Street"":""Prince St"", + ""ZipCode"":""00001"" + } + }"; + + // ACT + var message = serializer.Deserialize(jsonString); + + // ASSERT + Assert.Equal("Bob", message.FirstName); + Assert.Equal("Stone", message.LastName); + Assert.Equal(30, message.Age); + Assert.Equal(Gender.Male, message.Gender); + Assert.Equal(12, message.Address?.Unit); + Assert.Equal("Prince St", message.Address?.Street); + Assert.Equal("00001", message.Address?.ZipCode); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Deserialize_NoDataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration(); + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var jsonString = + @"{ + ""FirstName"":""Bob"", + ""LastName"":""Stone"", + ""Age"":30, + ""Gender"":""Male"", + ""Address"":{ + ""Unit"":12, + ""Street"":""Prince St"", + ""ZipCode"":""00001"" + } + }"; + + serializer.Deserialize(jsonString); + + Assert.Equal(1, _logger.Collector.Count); + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal("Deserializing the following message into type 'AWS.Messaging.UnitTests.Models.PersonInfo'", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Deserialize_DataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var jsonString = "{\"FirstName\":\"Bob\"}"; + + serializer.Deserialize(jsonString); + + Assert.Equal(1, _logger.Collector.Count); + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal("Deserializing the following message into type 'AWS.Messaging.UnitTests.Models.PersonInfo':\n{\"FirstName\":\"Bob\"}", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Deserialize_NoDataMessageLogging_WithError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration(); + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var jsonString = "{'FirstName':'Bob'}"; + + var exception = Assert.Throws(() => serializer.Deserialize(jsonString)); + + Assert.Equal("Failed to deserialize application message into an instance of AWS.Messaging.UnitTests.Models.PersonInfo.", exception.Message); + Assert.Null(exception.InnerException); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void Deserialize_DataMessageLogging_WithError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializer serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var jsonString = "{'FirstName':'Bob'}"; + + var exception = Assert.Throws(() => serializer.Deserialize(jsonString)); + + Assert.Equal("Failed to deserialize application message into an instance of AWS.Messaging.UnitTests.Models.PersonInfo.", exception.Message); + Assert.NotNull(exception.InnerException); + } + + // New tests for SerializeToBuffer + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void SerializeToBuffer_WritesExpectedJson(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + // ARRANGE + IMessageSerializerUtf8JsonWriter serializer = new MessageSerializerUtf8JsonWriter(new NullLogger(), new MessageConfiguration(), messageJsonSerializerContextFactory); + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + var buffer = new RentArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + + // ACT + serializer.SerializeToBuffer(writer, person); + writer.Flush(); + + var jsonString = Encoding.UTF8.GetString(buffer.WrittenSpan); + + // ASSERT + var expectedString = "{\"FirstName\":\"Bob\",\"LastName\":\"Stone\",\"Age\":30,\"Gender\":\"Male\",\"Address\":{\"Unit\":12,\"Street\":\"Prince St\",\"ZipCode\":\"00001\"}}"; + Assert.Equal(expectedString, jsonString); + Assert.Equal("application/json", serializer.ContentType); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void SerializeToBuffer_NoDataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration(); + IMessageSerializerUtf8JsonWriter serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + var buffer = new RentArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + + serializer.SerializeToBuffer(writer, person); + writer.Flush(); + + var contentLength = buffer.WrittenSpan.Length; + + Assert.Equal(1, _logger.Collector.Count); + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal($"Serialized the message object to a raw string with a content length of {contentLength}.", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void SerializeToBuffer_DataMessageLogging_NoError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializerUtf8JsonWriter serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + var person = new PersonInfo + { + FirstName= "Bob", + LastName = "Stone", + Age= 30, + Gender = Gender.Male, + Address= new AddressInfo + { + Unit = 12, + Street = "Prince St", + ZipCode = "00001" + } + }; + + var buffer = new RentArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + + serializer.SerializeToBuffer(writer, person); + writer.Flush(); + + var jsonString = Encoding.UTF8.GetString(buffer.WrittenSpan); + var contentLength = Encoding.UTF8.GetByteCount(jsonString); + + Assert.Equal(1, _logger.Collector.Count); + var lastRecord = _logger.LatestRecord; + Assert.Equal(0, lastRecord.Id.Id); + Assert.Equal(LogLevel.Trace, lastRecord.Level); + Assert.Equal($"Serialized the message object to a raw string with a content length of {contentLength}.", lastRecord.Message); + Assert.Null(lastRecord.Exception); + } + + [Fact] + public void SerializeToBuffer_NoDataMessageLogging_WithError() + { + var messageConfiguration = new MessageConfiguration(); + IMessageSerializerUtf8JsonWriter serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, new NullMessageJsonSerializerContextContainer()); + + // Creating an object with circular dependency to force an exception in the JsonSerializer.Serialize method. + var unsupportedType1 = new UnsupportedType { Name = "type1" }; + var unsupportedType2 = new UnsupportedType { Name = "type2" }; + unsupportedType1.Type = unsupportedType2; + unsupportedType2.Type = unsupportedType1; + + var buffer = new RentArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + + var exception = Assert.Throws(() => serializer.SerializeToBuffer(writer, unsupportedType1)); + + Assert.Equal("Failed to serialize application message into a string", exception.Message); + Assert.Null(exception.InnerException); + } + + [Theory] + [ClassData(typeof(JsonSerializerContextClassData))] + public void SerializeToBuffer_DataMessageLogging_WithError(IMessageJsonSerializerContextContainer messageJsonSerializerContextFactory) + { + var messageConfiguration = new MessageConfiguration{ LogMessageContent = true }; + IMessageSerializerUtf8JsonWriter serializer = new MessageSerializerUtf8JsonWriter(_logger, messageConfiguration, messageJsonSerializerContextFactory); + + // Creating an object with circular dependency to force an exception in the JsonSerializer.Serialize method. + var unsupportedType1 = new UnsupportedType { Name = "type1" }; + var unsupportedType2 = new UnsupportedType { Name = "type2" }; + unsupportedType1.Type = unsupportedType2; + unsupportedType2.Type = unsupportedType1; + + var buffer = new RentArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + + var exception = Assert.Throws(() => serializer.SerializeToBuffer(writer, unsupportedType1)); + + Assert.Equal("Failed to serialize application message into a string", exception.Message); + Assert.NotNull(exception.InnerException); + } +}