diff --git a/src/AWS.Messaging/EventBridgeMetadata.cs b/src/AWS.Messaging/EventBridgeMetadata.cs index e12cb638..33c0f8a6 100644 --- a/src/AWS.Messaging/EventBridgeMetadata.cs +++ b/src/AWS.Messaging/EventBridgeMetadata.cs @@ -1,11 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace AWS.Messaging { diff --git a/src/AWS.Messaging/Serialization/EnvelopeSerializer.cs b/src/AWS.Messaging/Serialization/EnvelopeSerializer.cs index cac5e738..29749f43 100644 --- a/src/AWS.Messaging/Serialization/EnvelopeSerializer.cs +++ b/src/AWS.Messaging/Serialization/EnvelopeSerializer.cs @@ -4,12 +4,13 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; using Amazon.SQS.Model; using AWS.Messaging.Configuration; using AWS.Messaging.Internal; +using AWS.Messaging.Serialization.Helpers; using AWS.Messaging.Services; using Microsoft.Extensions.Logging; +using AWS.Messaging.Serialization.Parsers; namespace AWS.Messaging.Serialization; @@ -28,6 +29,15 @@ internal class EnvelopeSerializer : IEnvelopeSerializer private readonly IMessageSourceHandler _messageSourceHandler; private readonly ILogger _logger; + // 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 EnvelopeSerializer( ILogger logger, IMessageConfiguration messageConfiguration, @@ -138,44 +148,19 @@ public async ValueTask ConvertToEnvelopeAsync(Message s { try { - sqsMessage.Body = await InvokePreDeserializationCallback(sqsMessage.Body); - var messageEnvelopeConfiguration = GetMessageEnvelopeConfiguration(sqsMessage); - var intermediateEnvelope = JsonSerializer.Deserialize>(messageEnvelopeConfiguration.MessageEnvelopeBody!, MessagingJsonSerializerContext.Default.MessageEnvelopeString)!; - ValidateMessageEnvelope(intermediateEnvelope); - var messageTypeIdentifier = intermediateEnvelope.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)}"); - } + // Get the raw envelope JSON and metadata from the appropriate wrapper (SNS/EventBridge/SQS) + var (envelopeJson, metadata) = await ParseOuterWrapper(sqsMessage); - var messageType = subscriberMapping.MessageType; - var message = _messageSerializer.Deserialize(intermediateEnvelope.Message, messageType); - var finalMessageEnvelope = subscriberMapping.MessageEnvelopeFactory.Invoke(); - - finalMessageEnvelope.Id = intermediateEnvelope.Id; - finalMessageEnvelope.Source = intermediateEnvelope.Source; - finalMessageEnvelope.Version = intermediateEnvelope.Version; - finalMessageEnvelope.MessageTypeIdentifier = intermediateEnvelope.MessageTypeIdentifier; - finalMessageEnvelope.TimeStamp = intermediateEnvelope.TimeStamp; - finalMessageEnvelope.Metadata = intermediateEnvelope.Metadata; - finalMessageEnvelope.SQSMetadata = messageEnvelopeConfiguration.SQSMetadata; - finalMessageEnvelope.SNSMetadata = messageEnvelopeConfiguration.SNSMetadata; - finalMessageEnvelope.EventBridgeMetadata = messageEnvelopeConfiguration.EventBridgeMetadata; - finalMessageEnvelope.SetMessage(message); - - await InvokePostDeserializationCallback(finalMessageEnvelope); - var result = new ConvertToEnvelopeResult(finalMessageEnvelope, subscriberMapping); - - _logger.LogTrace("Created a generic {MessageEnvelopeName} of type '{MessageEnvelopeType}'", nameof(MessageEnvelope), result.Envelope.GetType()); - return result; + // 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) { @@ -189,169 +174,189 @@ public async ValueTask ConvertToEnvelopeAsync(Message s } } - private void ValidateMessageEnvelope(MessageEnvelope? messageEnvelope) + private (MessageEnvelope Envelope, SubscriberMapping Mapping) DeserializeEnvelope(string envelopeString) { - if (messageEnvelope is null) - { - _logger.LogError("{MessageEnvelope} cannot be null", nameof(messageEnvelope)); - throw new InvalidDataException($"{nameof(messageEnvelope)} cannot be null"); - } + using var document = JsonDocument.Parse(envelopeString); + var root = document.RootElement; - var strBuilder = new StringBuilder(); + // 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); - if (string.IsNullOrEmpty(messageEnvelope.Id)) - strBuilder.Append($"{nameof(messageEnvelope.Id)} cannot be null or empty.{Environment.NewLine}"); + var envelope = subscriberMapping.MessageEnvelopeFactory.Invoke(); - if (messageEnvelope.Source is null) - strBuilder.Append($"{nameof(messageEnvelope.Source)} cannot be null.{Environment.NewLine}"); - - if (string.IsNullOrEmpty(messageEnvelope.Version)) - strBuilder.Append($"{nameof(messageEnvelope.Version)} cannot be null or empty.{Environment.NewLine}"); - - if (string.IsNullOrEmpty(messageEnvelope.MessageTypeIdentifier)) - strBuilder.Append($"{nameof(messageEnvelope.MessageTypeIdentifier)} cannot be null or empty.{Environment.NewLine}"); - - if (messageEnvelope.TimeStamp == DateTimeOffset.MinValue) - strBuilder.Append($"{nameof(messageEnvelope.TimeStamp)} is not set."); - - if (messageEnvelope.Message is null) - strBuilder.Append($"{nameof(messageEnvelope.Message)} cannot be null.{Environment.NewLine}"); - - var validationFailures = strBuilder.ToString(); - if (!string.IsNullOrEmpty(validationFailures)) + try { - _logger.LogError("MessageEnvelope instance is not valid" + Environment.NewLine +"{ValidationFailures}", validationFailures); - throw new InvalidDataException($"MessageEnvelope instance is not valid{Environment.NewLine}{validationFailures}"); - } - } - - private MessageEnvelopeConfiguration GetMessageEnvelopeConfiguration(Message sqsMessage) - { - var envelopeConfiguration = new MessageEnvelopeConfiguration(); - envelopeConfiguration.MessageEnvelopeBody = sqsMessage.Body; - using (var document = JsonDocument.Parse(sqsMessage.Body)) - { - var root = document.RootElement; - // Check if the SQS message body contains an outer envelope injected by SNS. - if (root.TryGetProperty("Type", out var messageType) && string.Equals("Notification", messageType.GetString())) + var knownProperties = new HashSet { - // Retrieve the inner message envelope. - envelopeConfiguration.MessageEnvelopeBody = GetJsonPropertyAsString(root, "Message"); - if (string.IsNullOrEmpty(envelopeConfiguration.MessageEnvelopeBody)) - { - _logger.LogError("Failed to create a message envelope configuration because the SNS message envelope does not contain a valid message property."); - throw new FailedToCreateMessageEnvelopeConfigurationException("The SNS message envelope does not contain a valid message property."); - } - SetSNSMetadata(envelopeConfiguration, root); - } - // Check if the SQS message body contains an outer envelope injected by EventBridge. - else if (root.TryGetProperty("detail", out var _) - && root.TryGetProperty("id", out var _) - && root.TryGetProperty("version", out var _) - && root.TryGetProperty("region", out var _)) + "id", + "source", + "specversion", + "type", + "time", + "data" + }; + + // 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()); + + // Handle metadata - copy any properties that aren't standard envelope properties + foreach (var property in root.EnumerateObject()) { - // Retrieve the inner message envelope. - envelopeConfiguration.MessageEnvelopeBody = GetJsonPropertyAsString(root, "detail"); - if (string.IsNullOrEmpty(envelopeConfiguration.MessageEnvelopeBody)) + if (!knownProperties.Contains(property.Name)) { - _logger.LogError("Failed to create a message envelope configuration because the EventBridge message envelope does not contain a valid 'detail' property."); - throw new FailedToCreateMessageEnvelopeConfigurationException("The EventBridge message envelope does not contain a valid 'detail' property."); + envelope.Metadata[property.Name] = property.Value.Clone(); } - SetEventBridgeMetadata(envelopeConfiguration, root); } - } - SetSQSMetadata(envelopeConfiguration, sqsMessage); - return envelopeConfiguration; - } + // Deserialize the message content using the custom serializer + var dataContent = JsonPropertyHelper.GetRequiredProperty(root, "data", element => element.GetString()!); + var message = _messageSerializer.Deserialize(dataContent, subscriberMapping.MessageType); + envelope.SetMessage(message); - private void SetSQSMetadata(MessageEnvelopeConfiguration envelopeConfiguration, Message sqsMessage) - { - envelopeConfiguration.SQSMetadata = new SQSMetadata - { - MessageID = sqsMessage.MessageId, - MessageAttributes = sqsMessage.MessageAttributes, - ReceiptHandle = sqsMessage.ReceiptHandle - }; - if (sqsMessage.Attributes != null && sqsMessage.Attributes.TryGetValue("MessageGroupId", out var attribute)) - { - envelopeConfiguration.SQSMetadata.MessageGroupId = attribute; + return (envelope, subscriberMapping); } - if (sqsMessage.Attributes != null && sqsMessage.Attributes.TryGetValue("MessageDeduplicationId", out var messageAttribute)) + catch (Exception ex) { - envelopeConfiguration.SQSMetadata.MessageDeduplicationId = messageAttribute; + _logger.LogError(ex, "Failed to deserialize or validate MessageEnvelope"); + throw new InvalidDataException("MessageEnvelope instance is not valid", ex); } } - private void SetSNSMetadata(MessageEnvelopeConfiguration envelopeConfiguration, JsonElement root) + private async Task<(string MessageBody, MessageMetadata Metadata)> ParseOuterWrapper(Message sqsMessage) { - envelopeConfiguration.SNSMetadata = new SNSMetadata - { - MessageId = GetJsonPropertyAsString(root, "MessageId"), - TopicArn = GetJsonPropertyAsString(root, "TopicArn"), - Subject = GetJsonPropertyAsString(root, "Subject"), - UnsubscribeURL = GetJsonPropertyAsString(root, "UnsubscribeURL"), - Timestamp = GetJsonPropertyAsDateTimeOffset(root, "Timestamp") - }; - if (root.TryGetProperty("MessageAttributes", out var messageAttributes)) - { - envelopeConfiguration.SNSMetadata.MessageAttributes = messageAttributes.Deserialize(MessagingJsonSerializerContext.Default.DictionarySNSMessageAttributeValue); + 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 + } + } + */ - private void SetEventBridgeMetadata(MessageEnvelopeConfiguration envelopeConfiguration, JsonElement root) - { - envelopeConfiguration.EventBridgeMetadata = new EventBridgeMetadata - { - EventId = GetJsonPropertyAsString(root, "id"), - Source = GetJsonPropertyAsString(root, "source"), - DetailType = GetJsonPropertyAsString(root, "detail-type"), - Time = GetJsonPropertyAsDateTimeOffset(root, "time"), - AWSAccount = GetJsonPropertyAsString(root, "account"), - AWSRegion = GetJsonPropertyAsString(root, "region"), - Resources = GetJsonPropertyAsList(root, "resources") - }; - } + var document = JsonDocument.Parse(sqsMessage.Body); - private string? GetJsonPropertyAsString(JsonElement node, string propertyName) - { - if (node.TryGetProperty(propertyName, out var propertyValue)) + try { - return propertyValue.ValueKind switch + string currentMessageBody = sqsMessage.Body; + var combinedMetadata = new MessageMetadata(); + + // Try each parser in order + foreach (var parser in _parsers.Where(p => p.CanParse(document.RootElement))) { - JsonValueKind.Object => propertyValue.GetRawText(), - JsonValueKind.String => propertyValue.GetString(), - JsonValueKind.Number => propertyValue.ToString(), - JsonValueKind.True => propertyValue.ToString(), - JsonValueKind.False => propertyValue.ToString(), - _ => throw new InvalidDataException($"{propertyValue.ValueKind} cannot be converted to a string value"), - }; - } - return null; - } + // 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); + } - private DateTimeOffset GetJsonPropertyAsDateTimeOffset(JsonElement node, string propertyName) - { - if (node.TryGetProperty(propertyName, out var propertyValue)) + // 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 { - return JsonSerializer.Deserialize(propertyValue, MessagingJsonSerializerContext.Default.DateTimeOffset); + document.Dispose(); } - return DateTimeOffset.MinValue; } - private List? GetJsonPropertyAsList(JsonElement node, string propertyName) + private SubscriberMapping GetAndValidateSubscriberMapping(string messageTypeIdentifier) { - if (node.TryGetProperty(propertyName, out var propertyValue)) + var subscriberMapping = _messageConfiguration.GetSubscriberMapping(messageTypeIdentifier); + if (subscriberMapping is null) { - var jsonTypeInfo = MessagingJsonSerializerContext.Default.GetTypeInfo(typeof(List)) as JsonTypeInfo>; - if (jsonTypeInfo == null) - { - throw new InvalidOperationException($"Missing JsonSerializable registeration for type {typeof(List).FullName}"); - } - return JsonSerializer.Deserialize>(propertyValue, jsonTypeInfo); + 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 null; + return subscriberMapping; } private async ValueTask InvokePreSerializationCallback(MessageEnvelope messageEnvelope) diff --git a/src/AWS.Messaging/Serialization/Handlers/MessageMetadataHandler.cs b/src/AWS.Messaging/Serialization/Handlers/MessageMetadataHandler.cs new file mode 100644 index 00000000..7b80746c --- /dev/null +++ b/src/AWS.Messaging/Serialization/Handlers/MessageMetadataHandler.cs @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Internal; +using AWS.Messaging.Serialization.Helpers; +using MessageAttributeValue = Amazon.SimpleNotificationService.Model.MessageAttributeValue; + +namespace AWS.Messaging.Serialization.Handlers; + +/// +/// Handles the creation of metadata objects from various AWS messaging services. +/// +internal static class MessageMetadataHandler +{ + /// + /// Creates SQS metadata from an SQS message. + /// + /// The SQS message containing metadata information. + /// An SQSMetadata object containing the extracted metadata. + public static SQSMetadata CreateSQSMetadata(Message message) + { + var metadata = new SQSMetadata + { + MessageID = message.MessageId, + ReceiptHandle = message.ReceiptHandle, + MessageAttributes = message.MessageAttributes, + }; + + if (message.Attributes != null) + { + metadata.MessageGroupId = JsonPropertyHelper.GetAttributeValue(message.Attributes, "MessageGroupId"); + metadata.MessageDeduplicationId = JsonPropertyHelper.GetAttributeValue(message.Attributes, "MessageDeduplicationId"); + } + + return metadata; + } + + /// + /// Creates SNS metadata from a JSON element representing an SNS message. + /// + /// The root JSON element containing SNS metadata information. + /// An SNSMetadata object containing the extracted metadata. + public static SNSMetadata CreateSNSMetadata(JsonElement root) + { + var metadata = new SNSMetadata + { + MessageId = JsonPropertyHelper.GetStringProperty(root, "MessageId"), + TopicArn = JsonPropertyHelper.GetStringProperty(root, "TopicArn"), + Timestamp = JsonPropertyHelper.GetDateTimeOffsetProperty(root, "Timestamp") ?? default, + UnsubscribeURL = JsonPropertyHelper.GetStringProperty(root, "UnsubscribeURL"), + Subject = JsonPropertyHelper.GetStringProperty(root, "Subject"), + }; + + if (root.TryGetProperty("MessageAttributes", out var messageAttributes)) + { + metadata.MessageAttributes = messageAttributes.Deserialize(MessagingJsonSerializerContext.Default.DictionarySNSMessageAttributeValue); + + } + + return metadata; + } + + /// + /// Creates EventBridge metadata from a JSON element representing an EventBridge event. + /// + /// The root JSON element containing EventBridge metadata information. + /// An EventBridgeMetadata object containing the extracted metadata. + public static EventBridgeMetadata CreateEventBridgeMetadata(JsonElement root) + { + var metadata = new EventBridgeMetadata + { + EventId = JsonPropertyHelper.GetStringProperty(root, "id"), + DetailType = JsonPropertyHelper.GetStringProperty(root, "detail-type"), + Source = JsonPropertyHelper.GetStringProperty(root, "source"), + AWSAccount = JsonPropertyHelper.GetStringProperty(root, "account"), + Time = JsonPropertyHelper.GetDateTimeOffsetProperty(root, "time") ?? default, + AWSRegion = JsonPropertyHelper.GetStringProperty(root, "region"), + }; + + if (root.TryGetProperty("resources", out var resources)) + { + metadata.Resources = resources.EnumerateArray() + .Select(x => x.GetString()) + .Where(x => x != null) + .ToList()!; + } + + return metadata; + } +} diff --git a/src/AWS.Messaging/Serialization/Helpers/JsonPropertyHelper.cs b/src/AWS.Messaging/Serialization/Helpers/JsonPropertyHelper.cs new file mode 100644 index 00000000..7dd101ed --- /dev/null +++ b/src/AWS.Messaging/Serialization/Helpers/JsonPropertyHelper.cs @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; + +namespace AWS.Messaging.Serialization.Helpers; + +/// +/// Provides helper methods for safely extracting values from JsonElement and Dictionary objects. +/// +internal static class JsonPropertyHelper +{ + /// + /// Safely extracts a value from a JsonElement using the provided conversion function. + /// + /// The type to convert the property value to. + /// The root JsonElement containing the property. + /// The name of the property to extract. + /// The function to convert the property value to type T. + /// The converted value or default if the property doesn't exist. + public static T? GetPropertyValue(JsonElement root, string propertyName, Func getValue) + { + if (getValue == null) + { + throw new ArgumentNullException(nameof(getValue)); + } + + + return root.TryGetProperty(propertyName, out var property) ? getValue(property) : default; + } + + /// + /// Extracts a required value from a JsonElement using the provided conversion function. + /// + /// The type to convert the property value to. + /// The root JsonElement containing the property. + /// The name of the property to extract. + /// The function to convert the property value to type T. + /// The converted value. + /// Thrown when the property is missing or conversion fails. + public static T GetRequiredProperty(JsonElement root, string propertyName, Func getValue) + { + if (root.TryGetProperty(propertyName, out var property)) + { + try + { + return getValue(property); + } + catch (Exception ex) + { + throw new InvalidDataException($"Failed to get or convert property '{propertyName}'", ex); + } + } + throw new InvalidDataException($"Required property '{propertyName}' is missing"); + } + + /// + /// Safely extracts a string value from a JsonElement. + /// + /// The root JsonElement containing the property. + /// The name of the property to extract. + /// The string value or null if the property doesn't exist. + public static string? GetStringProperty(JsonElement root, string propertyName) + => GetPropertyValue(root, propertyName, element => element.GetString()); + + /// + /// Safely extracts a DateTimeOffset value from a JsonElement. + /// + /// The root JsonElement containing the property. + /// The name of the property to extract. + /// The DateTimeOffset value or null if the property doesn't exist. + public static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement root, string propertyName) + => GetPropertyValue(root, propertyName, element => element.GetDateTimeOffset()); + + /// + /// Safely extracts a Uri value from a JsonElement. + /// + /// The root JsonElement containing the property. + /// The name of the property to extract. + /// The Uri value or null if the property doesn't exist. + public static Uri? GetUriProperty(JsonElement root, string propertyName) + => GetPropertyValue(root, propertyName, element => new Uri(element.GetString()!, UriKind.RelativeOrAbsolute)); + + /// + /// Safely extracts a value from a dictionary. + /// + /// The dictionary containing the value. + /// The key of the value to extract. + /// The value or null if the key doesn't exist. + public static string? GetAttributeValue(Dictionary attributes, string key) + { + return attributes.TryGetValue(key, out var value) ? value : null; + } +} diff --git a/src/AWS.Messaging/Serialization/MessageMetadata.cs b/src/AWS.Messaging/Serialization/MessageMetadata.cs new file mode 100644 index 00000000..1a1bc5a2 --- /dev/null +++ b/src/AWS.Messaging/Serialization/MessageMetadata.cs @@ -0,0 +1,45 @@ +namespace AWS.Messaging.Serialization; + +/// +/// Represents metadata associated with a message, including SQS, SNS, and EventBridge specific information. +/// +internal class MessageMetadata +{ + /// + /// Gets or sets the SQS-specific metadata. + /// + public SQSMetadata? SQSMetadata { get; set; } + + /// + /// Gets or sets the SNS-specific metadata. + /// + public SNSMetadata? SNSMetadata { get; set; } + + /// + /// Gets or sets the EventBridge-specific metadata. + /// + public EventBridgeMetadata? EventBridgeMetadata { get; set; } + + /// + /// Initializes a new instance of the MessageMetadata class. + /// + public MessageMetadata() + { + } + + /// + /// Initializes a new instance of the MessageMetadata class with specified metadata. + /// + /// The SQS metadata. + /// The SNS metadata. + /// The EventBridge metadata. + public MessageMetadata( + SQSMetadata? sqsMetadata = null, + SNSMetadata? snsMetadata = null, + EventBridgeMetadata? eventBridgeMetadata = null) + { + SQSMetadata = sqsMetadata; + SNSMetadata = snsMetadata; + EventBridgeMetadata = eventBridgeMetadata; + } +} diff --git a/src/AWS.Messaging/Serialization/Parsers/EventBridgeMessageParser.cs b/src/AWS.Messaging/Serialization/Parsers/EventBridgeMessageParser.cs new file mode 100644 index 00000000..8a343abe --- /dev/null +++ b/src/AWS.Messaging/Serialization/Parsers/EventBridgeMessageParser.cs @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Handlers; + +namespace AWS.Messaging.Serialization.Parsers +{ + /// + /// Parser for messages originating from Amazon EventBridge. + /// + internal class EventBridgeMessageParser : IMessageParser + { + /// + /// Determines if the JSON element represents an EventBridge message by checking for required properties. + /// + /// The root JSON element to examine. + /// True if the message can be parsed as an EventBridge message; otherwise, false. + public bool CanParse(JsonElement root) + { + return root.TryGetProperty("detail", out _) && + root.TryGetProperty("detail-type", out _) && + root.TryGetProperty("source", out _) && + root.TryGetProperty("time", out _); + } + + /// + /// Parses an EventBridge message, extracting the message body and metadata. + /// + /// The root JSON element containing the EventBridge message. + /// The original SQS message. + /// A tuple containing the message body and associated metadata. + /// Thrown when the EventBridge message does not contain a valid detail property. + public (string MessageBody, MessageMetadata Metadata) Parse(JsonElement root, Message originalMessage) + { + // The detail property can be either a string or an object + var detailElement = root.GetProperty("detail"); + + // Add explicit check for null detail + if (detailElement.ValueKind == JsonValueKind.Null) + { + throw new InvalidOperationException("EventBridge message does not contain a valid detail property"); + } + + var messageBody = detailElement.ValueKind == JsonValueKind.String + ? detailElement.GetString() + : detailElement.GetRawText(); + + if (string.IsNullOrEmpty(messageBody)) + { + throw new InvalidOperationException("EventBridge message does not contain a valid detail property"); + } + + var metadata = new MessageMetadata + { + EventBridgeMetadata = MessageMetadataHandler.CreateEventBridgeMetadata(root) + }; + + return (messageBody, metadata); + } + } +} diff --git a/src/AWS.Messaging/Serialization/Parsers/IMessageParser.cs b/src/AWS.Messaging/Serialization/Parsers/IMessageParser.cs new file mode 100644 index 00000000..8fe6a6af --- /dev/null +++ b/src/AWS.Messaging/Serialization/Parsers/IMessageParser.cs @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization; + +namespace AWS.Messaging.Serialization.Parsers; + +/// +/// Defines the contract for message parsers capable of handling different message formats. +/// +internal interface IMessageParser +{ + /// + /// Determines if the parser can handle the given JSON element. + /// + /// The root JSON element to examine. + /// True if the parser can handle the message; otherwise, false. + bool CanParse(JsonElement root); + + /// + /// Parses the message, extracting the message body and associated metadata. + /// + /// The root JSON element containing the message to parse. + /// The original SQS message. + /// A tuple containing the extracted message body and associated metadata. + (string MessageBody, MessageMetadata Metadata) Parse(JsonElement root, Message originalMessage); +} diff --git a/src/AWS.Messaging/Serialization/Parsers/SNSMessageParser.cs b/src/AWS.Messaging/Serialization/Parsers/SNSMessageParser.cs new file mode 100644 index 00000000..6fe23d67 --- /dev/null +++ b/src/AWS.Messaging/Serialization/Parsers/SNSMessageParser.cs @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Handlers; + +namespace AWS.Messaging.Serialization.Parsers +{ + /// + /// Parser for messages originating from Amazon Simple Notification Service (SNS). + /// + internal class SNSMessageParser : IMessageParser + { + /// + /// Determines if the JSON element represents an SNS message by checking for required properties. + /// + /// The root JSON element to examine. + /// True if the message can be parsed as an SNS message; otherwise, false. + public bool CanParse(JsonElement root) + { + return root.TryGetProperty("Type", out var type) && + type.GetString() == "Notification" && + root.TryGetProperty("MessageId", out _) && + root.TryGetProperty("TopicArn", out _); + } + + /// + /// Parses an SNS message, extracting the inner message body and metadata. + /// + /// The root JSON element containing the SNS message. + /// The original SQS message. + /// A tuple containing the extracted message body and associated metadata. + /// Thrown when the SNS message does not contain a valid Message property. + public (string MessageBody, MessageMetadata Metadata) Parse(JsonElement root, Message originalMessage) + { + // Extract the inner message from the SNS wrapper + var messageBody = root.GetProperty("Message").GetString() + ?? throw new InvalidOperationException("SNS message does not contain a valid Message property"); + + var metadata = new MessageMetadata + { + SNSMetadata = MessageMetadataHandler.CreateSNSMetadata(root) + }; + + return (messageBody, metadata); + } + } +} diff --git a/src/AWS.Messaging/Serialization/Parsers/SQSMessageParser.cs b/src/AWS.Messaging/Serialization/Parsers/SQSMessageParser.cs new file mode 100644 index 00000000..cd0380ea --- /dev/null +++ b/src/AWS.Messaging/Serialization/Parsers/SQSMessageParser.cs @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Handlers; + +namespace AWS.Messaging.Serialization.Parsers; + +/// +/// Default fallback parser for Amazon Simple Queue Service (SQS) messages. +/// This parser handles messages that don't match other specialized parsers. +/// +internal class SQSMessageParser : IMessageParser +{ + /// + /// Always returns true as this is the default fallback parser for any message format. + /// + /// The root JSON element (unused in this implementation). + /// Always returns true, indicating this parser can handle any remaining message format. + public bool CanParse(JsonElement _) => true; // Default fallback parser + + /// + /// Parses an SQS message, preserving the original message body and adding SQS metadata. + /// + /// The root JSON element containing the message content. + /// The original SQS message containing metadata information. + /// A tuple containing the unchanged message body and associated SQS metadata. + public (string MessageBody, MessageMetadata Metadata) Parse(JsonElement root, Message originalMessage) + { + var metadata = new MessageMetadata + { + SQSMetadata = MessageMetadataHandler.CreateSQSMetadata(originalMessage) + }; + + // Return the raw message without modification since this is the base parser + return (root.GetRawText(), metadata); + } +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs index bf231999..22a79d7a 100644 --- a/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs +++ b/test/AWS.Messaging.UnitTests/SerializationTests/EnvelopeSerializerTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -227,7 +228,7 @@ public async Task ConvertToEnvelope_With_SNSOuterEnvelope_In_SQSMessageBody() var sqsMessage = new Message { - Body = JsonSerializer.Serialize(outerMessageEnvelope) + Body = JsonSerializer.Serialize(outerMessageEnvelope), }; // ACT @@ -480,42 +481,74 @@ public async Task SerializeAsync_DataMessageLogging_NoError(bool dataMessageLogg [InlineData(true)] public async Task SerializeAsync_DataMessageLogging_WithError(bool dataMessageLogging) { + // ARRANGE var logger = new Mock>(); - var messageConfiguration = new MessageConfiguration { LogMessageContent = dataMessageLogging }; + 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 + 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 MessageEnvelope + Message = new AddressInfo { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate + Street = "Prince St", + Unit = 123, + ZipCode = "00001" } }; - messageSerializer.Setup(x => x.Serialize(It.IsAny())).Throws(new JsonException("Test exception")); - var exception = await Assert.ThrowsAsync(async () => await envelopeSerializer.SerializeAsync(messageEnvelope)); + // 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] @@ -523,49 +556,125 @@ public async Task SerializeAsync_DataMessageLogging_WithError(bool dataMessageLo [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); - var messageEnvelope = new MessageEnvelope - { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = new MessageEnvelope - { - Id = "123", - Source = new Uri("/aws/messaging", UriKind.Relative), - Version = "1.0", - MessageTypeIdentifier = "addressInfo", - TimeStamp = _testdate, - Message = "Test" - } - }; + 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 = JsonSerializer.Serialize(messageEnvelope), + Body = "invalid json {", ReceiptHandle = "receipt-handle" }; - messageSerializer.Setup(x => x.Serialize(It.IsAny())).Returns(@"{}"); - messageSerializer.Setup(x => x.Deserialize(It.IsAny(), It.IsAny())).Throws(new JsonException("Test exception")); - var exception = await Assert.ThrowsAsync(async () => await envelopeSerializer.ConvertToEnvelopeAsync(sqsMessage)); + // 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 jsonString = @"{ + ""id"": ""test-id-123"", + ""source"": ""/aws/messaging"", + ""specversion"": ""1.0"", + ""type"": ""addressInfo"", + ""time"": ""2000-12-05T10:30:55+00:00"", + ""data"": ""{\""Unit\"":123,\""Street\"":\""Prince St\"",\""ZipCode\"":\""00001\""}"", + ""customString"": ""test-value"", + ""customNumber"": 42, + ""customBoolean"": true, + ""customObject"": {""nestedKey"": ""nestedValue""} + }"; + + var sqsMessage = new Message + { + Body = jsonString + }; + + // 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] diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/Handlers/MessageDataHandlerTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/Handlers/MessageDataHandlerTests.cs new file mode 100644 index 00000000..61171661 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/Handlers/MessageDataHandlerTests.cs @@ -0,0 +1,188 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Handlers; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests.Handlers; + +public class MessageMetadataHandlerTests +{ + [Fact] + public void CreateSQSMetadata_WithBasicMessage_ReturnsCorrectMetadata() + { + // Arrange + var message = new Message + { + MessageId = "test-message-id", + ReceiptHandle = "test-receipt-handle", + MessageAttributes = new Dictionary + { + { "TestAttribute", new MessageAttributeValue { StringValue = "TestValue" } } + } + }; + + // Act + var metadata = MessageMetadataHandler.CreateSQSMetadata(message); + + // Assert + Assert.Equal("test-message-id", metadata.MessageID); + Assert.Equal("test-receipt-handle", metadata.ReceiptHandle); + Assert.Single(metadata.MessageAttributes); + Assert.Equal("TestValue", metadata.MessageAttributes["TestAttribute"].StringValue); + } + + [Fact] + public void CreateSQSMetadata_WithFIFOAttributes_ReturnsCorrectMetadata() + { + // Arrange + var message = new Message + { + MessageId = "test-message-id", + Attributes = new Dictionary + { + { "MessageGroupId", "group-1" }, + { "MessageDeduplicationId", "dedup-1" } + } + }; + + // Act + var metadata = MessageMetadataHandler.CreateSQSMetadata(message); + + // Assert + Assert.Equal("group-1", metadata.MessageGroupId); + Assert.Equal("dedup-1", metadata.MessageDeduplicationId); + } + + [Fact] + public void CreateSNSMetadata_WithValidJson_ReturnsCorrectMetadata() + { + // Arrange + var json = @"{ + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Timestamp"": ""2024-03-15T10:00:00.000Z"", + ""UnsubscribeURL"": ""https://sns.region.amazonaws.com/unsubscribe"", + ""Subject"": ""Test Subject"", + ""MessageAttributes"": { + ""TestAttribute"": { + ""Type"": ""String"", + ""Value"": ""TestValue"" + } + } + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var metadata = MessageMetadataHandler.CreateSNSMetadata(root); + + // Assert + Assert.Equal("test-message-id", metadata.MessageId); + Assert.Equal("arn:aws:sns:region:account:topic", metadata.TopicArn); + Assert.Equal(DateTimeOffset.Parse("2024-03-15T10:00:00.000Z"), metadata.Timestamp); + Assert.Equal("https://sns.region.amazonaws.com/unsubscribe", metadata.UnsubscribeURL); + Assert.Equal("Test Subject", metadata.Subject); + Assert.NotNull(metadata.MessageAttributes); + Assert.Single(metadata.MessageAttributes); + } + + [Fact] + public void CreateSNSMetadata_WithMissingOptionalFields_ReturnsPartialMetadata() + { + // Arrange + var json = @"{ + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"" + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var metadata = MessageMetadataHandler.CreateSNSMetadata(root); + + // Assert + Assert.Equal("test-message-id", metadata.MessageId); + Assert.Equal("arn:aws:sns:region:account:topic", metadata.TopicArn); + Assert.Equal(default, metadata.Timestamp); + Assert.Null(metadata.UnsubscribeURL); + Assert.Null(metadata.Subject); + Assert.Null(metadata.MessageAttributes); + } + + [Fact] + public void CreateEventBridgeMetadata_WithValidJson_ReturnsCorrectMetadata() + { + // Arrange + var json = @"{ + ""id"": ""test-event-id"", + ""detail-type"": ""test-detail-type"", + ""source"": ""test-source"", + ""account"": ""123456789012"", + ""time"": ""2024-03-15T10:00:00Z"", + ""region"": ""us-east-1"", + ""resources"": [ + ""resource1"", + ""resource2"" + ] + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var metadata = MessageMetadataHandler.CreateEventBridgeMetadata(root); + + // Assert + Assert.Equal("test-event-id", metadata.EventId); + Assert.Equal("test-detail-type", metadata.DetailType); + Assert.Equal("test-source", metadata.Source); + Assert.Equal("123456789012", metadata.AWSAccount); + Assert.Equal(DateTimeOffset.Parse("2024-03-15T10:00:00Z"), metadata.Time); + Assert.Equal("us-east-1", metadata.AWSRegion); + Assert.Equal(2, metadata.Resources.Count); + Assert.Contains("resource1", metadata.Resources); + Assert.Contains("resource2", metadata.Resources); + } + + [Fact] + public void CreateEventBridgeMetadata_WithMissingOptionalFields_ReturnsPartialMetadata() + { + // Arrange + var json = @"{ + ""id"": ""test-event-id"", + ""source"": ""test-source"" + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var metadata = MessageMetadataHandler.CreateEventBridgeMetadata(root); + + // Assert + Assert.Equal("test-event-id", metadata.EventId); + Assert.Equal("test-source", metadata.Source); + Assert.Null(metadata.DetailType); + Assert.Null(metadata.AWSAccount); + Assert.Equal(default, metadata.Time); + Assert.Null(metadata.AWSRegion); + Assert.Null(metadata.Resources); + } + + [Fact] + public void CreateEventBridgeMetadata_WithEmptyResources_ReturnsEmptyResourcesList() + { + // Arrange + var json = @"{ + ""id"": ""test-event-id"", + ""resources"": [] + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var metadata = MessageMetadataHandler.CreateEventBridgeMetadata(root); + + // Assert + Assert.NotNull(metadata.Resources); + Assert.Empty(metadata.Resources); + } +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/Helpers/JsonPropertyHelperTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/Helpers/JsonPropertyHelperTests.cs new file mode 100644 index 00000000..8ff85427 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/Helpers/JsonPropertyHelperTests.cs @@ -0,0 +1,209 @@ +// 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.Text.Json; +using AWS.Messaging.Serialization.Helpers; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests.Helpers; + +public class JsonPropertyHelperTests +{ + [Fact] + public void GetPropertyValue_WithExistingProperty_ReturnsValue() + { + // Arrange + var json = @"{""testProperty"": ""testValue""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = JsonPropertyHelper.GetPropertyValue(root, "testProperty", element => element.GetString()); + + // Assert + Assert.Equal("testValue", result); + } + + [Fact] + public void GetPropertyValue_WithMissingProperty_ReturnsDefault() + { + // Arrange + var json = @"{""otherProperty"": ""value""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = JsonPropertyHelper.GetPropertyValue(root, "testProperty", element => element.GetString()); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetRequiredProperty_WithExistingProperty_ReturnsValue() + { + // Arrange + var json = @"{""testProperty"": ""testValue""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = JsonPropertyHelper.GetRequiredProperty(root, "testProperty", element => element.GetString()); + + // Assert + Assert.Equal("testValue", result); + } + + [Fact] + public void GetRequiredProperty_WithMissingProperty_ThrowsException() + { + // Arrange + var json = @"{""otherProperty"": ""value""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act & Assert + var exception = Assert.Throws( + () => JsonPropertyHelper.GetRequiredProperty(root, "testProperty", element => element.GetString())); + Assert.Equal("Required property 'testProperty' is missing", exception.Message); + } + + [Fact] + public void GetRequiredProperty_WithInvalidConversion_ThrowsException() + { + // Arrange + var json = @"{""testProperty"": ""not-a-number""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act & Assert + var exception = Assert.Throws( + () => JsonPropertyHelper.GetRequiredProperty(root, "testProperty", element => element.GetInt32())); + Assert.Equal("Failed to get or convert property 'testProperty'", exception.Message); + } + + [Fact] + public void GetStringProperty_WithValidString_ReturnsString() + { + // Arrange + var json = @"{""testProperty"": ""testValue""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = JsonPropertyHelper.GetStringProperty(root, "testProperty"); + + // Assert + Assert.Equal("testValue", result); + } + + [Fact] + public void GetStringProperty_WithMissingProperty_ReturnsNull() + { + // Arrange + var json = @"{""otherProperty"": ""value""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = JsonPropertyHelper.GetStringProperty(root, "testProperty"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetDateTimeOffsetProperty_WithValidDate_ReturnsDateTimeOffset() + { + // Arrange + var json = @"{""testDate"": ""2024-03-15T10:00:00Z""}"; + var root = JsonDocument.Parse(json).RootElement; + var expectedDate = DateTimeOffset.Parse("2024-03-15T10:00:00Z"); + + // Act + var result = JsonPropertyHelper.GetDateTimeOffsetProperty(root, "testDate"); + + // Assert + Assert.Equal(expectedDate, result); + } + + [Fact] + public void GetDateTimeOffsetProperty_WithInvalidDate_ReturnsNull() + { + // Arrange + var json = @"{""testDate"": ""invalid-date""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act & Assert + Assert.Throws( + () => JsonPropertyHelper.GetDateTimeOffsetProperty(root, "testDate")); + } + + [Fact] + public void GetUriProperty_WithValidUri_ReturnsUri() + { + // Arrange + var json = @"{""testUri"": ""https://example.com""}"; + var root = JsonDocument.Parse(json).RootElement; + var expectedUri = new Uri("https://example.com"); + + // Act + var result = JsonPropertyHelper.GetUriProperty(root, "testUri"); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void GetAttributeValue_WithExistingKey_ReturnsValue() + { + // Arrange + var attributes = new Dictionary + { + { "testKey", "testValue" } + }; + + // Act + var result = JsonPropertyHelper.GetAttributeValue(attributes, "testKey"); + + // Assert + Assert.Equal("testValue", result); + } + + [Fact] + public void GetAttributeValue_WithMissingKey_ReturnsNull() + { + // Arrange + var attributes = new Dictionary + { + { "otherKey", "value" } + }; + + // Act + var result = JsonPropertyHelper.GetAttributeValue(attributes, "testKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAttributeValue_WithEmptyDictionary_ReturnsNull() + { + // Arrange + var attributes = new Dictionary(); + + // Act + var result = JsonPropertyHelper.GetAttributeValue(attributes, "testKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetPropertyValue_WithNullConverter_ThrowsArgumentNullException() + { + // Arrange + var json = @"{""testProperty"": ""testValue""}"; + var root = JsonDocument.Parse(json).RootElement; + + // Act & Assert + Assert.Throws(() => + JsonPropertyHelper.GetPropertyValue(root, "testProperty", null!)); + } +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/EventBridgeMessageParserTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/EventBridgeMessageParserTests.cs new file mode 100644 index 00000000..8d218a74 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/EventBridgeMessageParserTests.cs @@ -0,0 +1,174 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Parsers; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests.Parsers; + +public class EventBridgeMessageParserTests +{ + private readonly EventBridgeMessageParser _parser; + + public EventBridgeMessageParserTests() + { + _parser = new EventBridgeMessageParser(); + } + + [Fact] + public void CanParse_WithValidEventBridgeMessage_ReturnsTrue() + { + // Arrange + var json = @"{ + ""detail"": { ""someData"": ""value"" }, + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"" + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = _parser.CanParse(root); + + // Assert + Assert.True(result); + } + + [Theory] + [MemberData(nameof(InvalidEventBridgeMessages))] + public void CanParse_WithInvalidEventBridgeMessage_ReturnsFalse(string json) + { + // Arrange + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = _parser.CanParse(root); + + // Assert + Assert.False(result); + } + + [Fact] + public void Parse_WithObjectDetail_ReturnsRawJson() + { + // Arrange + var json = @"{ + ""detail"": { ""someData"": ""value"" }, + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"", + ""id"": ""test-id"", + ""account"": ""123456789012"", + ""region"": ""us-east-1"", + ""resources"": [""resource1"", ""resource2""] + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act + var (messageBody, metadata) = _parser.Parse(root, message); + + // Assert + Assert.Contains("\"someData\"", messageBody); + Assert.Contains("\"value\"", messageBody); + Assert.NotNull(metadata.EventBridgeMetadata); + Assert.Equal("test-type", metadata.EventBridgeMetadata.DetailType); + Assert.Equal("test-source", metadata.EventBridgeMetadata.Source); + Assert.Equal("test-id", metadata.EventBridgeMetadata.EventId); + Assert.Equal(2, metadata.EventBridgeMetadata.Resources?.Count); + } + + [Fact] + public void Parse_WithStringDetail_ReturnsString() + { + // Arrange + var json = @"{ + ""detail"": ""string message"", + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"", + ""id"": ""test-id"" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act + var (messageBody, metadata) = _parser.Parse(root, message); + + // Assert + Assert.Equal("string message", messageBody); + Assert.NotNull(metadata.EventBridgeMetadata); + Assert.Equal("test-type", metadata.EventBridgeMetadata.DetailType); + } + + [Fact] + public void Parse_WithEmptyDetail_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{ + ""detail"": """", + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act & Assert + var exception = Assert.Throws( + () => _parser.Parse(root, message)); + Assert.Equal("EventBridge message does not contain a valid detail property", exception.Message); + } + + [Fact] + public void Parse_WithNullDetail_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{ + ""detail"": null, + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act & Assert + var exception = Assert.Throws( + () => _parser.Parse(root, message)); + Assert.Equal("EventBridge message does not contain a valid detail property", exception.Message); + } + + public static TheoryData InvalidEventBridgeMessages => new() + { + // Missing detail + @"{ + ""detail-type"": ""test-type"", + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"" + }", + // Missing detail-type + @"{ + ""detail"": { ""someData"": ""value"" }, + ""source"": ""test-source"", + ""time"": ""2024-03-15T10:00:00Z"" + }", + // Missing source + @"{ + ""detail"": { ""someData"": ""value"" }, + ""detail-type"": ""test-type"", + ""time"": ""2024-03-15T10:00:00Z"" + }", + // Missing time + @"{ + ""detail"": { ""someData"": ""value"" }, + ""detail-type"": ""test-type"", + ""source"": ""test-source"" + }", + // Empty object + @"{}" + }; +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SNSMessageParserTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SNSMessageParserTests.cs new file mode 100644 index 00000000..a83bf8d4 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SNSMessageParserTests.cs @@ -0,0 +1,199 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Parsers; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests.Parsers; + +public class SNSMessageParserTests +{ + private readonly SNSMessageParser _parser; + + public SNSMessageParserTests() + { + _parser = new SNSMessageParser(); + } + + [Fact] + public void CanParse_WithValidSNSMessage_ReturnsTrue() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""test message"" + }"; + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = _parser.CanParse(root); + + // Assert + Assert.True(result); + } + + [Theory] + [MemberData(nameof(InvalidSNSMessages))] + public void CanParse_WithInvalidSNSMessage_ReturnsFalse(string json) + { + // Arrange + var root = JsonDocument.Parse(json).RootElement; + + // Act + var result = _parser.CanParse(root); + + // Assert + Assert.False(result); + } + + [Fact] + public void Parse_WithValidMessage_ReturnsMessageAndMetadata() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""test message"", + ""Timestamp"": ""2024-03-15T10:00:00.000Z"", + ""Subject"": ""Test Subject"", + ""UnsubscribeURL"": ""https://sns.region.amazonaws.com/unsubscribe"", + ""MessageAttributes"": { + ""TestAttribute"": { + ""Type"": ""String"", + ""Value"": ""TestValue"" + } + } + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act + var (messageBody, metadata) = _parser.Parse(root, message); + + // Assert + Assert.Equal("test message", messageBody); + Assert.NotNull(metadata.SNSMetadata); + Assert.Equal("test-message-id", metadata.SNSMetadata.MessageId); + Assert.Equal("arn:aws:sns:region:account:topic", metadata.SNSMetadata.TopicArn); + Assert.Equal("Test Subject", metadata.SNSMetadata.Subject); + Assert.Equal("https://sns.region.amazonaws.com/unsubscribe", metadata.SNSMetadata.UnsubscribeURL); + } + + [Fact] + public void Parse_WithMissingMessage_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act & Assert + var exception = Assert.Throws( + () => _parser.Parse(root, message)); + } + + [Fact] + public void Parse_WithNullMessage_ThrowsInvalidOperationException() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": null + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act & Assert + var exception = Assert.Throws( + () => _parser.Parse(root, message)); + Assert.Equal("SNS message does not contain a valid Message property", exception.Message); + } + + [Fact] + public void Parse_WithEmptyMessage_ReturnsEmptyString() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": """" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act + var (messageBody, metadata) = _parser.Parse(root, message); + + // Assert + Assert.Equal("", messageBody); + Assert.NotNull(metadata.SNSMetadata); + } + + public static TheoryData InvalidSNSMessages => new() + { + // Missing Type + @"{ + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""test message"" + }", + // Wrong Type + @"{ + ""Type"": ""WrongType"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""test message"" + }", + // Missing MessageId + @"{ + ""Type"": ""Notification"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""test message"" + }", + // Missing TopicArn + @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""Message"": ""test message"" + }", + // Empty object + @"{}" + }; + + [Fact] + public void Parse_WithJsonObjectMessage_ReturnsJsonString() + { + // Arrange + var json = @"{ + ""Type"": ""Notification"", + ""MessageId"": ""test-message-id"", + ""TopicArn"": ""arn:aws:sns:region:account:topic"", + ""Message"": ""{\""key\"":\""value\""}"" + }"; + var root = JsonDocument.Parse(json).RootElement; + var message = new Message(); + + // Act + var (messageBody, metadata) = _parser.Parse(root, message); + + // Assert + Assert.Equal("{\"key\":\"value\"}", messageBody); + Assert.NotNull(metadata.SNSMetadata); + } + + +} diff --git a/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SQSMessageParserTests.cs b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SQSMessageParserTests.cs new file mode 100644 index 00000000..36179e67 --- /dev/null +++ b/test/AWS.Messaging.UnitTests/SerializationTests/Parsers/SQSMessageParserTests.cs @@ -0,0 +1,184 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Text.Json; +using Amazon.SQS.Model; +using AWS.Messaging.Serialization.Parsers; +using Xunit; + +namespace AWS.Messaging.UnitTests.SerializationTests.Parsers; + +public class SQSMessageParserTests +{ + private readonly SQSMessageParser _parser; + + public SQSMessageParserTests() + { + _parser = new SQSMessageParser(); + } + + [Fact] + public void CanParse_AlwaysReturnsTrue() + { + // Arrange + var validJson = @"{""key"": ""value""}"; + var emptyJson = "{}"; + var arrayJson = @"[1,2,3]"; + var validRoot = JsonDocument.Parse(validJson).RootElement; + var emptyRoot = JsonDocument.Parse(emptyJson).RootElement; + var arrayRoot = JsonDocument.Parse(arrayJson).RootElement; + + // Act & Assert + Assert.True(_parser.CanParse(validRoot)); + Assert.True(_parser.CanParse(emptyRoot)); + Assert.True(_parser.CanParse(arrayRoot)); + } + + [Fact] + public void Parse_WithSimpleMessage_ReturnsOriginalMessageAndMetadata() + { + // Arrange + var json = @"{""key"": ""value""}"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id", + ReceiptHandle = "test-receipt-handle" + }; + + // Act + var (messageBody, metadata) = _parser.Parse(root, originalMessage); + + // Assert + Assert.Equal(json, messageBody); + Assert.NotNull(metadata.SQSMetadata); + Assert.Equal("test-message-id", metadata.SQSMetadata.MessageID); + Assert.Equal("test-receipt-handle", metadata.SQSMetadata.ReceiptHandle); + } + + [Fact] + public void Parse_WithMessageAttributes_IncludesAttributesInMetadata() + { + // Arrange + var json = @"{""content"": ""test""}"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id", + MessageAttributes = new Dictionary + { + { + "TestAttribute", + new Amazon.SQS.Model.MessageAttributeValue { StringValue = "TestValue" } + } + } + }; + + // Act + var (messageBody, metadata) = _parser.Parse(root, originalMessage); + + // Assert + Assert.Equal(json, messageBody); + Assert.NotNull(metadata.SQSMetadata); + Assert.Single(metadata.SQSMetadata.MessageAttributes); + Assert.Equal("TestValue", metadata.SQSMetadata.MessageAttributes["TestAttribute"].StringValue); + } + + [Fact] + public void Parse_WithFIFOQueueAttributes_IncludesAttributesInMetadata() + { + // Arrange + var json = @"{""data"": ""test""}"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id", + Attributes = new Dictionary + { + { "MessageGroupId", "group-1" }, + { "MessageDeduplicationId", "dedup-1" } + } + }; + + // Act + var (messageBody, metadata) = _parser.Parse(root, originalMessage); + + // Assert + Assert.Equal(json, messageBody); + Assert.NotNull(metadata.SQSMetadata); + Assert.Equal("group-1", metadata.SQSMetadata.MessageGroupId); + Assert.Equal("dedup-1", metadata.SQSMetadata.MessageDeduplicationId); + } + + [Fact] + public void Parse_WithArrayMessage_ReturnsOriginalArray() + { + // Arrange + var json = @"[1,2,3]"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id" + }; + + // Act + var (messageBody, metadata) = _parser.Parse(root, originalMessage); + + // Assert + Assert.Equal(json, messageBody); + Assert.NotNull(metadata.SQSMetadata); + } + + [Fact] + public void Parse_WithEmptyMessage_ReturnsEmptyObject() + { + // Arrange + var json = "{}"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id" + }; + + // Act + var (messageBody, metadata) = _parser.Parse(root, originalMessage); + + // Assert + Assert.Equal(json, messageBody); + Assert.NotNull(metadata.SQSMetadata); + } + + [Fact] + public void Parse_WithComplexNestedMessage_PreservesStructure() + { + // Arrange + var json = @"{ + ""id"": 1, + ""nested"": { + ""array"": [1,2,3], + ""object"": { + ""key"": ""value"" + } + } + }"; + var root = JsonDocument.Parse(json).RootElement; + var originalMessage = new Message + { + MessageId = "test-message-id" + }; + + // Act + var (messageBody, _) = _parser.Parse(root, originalMessage); + + // Assert + var deserializedMessage = JsonDocument.Parse(messageBody).RootElement; + Assert.Equal(1, deserializedMessage.GetProperty("id").GetInt32()); + Assert.True(deserializedMessage.GetProperty("nested").TryGetProperty("array", out var array)); + Assert.Equal(3, array.GetArrayLength()); + Assert.Equal("value", deserializedMessage.GetProperty("nested") + .GetProperty("object") + .GetProperty("key") + .GetString()); + } +}