diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs index 35a739a8..dda4583f 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs @@ -63,7 +63,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto var conversationId = Guid.CreateVersion7().ToString("N"); var now = DateTimeOffset.UtcNow; - var item = new Dictionary + var item = new Dictionary(8) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue, @@ -111,7 +111,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto var request = new GetItemRequest { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -145,7 +145,7 @@ public async Task> GetConversationWithMessages { TableName = _configuration.TableName, KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk AND begins_with({_configuration.SortKeyName}, :skPrefix)", - ExpressionAttributeValues = new Dictionary + ExpressionAttributeValues = new Dictionary(2) { [":pk"] = _configuration.ConversationPkFormat(conversationId), [":skPrefix"] = _configuration.MessageSkPrefix @@ -212,10 +212,10 @@ public Task>> AddMessagesAsync( IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage)> messages, CancellationToken ct) { - return AddMessagesInternalAsync( - conversationId, - messages.Select(m => (m.Role, m.Message, m.TokenUsage, (IReadOnlyDictionary>?)null)), - ct); + var expanded = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)>(); + foreach (var m in messages) + expanded.Add((m.Role, m.Message, m.TokenUsage, null)); + return AddMessagesInternalAsync(conversationId, expanded, ct); } private async Task>> AddMessagesInternalAsync( @@ -223,7 +223,7 @@ private async Task>> AddMessagesInter IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)> messages, CancellationToken ct) { - var messageList = messages.ToList(); + var messageList = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary>? ExtractedTags)>(messages); if (messageList.Count == 0) return Error.Validation(ConversationErrorCodes.MessagesEmpty, "At least one message must be provided"); @@ -255,7 +255,7 @@ private async Task>> AddMessagesInter var sequenceNumber = startSequence + i; var messageId = Guid.NewGuid().ToString("N"); - var messageItem = new Dictionary + var messageItem = new Dictionary(12) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.MessageSkFormat(conversationId, sequenceNumber), @@ -292,7 +292,7 @@ private async Task>> AddMessagesInter // Add extracted tags if present if (extractedTags is not null && extractedTags.Count > 0) { - var tagsMap = new Dictionary(); + var tagsMap = new Dictionary(extractedTags.Count); foreach (var kvp in extractedTags) { var tagValues = new List(); @@ -335,13 +335,13 @@ private async Task>> AddMessagesInter } // Build update expression for conversation - var updateParts = new List + var updateParts = new List(5) { $"{MessageCountAttribute} = {MessageCountAttribute} + :msgCount", $"{UpdatedAtAttribute} = :updatedAt" }; - var updateExpressionValues = new Dictionary + var updateExpressionValues = new Dictionary(6) { [":msgCount"] = AttributeValue.Number(messageList.Count.ToString()), [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) @@ -363,7 +363,7 @@ private async Task>> AddMessagesInter Update = new TransactUpdateItem { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -393,12 +393,12 @@ private async Task>> AddMessagesInter { var now = DateTimeOffset.UtcNow; - var updateParts = new List { $"{UpdatedAtAttribute} = :updatedAt" }; - var expressionAttributeValues = new Dictionary + var updateParts = new List(4) { $"{UpdatedAtAttribute} = :updatedAt" }; + var expressionAttributeValues = new Dictionary(4) { [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) }; - var expressionAttributeNames = new Dictionary(); + var expressionAttributeNames = new Dictionary(1); if (metadata.Title is not null) { @@ -422,7 +422,7 @@ private async Task>> AddMessagesInter { updateParts.Add($"#customData = :customData"); expressionAttributeNames["#customData"] = CustomDataAttribute; - var customDataMap = new Dictionary(); + var customDataMap = new Dictionary(metadata.CustomData.Count); foreach (var kvp in metadata.CustomData) { customDataMap[kvp.Key] = kvp.Value; @@ -433,7 +433,7 @@ private async Task>> AddMessagesInter var updateRequest = new UpdateItemRequest { TableName = _configuration.TableName, - Key = new Dictionary + Key = new Dictionary(2) { [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue @@ -469,7 +469,7 @@ public async Task> DeleteConversationAsync(string conversationI { TableName = _configuration.TableName, KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk", - ExpressionAttributeValues = new Dictionary + ExpressionAttributeValues = new Dictionary(1) { [":pk"] = pk } @@ -486,7 +486,7 @@ public async Task> DeleteConversationAsync(string conversationI foreach (var item in queryResult.Value.Items) { - allItems.Add(new Dictionary + allItems.Add(new Dictionary(2) { [_configuration.PartitionKeyName] = item[_configuration.PartitionKeyName]!.Value, [_configuration.SortKeyName] = item[_configuration.SortKeyName]!.Value @@ -503,15 +503,19 @@ public async Task> DeleteConversationAsync(string conversationI const int batchSize = 25; for (var i = 0; i < allItems.Count; i += batchSize) { - var batch = allItems.Skip(i).Take(batchSize).ToList(); - var transactItems = batch.Select(key => new TransactWriteItem + var end = Math.Min(i + batchSize, allItems.Count); + var transactItems = new List(end - i); + for (var j = i; j < end; j++) { - Delete = new TransactDeleteItem + transactItems.Add(new TransactWriteItem { - TableName = _configuration.TableName, - Key = key - } - }).ToList(); + Delete = new TransactDeleteItem + { + TableName = _configuration.TableName, + Key = allItems[j] + } + }); + } var transactRequest = new TransactWriteRequest { @@ -539,7 +543,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< if (metadata.CustomData.Count > 0) { - var customDataMap = new Dictionary(); + var customDataMap = new Dictionary(metadata.CustomData.Count); foreach (var kvp in metadata.CustomData) { customDataMap[kvp.Key] = kvp.Value; @@ -607,7 +611,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< if (record.TryGetStringSet(TagsAttribute, out var tags)) { - metadata.Tags = tags.ToList(); + metadata.Tags = new List(tags); hasMetadata = true; } @@ -665,7 +669,7 @@ private ErrorOr DeserializeMessage(DynamoRecord record) IReadOnlyDictionary>? extractedTags = null; if (record.TryGetMap(ExtractedTagsAttribute, out var tagsMap) && tagsMap is not null) { - var tags = new Dictionary>(); + var tags = new Dictionary>(tagsMap.Count); foreach (var kvp in tagsMap) { if (kvp.Value.L is not null) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs index 54092c39..b338c978 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStoreConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Dynamo; /// /// Configuration for the DynamoDB-backed conversation store. /// -public class DynamoConversationStoreConfiguration +public sealed class DynamoConversationStoreConfiguration { /// /// The name of the DynamoDB table to store conversations. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs index 95a772d9..0c9d5150 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs @@ -26,7 +26,7 @@ public static ErrorOr Serialize(ContentBlock block) { if (block.Text is not null) { - return AttributeValue.FromMap(new Dictionary + return AttributeValue.FromMap(new Dictionary(2) { [TypeAttribute] = TextType, ["text"] = block.Text @@ -39,7 +39,7 @@ public static ErrorOr Serialize(ContentBlock block) if (validation.IsError) return validation.Errors; - var imageMap = new Dictionary + var imageMap = new Dictionary(4) { [TypeAttribute] = ImageType, ["format"] = block.Image.Format @@ -61,7 +61,7 @@ public static ErrorOr Serialize(ContentBlock block) if (validation.IsError) return validation.Errors; - var docMap = new Dictionary + var docMap = new Dictionary(5) { [TypeAttribute] = DocumentType, ["format"] = block.Document.Format, @@ -80,7 +80,7 @@ public static ErrorOr Serialize(ContentBlock block) if (block.ToolUse is not null) { - var toolUseMap = new Dictionary + var toolUseMap = new Dictionary(4) { [TypeAttribute] = ToolUseType, ["toolUseId"] = block.ToolUse.ToolUseId, @@ -102,7 +102,7 @@ public static ErrorOr Serialize(ContentBlock block) contentList.Add(serialized.Value); } - var toolResultMap = new Dictionary + var toolResultMap = new Dictionary(4) { [TypeAttribute] = ToolResultType, ["toolUseId"] = block.ToolResult.ToolUseId, diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs index bb870330..d31fa971 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs @@ -88,7 +88,7 @@ public async Task> SendAsync(IEnumerable con var userMessage = new Message { Role = ConversationRole.User, - Content = content.ToList() + Content = new List(content) }; // Clean message content (extract XML tags) @@ -163,24 +163,32 @@ public async Task> SendAsync(IEnumerable con } // Execute tools - var toolUseBlocks = assistantMessage.Content - .Where(c => c.ToolUse != null) - .Select(c => c.ToolUse!) - .ToList(); - var toolResults = new List(); - foreach (var toolUse in toolUseBlocks) + foreach (var block in assistantMessage.Content) { + var toolUse = block.ToolUse; + if (toolUse is null) continue; + var toolResult = await _toolAdapter.ExecuteToolAsync(toolUse, ct).ConfigureAwait(false); toolResults.Add(new ContentBlock { ToolResult = toolResult }); + string resultText = string.Empty; + foreach (var resultContent in toolResult.Content) + { + if (resultContent.Text is not null) + { + resultText = resultContent.Text; + break; + } + } + // Track tool execution toolExecutions.Add(new ToolExecution { ToolName = toolUse.Name, ToolUseId = toolUse.ToolUseId, Input = JsonDocument.Parse(toolUse.Input.GetRawText()), - Result = toolResult.Content.FirstOrDefault()?.Text ?? string.Empty, + Result = resultText, Success = toolResult.Status == "success" }); } @@ -218,10 +226,15 @@ public async Task> SendAsync(IEnumerable con var (cleanedFinalMessage, finalExtractedTags) = CleanMessageContent(finalMessage); // Extract cleaned text content - var cleanedText = cleanedFinalMessage.Content - .Where(c => c.Text != null) - .Select(c => c.Text!) - .FirstOrDefault() ?? string.Empty; + var cleanedText = string.Empty; + foreach (var block in cleanedFinalMessage.Content) + { + if (block.Text is not null) + { + cleanedText = block.Text; + break; + } + } // Detect thinking-only response: tags were extracted but no real text remains. // Note: CleanMessageContent preserves original content when all text blocks are @@ -305,13 +318,17 @@ public async Task> SendAsync(IEnumerable con /// public Task>> GetHistoryAsync(CancellationToken ct = default) { - var history = _messages.Select((m, index) => new ChatMessage + var history = new List(_messages.Count); + foreach (var m in _messages) { - Role = m.Role, - Content = m.Content, - Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps - TokenUsage = null - }).ToList(); + history.Add(new ChatMessage + { + Role = m.Role, + Content = m.Content, + Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps + TokenUsage = null + }); + } return Task.FromResult>>(history); } @@ -416,14 +433,16 @@ private static (Message CleanedMessage, IReadOnlyDictionary kvp.Key, - kvp => (IReadOnlyList)kvp.Value); + var result = new Dictionary>(allTags.Count); + foreach (var kvp in allTags) + { + result[kvp.Key] = kvp.Value; + } if (cleanedContent.Count == 0 && message.Content.Count > 0) { // Tag extraction emptied all content - preserve original to avoid empty messages - return (new Message { Role = message.Role, Content = message.Content.ToList() }, result); + return (new Message { Role = message.Role, Content = new List(message.Content) }, result); } return (new Message { Role = message.Role, Content = cleanedContent }, result); @@ -452,7 +471,10 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList? bedrockTools) if (_options.StopSequences is { Count: > 0 }) { - builder.WithStopSequences(_options.StopSequences.ToArray()); + if (_options.StopSequences is string[] arr) + builder.WithStopSequences(arr); + else + builder.WithStopSequences(_options.StopSequences.ToArray()); } if (_options.ServiceTier.HasValue) @@ -471,7 +493,7 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList? bedrockTools) // Add tools if any are registered if (bedrockTools is { Count: > 0 }) { - request.ToolConfig = new ToolConfiguration { Tools = bedrockTools.ToList() }; + request.ToolConfig = new ToolConfiguration { Tools = new List(bedrockTools) }; } if (_options.OutputConfig is not null) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs index df1be89c..ace422fd 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/Conversation.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a conversation with a Bedrock model. /// -public class Conversation +public sealed class Conversation { /// /// The unique identifier for the conversation. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs index fc4fea2f..be86902e 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationListResult.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents the result of listing conversations. /// -public class ConversationListResult +public sealed class ConversationListResult { /// /// The list of conversations. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs index 98a61e7a..12dfa869 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMessage.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a message within a conversation. /// -public class ConversationMessage +public sealed class ConversationMessage { /// /// The unique identifier for the message. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs index 8fedd863..c1e5b87d 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationMetadata.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Metadata associated with a conversation. /// -public class ConversationMetadata +public sealed class ConversationMetadata { /// /// The title of the conversation. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs index c62116c3..8ae8e559 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Entities/ConversationWithMessages.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities; /// /// Represents a conversation with its associated messages. /// -public class ConversationWithMessages +public sealed class ConversationWithMessages { /// /// The conversation. diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs b/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs index 94e5e198..226c90e3 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation/Internal/XmlTagParser.cs @@ -43,7 +43,7 @@ public static (string CleanedText, IReadOnlyDictionary>(); + var result = new Dictionary>(tags.Count); foreach (var kvp in tags) { result[kvp.Key] = kvp.Value; diff --git a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs index 78870286..7a55300a 100644 --- a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs +++ b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClient.cs @@ -17,7 +17,7 @@ namespace Goa.Clients.Bedrock; /// High-performance Bedrock service client that implements IBedrockClient using AWS service client infrastructure. /// Provides strongly-typed Bedrock operations with built-in error handling, logging, and AWS authentication. /// -public class BedrockServiceClient : JsonAwsServiceClient, IBedrockClient +public sealed class BedrockServiceClient : JsonAwsServiceClient, IBedrockClient { /// /// Initializes a new instance of the BedrockServiceClient class. @@ -111,7 +111,7 @@ public async Task> InvokeModelAsync(InvokeModelRequ return new InvokeModelResponse { Body = response.Value ?? string.Empty, - ContentType = response.Headers?.ContentType + ContentType = response.ContentType }; } diff --git a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs index 23f82172..b7bf196f 100644 --- a/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/BedrockServiceClientConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock; /// /// Configuration class for Bedrock Runtime service providing Bedrock-specific settings. /// -public class BedrockServiceClientConfiguration : AwsServiceConfiguration +public sealed class BedrockServiceClientConfiguration : AwsServiceConfiguration { /// /// Initializes a new instance of the BedrockServiceClientConfiguration class. diff --git a/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs b/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs index 2ed6c7cb..7fdb32b2 100644 --- a/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs +++ b/src/Clients/Goa.Clients.Bedrock/Errors/ErrorExtensions.cs @@ -16,7 +16,12 @@ public static class ErrorExtensions public static bool IsBedrockError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => e.Code.StartsWith("Goa.Bedrock.", StringComparison.Ordinal)); + foreach (var e in errorOr.Errors) + { + if (e.Code.StartsWith("Goa.Bedrock.", StringComparison.Ordinal)) + return true; + } + return false; } /// @@ -28,9 +33,13 @@ public static bool IsBedrockError(this ErrorOr errorOr) public static bool IsBedrockThrottlingError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ThrottlingException || - e.Code == BedrockErrorCodes.ServiceQuotaExceededException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ThrottlingException || + e.Code == BedrockErrorCodes.ServiceQuotaExceededException) + return true; + } + return false; } /// @@ -42,9 +51,13 @@ public static bool IsBedrockThrottlingError(this ErrorOr errorOr) public static bool IsBedrockValidationError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ValidationException || - e.Type == ErrorType.Validation); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ValidationException || + e.Type == ErrorType.Validation) + return true; + } + return false; } /// @@ -56,10 +69,14 @@ public static bool IsBedrockValidationError(this ErrorOr errorOr) public static bool IsBedrockModelError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == BedrockErrorCodes.ModelErrorException || - e.Code == BedrockErrorCodes.ModelNotReadyException || - e.Code == BedrockErrorCodes.ModelTimeoutException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ModelErrorException || + e.Code == BedrockErrorCodes.ModelNotReadyException || + e.Code == BedrockErrorCodes.ModelTimeoutException) + return true; + } + return false; } /// @@ -72,7 +89,13 @@ public static bool IsBedrockModelError(this ErrorOr errorOr) public static bool IsBedrockRetryableError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.IsBedrockThrottlingError() || - errorOr.Errors.Any(e => e.Code == BedrockErrorCodes.ModelTimeoutException); + foreach (var e in errorOr.Errors) + { + if (e.Code == BedrockErrorCodes.ThrottlingException || + e.Code == BedrockErrorCodes.ServiceQuotaExceededException || + e.Code == BedrockErrorCodes.ModelTimeoutException) + return true; + } + return false; } } diff --git a/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs b/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs index cdb2403e..1295f1e7 100644 --- a/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs +++ b/src/Clients/Goa.Clients.Bedrock/Mcp/McpToolDefinition.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Mcp; /// /// Represents an MCP tool definition that can be converted to Bedrock format. /// -public class McpToolDefinition +public sealed class McpToolDefinition { /// /// The name of the tool. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs b/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs index 1eaa9a45..3ff9d42e 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/CachePoint.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A cache checkpoint for prompt caching in Bedrock. /// -public class CachePoint +public sealed class CachePoint { /// /// The type of cache point. Currently only "default" is supported. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs index 697148d2..9142af95 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ContentBlock.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A block of content in a message. Only one type of content can be set per block. /// -public class ContentBlock +public sealed class ContentBlock { /// /// Text content. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs b/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs index dcefe130..e41b2833 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ConverseMetrics.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Performance metrics for a conversation request. /// -public class ConverseMetrics +public sealed class ConverseMetrics { /// /// The latency of the request in milliseconds. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs index 9c7fec43..f78f1c80 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/DocumentBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A document block in a message. /// -public class DocumentBlock +public sealed class DocumentBlock { /// /// The format of the document (e.g., "pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"). @@ -24,7 +24,7 @@ public class DocumentBlock /// /// The source of a document. /// -public class DocumentSource +public sealed class DocumentSource { /// /// Base64-encoded document bytes. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs index 11764a2d..95c615d4 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/GuardrailConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for guardrails to apply to the conversation. /// -public class GuardrailConfiguration +public sealed class GuardrailConfiguration { /// /// The identifier of the guardrail to apply. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs index e05b6adb..38bce5dd 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ImageBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// An image block in a message. /// -public class ImageBlock +public sealed class ImageBlock { /// /// The format of the image (e.g., "png", "jpeg", "gif", "webp"). @@ -19,7 +19,7 @@ public class ImageBlock /// /// The source of an image. /// -public class ImageSource +public sealed class ImageSource { /// /// Base64-encoded image bytes. @@ -35,7 +35,7 @@ public class ImageSource /// /// S3 location reference for content. /// -public class S3Location +public sealed class S3Location { /// /// The S3 URI of the content. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs index bb53a1eb..2c5f1b96 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/InferenceConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for model inference parameters. /// -public class InferenceConfiguration +public sealed class InferenceConfiguration { /// /// The maximum number of tokens to generate in the response. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/Message.cs b/src/Clients/Goa.Clients.Bedrock/Models/Message.cs index dc7106bb..b0e0a4c9 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/Message.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/Message.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A message in a conversation with a model. /// -public class Message +public sealed class Message { /// /// The role of the entity sending the message. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs b/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs index a9931720..ac771c0f 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/OutputConfig.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for the output format of a Converse request. /// -public class OutputConfig +public sealed class OutputConfig { /// /// The text format configuration for structured output. @@ -14,7 +14,7 @@ public class OutputConfig /// /// The text output format specification. Set Type to "json_schema" and provide Structure for JSON schema output. /// -public class OutputFormat +public sealed class OutputFormat { /// /// The type of output format. Use "json_schema" for structured output. @@ -30,7 +30,7 @@ public class OutputFormat /// /// The structure definition for a structured output format. This is a union type - only one member should be set. /// -public class OutputFormatStructure +public sealed class OutputFormatStructure { /// /// The JSON schema definition for structured output. @@ -41,7 +41,7 @@ public class OutputFormatStructure /// /// Defines a JSON schema that the model's output must adhere to. /// -public class JsonSchemaDefinition +public sealed class JsonSchemaDefinition { /// /// The JSON schema as a JSON-stringified string. The Converse API requires this as a string, not a JSON object. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs index 381bddb1..441f3ea1 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/PerformanceConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for performance optimization. /// -public class PerformanceConfiguration +public sealed class PerformanceConfiguration { /// /// The latency mode for model inference. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs b/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs index 322a4899..6d574156 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/TokenUsage.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Token usage information for a conversation request. /// -public class TokenUsage +public sealed class TokenUsage { /// /// The number of tokens in the input. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs b/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs index df936fd7..9d0fe783 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/Tool.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool definition that the model can use. /// -public class Tool +public sealed class Tool { /// /// The specification of the tool. @@ -16,7 +16,7 @@ public class Tool /// /// The specification of a tool including its name, description, and input schema. /// -public class ToolSpec +public sealed class ToolSpec { /// /// The name of the tool. @@ -42,7 +42,7 @@ public class ToolSpec /// /// The input schema for a tool. This is a union type - only the Json member should be set. /// -public class ToolInputSchema +public sealed class ToolInputSchema { /// /// The JSON schema for the tool's input parameters. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs index c6f8a696..56b479fb 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolConfiguration.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// Configuration for tool use in a conversation. /// -public class ToolConfiguration +public sealed class ToolConfiguration { /// /// The list of tools available to the model. @@ -19,7 +19,7 @@ public class ToolConfiguration /// /// Configuration for how the model should choose which tool to use. /// -public class ToolChoice +public sealed class ToolChoice { /// /// If set, the model will automatically decide whether to use a tool. @@ -40,17 +40,17 @@ public class ToolChoice /// /// Marker class indicating automatic tool choice. /// -public class AutoToolChoice { } +public sealed class AutoToolChoice { } /// /// Marker class indicating any tool can be chosen. /// -public class AnyToolChoice { } +public sealed class AnyToolChoice { } /// /// Configuration to force the model to use a specific tool. /// -public class SpecificToolChoice +public sealed class SpecificToolChoice { /// /// The name of the tool the model must use. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs index 16680146..d9985811 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolResultBlock.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool result block containing the response from a tool invocation. /// -public class ToolResultBlock +public sealed class ToolResultBlock { /// /// The identifier of the tool use request this is a response to. diff --git a/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs b/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs index f780a7f4..5dffedf5 100644 --- a/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs +++ b/src/Clients/Goa.Clients.Bedrock/Models/ToolUseBlock.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Models; /// /// A tool use block indicating the model wants to invoke a tool. /// -public class ToolUseBlock +public sealed class ToolUseBlock { /// /// A unique identifier for this tool use request. diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs index ec3bf008..0a507582 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// Fluent builder for constructing Bedrock Converse requests. /// /// The identifier of the model to use. -public class ConverseBuilder(string modelId) +public sealed class ConverseBuilder(string modelId) { private readonly ConverseRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs index a4215804..66edd8a1 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// /// Request for the Bedrock Converse API. /// -public class ConverseRequest +public sealed class ConverseRequest { /// /// The identifier of the model to use. @@ -73,7 +73,7 @@ public class ConverseRequest /// /// A system content block for providing context. /// -public class SystemContentBlock +public sealed class SystemContentBlock { /// /// Text content for the system prompt. @@ -91,7 +91,7 @@ public class SystemContentBlock /// /// Request metadata including service tier. /// -public class RequestMetadata +public sealed class RequestMetadata { /// /// The service tier for request processing. diff --git a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs index 9d445b90..367f14f6 100644 --- a/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs +++ b/src/Clients/Goa.Clients.Bedrock/Operations/Converse/ConverseResponse.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Bedrock.Operations.Converse; /// /// Response from the Bedrock Converse API. /// -public class ConverseResponse +public sealed class ConverseResponse { /// /// The output from the model. @@ -37,7 +37,7 @@ public class ConverseResponse /// /// The output from a Converse API call. /// -public class ConverseOutput +public sealed class ConverseOutput { /// /// The message generated by the model. diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs index e11587ad..38c2d593 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/ConversationRoleConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -18,7 +19,7 @@ public override ConversationRole Read(ref Utf8JsonReader reader, Type typeToConv { "user" => ConversationRole.User, "assistant" => ConversationRole.Assistant, - _ => throw new JsonException($"Unknown ConversationRole: {value}") + _ => Throw.JsonException($"Unknown ConversationRole: {value}") }; } @@ -29,7 +30,7 @@ public override void Write(Utf8JsonWriter writer, ConversationRole value, JsonSe { ConversationRole.User => "user", ConversationRole.Assistant => "assistant", - _ => throw new JsonException($"Unknown ConversationRole: {value}") + _ => Throw.JsonException($"Unknown ConversationRole: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs index 64b999e0..3eeaf783 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/GuardrailTextQualifierConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Operations.ApplyGuardrail; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -19,7 +20,7 @@ public override GuardrailTextQualifier Read(ref Utf8JsonReader reader, Type type "grounding_source" => GuardrailTextQualifier.GroundingSource, "query" => GuardrailTextQualifier.Query, "guard_content" => GuardrailTextQualifier.GuardContent, - _ => throw new JsonException($"Unknown GuardrailTextQualifier: {value}") + _ => Throw.JsonException($"Unknown GuardrailTextQualifier: {value}") }; } @@ -31,7 +32,7 @@ public override void Write(Utf8JsonWriter writer, GuardrailTextQualifier value, GuardrailTextQualifier.GroundingSource => "grounding_source", GuardrailTextQualifier.Query => "query", GuardrailTextQualifier.GuardContent => "guard_content", - _ => throw new JsonException($"Unknown GuardrailTextQualifier: {value}") + _ => Throw.JsonException($"Unknown GuardrailTextQualifier: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs index 21abd775..8101ef44 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/LatencyModeConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -18,7 +19,7 @@ public override LatencyMode Read(ref Utf8JsonReader reader, Type typeToConvert, { "standard" => LatencyMode.Standard, "optimized" => LatencyMode.Optimized, - _ => throw new JsonException($"Unknown LatencyMode: {value}") + _ => Throw.JsonException($"Unknown LatencyMode: {value}") }; } @@ -29,7 +30,7 @@ public override void Write(Utf8JsonWriter writer, LatencyMode value, JsonSeriali { LatencyMode.Standard => "standard", LatencyMode.Optimized => "optimized", - _ => throw new JsonException($"Unknown LatencyMode: {value}") + _ => Throw.JsonException($"Unknown LatencyMode: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs index 8db81f37..fad624aa 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/ServiceTierConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Models; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -19,7 +20,7 @@ public override ServiceTier Read(ref Utf8JsonReader reader, Type typeToConvert, "default" => ServiceTier.Default, "priority" => ServiceTier.Priority, "flex" => ServiceTier.Flex, - _ => throw new JsonException($"Unknown ServiceTier: {value}") + _ => Throw.JsonException($"Unknown ServiceTier: {value}") }; } @@ -31,7 +32,7 @@ public override void Write(Utf8JsonWriter writer, ServiceTier value, JsonSeriali ServiceTier.Default => "default", ServiceTier.Priority => "priority", ServiceTier.Flex => "flex", - _ => throw new JsonException($"Unknown ServiceTier: {value}") + _ => Throw.JsonException($"Unknown ServiceTier: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs b/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs index ba29002a..da0ac771 100644 --- a/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs +++ b/src/Clients/Goa.Clients.Bedrock/Serialization/StopReasonConverter.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Goa.Clients.Bedrock.Enums; +using Goa.Clients.Core; namespace Goa.Clients.Bedrock.Serialization; @@ -23,7 +24,7 @@ public override StopReason Read(ref Utf8JsonReader reader, Type typeToConvert, J "guardrail_intervened" => StopReason.GuardrailIntervened, "content_filtered" => StopReason.ContentFiltered, "model_context_window_exceeded" => StopReason.ModelContextWindowExceeded, - _ => throw new JsonException($"Unknown StopReason: {value}") + _ => Throw.JsonException($"Unknown StopReason: {value}") }; } @@ -39,7 +40,7 @@ public override void Write(Utf8JsonWriter writer, StopReason value, JsonSerializ StopReason.GuardrailIntervened => "guardrail_intervened", StopReason.ContentFiltered => "content_filtered", StopReason.ModelContextWindowExceeded => "model_context_window_exceeded", - _ => throw new JsonException($"Unknown StopReason: {value}") + _ => Throw.JsonException($"Unknown StopReason: {value}") }; writer.WriteStringValue(stringValue); } diff --git a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs index c60d3953..ea48fb86 100644 --- a/src/Clients/Goa.Clients.Core/AwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/AwsServiceClient.cs @@ -2,6 +2,7 @@ using Goa.Clients.Core.Http; using Goa.Clients.Core.Logging; using Microsoft.Extensions.Logging; +using System.Buffers; using System.Diagnostics; using System.Net.Http.Headers; @@ -23,6 +24,17 @@ public abstract class AwsServiceClient where T : AwsServiceConfiguration /// public const string XAmzErrorType = "x-amzn-ErrorType"; + private const string LogKeyClient = "Client"; + private const string LogKeyRegion = "Region"; + private const string LogKeyService = "Service"; + private const string LogKeySigningService = "SigningService"; + private const string LogKeyTarget = "Target"; + private const string LogKeyApiVersion = "ApiVersion"; + private const string LogKeyMethod = "Method"; + private const string LogKeyUri = "Uri"; + private const string LogKeyStatusCode = "StatusCode"; + private const string LogKeyReasonPhrase = "ReasonPhrase"; + private readonly IHttpClientFactory _httpClientFactory; private readonly string _clientType; private Uri? _cachedBaseUri; @@ -67,16 +79,18 @@ protected async Task SendAsync(HttpRequestMessage request, request.Options.Set(HttpOptions.Target, target); request.Options.Set(HttpOptions.ApiVersion, Configuration.ApiVersion); - using var logContext = Logger.BeginScope(new LogScope8( - new("Client", _clientType), - new("Region", Configuration.Region), - new("Service", Configuration.Service), - new("SigningService", Configuration.SigningService), - new("Target", target), - new("ApiVersion", Configuration.ApiVersion), - new("Method", request.Method.Method), - new("Uri", request.RequestUri?.AbsoluteUri ?? "Unknown") - )); + using var logContext = Logger.IsEnabled(Configuration.LogLevel) + ? Logger.BeginScope(new LogScope8( + new(LogKeyClient, _clientType), + new(LogKeyRegion, Configuration.Region), + new(LogKeyService, Configuration.Service), + new(LogKeySigningService, Configuration.SigningService), + new(LogKeyTarget, target), + new(LogKeyApiVersion, Configuration.ApiVersion), + new(LogKeyMethod, request.Method.Method), + new(LogKeyUri, request.RequestUri?.AbsoluteUri ?? "Unknown") + )) + : null; try { @@ -85,18 +99,33 @@ protected async Task SendAsync(HttpRequestMessage request, var start = Stopwatch.GetTimestamp(); var response = await client.SendAsync(request, cancellationToken); - // Ensure that we log the applicable response headers and status code - var context = new Dictionary - { - ["StatusCode"] = ((int)response.StatusCode).ToString(), - ["ReasonPhrase"] = response.ReasonPhrase ?? response.StatusCode.ToString() - }; - foreach (var header in response.Headers) + // Log fixed response fields with zero-allocation scope + using var responseLogContext = Logger.IsEnabled(Configuration.LogLevel) + ? Logger.BeginScope(new LogScope2( + new(LogKeyStatusCode, GetStatusCodeString(response.StatusCode)), + new(LogKeyReasonPhrase, response.ReasonPhrase ?? GetStatusCodeString(response.StatusCode)) + )) + : null; + + // Log x-amz response headers separately with capacity hint + IDisposable? amzLogContext = null; + if (Logger.IsEnabled(Configuration.LogLevel)) { - if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) - context[header.Key] = string.Join(", ", header.Value); + Dictionary? amzHeaders = null; + foreach (var header in response.Headers.NonValidated) + { + if (header.Key.StartsWith("x-amz", StringComparison.OrdinalIgnoreCase)) + { + amzHeaders ??= new Dictionary(4); +#pragma warning disable GOA1501 // Boxing HeaderStringValues to object is required for ILogger.BeginScope + amzHeaders[header.Key] = string.Join(", ", header.Value); +#pragma warning restore GOA1501 + } + } + if (amzHeaders is not null) + amzLogContext = Logger.BeginScope(amzHeaders); } - using var responseLogContext = Logger.BeginScope(context); + using var _ = amzLogContext; Logger.RequestComplete(Configuration.LogLevel, Stopwatch.GetElapsedTime(start).TotalMilliseconds); @@ -109,6 +138,46 @@ protected async Task SendAsync(HttpRequestMessage request, } } + /// + /// Applies AWS-specific error header processing to the error object. + /// + protected static ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) + { + if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) + { + error = error with { Message = string.Join(", ", messages) }; + } + + if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) + { + error = error with { Type = string.Join(", ", types) }; + } + + var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; + if (infoSeparator > 0) + { + error = error with { Type = error.Type![..infoSeparator] }; + } + + var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; + if (typeSeparator > 0) + { + error = error with { Type = error.Type![(typeSeparator + 1)..] }; + } + + return error; + } + + private static string GetStatusCodeString(System.Net.HttpStatusCode statusCode) => statusCode switch + { + System.Net.HttpStatusCode.OK => "200", System.Net.HttpStatusCode.Created => "201", System.Net.HttpStatusCode.Accepted => "202", System.Net.HttpStatusCode.NoContent => "204", + System.Net.HttpStatusCode.MovedPermanently => "301", System.Net.HttpStatusCode.Found => "302", System.Net.HttpStatusCode.NotModified => "304", + System.Net.HttpStatusCode.BadRequest => "400", System.Net.HttpStatusCode.Unauthorized => "401", System.Net.HttpStatusCode.Forbidden => "403", System.Net.HttpStatusCode.NotFound => "404", + System.Net.HttpStatusCode.Conflict => "409", System.Net.HttpStatusCode.TooManyRequests => "429", + System.Net.HttpStatusCode.InternalServerError => "500", System.Net.HttpStatusCode.BadGateway => "502", System.Net.HttpStatusCode.ServiceUnavailable => "503", System.Net.HttpStatusCode.GatewayTimeout => "504", + _ => ((int)statusCode).ToString() + }; + /// /// Creates an HTTP request message with the specified content. /// @@ -209,4 +278,123 @@ protected HttpRequestMessage CreateRequestMessage(HttpMethod method, string requ return requestMessage; } + + /// + /// Maximum allowed response size in bytes (4 MB). + /// + protected const int MaxResponseSize = 4 * 1024 * 1024; + + /// + /// Reads the HTTP response body into a pooled byte buffer for zero-copy deserialization. + /// + /// The HTTP response message. + /// Token to cancel the operation. + /// A pooled buffer containing the response bytes. Must be disposed after use. + protected static async Task ReadResponseBytesAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + var headerLength = response.Content.Headers.ContentLength; + if (headerLength is 0) + return default; + + if (headerLength is not null) + { + if (headerLength < 0 || headerLength > MaxResponseSize) + Throw.InvalidOperation($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); + + var contentLength = (int)headerLength; + var knownBuffer = ArrayPool.Shared.Rent(contentLength); + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var totalRead = 0; + while (totalRead < contentLength) + { + var read = await stream.ReadAsync(knownBuffer.AsMemory(totalRead, contentLength - totalRead), cancellationToken); + if (read == 0) break; + totalRead += read; + } + return new PooledBuffer(knownBuffer, totalRead); + } + catch + { + ArrayPool.Shared.Return(knownBuffer); + throw; + } + } + + const int initialSize = 4096; + var buffer = ArrayPool.Shared.Rent(initialSize); + try + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var totalRead = 0; + while (true) + { + if (totalRead == buffer.Length) + { + var newSize = buffer.Length * 2; + if (newSize > MaxResponseSize) + newSize = MaxResponseSize + 1; // allow one more read to detect overflow + + var newBuffer = ArrayPool.Shared.Rent(newSize); + buffer.AsSpan(0, totalRead).CopyTo(newBuffer); + ArrayPool.Shared.Return(buffer); + buffer = newBuffer; + } + + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken); + if (read == 0) break; + totalRead += read; + + if (totalRead > MaxResponseSize) + Throw.InvalidOperation($"Response size exceeds maximum {MaxResponseSize} bytes."); + } + + if (totalRead == 0) + { + ArrayPool.Shared.Return(buffer); + return default; + } + + return new PooledBuffer(buffer, totalRead); + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + /// + /// A disposable buffer backed by ArrayPool for zero-allocation response reading. + /// + protected struct PooledBuffer : IDisposable + { + private byte[]? _buffer; + + /// The number of valid bytes in the buffer. + public readonly int Length; + + /// Creates a new PooledBuffer wrapping the given rented array. + public PooledBuffer(byte[] buffer, int length) + { + _buffer = buffer; + Length = length; + } + + /// Gets a read-only span over the valid portion of the buffer. + public readonly ReadOnlySpan Span => _buffer is null ? default : _buffer.AsSpan(0, Length); + + /// Returns the rented buffer to the shared ArrayPool. + public void Dispose() + { + var buf = _buffer; + if (buf is not null) + { + _buffer = null; + ArrayPool.Shared.Return(buf); + } + } + } } diff --git a/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs b/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs index 2ddd6470..af09a609 100644 --- a/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs +++ b/src/Clients/Goa.Clients.Core/Http/ApiErrorReader.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Text; using System.Text.Json; namespace Goa.Clients.Core.Http; @@ -8,19 +10,19 @@ namespace Goa.Clients.Core.Http; internal static class ApiErrorReader { /// - /// Reads an ApiError from a JSON string using Utf8JsonReader. + /// Reads an ApiError from UTF-8 bytes using Utf8JsonReader. /// Matches fields case-insensitively: "message", "__type", "code". /// - /// The JSON string to deserialize. + /// The UTF-8 encoded JSON bytes to deserialize. /// The deserialized ApiError, or null if the input is malformed or empty. - public static ApiError? ReadApiError(string content) + public static ApiError? ReadApiError(ReadOnlySpan utf8Json) { - if (string.IsNullOrWhiteSpace(content)) + if (utf8Json.IsEmpty) return null; try { - var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(content)); + var reader = new Utf8JsonReader(utf8Json); if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) return null; @@ -68,4 +70,36 @@ internal static class ApiErrorReader return null; } } + + /// + /// Reads an ApiError from a JSON string using Utf8JsonReader. + /// Uses stackalloc for small payloads, ArrayPool for larger ones. + /// + /// The JSON string to deserialize. + /// The deserialized ApiError, or null if the input is malformed or empty. + public static ApiError? ReadApiError(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + var byteCount = Encoding.UTF8.GetByteCount(content); + + if (byteCount <= 1024) + { + Span buffer = stackalloc byte[byteCount]; + Encoding.UTF8.GetBytes(content, buffer); + return ReadApiError((ReadOnlySpan)buffer); + } + + var rented = ArrayPool.Shared.Rent(byteCount); + try + { + var written = Encoding.UTF8.GetBytes(content, rented); + return ReadApiError(rented.AsSpan(0, written)); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } } diff --git a/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs b/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs index 476101af..c1afc279 100644 --- a/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs +++ b/src/Clients/Goa.Clients.Core/Http/ApiResponse.cs @@ -17,9 +17,9 @@ public sealed class ApiResponse public ApiError? Error { get; } /// - /// Gets the HTTP response headers. + /// Gets the Content-Type of the response body. /// - public ResponseHeaders? Headers { get; } + public string? ContentType { get; } /// /// Gets a value indicating whether the operation was successful. @@ -36,14 +36,14 @@ public ApiResponse(T? value) } /// - /// Initializes a new instance with a successful response value and headers. + /// Initializes a new instance with a successful response value and content type. /// /// The successful response value. - /// The HTTP response headers. - public ApiResponse(T? value, ResponseHeaders? headers) + /// The Content-Type of the response body. + public ApiResponse(T? value, string? contentType) { Value = value; - Headers = headers; + ContentType = contentType; } /// diff --git a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs index ba3ed03c..4ee4e666 100644 --- a/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs +++ b/src/Clients/Goa.Clients.Core/Http/RequestSigner.cs @@ -1,6 +1,7 @@ using Goa.Clients.Core.Credentials; using System.Buffers; using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -29,6 +30,12 @@ internal sealed class RequestSigner private static readonly string? _cachedAwsRegion = Environment.GetEnvironmentVariable("AWS_REGION"); private const string RootPath = "/"; private const string CredentialSuffix = "/aws4_request"; + private const string HeaderHost = "host"; + private const string HeaderAmzContentSha256 = "x-amz-content-sha256"; + private const string HeaderAmzDate = "x-amz-date"; + private const string HeaderAmzTarget = "x-amz-target"; + private const string HeaderAmzApiVersion = "x-amz-api-version"; + private const string HeaderAmzSecurityToken = "x-amz-security-token"; private readonly ICredentialProviderChain _credentialProvider; @@ -59,7 +66,7 @@ public async ValueTask SignRequestAsync(HttpRequestMessage request, Date if (i != 0) sb.Append("; "); sb.Append(errs[i].Description); } - ThrowInvalidOperationException($"Failed to retrieve AWS credentials: {sb}"); + Throw.InvalidOperation($"Failed to retrieve AWS credentials: {sb}"); } var credentials = credentialsResult.Value; @@ -146,7 +153,7 @@ private async ValueTask SignRequestWithCredentialsAsync( if (i != 0) sb.Append("; "); sb.Append(errs[i].Description); } - ThrowInvalidOperationException($"Failed to retrieve AWS credentials: {sb}"); + Throw.InvalidOperation($"Failed to retrieve AWS credentials: {sb}"); } var credentials = credentialsResult.Value; @@ -229,11 +236,11 @@ private async ValueTask SignRequestWithCredentialsAsync( // ---- Build Canonical Request with precise memory management ---- // Enumerate headers ONCE into rented arrays to avoid repeated GetEnumeratorCore allocations const int InitialHeaderCapacity = 16; - var reqHeaders = ArrayPool>>.Shared.Rent(InitialHeaderCapacity); - var contentHeaders = ArrayPool>>.Shared.Rent(InitialHeaderCapacity); + var reqHeaders = ArrayPool>.Shared.Rent(InitialHeaderCapacity); + var contentHeaders = ArrayPool>.Shared.Rent(InitialHeaderCapacity); int rhIndex = 0, chIndex = 0; - foreach (var kv in request.Headers) + foreach (var kv in request.Headers.NonValidated) { if (rhIndex == reqHeaders.Length) reqHeaders = GrowArray(reqHeaders, ref rhIndex); @@ -241,7 +248,7 @@ private async ValueTask SignRequestWithCredentialsAsync( } if (request.Content?.Headers is not null) - foreach (var kv in request.Content.Headers) + foreach (var kv in request.Content.Headers.NonValidated) { if (chIndex == contentHeaders.Length) contentHeaders = GrowArray(contentHeaders, ref chIndex); @@ -268,13 +275,13 @@ private async ValueTask SignRequestWithCredentialsAsync( // Build AWS required headers first if (request.RequestUri is not null) - headerRefs[hrefIndex++] = HeaderRef.Create("host", HeaderKind.Host, -1); - headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-content-sha256", HeaderKind.AmzSha256, -1); - headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-date", HeaderKind.AmzDate, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderHost, HeaderKind.Host, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzContentSha256, HeaderKind.AmzSha256, -1); + headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzDate, HeaderKind.AmzDate, -1); - if (hasApiVersion) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-api-version", HeaderKind.AmzApiVersion, -1); - if (!string.IsNullOrWhiteSpace(credentials.SessionToken)) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-security-token", HeaderKind.AmzSecurityToken, -1); - if (hasTarget) headerRefs[hrefIndex++] = HeaderRef.Create("x-amz-target", HeaderKind.AmzTarget, -1); + if (hasApiVersion) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzApiVersion, HeaderKind.AmzApiVersion, -1); + if (!string.IsNullOrWhiteSpace(credentials.SessionToken)) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzSecurityToken, HeaderKind.AmzSecurityToken, -1); + if (hasTarget) headerRefs[hrefIndex++] = HeaderRef.Create(HeaderAmzTarget, HeaderKind.AmzTarget, -1); // Add request headers from cached array, skipping AWS-managed headers var filteredIndex = 0; @@ -461,8 +468,8 @@ private async ValueTask SignRequestWithCredentialsAsync( // Return rented arrays ArrayPool.Shared.Return(headerRefs); - ArrayPool>>.Shared.Return(reqHeaders); - ArrayPool>>.Shared.Return(contentHeaders); + ArrayPool>.Shared.Return(reqHeaders); + ArrayPool>.Shared.Return(contentHeaders); return (signature, signedHeaders); } @@ -664,8 +671,8 @@ private static int WriteHeaderValue( in HeaderRef r, HttpRequestMessage req, AwsCredentials creds, - KeyValuePair>[] reqHeaders, - KeyValuePair>[] contentHeaders, + KeyValuePair[] reqHeaders, + KeyValuePair[] contentHeaders, ReadOnlySpan longDate, ReadOnlySpan payloadHashBytes) { @@ -729,7 +736,7 @@ private static int WriteHost(Span dest, Uri u) /// Collapses whitespace and joins with commas as per HTTP/AWS specifications. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int WriteNormalizedJoined(Span dest, IEnumerable values) + private static int WriteNormalizedJoined(Span dest, HeaderStringValues values) { var pos = 0; var firstVal = true; foreach (var raw in values) @@ -756,8 +763,8 @@ private static int WriteNormalizedJoined(Span dest, IEnumerable va private static int EstimateHeadersCharCount( HeaderRef[] refs, int count, HttpRequestMessage req, AwsCredentials creds, - KeyValuePair>[] reqHeaders, - KeyValuePair>[] contentHeaders) + KeyValuePair[] reqHeaders, + KeyValuePair[] contentHeaders) { var total = 0; for (var i = 0; i < count; i++) @@ -885,10 +892,10 @@ private static (string region, string serviceName) GetRequestParameters(HttpRequ region = regionOption; if (string.IsNullOrWhiteSpace(region)) - ThrowInvalidOperationException("Region is required"); + Throw.InvalidOperation("Region is required"); if (!request.Options.TryGetValue(HttpOptions.Service, out var service) || string.IsNullOrWhiteSpace(service)) - ThrowInvalidOperationException("Service name is required"); + Throw.InvalidOperation("Service name is required"); return (region!, service); } @@ -1101,25 +1108,14 @@ private static string BuildSignedHeadersFromRefs(HeaderRef[] headerRefs, int cou [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsAwsManagedHeader(string headerName) { - return string.Equals(headerName, "Host", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-content-sha256", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-date", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-target", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-api-version", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "x-amz-security-token", StringComparison.OrdinalIgnoreCase); + return string.Equals(headerName, HeaderHost, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzContentSha256, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzDate, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzTarget, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzApiVersion, StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, HeaderAmzSecurityToken, StringComparison.OrdinalIgnoreCase); } - /// - /// Exception helpers with no-inlining to keep hot paths small. - /// - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - private static void ThrowInvalidOperationException(string message) => throw new InvalidOperationException(message); - - [MethodImpl(MethodImplOptions.NoInlining)] - [DoesNotReturn] - private static void ThrowArgumentNullException(string paramName) => throw new ArgumentNullException(paramName); - /// /// Creates credential scope string for AWS signature computation. /// diff --git a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs b/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs deleted file mode 100644 index 40e54db1..00000000 --- a/src/Clients/Goa.Clients.Core/Http/ResponseHeaders.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Net.Http.Headers; - -namespace Goa.Clients.Core.Http; - -/// -/// Lightweight representation of key AWS response headers. -/// -public sealed class ResponseHeaders -{ - /// - /// Gets the AWS request ID from the response headers. - /// - public string? RequestId { get; init; } - - /// - /// Gets the error type from the response headers. - /// - public string? ErrorType { get; init; } - - /// - /// Gets the error message from the response headers. - /// - public string? ErrorMessage { get; init; } - - /// - /// Gets the Content-Type of the response body. - /// - public string? ContentType { get; init; } - - /// - /// Extracts key headers from an HTTP response. - /// - internal static ResponseHeaders FromHttpResponse(HttpResponseHeaders headers, HttpContentHeaders? contentHeaders = null) - { - string? requestId = null; - if (headers.TryGetValues("x-amzn-RequestId", out var values) - || headers.TryGetValues("x-amz-request-id", out values)) - { - requestId = values.FirstOrDefault(); - } - - string? errorType = null; - if (headers.TryGetValues("x-amzn-ErrorType", out var typeValues)) - { - errorType = typeValues.FirstOrDefault(); - } - - string? errorMessage = null; - if (headers.TryGetValues("x-amzn-ErrorMessage", out var errorValues)) - { - errorMessage = errorValues.FirstOrDefault(); - } - - return new ResponseHeaders - { - RequestId = requestId, - ErrorType = errorType, - ErrorMessage = errorMessage, - ContentType = contentHeaders?.ContentType?.MediaType - }; - } -} diff --git a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs index d7751b8e..715c1113 100644 --- a/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/JsonAwsServiceClient.cs @@ -2,7 +2,6 @@ using Goa.Clients.Core.Http; using Goa.Clients.Core.Logging; using Microsoft.Extensions.Logging; -using System.Buffers; using System.Net.Http.Headers; using System.Text; @@ -95,8 +94,8 @@ protected async Task> ProcessJsonResponseAsync return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var error = ApiErrorReader.ReadApiError(errorBuffer.Span); var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); - var error = DeserializeJsonError(errorPayload); if (error is not null) { error = error with @@ -117,22 +116,22 @@ protected async Task> ProcessJsonResponseAsync return new ApiResponse(error); } - var headers = ResponseHeaders.FromHttpResponse(response.Headers, response.Content.Headers); + var contentType = response.Content.Headers.ContentType?.MediaType; using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); if (typeof(TResponse) == typeof(string)) { var str = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; - return new ApiResponse(str as TResponse, headers); + return new ApiResponse(str as TResponse, contentType); } if (pooledBuffer.Length == 0) - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse), contentType); var typeInfo = ResolveJsonTypeInfo(); var jsonReader = new Utf8JsonReader(pooledBuffer.Span); var result = JsonSerializer.Deserialize(ref jsonReader, typeInfo); - return new ApiResponse(result, headers); + return new ApiResponse(result, contentType); } /// @@ -143,40 +142,6 @@ protected async Task> ProcessJsonResponseAsync /// The resolved JSON type info. protected abstract JsonTypeInfo ResolveJsonTypeInfo(); - /// - /// Applies AWS-specific error header processing to the error object. - /// - protected ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L79 - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L60 - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L68 - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - // Logic: https://github.com/aws/aws-sdk-net/blob/a9aa4e78a927e1021114b6531e21fc25f87e0dd9/sdk/src/Core/Amazon.Runtime/Internal/Transform/JsonErrorResponseUnmarshaller.cs#L95 - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; - } - /// /// Deserializes JSON error response content to an ApiError. /// @@ -187,126 +152,7 @@ protected ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError /// private static bool IsJsonSerialized(string input) { - var trimmed = input.TrimStart(); - return trimmed.StartsWith('{') || trimmed.StartsWith('['); - } - - /// - /// Maximum allowed response size in bytes (4 MB). - /// - protected const int MaxResponseSize = 4 * 1024 * 1024; - - /// - /// Reads the HTTP response body into a pooled byte buffer for zero-copy deserialization. - /// - /// The HTTP response message. - /// Token to cancel the operation. - /// A pooled buffer containing the response bytes. Must be disposed after use. - protected static async Task ReadResponseBytesAsync( - HttpResponseMessage response, CancellationToken cancellationToken) - { - var headerLength = response.Content.Headers.ContentLength; - if (headerLength is 0) - return default; - - if (headerLength is not null) - { - if (headerLength < 0 || headerLength > MaxResponseSize) - throw new InvalidOperationException($"Response Content-Length {headerLength} exceeds maximum {MaxResponseSize} bytes."); - - var contentLength = (int)headerLength; - var knownBuffer = System.Buffers.ArrayPool.Shared.Rent(contentLength); - try - { - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var totalRead = 0; - while (totalRead < contentLength) - { - var read = await stream.ReadAsync(knownBuffer.AsMemory(totalRead, contentLength - totalRead), cancellationToken); - if (read == 0) break; - totalRead += read; - } - return new PooledBuffer(knownBuffer, totalRead); - } - catch - { - System.Buffers.ArrayPool.Shared.Return(knownBuffer); - throw; - } - } - - const int initialSize = 4096; - var buffer = System.Buffers.ArrayPool.Shared.Rent(initialSize); - try - { - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var totalRead = 0; - while (true) - { - if (totalRead == buffer.Length) - { - var newSize = buffer.Length * 2; - if (newSize > MaxResponseSize) - newSize = MaxResponseSize + 1; // allow one more read to detect overflow - - var newBuffer = System.Buffers.ArrayPool.Shared.Rent(newSize); - buffer.AsSpan(0, totalRead).CopyTo(newBuffer); - System.Buffers.ArrayPool.Shared.Return(buffer); - buffer = newBuffer; - } - - var read = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken); - if (read == 0) break; - totalRead += read; - - if (totalRead > MaxResponseSize) - throw new InvalidOperationException($"Response size exceeds maximum {MaxResponseSize} bytes."); - } - - if (totalRead == 0) - { - System.Buffers.ArrayPool.Shared.Return(buffer); - return default; - } - - return new PooledBuffer(buffer, totalRead); - } - catch - { - System.Buffers.ArrayPool.Shared.Return(buffer); - throw; - } - } - - /// - /// A disposable buffer backed by ArrayPool for zero-allocation response reading. - /// - protected struct PooledBuffer : IDisposable - { - private byte[]? _buffer; - - /// The number of valid bytes in the buffer. - public readonly int Length; - - /// Creates a new PooledBuffer wrapping the given rented array. - public PooledBuffer(byte[] buffer, int length) - { - _buffer = buffer; - Length = length; - } - - /// Gets a read-only span over the valid portion of the buffer. - public readonly ReadOnlySpan Span => _buffer is null ? default : _buffer.AsSpan(0, Length); - - /// Returns the rented buffer to the shared ArrayPool. - public void Dispose() - { - var buf = _buffer; - if (buf is not null) - { - _buffer = null; - System.Buffers.ArrayPool.Shared.Return(buf); - } - } + var trimmed = input.AsSpan().TrimStart(); + return trimmed.StartsWith("{") || trimmed.StartsWith("["); } } diff --git a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs index 9362351e..7808ac2a 100644 --- a/src/Clients/Goa.Clients.Core/Logging/LogScope.cs +++ b/src/Clients/Goa.Clients.Core/Logging/LogScope.cs @@ -22,12 +22,14 @@ public LogScope8( public int Count => 8; - public KeyValuePair this[int index] => index switch + public KeyValuePair this[int index] { - 0 => _0, 1 => _1, 2 => _2, 3 => _3, - 4 => _4, 5 => _5, 6 => _6, 7 => _7, - _ => throw new IndexOutOfRangeException() - }; + get + { + if ((uint)index >= 8) Throw.IndexOutOfRange(); + return index switch { 0 => _0, 1 => _1, 2 => _2, 3 => _3, 4 => _4, 5 => _5, 6 => _6, _ => _7 }; + } + } public Enumerator GetEnumerator() => new(this); IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); @@ -48,6 +50,49 @@ public void Dispose() { } } } +/// +/// Zero-allocation log scope with 2 entries. Implements IReadOnlyList so +/// Microsoft.Extensions.Logging can enumerate properties without a Dictionary allocation. +/// +internal readonly struct LogScope2 : IReadOnlyList> +{ + private readonly KeyValuePair _0, _1; + + public LogScope2(KeyValuePair p0, KeyValuePair p1) + { + _0 = p0; _1 = p1; + } + + public int Count => 2; + + public KeyValuePair this[int index] + { + get + { + if ((uint)index >= 2) Throw.IndexOutOfRange(); + return index switch { 0 => _0, _ => _1 }; + } + } + + public Enumerator GetEnumerator() => new(this); + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public struct Enumerator : IEnumerator> + { + private readonly LogScope2 _scope; + private int _index; + + internal Enumerator(LogScope2 scope) { _scope = scope; _index = -1; } + + public KeyValuePair Current => _scope[_index]; + object IEnumerator.Current => Current; + public bool MoveNext() => ++_index < 2; + public void Reset() => _index = -1; + public void Dispose() { } + } +} + /// /// Zero-allocation log scope with up to 4 entries. Implements IReadOnlyList so /// Microsoft.Extensions.Logging can enumerate properties without a Dictionary allocation. @@ -82,12 +127,14 @@ public LogScope4( public int Count => _count; - public KeyValuePair this[int index] => - index < _count ? index switch + public KeyValuePair this[int index] + { + get { - 0 => _0, 1 => _1, 2 => _2, 3 => _3, - _ => throw new IndexOutOfRangeException() - } : throw new IndexOutOfRangeException(); + if ((uint)index >= (uint)_count) Throw.IndexOutOfRange(); + return index switch { 0 => _0, 1 => _1, 2 => _2, _ => _3 }; + } + } public Enumerator GetEnumerator() => new(this); IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); diff --git a/src/Clients/Goa.Clients.Core/Throw.cs b/src/Clients/Goa.Clients.Core/Throw.cs new file mode 100644 index 00000000..5dbaaa30 --- /dev/null +++ b/src/Clients/Goa.Clients.Core/Throw.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Goa.Clients.Core; + +/// +/// Shared throw helpers annotated with so the JIT +/// keeps throw-site code out of the inlined hot path. +/// +public static class Throw +{ + /// Throws . + [DoesNotReturn] + public static void IndexOutOfRange() => throw new IndexOutOfRangeException(); + + /// Throws with the specified message. + [DoesNotReturn] + public static void InvalidOperation(string message) => throw new InvalidOperationException(message); + + /// Throws for the specified parameter. + [DoesNotReturn] + public static void ArgumentNull(string paramName) => throw new ArgumentNullException(paramName); + + /// Throws . Returns for use in switch expression arms. + [DoesNotReturn] + public static T InvalidOperation(string message) => throw new InvalidOperationException(message); + + /// Throws . Returns for use in switch expression arms. + [DoesNotReturn] + public static T JsonException(string message) => throw new JsonException(message); +} diff --git a/src/Clients/Goa.Clients.Core/XmlApiError.cs b/src/Clients/Goa.Clients.Core/XmlApiError.cs index f6c94613..3ffd2752 100644 --- a/src/Clients/Goa.Clients.Core/XmlApiError.cs +++ b/src/Clients/Goa.Clients.Core/XmlApiError.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Core; /// /// Represents an XML API error response from AWS services, extending the base ApiError with XML deserialization capabilities. /// -public class XmlApiError : IDeserializeFromXml +public sealed class XmlApiError : IDeserializeFromXml { /// /// Gets or sets the AWS request ID associated with this error. diff --git a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs index 6ed782f6..b7c22940 100644 --- a/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs +++ b/src/Clients/Goa.Clients.Core/XmlAwsServiceClient.cs @@ -15,6 +15,7 @@ namespace Goa.Clients.Core; /// The configuration type that extends AwsServiceConfiguration. public abstract class XmlAwsServiceClient : AwsServiceClient where T : AwsServiceConfiguration { + private static readonly MediaTypeHeaderValue XmlContentType = new("application/xml"); private readonly ApiError _deserializationError = new("Failed to deserialize response", "DeserializationError"); /// @@ -57,29 +58,28 @@ protected async Task> SendAsync(Http } } - using var requestMessage = CreateRequestMessage(method, requestUri + $"?Action={UrlEncoder.Default.Encode(target)}", content, new MediaTypeHeaderValue("application/xml"), headers); + using var requestMessage = CreateRequestMessage(method, requestUri + $"?Action={UrlEncoder.Default.Encode(target)}", content, XmlContentType, headers); using var response = await SendAsync(requestMessage, target, cancellationToken); - return await ProcessXmlResponseAsync(response); + return await ProcessXmlResponseAsync(response, cancellationToken); } /// /// Processes an HTTP response and converts it to an API response with XML deserialization. /// - private async Task> ProcessXmlResponseAsync(HttpResponseMessage response) + private async Task> ProcessXmlResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { - var headers = ResponseHeaders.FromHttpResponse(response.Headers); - if (!response.IsSuccessStatusCode) { - var errorPayload = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(errorPayload)) + using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); + if (errorBuffer.Length == 0) { Logger.RequestFailed("No payload present"); return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); var error = DeserializeXmlError(errorPayload); if (error is not null) { @@ -101,53 +101,24 @@ private async Task> ProcessXmlResponseAsync(Ht return new ApiResponse(error ?? _deserializationError); } - var contentPayload = await response.Content.ReadAsStringAsync(); + using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); + var contentPayload = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; Debug.WriteLine("RESPONSE: " + contentPayload); // Handle string responses specially if (typeof(TResponse) == typeof(string)) { - return new ApiResponse(contentPayload as TResponse, headers); + return new ApiResponse(contentPayload as TResponse); } if (string.IsNullOrWhiteSpace(contentPayload)) { - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse)); } var result = new TResponse(); result.DeserializeFromXml(contentPayload); - return new ApiResponse(result, headers); - } - - /// - /// Applies AWS-specific error header processing to the error object. - /// - private ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; + return new ApiResponse(result); } /// @@ -179,7 +150,9 @@ private static byte[] SerializeToXmlBytes(object request) } catch (Exception ex) { +#pragma warning disable GOA1001 // Error-handling path: params allocation is acceptable here Logger.LogWarning(ex, "Failed to deserialize XML error response: {Content}", content); +#pragma warning restore GOA1001 return null; } } @@ -189,6 +162,6 @@ private static byte[] SerializeToXmlBytes(object request) /// private static bool IsXmlSerialized(string input) { - return input.TrimStart().StartsWith('<'); + return input.AsSpan().TrimStart().StartsWith("<"); } } \ No newline at end of file diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index a44966d1..a1c533f1 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -32,6 +32,7 @@ public string GenerateCode(IEnumerable types, GenerationContext builder.AppendLine("using System.Collections.Generic;"); builder.AppendLine("using System.Globalization;"); builder.AppendLine("using System.Text.Json;"); + builder.AppendLine("using Goa.Clients.Core;"); foreach (var ns in typesByNamespace) { @@ -113,7 +114,8 @@ private void GenerateAbstractWriteDispatch(CodeBuilder builder, DynamoTypeInfo t builder.AppendLine("default:"); builder.Indent(); - builder.AppendLine($"throw new InvalidOperationException($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); + builder.AppendLine($"Throw.InvalidOperation($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); + builder.AppendLine("return;"); builder.Unindent(); builder.CloseBrace(); } @@ -527,7 +529,7 @@ private void GenerateAbstractReadDispatch(CodeBuilder builder, DynamoTypeInfo ty builder.CloseBrace(); builder.AppendLine(); builder.AppendLine($"if (typeDiscriminator == null)"); - builder.Indent().AppendLine($"throw new InvalidOperationException(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); + builder.Indent().AppendLine($"Throw.InvalidOperation(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); builder.AppendLine(); builder.OpenBraceWithLine("return typeDiscriminator switch"); @@ -540,7 +542,7 @@ private void GenerateAbstractReadDispatch(CodeBuilder builder, DynamoTypeInfo ty } } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown type: {{typeDiscriminator}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation<{type.FullName}>($\"Unknown type: {{typeDiscriminator}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); builder.AppendLine(); } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs index 3a0487b3..6381b55a 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs @@ -37,6 +37,7 @@ public string GenerateCode(IEnumerable types, GenerationContext builder.AppendLine("using Goa.Clients.Dynamo.Models;"); builder.AppendLine("using Goa.Clients.Dynamo.Exceptions;"); builder.AppendLine("using Goa.Clients.Dynamo.Extensions;"); + builder.AppendLine("using Goa.Clients.Core;"); foreach (var ns in typesByNamespace) { @@ -135,7 +136,7 @@ private void GenerateAbstractToDynamoRecordDispatch(CodeBuilder builder, DynamoT builder.AppendLine($"{concreteType.FullName} concrete => DynamoMapper.{normalizedTypeName}.ToDynamoRecord(concrete),"); } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); } @@ -378,7 +379,7 @@ private void GenerateAbstractTypeDispatch(CodeBuilder builder, DynamoTypeInfo ty var typeNameField = dynamoModelAttr?.TypeName ?? "Type"; builder.AppendLine($"if (!record.TryGetNullableString(\"{typeNameField}\", out var typeValue) || typeValue == null)"); - builder.Indent().AppendLine($"throw new InvalidOperationException(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); + builder.Indent().AppendLine($"Throw.InvalidOperation(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); builder.AppendLine(); builder.OpenBraceWithLine("return typeValue switch"); @@ -391,7 +392,7 @@ private void GenerateAbstractTypeDispatch(CodeBuilder builder, DynamoTypeInfo ty } } - builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown type: {{typeValue}} for abstract type {type.FullName}\")"); + builder.AppendLine($"_ => Throw.InvalidOperation<{type.FullName}>($\"Unknown type: {{typeValue}} for abstract type {type.FullName}\")"); builder.CloseBrace().Append(";"); } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs index 1b33432a..68fc6c8b 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs @@ -218,9 +218,13 @@ private static void CollectReferencedTypes(INamedTypeSymbol type, HashSet> -> MyType) + // before the system type check, so that system collections are resolved + // to their innermost element type + while (IsCollectionType(propertyType, out var elementType)) + { + propertyType = elementType; + } // Handle nullable types if (propertyType is INamedTypeSymbol nullableType && nullableType.IsGenericType) @@ -228,11 +232,9 @@ private static void CollectReferencedTypes(INamedTypeSymbol type, HashSet QueryAllAsync(this IDynamoCli var result = await client.QueryAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -200,7 +200,7 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClie var result = await client.ScanAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -232,7 +232,7 @@ public static async IAsyncEnumerable QueryAllAsync(this IDynamoClient clie var result = await client.QueryAsync(request, itemReader, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -264,7 +264,7 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClient clien var result = await client.ScanAsync(request, itemReader, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var item in result.Value.Items) @@ -302,7 +302,7 @@ public static async IAsyncEnumerable> BatchGe var result = await client.BatchGetItemAsync(request, cancellationToken); if (result.IsError) { - throw new DynamoPaginationException(result.FirstError); + DynamoPaginationException.Throw(result.FirstError); } foreach (var tableResponse in result.Value.Responses) @@ -320,7 +320,7 @@ public static async IAsyncEnumerable> BatchGe if (attempts >= maxAttempts) { - throw new DynamoPaginationException( + DynamoPaginationException.Throw( ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); } @@ -350,7 +350,7 @@ public static async IAsyncEnumerable> BatchGetAllAsync> BatchGetAllAsync= maxAttempts) { - throw new DynamoPaginationException( + DynamoPaginationException.Throw( ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); } diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs b/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs index 6d98a01f..d39b5ece 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoModelAttribute.cs @@ -5,7 +5,7 @@ /// This attribute is inherited, allowing base classes to define common PK/SK patterns. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] -public class DynamoModelAttribute : Attribute +public sealed class DynamoModelAttribute : Attribute { /// /// The partition key pattern. Supports placeholders like "ENTITY#<PropertyName>" diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index 2db6b367..f5aaae78 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -21,7 +21,7 @@ namespace Goa.Clients.Dynamo; /// High-performance DynamoDB service client that implements IDynamoClient using AWS service client infrastructure. /// Provides strongly-typed DynamoDB operations with built-in error handling, logging, and AWS authentication. /// -public class DynamoServiceClient : JsonAwsServiceClient, IDynamoClient +public sealed class DynamoServiceClient : JsonAwsServiceClient, IDynamoClient { /// /// Initializes a new instance of the DynamoServiceClient class. @@ -366,7 +366,9 @@ private async Task HandleTypedErrorAsync(HttpResponseMessage response, Ca var metadata = new Dictionary { ["Payload"] = errorPayload, +#pragma warning disable GOA1501 // Boxing unavoidable with Dictionary on error path ["StatusCode"] = response.StatusCode +#pragma warning restore GOA1501 }; return Error.Failure( code: MapErrorCodeToDynamo(errorType), diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs index 245d69ce..14853898 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClientConfiguration.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// /// Configuration class for DynamoDB service providing DynamoDB-specific settings. /// -public class DynamoServiceClientConfiguration : AwsServiceConfiguration +public sealed class DynamoServiceClientConfiguration : AwsServiceConfiguration { /// /// Initializes a new instance of the DynamoServiceClientConfiguration class. diff --git a/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs index 6625920c..1a7064ac 100644 --- a/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Errors/ErrorExtensions.cs @@ -16,7 +16,12 @@ public static class ErrorExtensions public static bool IsDynamoError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => e.Code.StartsWith("Goa.DynamoDb.", StringComparison.Ordinal)); + foreach (var e in errorOr.Errors) + { + if (e.Code.StartsWith("Goa.DynamoDb.", StringComparison.Ordinal)) + return true; + } + return false; } /// @@ -28,13 +33,17 @@ public static bool IsDynamoError(this ErrorOr errorOr) public static bool IsDynamoConcurrencyError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ConditionalCheckFailedException || - e.Code == DynamoErrorCodes.ConditionalCheckFailed || - e.Code == DynamoErrorCodes.TransactionConflictException || - e.Code == DynamoErrorCodes.TransactionConflict || - e.Code == DynamoErrorCodes.ReplicatedWriteConflictException || - (e.Code == DynamoErrorCodes.ValidationException && e.Description.Contains("condition", StringComparison.OrdinalIgnoreCase))); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ConditionalCheckFailedException || + e.Code == DynamoErrorCodes.ConditionalCheckFailed || + e.Code == DynamoErrorCodes.TransactionConflictException || + e.Code == DynamoErrorCodes.TransactionConflict || + e.Code == DynamoErrorCodes.ReplicatedWriteConflictException || + (e.Code == DynamoErrorCodes.ValidationException && e.Description.Contains("condition", StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; } /// @@ -46,15 +55,19 @@ public static bool IsDynamoConcurrencyError(this ErrorOr errorOr) public static bool IsDynamoItemNotFoundError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ResourceNotFoundException || - e.Code == DynamoErrorCodes.ItemNotFound || - e.Code == DynamoErrorCodes.NotFound || - e.Code == DynamoErrorCodes.TableNotFoundException || - e.Code == DynamoErrorCodes.TableNotFound || - e.Code == DynamoErrorCodes.IndexNotFoundException || - e.Code == DynamoErrorCodes.IndexNotFound || - e.Type == ErrorType.NotFound); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ResourceNotFoundException || + e.Code == DynamoErrorCodes.ItemNotFound || + e.Code == DynamoErrorCodes.NotFound || + e.Code == DynamoErrorCodes.TableNotFoundException || + e.Code == DynamoErrorCodes.TableNotFound || + e.Code == DynamoErrorCodes.IndexNotFoundException || + e.Code == DynamoErrorCodes.IndexNotFound || + e.Type == ErrorType.NotFound) + return true; + } + return false; } /// @@ -66,13 +79,17 @@ public static bool IsDynamoItemNotFoundError(this ErrorOr errorOr) public static bool IsDynamoThrottlingError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ProvisionedThroughputExceededException || - e.Code == DynamoErrorCodes.ProvisionedThroughputExceeded || - e.Code == DynamoErrorCodes.RequestLimitExceeded || - e.Code == DynamoErrorCodes.ThrottlingException || - e.Code == DynamoErrorCodes.TooManyRequestsException || - e.Code == DynamoErrorCodes.TooManyRequests); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ProvisionedThroughputExceededException || + e.Code == DynamoErrorCodes.ProvisionedThroughputExceeded || + e.Code == DynamoErrorCodes.RequestLimitExceeded || + e.Code == DynamoErrorCodes.ThrottlingException || + e.Code == DynamoErrorCodes.TooManyRequestsException || + e.Code == DynamoErrorCodes.TooManyRequests) + return true; + } + return false; } /// @@ -84,13 +101,17 @@ public static bool IsDynamoThrottlingError(this ErrorOr errorOr) public static bool IsDynamoValidationError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ValidationException || - e.Code == DynamoErrorCodes.InvalidParameterValueException || - e.Code == DynamoErrorCodes.InvalidParameterValue || - e.Code == DynamoErrorCodes.MissingParameterException || - e.Code == DynamoErrorCodes.MissingParameter || - e.Type == ErrorType.Validation); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ValidationException || + e.Code == DynamoErrorCodes.InvalidParameterValueException || + e.Code == DynamoErrorCodes.InvalidParameterValue || + e.Code == DynamoErrorCodes.MissingParameterException || + e.Code == DynamoErrorCodes.MissingParameter || + e.Type == ErrorType.Validation) + return true; + } + return false; } /// @@ -102,16 +123,20 @@ public static bool IsDynamoValidationError(this ErrorOr errorOr) public static bool IsDynamoAuthError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.UnauthorizedException || - e.Code == DynamoErrorCodes.Unauthorized || - e.Code == DynamoErrorCodes.AccessDeniedException || - e.Code == DynamoErrorCodes.AccessDenied || - e.Code == DynamoErrorCodes.InvalidUserPoolConfigurationException || - e.Code == DynamoErrorCodes.InvalidUserPoolConfiguration || - e.Code == DynamoErrorCodes.NotAuthorizedException || - e.Code == DynamoErrorCodes.NotAuthorized || - e.Type == ErrorType.Unauthorized); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.UnauthorizedException || + e.Code == DynamoErrorCodes.Unauthorized || + e.Code == DynamoErrorCodes.AccessDeniedException || + e.Code == DynamoErrorCodes.AccessDenied || + e.Code == DynamoErrorCodes.InvalidUserPoolConfigurationException || + e.Code == DynamoErrorCodes.InvalidUserPoolConfiguration || + e.Code == DynamoErrorCodes.NotAuthorizedException || + e.Code == DynamoErrorCodes.NotAuthorized || + e.Type == ErrorType.Unauthorized) + return true; + } + return false; } /// @@ -123,13 +148,18 @@ public static bool IsDynamoAuthError(this ErrorOr errorOr) public static bool IsDynamoRetryableError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.IsDynamoThrottlingError() || - errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.InternalServerError || - e.Code == DynamoErrorCodes.ServiceUnavailable || - e.Code == DynamoErrorCodes.RequestTimeoutException || - e.Code == DynamoErrorCodes.RequestTimeout || - (e.Type == ErrorType.Failure && !e.Description.Contains("permanent", StringComparison.OrdinalIgnoreCase))); + if (errorOr.IsDynamoThrottlingError()) + return true; + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.InternalServerError || + e.Code == DynamoErrorCodes.ServiceUnavailable || + e.Code == DynamoErrorCodes.RequestTimeoutException || + e.Code == DynamoErrorCodes.RequestTimeout || + (e.Type == ErrorType.Failure && !e.Description.Contains("permanent", StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; } /// @@ -141,9 +171,13 @@ public static bool IsDynamoRetryableError(this ErrorOr errorOr) public static bool IsDynamoResourceStateError(this ErrorOr errorOr) { if (!errorOr.IsError) return false; - return errorOr.Errors.Any(e => - e.Code == DynamoErrorCodes.ResourceInUseException || - e.Code == DynamoErrorCodes.ResourceInUse); + foreach (var e in errorOr.Errors) + { + if (e.Code == DynamoErrorCodes.ResourceInUseException || + e.Code == DynamoErrorCodes.ResourceInUse) + return true; + } + return false; } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs index df5f8255..0b3c28a6 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/DynamoPaginationException.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using ErrorOr; namespace Goa.Clients.Dynamo.Exceptions; @@ -6,7 +7,7 @@ namespace Goa.Clients.Dynamo.Exceptions; /// Exception thrown when an auto-pagination operation encounters an error during a page request. /// This prevents silent truncation of results in methods like QueryAllAsync, ScanAllAsync, and BatchGetAllAsync. /// -public class DynamoPaginationException : Exception +public sealed class DynamoPaginationException : Exception { /// /// Gets the underlying error that caused the pagination failure. @@ -33,4 +34,7 @@ public DynamoPaginationException(Error error, Exception innerException) { Error = error; } + + [DoesNotReturn] + internal static void Throw(Error error) => throw new DynamoPaginationException(error); } diff --git a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs index 5362fb2a..9d641d89 100644 --- a/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs +++ b/src/Clients/Goa.Clients.Dynamo/Exceptions/MissingAttributeException.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace Goa.Clients.Dynamo.Exceptions; /// /// Exception thrown when a required DynamoDB attribute is missing or null. /// -public class MissingAttributeException : Exception +public sealed class MissingAttributeException : Exception { /// /// Gets the name of the missing attribute. @@ -70,6 +72,8 @@ public MissingAttributeException(string attributeName, Exception innerException, /// The name of the missing attribute. /// Never returns (always throws). /// Always thrown. + [DoesNotReturn] + public static T Throw(string attributeName) { throw new MissingAttributeException(attributeName); @@ -83,6 +87,8 @@ public static T Throw(string attributeName) /// The sort key value for context. /// Never returns (always throws). /// Always thrown. + [DoesNotReturn] + public static T Throw(string attributeName, string? partitionKey, string? sortKey) { throw new MissingAttributeException(attributeName, partitionKey, sortKey); @@ -94,6 +100,8 @@ public static T Throw(string attributeName, string? partitionKey, string? sor /// /// The name of the missing attribute. /// Always thrown. + [DoesNotReturn] + public static void Throw(string attributeName) { throw new MissingAttributeException(attributeName); @@ -107,6 +115,8 @@ public static void Throw(string attributeName) /// The partition key value for context. /// The sort key value for context. /// Always thrown. + [DoesNotReturn] + public static void Throw(string attributeName, string? partitionKey, string? sortKey) { throw new MissingAttributeException(attributeName, partitionKey, sortKey); diff --git a/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs b/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs index 393821f7..2d7d43fe 100644 --- a/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/ExtensionAttribute.cs @@ -11,6 +11,6 @@ namespace Goa.Clients.Dynamo; /// in your project file. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public class ExtensionAttribute : Attribute +public sealed class ExtensionAttribute : Attribute { } diff --git a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs index 7202c357..54a86d07 100644 --- a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs @@ -541,9 +541,11 @@ public static bool TryGetIntSet(this DynamoRecord record, string columnName, out if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => int.TryParse(x, out var val) ? val : (int?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (int.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -569,9 +571,11 @@ public static bool TryGetLongSet(this DynamoRecord record, string columnName, ou if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => long.TryParse(x, out var val) ? val : (long?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (long.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -597,9 +601,11 @@ public static bool TryGetDoubleSet(this DynamoRecord record, string columnName, if (attributeValue.NS == null) return false; - value = attributeValue.NS.Select(x => double.TryParse(x, out var val) ? val : (double?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.NS) + if (double.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } @@ -626,9 +632,13 @@ public static bool TryGetEnumSet(this DynamoRecord record, string columnName, if (attributeValue.SS == null) return false; - value = attributeValue.SS.Select(x => Enum.TryParse(x, out var val) ? val : (T?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.SS) + { + if (Enum.TryParse(x, out var val)) + result.Add(val); + } + value = result; return true; } @@ -1046,9 +1056,11 @@ public static bool TryGetDateTimeSet(this DynamoRecord record, string columnName if (attributeValue.SS == null) return false; - value = attributeValue.SS.Select(x => DateTime.TryParse(x, out var val) ? val : (DateTime?)null) - .Where(x => x.HasValue) - .Select(x => x!.Value); + var result = new List(); + foreach (var x in attributeValue.SS) + if (DateTime.TryParse(x, out var val)) + result.Add(val); + value = result; return true; } diff --git a/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs b/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs index 9d58ea37..fe9e001d 100644 --- a/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/GlobalSecondaryIndexAttribute.cs @@ -5,7 +5,7 @@ /// This attribute is inherited, allowing base classes to define common GSI patterns. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public class GlobalSecondaryIndexAttribute : Attribute +public sealed class GlobalSecondaryIndexAttribute : Attribute { /// /// The name of the Global Secondary Index in DynamoDB. diff --git a/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs b/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs index d5bfc056..fbedefad 100644 --- a/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/IgnoreAttribute.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// By default, ignores the property in both directions (reading and writing). /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class IgnoreAttribute : Attribute +public sealed class IgnoreAttribute : Attribute { /// /// The direction in which to ignore the property. Defaults to Always. diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index f5ab7b83..78cf8d56 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Goa.Clients.Dynamo.Models; using Goa.Clients.Dynamo.Operations.Batch; +using Goa.Clients.Dynamo.Operations.GetItem; using Goa.Clients.Dynamo.Operations.Query; using Goa.Clients.Dynamo.Operations.Scan; using Goa.Clients.Dynamo.Operations.Transactions; @@ -161,6 +162,228 @@ internal static DynamoRecord ReadDynamoRecordItemCached(ref Utf8JsonReader reade return record; } + internal static QueryResponse ReadDynamoRecordQueryResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new QueryResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Items"u8)) + { + reader.Read(); + result.Items = ReadDynamoRecordItems(ref reader, ref cache); + } + else if (reader.ValueTextEquals("Count"u8)) + { + reader.Read(); + // DynamoDB Count always equals Items.Count (post-filter); ScannedCount is the pre-filter total + reader.GetInt32(); + } + else if (reader.ValueTextEquals("ScannedCount"u8)) + { + reader.Read(); + result.ScannedCount = reader.GetInt32(); + } + else if (reader.ValueTextEquals("LastEvaluatedKey"u8)) + { + reader.Read(); + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static ScanResponse ReadDynamoRecordScanResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new ScanResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Items"u8)) + { + reader.Read(); + result.Items = ReadDynamoRecordItems(ref reader, ref cache); + } + else if (reader.ValueTextEquals("Count"u8)) + { + reader.Read(); + reader.GetInt32(); + } + else if (reader.ValueTextEquals("ScannedCount"u8)) + { + reader.Read(); + result.ScannedCount = reader.GetInt32(); + } + else if (reader.ValueTextEquals("LastEvaluatedKey"u8)) + { + reader.Read(); + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static GetItemResponse ReadDynamoRecordGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new GetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Item"u8)) + { + reader.Read(); + result.Item = ReadDynamoRecordItemCached(ref reader, ref cache); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static BatchGetItemResponse ReadDynamoRecordBatchGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new BatchGetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Responses"u8)) + { + reader.Read(); + result.Responses = ReadDynamoRecordTableResponses(ref reader, ref cache); + } + else if (reader.ValueTextEquals("UnprocessedKeys"u8)) + { + reader.Read(); + result.UnprocessedKeys = ReadUnprocessedKeys(ref reader); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + internal static TransactGetItemResponse ReadDynamoRecordTransactGetItemResponse(ref Utf8JsonReader reader) + { + var cache = new PropertyNameCache(); + var result = new TransactGetItemResponse(); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("Responses"u8)) + { + reader.Read(); + result.Responses = ReadDynamoRecordTransactGetResponses(ref reader, ref cache); + } + else if (reader.ValueTextEquals("ConsumedCapacity"u8)) + { + reader.Read(); + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + } + else + { + reader.Read(); + reader.Skip(); + } + } + + return result; + } + + private static List ReadDynamoRecordItems(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var items = new List(); + if (reader.TokenType == JsonTokenType.Null) + return items; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + items.Add(ReadDynamoRecordItemCached(ref reader, ref cache)); + return items; + } + + private static Dictionary> ReadDynamoRecordTableResponses(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var responses = new Dictionary>(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var tableName = reader.GetString()!; + reader.Read(); // StartArray + responses[tableName] = ReadDynamoRecordItems(ref reader, ref cache); + } + return responses; + } + + private static List ReadDynamoRecordTransactGetResponses(ref Utf8JsonReader reader, ref PropertyNameCache cache) + { + var items = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + DynamoRecord? item = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.ValueTextEquals("Item"u8)) + { + reader.Read(); + item = ReadDynamoRecordItemCached(ref reader, ref cache); + } + else + { + reader.Read(); + reader.Skip(); + } + } + items.Add(new TransactGetResult { Item = item }); + } + return items; + } + private static List ReadItems(ref Utf8JsonReader reader, DynamoItemReader itemReader) { var items = new List(); diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs index a6097702..b03d2f22 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReaderRegistry.cs @@ -30,6 +30,7 @@ private static class Cache static Cache() { +#pragma warning disable GOA1503 // One-time method group allocations cached in static readonly fields if (typeof(T) == typeof(QueryResponse)) Reader = (JsonReader)(object)(JsonReader)ReadQueryResponse; else if (typeof(T) == typeof(ScanResponse)) @@ -42,6 +43,7 @@ static Cache() Reader = (JsonReader)(object)(JsonReader)ReadTransactGetItemResponse; else Reader = FallbackReader; +#pragma warning restore GOA1503 } private static T FallbackReader(ref Utf8JsonReader reader) @@ -55,55 +57,26 @@ as System.Text.Json.Serialization.Metadata.JsonTypeInfo private static QueryResponse ReadQueryResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadQueryResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new QueryResponse - { - Items = result.Items, - LastEvaluatedKey = result.LastEvaluatedKey, - ScannedCount = result.ScannedCount, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordQueryResponse(ref reader); } private static ScanResponse ReadScanResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadScanResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new ScanResponse - { - Items = result.Items, - LastEvaluatedKey = result.LastEvaluatedKey, - ScannedCount = result.ScannedCount, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordScanResponse(ref reader); } private static GetItemResponse ReadGetItemResponse(ref Utf8JsonReader reader) { - var item = DynamoResponseReader.ReadGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new GetItemResponse { Item = item }; + return DynamoResponseReader.ReadDynamoRecordGetItemResponse(ref reader); } private static BatchGetItemResponse ReadBatchGetItemResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadBatchGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - return new BatchGetItemResponse - { - Responses = result.Responses, - UnprocessedKeys = result.UnprocessedKeys, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordBatchGetItemResponse(ref reader); } private static TransactGetItemResponse ReadTransactGetItemResponse(ref Utf8JsonReader reader) { - var result = DynamoResponseReader.ReadTransactGetItemResponse(ref reader, DynamoResponseReader.ReadDynamoRecordItem); - var responses = new List(result.Items.Count); - foreach (var item in result.Items) - responses.Add(new TransactGetResult { Item = item }); - return new TransactGetItemResponse - { - Responses = responses, - ConsumedCapacity = result.ConsumedCapacity - }; + return DynamoResponseReader.ReadDynamoRecordTransactGetItemResponse(ref reader); } } diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs b/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs index 2b24d719..8cc45c8f 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/PropertyNameCache.cs @@ -11,7 +11,11 @@ internal struct PropertyNameCache { private readonly Dictionary _cache; - public PropertyNameCache(int capacity = 16) + public PropertyNameCache() : this(15) + { + } + + public PropertyNameCache(int capacity) { _cache = new Dictionary(capacity); } diff --git a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs index ec82cc43..017f9d42 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Detailed capacity information for DynamoDB operations. /// -public class CapacityDetail +public sealed class CapacityDetail { /// /// The number of read capacity units consumed by the operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs index 5deb7288..6a3ef92c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Represents consumed capacity information for DynamoDB operations. /// -public class ConsumedCapacity +public sealed class ConsumedCapacity { /// /// The name of the table that was affected by the operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs index 71a9ea12..ea22c5e8 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs @@ -3,7 +3,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Represents a DynamoDB record with strongly-typed attribute values. /// -public class DynamoRecord : Dictionary +public sealed class DynamoRecord : Dictionary { /// /// Initializes a new, empty instance of the class. diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs index bd3eb9c4..19d3f1a4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Models; /// /// Information about item collections, if any, that were affected by the operation. /// -public class ItemCollectionMetrics +public sealed class ItemCollectionMetrics { /// /// The partition key value of the item collection. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs index 71356ed0..c8a12346 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for constructing DynamoDB BatchGetItem requests with a user-friendly API. /// -public class BatchGetItemBuilder +public sealed class BatchGetItemBuilder { private readonly BatchGetItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs index 20e38bf6..e33c469a 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Request for batch getting items from DynamoDB. /// -public class BatchGetItemRequest +public sealed class BatchGetItemRequest { /// /// A map of one or more table names and, for each table, a map that describes one or more items to retrieve from that table. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs index 61232af3..f6ae1aac 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Response for BatchGetItem operations. /// -public class BatchGetItemResponse +public sealed class BatchGetItemResponse { /// /// A map of table name to a list of items. Each item is represented as a set of attributes. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs index 6c1c8876..7b783e66 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Individual table request within a BatchGetItem operation. /// -public class BatchGetRequestItem +public sealed class BatchGetRequestItem { /// /// The primary keys of the items to retrieve. For each key, DynamoDB performs a GetItem operation and returns the entire item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs index 64f66bcc..4d544fe1 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for configuring table-specific requests within a BatchGetItem operation. /// -public class BatchGetTableBuilder +public sealed class BatchGetTableBuilder { private readonly BatchGetRequestItem _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs index c3f5c117..1689fc90 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for constructing DynamoDB BatchWriteItem requests with a user-friendly API. /// -public class BatchWriteItemBuilder +public sealed class BatchWriteItemBuilder { private readonly BatchWriteItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs index 97b26738..2063ac63 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Request for batch writing items to DynamoDB. /// -public class BatchWriteItemRequest +public sealed class BatchWriteItemRequest { /// /// A map of one or more table names and, for each table, a list of operations to be performed. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs index af643513..aa1169c7 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Response for BatchWriteItem operations. /// -public class BatchWriteItemResponse +public sealed class BatchWriteItemResponse { /// /// A map of tables and requests against those tables that were not processed. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs index 12522fa6..a0896a80 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Individual write request within a BatchWriteItem operation. /// -public class BatchWriteRequestItem +public sealed class BatchWriteRequestItem { /// /// A request to perform a PutItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs index 8a69683b..fdd8044b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Result wrapper for BatchWrite operations. /// -public class BatchWriteResult +public sealed class BatchWriteResult { /// /// Put items that were not processed during the BatchWriteItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs index c91c91a6..70973913 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteTableBuilder.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Fluent builder for configuring table-specific requests within a BatchWriteItem operation. /// -public class BatchWriteTableBuilder +public sealed class BatchWriteTableBuilder { private readonly List _requests = new(); @@ -30,7 +30,9 @@ public BatchWriteTableBuilder WithDelete(Dictionary key) /// The BatchWriteTableBuilder instance for method chaining. public BatchWriteTableBuilder WithDelete(IEnumerable> keys) { +#pragma warning disable GOA1301 // Enumerator allocation unavoidable - IEnumerable is public API parameter type foreach (var key in keys) +#pragma warning restore GOA1301 { WithDelete(key); } @@ -58,7 +60,9 @@ public BatchWriteTableBuilder WithPut(Dictionary item) /// The BatchWriteTableBuilder instance for method chaining. public BatchWriteTableBuilder WithPut(IEnumerable> items) { +#pragma warning disable GOA1301 // Enumerator allocation unavoidable - IEnumerable is public API parameter type foreach (var item in items) +#pragma warning restore GOA1301 { WithPut(item); } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs index dcaf8f65..7625bfb3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Delete request within a batch write operation. /// -public class DeleteRequest +public sealed class DeleteRequest { /// /// The primary key of the item to be deleted. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs index c0d766c8..b37c9021 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Put request within a batch write operation. /// -public class PutRequest +public sealed class PutRequest { /// /// A map of attribute names to AttributeValue objects representing the item to be put. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs index 8b304601..a5584bdd 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations; /// /// Represents a condition expression for DynamoDB operations with associated attribute names and values. /// -public class Condition +public sealed class Condition { /// /// Gets the condition expression string. @@ -41,8 +41,14 @@ public Condition(string expression) public Condition(string expression, IEnumerable> expressionNames, IEnumerable> expressionValues) { Expression = expression; - ExpressionNames = expressionNames.ToDictionary(); - ExpressionValues = expressionValues.ToDictionary(); + var names = new Dictionary(); + foreach (var kvp in expressionNames) + names.Add(kvp.Key, kvp.Value); + ExpressionNames = names; + var values = new Dictionary(); + foreach (var kvp in expressionValues) + values.Add(kvp.Key, kvp.Value); + ExpressionValues = values; } /// @@ -294,10 +300,19 @@ public static Condition And(Condition left, Condition right) /// A condition representing the logical AND of all conditions. public static Condition And(params Condition[] conditions) { - var expression = string.Join(" AND ", conditions.Select(c => c.Expression)); - var expressionNames = conditions.SelectMany(c => c.ExpressionNames).ToDictionary(); - var expressionValues = conditions.SelectMany(c => c.ExpressionValues).ToDictionary(); - return new Condition(expression, expressionNames, expressionValues); + var sb = new System.Text.StringBuilder(); + var expressionNames = new Dictionary(); + var expressionValues = new Dictionary(); + for (var i = 0; i < conditions.Length; i++) + { + if (i > 0) sb.Append(" AND "); + sb.Append(conditions[i].Expression); + foreach (var kvp in conditions[i].ExpressionNames) + expressionNames.Add(kvp.Key, kvp.Value); + foreach (var kvp in conditions[i].ExpressionValues) + expressionValues.Add(kvp.Key, kvp.Value); + } + return new Condition(sb.ToString(), expressionNames, expressionValues); } /// @@ -321,10 +336,19 @@ public static Condition Or(Condition left, Condition right) /// A condition representing the logical OR of all conditions. public static Condition Or(params Condition[] conditions) { - var expression = string.Join(" OR ", conditions.Select(c => c.Expression)); - var expressionNames = conditions.SelectMany(c => c.ExpressionNames).ToDictionary(); - var expressionValues = conditions.SelectMany(c => c.ExpressionValues).ToDictionary(); - return new Condition(expression, expressionNames, expressionValues); + var sb = new System.Text.StringBuilder(); + var expressionNames = new Dictionary(); + var expressionValues = new Dictionary(); + for (var i = 0; i < conditions.Length; i++) + { + if (i > 0) sb.Append(" OR "); + sb.Append(conditions[i].Expression); + foreach (var kvp in conditions[i].ExpressionNames) + expressionNames.Add(kvp.Key, kvp.Value); + foreach (var kvp in conditions[i].ExpressionValues) + expressionValues.Add(kvp.Key, kvp.Value); + } + return new Condition(sb.ToString(), expressionNames, expressionValues); } /// @@ -338,10 +362,17 @@ public static Condition In(string attributeName, params AttributeValue[] values) if (values is null || values.Length == 0) throw new ArgumentException("At least one value must be provided for IN condition", nameof(values)); - var valueParams = values.Select((_, i) => $":{attributeName}{i}").ToArray(); +#pragma warning disable GOA1401 // Explicit arrays required for building expression parameters + var valueParams = new string[values.Length]; + var expressionValues = new KeyValuePair[values.Length]; + for (var i = 0; i < values.Length; i++) + { + valueParams[i] = $":{attributeName}{i}"; + expressionValues[i] = new KeyValuePair($":{attributeName}{i}", values[i]); + } var expression = $"#{attributeName} IN ({string.Join(", ", valueParams)})"; - var expressionNames = new[] { new KeyValuePair($"#{attributeName}", attributeName) }; - var expressionValues = values.Select((value, i) => new KeyValuePair($":{attributeName}{i}", value)); + var expressionNames = new KeyValuePair[] { new($"#{attributeName}", attributeName) }; +#pragma warning restore GOA1401 return new Condition(expression, expressionNames, expressionValues); } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs index 184d8f45..35eba6d3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// Fluent builder for constructing DynamoDB DeleteItem requests with a user-friendly API. /// /// The name of the table to delete the item from. -public class DeleteItemBuilder(string tableName) +public sealed class DeleteItemBuilder(string tableName) { private readonly DeleteItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs index d3b30b93..b7b14f84 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// /// Request for deleting an item from DynamoDB. /// -public class DeleteItemRequest +public sealed class DeleteItemRequest { /// /// The name of the table from which to delete the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs index 44575d12..f7e3abe7 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.DeleteItem; /// /// Response for DeleteItem operations. /// -public class DeleteItemResponse +public sealed class DeleteItemResponse { /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was deleted. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs index f6a50a47..70f7e959 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemBuilder.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// Fluent builder for constructing DynamoDB GetItem requests with a user-friendly API. /// /// The name of the table to get the item from. -public class GetItemBuilder(string tableName) +public sealed class GetItemBuilder(string tableName) { private readonly GetItemRequest _request = new() { @@ -64,7 +64,7 @@ public GetItemBuilder WithProjection(params string[] attributes) /// The GetItemBuilder instance for method chaining. public GetItemBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs index d76357cd..acba3f41 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// /// Request for getting an item from DynamoDB. /// -public class GetItemRequest +public sealed class GetItemRequest { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs index 23ebfa8c..ffa40028 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.GetItem; /// /// Response for GetItem operations. /// -public class GetItemResponse +public sealed class GetItemResponse { /// /// A map of attribute names to AttributeValue objects, as specified by ProjectionExpression. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs index 4e2901d5..582de370 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// Fluent builder for constructing DynamoDB PutItem requests with a user-friendly API. /// /// The name of the table to put the item into. -public class PutItemBuilder(string tableName) +public sealed class PutItemBuilder(string tableName) { private readonly PutItemRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs index 0412ff60..88bd3356 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// /// Request for putting an item into DynamoDB. /// -public class PutItemRequest +public sealed class PutItemRequest { /// /// The name of the table to contain the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs index 264f2e06..38cf2b82 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; /// /// Response for PutItem operations. /// -public class PutItemResponse +public sealed class PutItemResponse { /// /// The attribute values as they appeared before the PutItem operation, diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs index 0a1e77b3..8f671826 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryBuilder.cs @@ -149,7 +149,7 @@ public QueryBuilder WithProjection(params string[] attributes) /// The QueryBuilder instance for method chaining. public QueryBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); return WithSelectionMode(Select.SPECIFIC_ATTRIBUTES); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs index 01d15151..d9f5f4fc 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Request for querying items from DynamoDB. /// -public class QueryRequest +public sealed class QueryRequest { /// /// The name of the table containing the requested items. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs index d6606f10..d6420f1b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Response for Query operations with pagination support. /// -public class QueryResponse +public sealed class QueryResponse { /// /// An array of item attributes that match the query criteria. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs index c3695f8b..6c73e0e4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Query; /// /// Result wrapper for Query operations with pagination support. /// -public class QueryResult +public sealed class QueryResult { /// /// The items returned by the Query operation. @@ -51,7 +51,7 @@ public class QueryResult /// Typed result wrapper for Query operations with direct deserialization support. /// /// The type of the deserialized items. -public class QueryResult +public sealed class QueryResult { /// [JsonPropertyName("Items")] diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs index 9231ffde..67505944 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanBuilder.cs @@ -8,7 +8,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// Fluent builder for constructing DynamoDB Scan requests with a user-friendly API. /// /// The name of the table to scan. -public class ScanBuilder(string tableName) +public sealed class ScanBuilder(string tableName) { private readonly ScanRequest _request = new() { @@ -103,7 +103,7 @@ public ScanBuilder WithProjection(params string[] attributes) /// The ScanBuilder instance for method chaining. public ScanBuilder WithProjection(IEnumerable attributes) { - if (attributes?.Any() == true) + if (attributes is ICollection { Count: > 0 }) { _request.ProjectionExpression = string.Join(", ", attributes); return WithSelectionMode(Select.SPECIFIC_ATTRIBUTES); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs index 8213917d..279e994e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Request for scanning items from DynamoDB. /// -public class ScanRequest +public sealed class ScanRequest { /// /// The name of the table containing the requested items. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs index b9fb807e..7f7faba4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Response for Scan operations with pagination support. /// -public class ScanResponse +public sealed class ScanResponse { /// /// An array of item attributes that match the scan criteria. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs index a47eb3ea..fbdfc418 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Scan; /// /// Result wrapper for Scan operations with pagination support. /// -public class ScanResult +public sealed class ScanResult { /// /// The items returned by the Scan operation. @@ -51,7 +51,7 @@ public class ScanResult /// Typed result wrapper for Scan operations with direct deserialization support. /// /// The type of the deserialized items. -public class ScanResult +public sealed class ScanResult { /// [JsonPropertyName("Items")] diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs index a7e46638..31ac4371 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Condition check operation in a transaction. /// -public class TransactConditionCheckItem +public sealed class TransactConditionCheckItem { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs index b19bc059..729c122d 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Delete operation in a transaction. /// -public class TransactDeleteItem +public sealed class TransactDeleteItem { /// /// The name of the table from which to delete the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs index c8ab5981..8f30c926 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetBuilder.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Fluent builder for constructing DynamoDB TransactGet requests with a user-friendly API. /// -public class TransactGetBuilder +public sealed class TransactGetBuilder { private readonly TransactGetRequest _request = new(); diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs index e41a7057..5cad71d3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact get item. /// -public class TransactGetItem +public sealed class TransactGetItem { /// /// Contains the primary key that identifies the item to get, together with the name of the table that contains the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs index 3a37f75c..2bc0dec9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Get request within a transact get operation. /// -public class TransactGetItemRequest +public sealed class TransactGetItemRequest { /// /// The name of the table containing the requested item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs index 9ca8c401..1fa71f78 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Response for TransactGetItem operations. /// -public class TransactGetItemResponse +public sealed class TransactGetItemResponse { /// /// An ordered array of up to 100 response items, each of which corresponds to the TransactGetItem request in the same position. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs index 692cf43e..d83c308c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Request for transactional get operations. /// -public class TransactGetRequest +public sealed class TransactGetRequest { /// /// An ordered array of up to 100 TransactGetItem objects, each of which contains a Get operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs index c7960c32..96d911d0 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Result item from TransactGetItem operation. /// -public class TransactGetResult +public sealed class TransactGetResult { /// /// Map of attribute names to values for the requested item, or null if the item was not found. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs index 5fd27526..4f7aa1d1 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Put operation in a transaction. /// -public class TransactPutItem +public sealed class TransactPutItem { /// /// The name of the table to contain the item. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs index aa9f671f..0f3a645c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Update operation in a transaction. /// -public class TransactUpdateItem +public sealed class TransactUpdateItem { /// /// The name of the table containing the item to be updated. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs index 071fc177..34d92147 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteBuilder.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Fluent builder for constructing DynamoDB TransactWrite requests with a user-friendly API. /// -public class TransactWriteBuilder +public sealed class TransactWriteBuilder { private readonly TransactWriteRequest _request = new() { diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs index 20ec5540..7abae7cf 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact write item. /// -public class TransactWriteItem +public sealed class TransactWriteItem { /// /// A request to perform a PutItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs index 08bf2f0b..d0ba3ce9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Response for TransactWriteItem operations. /// -public class TransactWriteItemResponse +public sealed class TransactWriteItemResponse { /// /// The capacity units consumed by the entire TransactWriteItem operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs index 85f84da6..928e6c63 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Represents a transaction write operation. /// -public class TransactWriteOperation +public sealed class TransactWriteOperation { /// /// The type of operation to perform (Put, Update, Delete, or ConditionCheck). diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs index 6a1cec6e..be91d7cd 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Request for transactional write operations. /// -public class TransactWriteRequest +public sealed class TransactWriteRequest { /// /// An ordered array of up to 100 TransactWriteItem objects, each of which contains a ConditionCheck, Put, Update, or Delete operation. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs index 76ae1e43..77e05ed4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs @@ -663,7 +663,7 @@ public UpdateItemBuilder AddToStringSet(string attributeName, params string[] va var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(new List(values)); _addActions.Add($"{nameKey} {valueKey}"); } @@ -686,8 +686,11 @@ public UpdateItemBuilder AddToNumberSet(string attributeName, params decimal[] v var nameKey = $"#n{_request.ExpressionAttributeNames.Count}"; var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; + var numberStrings = new List(values.Length); + foreach (var v in values) + numberStrings.Add(v.ToString(CultureInfo.InvariantCulture)); _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(numberStrings); _addActions.Add($"{nameKey} {valueKey}"); } @@ -754,7 +757,7 @@ public UpdateItemBuilder RemoveFromStringSet(string attributeName, params string var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(new List(values)); _deleteActions.Add($"{nameKey} {valueKey}"); } @@ -777,8 +780,11 @@ public UpdateItemBuilder RemoveFromNumberSet(string attributeName, params decima var nameKey = $"#n{_request.ExpressionAttributeNames.Count}"; var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; + var numberStrings = new List(values.Length); + foreach (var v in values) + numberStrings.Add(v.ToString(CultureInfo.InvariantCulture)); _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(numberStrings); _deleteActions.Add($"{nameKey} {valueKey}"); } @@ -919,8 +925,9 @@ private string BuildPathExpression(string path) var matches = PathSegmentRegex().Matches(path); var expressionParts = new List(); - foreach (Match match in matches) + for (var i = 0; i < matches.Count; i++) { + var match = matches[i]; if (match.Groups[1].Success) { // Attribute name diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs index 7094d476..9d08dff9 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs @@ -7,7 +7,7 @@ namespace Goa.Clients.Dynamo.Operations.UpdateItem; /// /// Request for updating an item in DynamoDB. /// -public class UpdateItemRequest +public sealed class UpdateItemRequest { /// /// The name of the table containing the item to update. diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs index 330778fc..f16a0d42 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs @@ -6,7 +6,7 @@ namespace Goa.Clients.Dynamo.Operations.UpdateItem; /// /// Response for UpdateItem operations. /// -public class UpdateItemResponse +public sealed class UpdateItemResponse { /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was updated. diff --git a/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs b/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs index 2df241fe..7a51ea56 100644 --- a/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/SerializedNameAttribute.cs @@ -5,7 +5,7 @@ namespace Goa.Clients.Dynamo; /// By default, property names are used as-is for DynamoDB attribute names. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class SerializedNameAttribute : Attribute +public sealed class SerializedNameAttribute : Attribute { /// /// Initializes a new instance of the SerializedNameAttribute class. diff --git a/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs b/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs index 37a0268e..a20f0701 100644 --- a/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs +++ b/src/Clients/Goa.Clients.Dynamo/UnixTimestampAttribute.cs @@ -6,7 +6,7 @@ /// When used in composite PK/SK patterns, they are formatted as strings. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class UnixTimestampAttribute : Attribute +public sealed class UnixTimestampAttribute : Attribute { /// /// The format for the unix timestamp. Defaults to Seconds (AWS TTL compatible). diff --git a/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs b/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs index c7492305..ffffe185 100644 --- a/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs +++ b/src/Clients/Goa.Clients.EventBridge/EventBridgeServiceClient.cs @@ -47,7 +47,7 @@ public async Task> PutEventsAsync(PutEventsRequest re } catch (Exception ex) { - Logger.LogError(ex, "Failed to put events to EventBridge"); + Logger.PutEventsFailed(ex); return Error.Failure("EventBridge.PutEvents.Failed", "Failed to put events to EventBridge"); } } diff --git a/src/Clients/Goa.Clients.EventBridge/Log.cs b/src/Clients/Goa.Clients.EventBridge/Log.cs new file mode 100644 index 00000000..17e0cbf3 --- /dev/null +++ b/src/Clients/Goa.Clients.EventBridge/Log.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.EventBridge; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to put events to EventBridge")] + public static partial void PutEventsFailed(this ILogger logger, Exception exception); +} diff --git a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs index bfc34192..6070892c 100644 --- a/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs +++ b/src/Clients/Goa.Clients.Lambda/LambdaServiceClient.cs @@ -41,7 +41,12 @@ public async Task> InvokeSynchronousAsync(InvokeRequest // Add log type header if (request.LogType != LogType.None) - headers["X-Amz-Log-Type"] = request.LogType.ToString(); + headers["X-Amz-Log-Type"] = request.LogType switch + { + LogType.None => nameof(LogType.None), + LogType.Tail => nameof(LogType.Tail), + _ => throw new ArgumentOutOfRangeException(nameof(request.LogType), request.LogType, null) + }; // Add client context header if (!string.IsNullOrWhiteSpace(request.ClientContext)) @@ -66,7 +71,7 @@ public async Task> InvokeSynchronousAsync(InvokeRequest return ConvertApiError(response.Error!); // TODO: Lambda-specific headers (X-Amz-Function-Error, X-Amz-Log-Result, X-Amz-Executed-Version) - // are not captured by ResponseHeaders. Will be addressed when Lambda invoke is refactored + // are not captured. Will be addressed when Lambda invoke is refactored // to read directly from HttpResponseMessage. var invokeResponse = InvokeResponse.FromHttpResponse( 200, @@ -77,7 +82,7 @@ public async Task> InvokeSynchronousAsync(InvokeRequest } catch (Exception ex) { - Logger.LogError(ex, "Failed to invoke Lambda function synchronously {FunctionName}", request.FunctionName); + Logger.InvokeSynchronousFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeSynchronous.Failed", $"Failed to invoke Lambda function synchronously {request.FunctionName}"); } } @@ -125,7 +130,7 @@ public async Task> InvokeAsynchronousAsync(InvokeAs } catch (Exception ex) { - Logger.LogError(ex, "Failed to invoke Lambda function asynchronously {FunctionName}", request.FunctionName); + Logger.InvokeAsynchronousFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeAsynchronous.Failed", $"Failed to invoke Lambda function asynchronously {request.FunctionName}"); } } @@ -173,7 +178,7 @@ public async Task> InvokeDryRunAsync(InvokeDryRunR } catch (Exception ex) { - Logger.LogError(ex, "Failed to dry run Lambda function {FunctionName}", request.FunctionName); + Logger.InvokeDryRunFailed(ex, request.FunctionName); return Error.Failure("Lambda.InvokeDryRun.Failed", $"Failed to dry run Lambda function {request.FunctionName}"); } } diff --git a/src/Clients/Goa.Clients.Lambda/Log.cs b/src/Clients/Goa.Clients.Lambda/Log.cs new file mode 100644 index 00000000..c60e667f --- /dev/null +++ b/src/Clients/Goa.Clients.Lambda/Log.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Lambda; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to invoke Lambda function synchronously {FunctionName}")] + public static partial void InvokeSynchronousFailed(this ILogger logger, Exception exception, string functionName); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed to invoke Lambda function asynchronously {FunctionName}")] + public static partial void InvokeAsynchronousFailed(this ILogger logger, Exception exception, string functionName); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Failed to dry run Lambda function {FunctionName}")] + public static partial void InvokeDryRunFailed(this ILogger logger, Exception exception, string functionName); +} diff --git a/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs b/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs index 9065ef7a..359cb0e3 100644 --- a/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs +++ b/src/Clients/Goa.Clients.Lambda/Operations/Invoke/InvokeResponse.cs @@ -57,11 +57,11 @@ public static InvokeResponse FromHttpResponse(int statusCode, string? payload, I if (headers != null) { if (headers.TryGetValue("X-Amz-Function-Error", out var functionError)) - response.FunctionError = functionError.FirstOrDefault(); + foreach (var v in functionError) { response.FunctionError = v; break; } if (headers.TryGetValue("X-Amz-Log-Result", out var logResult)) { - response.LogResult = logResult.FirstOrDefault(); + foreach (var v in logResult) { response.LogResult = v; break; } if (!string.IsNullOrWhiteSpace(response.LogResult)) { try @@ -76,7 +76,7 @@ public static InvokeResponse FromHttpResponse(int statusCode, string? payload, I } if (headers.TryGetValue("X-Amz-Executed-Version", out var executedVersion)) - response.ExecutedVersion = executedVersion.FirstOrDefault(); + foreach (var v in executedVersion) { response.ExecutedVersion = v; break; } } return response; diff --git a/src/Clients/Goa.Clients.Sns/Log.cs b/src/Clients/Goa.Clients.Sns/Log.cs new file mode 100644 index 00000000..009ecfaa --- /dev/null +++ b/src/Clients/Goa.Clients.Sns/Log.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Sns; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to publish message to SNS target {Target}")] + public static partial void PublishFailed(this ILogger logger, Exception exception, string? target); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Failed to deserialize SNS XML error response: {Content}")] + public static partial void DeserializeSnsErrorFailed(this ILogger logger, Exception exception, string content); +} diff --git a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs index 62d3be6e..e4ed858f 100644 --- a/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sns/SnsServiceClient.cs @@ -11,6 +11,7 @@ namespace Goa.Clients.Sns; internal sealed class SnsServiceClient : AwsServiceClient, ISnsClient { + private static readonly MediaTypeHeaderValue FormUrlEncodedContentType = new("application/x-www-form-urlencoded"); private static readonly ApiError DeserializationError = new("Failed to deserialize response", "DeserializationError"); public SnsServiceClient( @@ -47,20 +48,20 @@ public async Task> PublishAsync(PublishRequest request, HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-www-form-urlencoded")); + FormUrlEncodedContentType); // Send request and get raw HTTP response var httpResponse = await SendAsync(requestMessage, "Publish", cancellationToken); // Process the response - var apiResponse = await ProcessSnsResponseAsync(httpResponse); + var apiResponse = await ProcessSnsResponseAsync(httpResponse, cancellationToken); return ConvertApiResponse(apiResponse); } catch (Exception ex) { var target = request.TopicArn ?? request.TargetArn ?? request.PhoneNumber; - Logger.LogError(ex, "Failed to publish message to SNS target {Target}", target); + Logger.PublishFailed(ex, target); return Error.Failure("SNS.Publish.Failed", $"Failed to publish message to SNS target {target}"); } } @@ -70,7 +71,7 @@ public async Task> PublishAsync(PublishRequest request, /// private static string SerializeToQueryParameters(PublishRequest request) { - var parameters = new List + var parameters = new List(12) { "Action=Publish", "Version=2010-03-31" @@ -126,20 +127,19 @@ private static string SerializeToQueryParameters(PublishRequest request) /// /// Processes an SNS HTTP response and converts it to an API response. /// - private async Task> ProcessSnsResponseAsync(HttpResponseMessage response) + private async Task> ProcessSnsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) where TResponse : class, IDeserializeFromXml, new() { - var headers = ResponseHeaders.FromHttpResponse(response.Headers); - if (!response.IsSuccessStatusCode) { - var errorPayload = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(errorPayload)) + using var errorBuffer = await ReadResponseBytesAsync(response, cancellationToken); + if (errorBuffer.Length == 0) { Logger.RequestFailed("No payload present"); return new ApiResponse(new ApiError("Request not successful.") { StatusCode = response.StatusCode }); } + var errorPayload = Encoding.UTF8.GetString(errorBuffer.Span); var error = DeserializeSnsError(errorPayload); if (error is not null) { @@ -161,46 +161,17 @@ private async Task> ProcessSnsResponseAsync(Ht return new ApiResponse(error ?? DeserializationError); } - var contentPayload = await response.Content.ReadAsStringAsync(); + using var pooledBuffer = await ReadResponseBytesAsync(response, cancellationToken); + var contentPayload = pooledBuffer.Length > 0 ? Encoding.UTF8.GetString(pooledBuffer.Span) : string.Empty; if (string.IsNullOrWhiteSpace(contentPayload)) { - return new ApiResponse(default(TResponse), headers); + return new ApiResponse(default(TResponse)); } var result = new TResponse(); result.DeserializeFromXml(contentPayload); - return new ApiResponse(result, headers); - } - - /// - /// Applies AWS-specific error header processing to the error object. - /// - private ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError error) - { - if (response.Headers.TryGetValues(XAmznErrorMessage, out var messages)) - { - error = error with { Message = string.Join(", ", messages) }; - } - - if (string.IsNullOrWhiteSpace(error.Type) && response.Headers.TryGetValues(XAmzErrorType, out var types)) - { - error = error with { Type = string.Join(", ", types) }; - } - - var infoSeparator = error.Type?.LastIndexOf(':') ?? -1; - if (infoSeparator > 0) - { - error = error with { Type = error.Type![..infoSeparator] }; - } - - var typeSeparator = error.Type?.LastIndexOf('#') ?? -1; - if (typeSeparator > 0) - { - error = error with { Type = error.Type![(typeSeparator + 1)..] }; - } - - return error; + return new ApiResponse(result); } /// @@ -219,7 +190,7 @@ private ApiError ProcessAwsErrorHeaders(HttpResponseMessage response, ApiError e } catch (Exception ex) { - Logger.LogWarning(ex, "Failed to deserialize SNS XML error response: {Content}", content); + Logger.DeserializeSnsErrorFailed(ex, content); return null; } } diff --git a/src/Clients/Goa.Clients.Sqs/Log.cs b/src/Clients/Goa.Clients.Sqs/Log.cs new file mode 100644 index 00000000..3e7f6ecd --- /dev/null +++ b/src/Clients/Goa.Clients.Sqs/Log.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace Goa.Clients.Sqs; + +internal static partial class Log +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to send message to SQS queue {QueueUrl}")] + public static partial void SendMessageFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed to send message batch to SQS queue {QueueUrl}")] + public static partial void SendMessageBatchFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Failed to receive messages from SQS queue {QueueUrl}")] + public static partial void ReceiveMessageFailed(this ILogger logger, Exception exception, string queueUrl); + + [LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Failed to delete message from SQS queue {QueueUrl}")] + public static partial void DeleteMessageFailed(this ILogger logger, Exception exception, string queueUrl); +} diff --git a/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs b/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs index dc19c88e..b02be14a 100644 --- a/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs +++ b/src/Clients/Goa.Clients.Sqs/SqsExtensions.cs @@ -127,7 +127,9 @@ private static async Task> SendBatch } // Chunk into batches of 10 - var chunks = entries.Chunk(MaxBatchSize).ToList(); + var chunkCount = (entries.Count + MaxBatchSize - 1) / MaxBatchSize; + var chunks = new List(chunkCount); + chunks.AddRange(entries.Chunk(MaxBatchSize)); // Create batch requests var batchTasks = chunks.Select((chunk, batchIndex) => diff --git a/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs b/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs index 385e6793..bbe16a5e 100644 --- a/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs +++ b/src/Clients/Goa.Clients.Sqs/SqsServiceClient.cs @@ -50,7 +50,7 @@ public async Task> SendMessageAsync(SendMessageRequ } catch (Exception ex) { - Logger.LogError(ex, "Failed to send message to SQS queue {QueueUrl}", request.QueueUrl); + Logger.SendMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.SendMessage.Failed", $"Failed to send message to SQS queue {request.QueueUrl}"); } } @@ -90,7 +90,7 @@ public async Task> SendMessageBatchAsync(SendM } catch (Exception ex) { - Logger.LogError(ex, "Failed to send message batch to SQS queue {QueueUrl}", request.QueueUrl); + Logger.SendMessageBatchFailed(ex, request.QueueUrl); return Error.Failure("SQS.SendMessageBatch.Failed", $"Failed to send message batch to SQS queue {request.QueueUrl}"); } } @@ -118,7 +118,7 @@ public async Task> ReceiveMessageAsync(ReceiveMe } catch (Exception ex) { - Logger.LogError(ex, "Failed to receive messages from SQS queue {QueueUrl}", request.QueueUrl); + Logger.ReceiveMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.ReceiveMessage.Failed", $"Failed to receive messages from SQS queue {request.QueueUrl}"); } } @@ -146,7 +146,7 @@ public async Task> DeleteMessageAsync(DeleteMessa } catch (Exception ex) { - Logger.LogError(ex, "Failed to delete message from SQS queue {QueueUrl}", request.QueueUrl); + Logger.DeleteMessageFailed(ex, request.QueueUrl); return Error.Failure("SQS.DeleteMessage.Failed", $"Failed to delete message from SQS queue {request.QueueUrl}"); } } diff --git a/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs b/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs index 876dd40b..1093fc35 100644 --- a/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs +++ b/src/Functions/Goa.Functions.ApiGateway/Features/V1/LambdaHttpRequestFeatureV1.cs @@ -29,7 +29,19 @@ public LambdaHttpRequestFeatureV1(ProxyPayloadV1Request request) private static string BuildQueryString(IDictionary? queryStringParameters) { if (queryStringParameters == null || queryStringParameters.Count == 0) return string.Empty; - return "?" + string.Join("&", queryStringParameters.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + + var sb = new StringBuilder("?"); + var first = true; + foreach (var kvp in queryStringParameters) + { + if (!first) + sb.Append('&'); + sb.Append(Uri.EscapeDataString(kvp.Key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(kvp.Value)); + first = false; + } + return sb.ToString(); } private static IHeaderDictionary BuildHeaderDictionary(IDictionary? headers, IDictionary>? multiValueHeaders) diff --git a/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs b/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs index 78633dd4..c4850c3a 100644 --- a/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs +++ b/src/Functions/Goa.Functions.ApiGateway/Features/V2/LambdaHttpAuthenticationFeatureV2.cs @@ -30,11 +30,10 @@ public LambdaHttpAuthenticationFeatureV2(ProxyPayloadV2RequestAuthentication? au if (authorizer?.Jwt?.Claims != null) { - // Use the authorizer's JWT claims to build the ClaimsPrincipal - User = new ClaimsPrincipal(new ClaimsIdentity( - claims.Concat(authorizer.Jwt.Claims.Select(kvp => new Claim(kvp.Key, kvp.Value))), - "Jwt" - )); + foreach (var kvp in authorizer.Jwt.Claims) + claims.Add(new Claim(kvp.Key, kvp.Value)); + + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Jwt")); } else if (authorizer?.IAM is not null) { @@ -48,13 +47,10 @@ public LambdaHttpAuthenticationFeatureV2(ProxyPayloadV2RequestAuthentication? au } else if (authentication?.ClientCert != null) { - // Fallback to client certificate details - User = new ClaimsPrincipal(new ClaimsIdentity(claims.Concat(new[] - { - new Claim(ClaimTypes.Name, authentication.ClientCert.SubjectDN ?? "Anonymous"), - new Claim("Issuer", authentication.ClientCert.IssuerDN ?? string.Empty), - new Claim("SerialNumber", authentication.ClientCert.SerialNumber ?? string.Empty) - }), "ClientCert")); + claims.Add(new Claim(ClaimTypes.Name, authentication.ClientCert.SubjectDN ?? "Anonymous")); + claims.Add(new Claim("Issuer", authentication.ClientCert.IssuerDN ?? string.Empty)); + claims.Add(new Claim("SerialNumber", authentication.ClientCert.SerialNumber ?? string.Empty)); + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "ClientCert")); } else { @@ -74,11 +70,20 @@ private ClaimsPrincipal DecodeJwtToClaimsPrincipal(string jwt, List claim var token = handler.ReadJwtToken(jwt); - // Create claims from JWT payload - claims.AddRange(token.Claims.ToList()); + claims.AddRange(token.Claims); // Optionally add the token's subject as a Name claim if it exists - if (!string.IsNullOrEmpty(token.Subject) && claims.All(c => c.Type != ClaimTypes.NameIdentifier)) + var hasNameIdentifier = false; + foreach (var c in claims) + { + if (c.Type == ClaimTypes.NameIdentifier) + { + hasNameIdentifier = true; + break; + } + } + + if (!string.IsNullOrEmpty(token.Subject) && !hasNameIdentifier) { claims.Add(new Claim(ClaimTypes.NameIdentifier, token.Subject)); } diff --git a/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs b/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs index 52dd14f2..b5709b23 100644 --- a/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs +++ b/src/Functions/Goa.Functions.Core/Bootstrapping/LambdaRuntimeClient.cs @@ -45,15 +45,18 @@ public async Task> GetNextInvocationAsync(Cancellation if (response.Headers.TryGetValues("Lambda-Runtime-Aws-Request-Id", out var requestIdValues)) { - requestId = requestIdValues.FirstOrDefault() ?? string.Empty; + requestId = string.Empty; + foreach (var v in requestIdValues) { requestId = v; break; } } if (response.Headers.TryGetValues("Lambda-Runtime-Deadline-Ms", out var deadlineMsValues)) { - deadlineMs = deadlineMsValues.FirstOrDefault() ?? string.Empty; + deadlineMs = string.Empty; + foreach (var v in deadlineMsValues) { deadlineMs = v; break; } } if (response.Headers.TryGetValues("Lambda-Runtime-Invoked-Function-Arn", out var functionArnValues)) { - functionArn = functionArnValues.FirstOrDefault() ?? string.Empty; + functionArn = string.Empty; + foreach (var v in functionArnValues) { functionArn = v; break; } } var payload = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs b/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs index 08eacdbf..868b5108 100644 --- a/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs +++ b/src/Functions/Goa.Functions.Core/Logging/JsonLogger.cs @@ -120,11 +120,11 @@ private static void WriteScopeInformation(Dictionary data, IExte } else if (scope is KeyValuePair kvp) { - data[kvp.Key] = kvp.Value; + state[kvp.Key] = kvp.Value; } else if (scope is not null) { - data[FieldState] = scope; + state[FieldState] = scope; } }, data); } diff --git a/src/Goa.Core/CollectionExtensions.cs b/src/Goa.Core/CollectionExtensions.cs index bf2d48e6..7891eb50 100644 --- a/src/Goa.Core/CollectionExtensions.cs +++ b/src/Goa.Core/CollectionExtensions.cs @@ -22,7 +22,9 @@ public static IReadOnlyDictionary Merge { if (source is not Dictionary result) { - result = new Dictionary(source); + result = new Dictionary(source.Count); + foreach (var (key, value) in source) + result[key] = value; } foreach (var (key, value) in items) diff --git a/src/Goa.Core/LoggerExtensions.cs b/src/Goa.Core/LoggerExtensions.cs index 8f2ef32a..f2094bef 100644 --- a/src/Goa.Core/LoggerExtensions.cs +++ b/src/Goa.Core/LoggerExtensions.cs @@ -5,7 +5,7 @@ namespace Goa.Core; /// /// Extends all ILogger instances with commonly used extension methods /// -public static class LoggerExtensions +public static partial class LoggerExtensions { /// /// Attaches a series of values to the context, using a Microsoft.Extensions.Logging compatible approach @@ -17,7 +17,15 @@ public static class LoggerExtensions if (values.Length == 0) return null; - return logger.BeginScope(values.ToDictionary(x => x.Key, x => (object)x.Value!)); + var dict = new Dictionary(values.Length); + foreach (var kv in values) + { + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 + dict[kv.Key] = kv.Value!; +#pragma warning restore GOA1501 + } + return logger.BeginScope(dict); } /// @@ -30,7 +38,15 @@ public static class LoggerExtensions if (values.Length == 0) return null; - return logger.BeginScope(values.ToDictionary(x => x.key, x => (object)x.value!)); + var dict = new Dictionary(values.Length); + foreach (var (key, value) in values) + { + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 + dict[key] = value!; +#pragma warning restore GOA1501 + } + return logger.BeginScope(dict); } /// @@ -44,12 +60,21 @@ public static class LoggerExtensions if (string.IsNullOrWhiteSpace(key)) return null; + // Boxing T→object is unavoidable: ILogger.BeginScope requires IDictionary +#pragma warning disable GOA1501 return logger.BeginScope(new Dictionary { [key] = value! }); +#pragma warning restore GOA1501 } + [LoggerMessage(EventId = 0, Level = LogLevel.Critical, Message = "{ExceptionMessage}")] + private static partial void LogExceptionCritical(this ILogger logger, string? exceptionMessage); + + [LoggerMessage(EventId = 0, Level = LogLevel.Error, Message = "{ExceptionMessage}")] + private static partial void LogExceptionError(this ILogger logger, string? exceptionMessage); + /// /// Helper to log an exception when there is no specific message associated with it /// @@ -59,12 +84,12 @@ public static class LoggerExtensions /// The type of the exception thrown public static void LogException(this ILogger logger, T exception, bool critical = false) where T : Exception { - using var scope = logger.WithContext(new [] { ("Exception:Type", exception.GetType().FullName ), ("Exception:StackTrace", exception.StackTrace ) }); -#pragma warning disable CA2254 +#pragma warning disable GOA1401 // Explicit array required for params overload + using var scope = logger.WithContext(new KeyValuePair[] { new("Exception:Type", exception.GetType().FullName), new("Exception:StackTrace", exception.StackTrace) }); +#pragma warning restore GOA1401 if (critical) - logger.LogCritical(exception.Message); + logger.LogExceptionCritical(exception.Message); else - logger.LogError(exception.Message); -#pragma warning restore CA2254 + logger.LogExceptionError(exception.Message); } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 194ca5e8..1debe643 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -40,6 +40,8 @@ public class QueryAsyncBenchmarks [GlobalSetup] public void Setup() { + DynamoReaderRegistration.Initialize(); + _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs index 01f08637..8e85b24b 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs @@ -602,7 +602,7 @@ await Assert.That(result) await Assert.That(result) .Contains("TestNamespace.ConcreteEntity concrete => DynamoMapper.ConcreteEntity.ToDynamoRecord(concrete),"); await Assert.That(result) - .Contains("_ => throw new InvalidOperationException($\"Unknown concrete type: {model.GetType().FullName} for abstract type TestNamespace.BaseEntity\")"); + .Contains("_ => Throw.InvalidOperation($\"Unknown concrete type: {model.GetType().FullName} for abstract type TestNamespace.BaseEntity\")"); } private static TypeHandlerRegistry CreateTypeHandlerRegistry()