diff --git a/.agents/skills/new-event-source/SKILL.md b/.agents/skills/new-event-source/SKILL.md new file mode 100644 index 000000000..a6aad04d9 --- /dev/null +++ b/.agents/skills/new-event-source/SKILL.md @@ -0,0 +1,195 @@ +--- +name: new-event-source +description: Add a new AWS event source attribute (e.g., Kinesis, Kafka, MQ) to the Lambda .NET Annotations framework, including the attribute class, source generator integration, CloudFormation writer, unit tests, writer tests, source generator tests, and integration tests +--- + +# Adding a New Event Source to Lambda Annotations + +This skill guides you through adding a complete new event source attribute to the AWS Lambda .NET Annotations framework. Use this when a user asks to add support for a new AWS event source like Kinesis, Kafka, MQ, etc. + +## Prerequisites + +Before starting, gather from the user: +1. **Service name** (e.g., "Kinesis", "Kafka", "MQ") +2. **Primary resource identifier** (e.g., stream ARN, topic ARN, broker ARN) +3. **CloudFormation event type string** (e.g., "Kinesis", "MSK", "MQ") +4. **Event class name** from the corresponding `Amazon.Lambda.*Events` NuGet package (e.g., `KinesisEvent`) +5. **Optional properties** the attribute should support (e.g., BatchSize, StartingPosition, Filters) +6. **Whether `@` references use `Fn::GetAtt` or `Ref`** — event source mappings use `Fn::GetAtt`, subscriptions use `Ref` + +## Reference Examples + +Read these files to understand existing patterns before creating new ones: +- **SNS (simplest, subscription-based)**: `Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs` +- **SQS (event source mapping with batching)**: `Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs` +- **DynamoDB (stream-based)**: `Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs` +- **S3 (notification-based)**: `Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs` + +## Steps + +### Step 1: Create the Event Attribute Class + +**Create**: `Libraries/src/Amazon.Lambda.Annotations/{ServiceName}/{ServiceName}EventAttribute.cs` + +Key patterns: +- Add copyright header: `// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.` + `// SPDX-License-Identifier: Apache-2.0` +- Inherit from `Attribute` with `[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]` +- Constructor takes the primary resource identifier as a required `string` parameter +- All optional properties use nullable backing fields with `IsSet` internal properties +- Include auto-derived `ResourceName` property (strips `@` prefix or extracts name from ARN) +- Include `internal List Validate()` method with all validation rules +- Use `Regex("^[a-zA-Z0-9]+$")` for ResourceName validation + +### Step 2: Register Type Full Names + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs` + +Add constants: +```csharp +public const string {ServiceName}EventAttribute = "Amazon.Lambda.Annotations.{ServiceName}.{ServiceName}EventAttribute"; +public const string {ServiceName}Event = "Amazon.Lambda.{ServiceName}Events.{ServiceName}Event"; +``` + +Also add to `EventType` enum if needed in `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs`. + +### Step 3: Create the Attribute Builder + +**Create**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/{ServiceName}EventAttributeBuilder.cs` + +Extracts attribute data from Roslyn `AttributeData`. Use consistent `else if` chaining. Reference: `SNSEventAttributeBuilder.cs`. + +### Step 4: Register in AttributeModelBuilder + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs` + +Add `else if` block for the new attribute type after the existing event attribute blocks. + +### Step 5: Register in EventTypeBuilder + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs` + +Add `else if` block mapping the attribute to the `EventType` enum value. + +### Step 6: Add DiagnosticDescriptor + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs` + +Add descriptor with the next available `AWSLambda0XXX` ID for invalid attribute validation errors. + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` — add the new diagnostic ID. + +### Step 7: Add Validation in LambdaFunctionValidator + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` + +1. Add `Validate{ServiceName}Events()` call in `ValidateFunction` method +2. Create private `Validate{ServiceName}Events()` method that validates: + - Attribute properties via `Validate()` method + - Method parameters (first must be event type, optional second is `ILambdaContext`) + - Return type (usually `void` or `Task`) + +### Step 8: Add Dependency Check + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` + +In `ValidateDependencies`, add check for `Amazon.Lambda.{ServiceName}Events` NuGet package. + +### Step 9: Check SyntaxReceiver + +**Check**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs` + +Add the new attribute name if the SyntaxReceiver filters by attribute name strings. + +### Step 10: Add CloudFormation Writer Logic + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs` + +1. Add `case AttributeModel<{ServiceName}EventAttribute>` in the event processing switch +2. Create `Process{ServiceName}Attribute()` method that writes CF template properties + - Event source mappings (SQS, DynamoDB, Kinesis): use `Fn::GetAtt` for `@` references + - Subscription events (SNS): use `Ref` for `@` references + - Track synced properties in metadata + +### Step 11: Create Attribute Unit Tests + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/{ServiceName}EventAttributeTests.cs` + +Cover: constructor, defaults, property tracking, ResourceName derivation, all validation paths. Reference: `SQSEventAttributeTests.cs`, `DynamoDBEventAttributeTests.cs`, `SNSEventAttributeTests.cs`. + +### Step 12: Create CloudFormation Writer Tests + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/{ServiceName}EventsTests.cs` + +This is a `partial class CloudFormationWriterTests`. Include tests for: +1. `Verify{ServiceName}EventAttributes_AreCorrectlyApplied` — Theory with JSON/YAML and property combinations +2. `Verify{ServiceName}EventProperties_AreSyncedCorrectly` — Synced properties update when attributes change +3. `SwitchBetweenArnAndRef_For{Resource}` — ARN to `@` reference switching +4. `Verify{Resource}CanBeSet_FromCloudFormationParameter` — CF Parameters handling +5. `VerifyManuallySet{ServiceName}EventProperties_ArePreserved` — Hand-edited template preservation + +Reference: `SQSEventsTests.cs`, `DynamoDBEventsTests.cs`, `SNSEventsTests.cs`. + +### Step 13: Create Valid Event Examples + Source Generator Test + +**Create**: `Libraries/test/TestServerlessApp/{ServiceName}EventExamples/Valid{ServiceName}Events.cs.txt` +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` (generated handler snapshots) +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/{serviceName}Events.template` +**Modify**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` — add `VerifyValid{ServiceName}Events()` test + +### Step 14: Create Invalid Event Examples + Source Generator Test + +**Create**: `Libraries/test/TestServerlessApp/{ServiceName}EventExamples/Invalid{ServiceName}Events.cs.error` + +Cover: invalid property values, invalid params, invalid return type, multiple events, invalid ARN, invalid resource name. + +**Modify**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` — add `VerifyInvalid{ServiceName}Events_ThrowsCompilationErrors()` test with diagnostic assertions including line spans. + +### Step 15: Create Generated Code Snapshots + +**Create**: `Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` + +Tip: Run the source generator once to get actual output, then use as snapshot. + +### Step 16: Create Integration Test + +**Create**: `Libraries/test/TestServerlessApp.IntegrationTests/{ServiceName}EventSourceMapping.cs` +**Modify**: `Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs` — resource lookup +**Modify**: `Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1` — if needed + +### Step 17: Update AnalyzerReleases.Unshipped.md + +**Modify**: `Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` + +## File Map Summary + +| Action | File Path | +|--------|-----------| +| Create | `src/Amazon.Lambda.Annotations/{ServiceName}/{ServiceName}EventAttribute.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs` | +| Create | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/{ServiceName}EventAttributeBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs` | +| Modify | `src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/{ServiceName}EventAttributeTests.cs` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/{ServiceName}EventsTests.cs` | +| Create | `test/TestServerlessApp/{ServiceName}EventExamples/Valid{ServiceName}Events.cs.txt` | +| Create | `test/TestServerlessApp/{ServiceName}EventExamples/Invalid{ServiceName}Events.cs.error` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/{ServiceName}/` | +| Create | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/{serviceName}Events.template` | +| Modify | `test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs` | +| Create | `test/TestServerlessApp.IntegrationTests/{ServiceName}EventSourceMapping.cs` | +| Modify | `test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs` | + +## Important Conventions + +- **Copyright header** on every new `.cs` file +- **Consistent `else if` chaining** in attribute builders (never `if` then `if` for the same loop) +- **Both JSON and YAML** template formats must be tested in writer tests +- **Invalid event test spans** must reference exact line numbers in the `.cs.error` file +- **`.cs.txt` extension** for valid test files (prevents deployment) +- **`.cs.error` extension** for invalid test files (prevents compilation) +- **Use enums instead of strings** when you need to represent a fixed, known set of constants that do not change frequently (e.g., `StartingPosition`, `AuthType`, `HttpApiVersion`). Enums provide compile-time type safety, eliminate the need for manual string validation in the `Validate()` method, and prevent invalid values from being set. In attribute builders, enum values come through Roslyn's `AttributeData` as their underlying `int` value and must be cast accordingly (e.g., `(MyEnum)(int)pair.Value.Value`). When writing enum values to CloudFormation templates, use `.ToString()` to convert back to the string representation. diff --git a/.autover/changes/add-dynamodbevent-annotation.json b/.autover/changes/add-dynamodbevent-annotation.json new file mode 100644 index 000000000..7a11ec17f --- /dev/null +++ b/.autover/changes/add-dynamodbevent-annotation.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [DynamoDBEvent] annotation attribute for declaratively configuring DynamoDB stream-triggered Lambda functions with support for stream reference, batch size, starting position, batching window, filters, and enabled state." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index dc97dd7de..4c02947e0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -21,4 +21,5 @@ AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Fo AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function AWSLambda0136 | AWSLambdaCSharpGenerator | Error | Invalid S3EventAttribute +AWSLambda0137 | AWSLambdaCSharpGenerator | Error | Invalid DynamoDBEventAttribute AWSLambda0138 | AWSLambdaCSharpGenerator | Error | Invalid SNSEventAttribute diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 569fd4116..fbbdeadc3 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -282,6 +282,13 @@ public static class DiagnosticDescriptors DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor InvalidDynamoDBEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0137", + title: "Invalid DynamoDBEventAttribute", + messageFormat: "Invalid DynamoDBEventAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + public static readonly DiagnosticDescriptor InvalidSnsEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0138", title: "Invalid SNSEventAttribute", messageFormat: "Invalid SNSEventAttribute encountered: {0}", diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index add735fe5..bd03c4765 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -4,6 +4,7 @@ using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -113,6 +114,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.DynamoDBEventAttribute), SymbolEqualityComparer.Default)) + { + var data = DynamoDBEventAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.SNSEventAttribute), SymbolEqualityComparer.Default)) { var data = SNSEventAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs new file mode 100644 index 000000000..76c45c3c4 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/DynamoDBEventAttributeBuilder.cs @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.DynamoDB; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class DynamoDBEventAttributeBuilder + { + public static DynamoDBEventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + { + throw new NotSupportedException($"{TypeFullNames.DynamoDBEventAttribute} must have constructor with 1 argument."); + } + var stream = att.ConstructorArguments[0].Value as string; + var data = new DynamoDBEventAttribute(stream); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + else if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + { + data.BatchSize = batchSize; + } + else if (pair.Key == nameof(data.StartingPosition) && pair.Value.Value is int startingPosition) + { + data.StartingPosition = (StartingPosition)startingPosition; + } + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + { + data.Enabled = enabled; + } + else if (pair.Key == nameof(data.MaximumBatchingWindowInSeconds) && pair.Value.Value is uint maximumBatchingWindowInSeconds) + { + data.MaximumBatchingWindowInSeconds = maximumBatchingWindowInSeconds; + } + else if (pair.Key == nameof(data.Filters) && pair.Value.Value is string filters) + { + data.Filters = filters; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs index c58063c48..fa1202dc7 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SQSEventAttributeBuilder.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.SQS; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System; @@ -24,7 +27,7 @@ public static SQSEventAttribute Build(AttributeData att) { data.ResourceName = resourceName; } - if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) + else if (pair.Key == nameof(data.BatchSize) && pair.Value.Value is uint batchSize) { data.BatchSize = batchSize; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 960b83d97..d5249546b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -34,6 +34,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.S3); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.DynamoDBEventAttribute) + { + events.Add(EventType.DynamoDB); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.SNSEventAttribute) { events.Add(EventType.SNS); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index e54d97b44..cafee8081 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -28,6 +28,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, { "S3EventAttribute", "S3Event" }, + { "DynamoDBEventAttribute", "DynamoDBEvent" }, { "SNSEventAttribute", "SNSEvent" } }; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 40059193a..9ea767031 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -62,6 +62,9 @@ public static class TypeFullNames public const string S3Event = "Amazon.Lambda.S3Events.S3Event"; public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute"; + public const string DynamoDBEvent = "Amazon.Lambda.DynamoDBEvents.DynamoDBEvent"; + public const string DynamoDBEventAttribute = "Amazon.Lambda.Annotations.DynamoDB.DynamoDBEventAttribute"; + public const string SNSEvent = "Amazon.Lambda.SNSEvents.SNSEvent"; public const string SNSEventAttribute = "Amazon.Lambda.Annotations.SNS.SNSEventAttribute"; @@ -95,6 +98,7 @@ public static class TypeFullNames SQSEventAttribute, ALBApiAttribute, S3EventAttribute, + DynamoDBEventAttribute, SNSEventAttribute }; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 7193779ed..c0042dab1 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -7,6 +7,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.SNS; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -65,6 +66,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateDynamoDBEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSnsEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); @@ -116,6 +118,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.DynamoDBEvents" if the Lambda method is annotated with DynamoDBEvent attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.DynamoDBEventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.DynamoDBEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.DynamoDBEvents")); + return false; + } + } + // Check for references to "Amazon.Lambda.SNSEvents" if the Lambda method is annotated with SNSEvent attribute. if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.SNSEventAttribute)) { @@ -436,6 +448,45 @@ private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Lo } } + private static void ValidateDynamoDBEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.DynamoDB)) + { + return; + } + + // Validate DynamoDBEventAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.DynamoDBEventAttribute) + continue; + + var dynamoDBEventAttribute = ((AttributeModel)att).Data; + var validationErrors = dynamoDBEventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidDynamoDBEventAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters - When using DynamoDBEventAttribute, the method signature must be (DynamoDBEvent evnt) or (DynamoDBEvent evnt, ILambdaContext context) + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.DynamoDBEvent || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.DynamoDBEvent}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - When using DynamoDBEventAttribute, the return type must be either void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(DynamoDBEventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}"; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + } + private static void ValidateSnsEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) { if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.SNS)) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index f34f2d8bb..159b898f3 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.DynamoDB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -242,6 +243,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true); hasFunctionUrl = true; break; + case AttributeModel dynamoDBAttributeModel: + eventName = ProcessDynamoDBAttribute(lambdaFunction, dynamoDBAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; case AttributeModel snsAttributeModel: eventName = ProcessSnsAttribute(lambdaFunction, snsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); @@ -678,6 +683,68 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessDynamoDBAttribute(ILambdaFunctionSerializable lambdaFunction, DynamoDBEventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "DynamoDB"); + + // Stream + _templateWriter.RemoveToken($"{eventPath}.Properties.Stream"); + if (!att.Stream.StartsWith("@")) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Stream", att.Stream); + } + else + { + var resource = att.Stream.Substring(1); + if (_templateWriter.Exists($"{PARAMETERS}.{resource}")) + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Stream.{REF}", resource); + else + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Stream.{GET_ATTRIBUTE}", new List { resource, "StreamArn" }, TokenType.List); + } + + // StartingPosition + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "StartingPosition", att.StartingPosition.ToString()); + + // BatchSize + if (att.IsBatchSizeSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "BatchSize", att.BatchSize); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + // MaximumBatchingWindowInSeconds + if (att.IsMaximumBatchingWindowInSecondsSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "MaximumBatchingWindowInSeconds", att.MaximumBatchingWindowInSeconds); + } + + // FilterCriteria + if (att.IsFiltersSet) + { + const char SEPERATOR = ';'; + var filters = att.Filters.Split(SEPERATOR).Select(x => x.Trim()).ToList(); + var filterList = new List>(); + foreach (var filter in filters) + { + filterList.Add(new Dictionary { { "Pattern", filter } }); + } + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "FilterCriteria.Filters", filterList, TokenType.List); + } + + return att.ResourceName; + } + /// /// Writes all properties associated with to the serverless template. /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs new file mode 100644 index 000000000..f407ba609 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/DynamoDBEventAttribute.cs @@ -0,0 +1,154 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.DynamoDB +{ + /// + /// This attribute defines the DynamoDB event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class DynamoDBEventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The DynamoDB stream that will act as the event trigger for the Lambda function. + /// This can either be the stream ARN or reference to the DynamoDB table resource that is already defined in the serverless template. + /// To reference a DynamoDB table resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string Stream { get; set; } + + /// + /// The CloudFormation resource name for the DynamoDB event source mapping. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + { + return resourceName; + } + + if (string.IsNullOrWhiteSpace(Stream)) + { + return string.Empty; + } + + if (Stream.StartsWith("@")) + { + return Stream.Length > 1 ? Stream.Substring(1) : string.Empty; + } + + // DynamoDB stream ARN format: arn:aws:dynamodb:region:account:table/TableName/stream/timestamp + var arnParts = Stream.Split('/'); + if (arnParts.Length >= 2) + { + var tableName = arnParts[1]; + return string.Join(string.Empty, tableName.Where(char.IsLetterOrDigit)); + } + return string.Join(string.Empty, Stream.Where(char.IsLetterOrDigit)); + } + set => resourceName = value; + } + + private string resourceName { get; set; } = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// The maximum number of records in each batch that Lambda pulls from the stream. + /// Default value is 100. + /// + public uint BatchSize + { + get => batchSize.GetValueOrDefault(100); + set => batchSize = value; + } + private uint? batchSize { get; set; } + internal bool IsBatchSizeSet => batchSize.HasValue; + + /// + /// The position in the stream where Lambda starts reading. Valid values are TRIM_HORIZON and LATEST. + /// Default value is LATEST. + /// + public StartingPosition StartingPosition { get; set; } = StartingPosition.LATEST; + internal bool IsStartingPositionSet => true; + + /// + /// If set to false, the event source mapping will be disabled. Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(true); + set => enabled = value; + } + private bool? enabled { get; set; } + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// The maximum amount of time, in seconds, to gather records before invoking the function. + /// + public uint MaximumBatchingWindowInSeconds + { + get => maximumBatchingWindowInSeconds.GetValueOrDefault(); + set => maximumBatchingWindowInSeconds = value; + } + private uint? maximumBatchingWindowInSeconds { get; set; } + internal bool IsMaximumBatchingWindowInSecondsSet => maximumBatchingWindowInSeconds.HasValue; + + /// + /// A collection of semicolon (;) separated strings where each string denotes a filter pattern. + /// + public string Filters { get; set; } = null; + internal bool IsFiltersSet => Filters != null; + + /// + /// Creates an instance of the class. + /// + /// property + public DynamoDBEventAttribute(string stream) + { + Stream = stream; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (IsBatchSizeSet && (BatchSize < 1 || BatchSize > 10000)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.BatchSize)} = {BatchSize}. It must be between 1 and 10000"); + } + if (IsMaximumBatchingWindowInSecondsSet && MaximumBatchingWindowInSeconds > 300) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.MaximumBatchingWindowInSeconds)} = {MaximumBatchingWindowInSeconds}. It must be between 0 and 300"); + } + if (string.IsNullOrWhiteSpace(Stream)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} must not be null or empty"); + } + else if (Stream.StartsWith("@") && string.IsNullOrWhiteSpace(Stream.Substring(1))) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} = {Stream}. The '@' prefix must be followed by a non-empty resource or parameter name"); + } + else if (!Stream.StartsWith("@")) + { + if (!Stream.Contains(":dynamodb:") || !Stream.Contains("/stream/")) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.Stream)} = {Stream}. The DynamoDB stream ARN is invalid"); + } + } + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(DynamoDBEventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs new file mode 100644 index 000000000..5b1cc881a --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/DynamoDB/StartingPosition.cs @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.Annotations.DynamoDB +{ + /// + /// The position in the DynamoDB stream where Lambda starts reading. + /// + public enum StartingPosition + { + /// + /// Start reading at the most recent record in the shard. + /// + LATEST, + + /// + /// Start reading at the last untrimmed record in the shard. + /// This is the oldest record in the shard. + /// + TRIM_HORIZON + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs index e3d72464f..8f2ca892e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs @@ -1,4 +1,7 @@ -using System; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index a68fced1f..e99dce51a 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -208,6 +208,7 @@ + diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs index 026357530..75417bee1 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/CSharpSourceGeneratorVerifier.cs @@ -3,6 +3,7 @@ using Amazon.Lambda.Core; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.Lambda.DynamoDBEvents; using Amazon.Lambda.SNSEvents; using Amazon.Lambda.ApplicationLoadBalancerEvents; using Amazon.Lambda.SQSEvents; @@ -59,6 +60,7 @@ public Test(ReferencesMode referencesMode = ReferencesMode.All, TargetFramework { return solution.AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(ILambdaContext).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(APIGatewayProxyRequest).Assembly.Location)) + .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(DynamoDBEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(SNSEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(SQSEvent).Assembly.Location)) .AddMetadataReference(projectId, MetadataReference.CreateFromFile(typeof(ApplicationLoadBalancerRequest).Assembly.Location)) diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs new file mode 100644 index 000000000..4f2a0fe91 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/DynamoDBEventAttributeTests.cs @@ -0,0 +1,313 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.DynamoDB; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class DynamoDBEventAttributeTests + { + // ===== Constructor and Default Values ===== + + [Fact] + public void Constructor_SetsStreamProperty() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal("@MyTable", attr.Stream); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal(StartingPosition.LATEST, attr.StartingPosition); + Assert.False(attr.IsBatchSizeSet); + Assert.False(attr.IsEnabledSet); + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyTable", attr.ResourceName); + } + + // ===== ResourceName Tests ===== + + [Fact] + public void ResourceName_DerivedFromStream_WithAtPrefix() + { + var attr = new DynamoDBEventAttribute("@TestTable"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("TestTable", attr.ResourceName); + } + + [Fact] + public void ResourceName_DerivedFromStreamArn() + { + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable/stream/2024-01-01T00:00:00.000"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyTable", attr.ResourceName); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "CustomEventName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("CustomEventName", attr.ResourceName); + } + + // ===== BatchSize Tests ===== + + [Fact] + public void BatchSize_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsBatchSizeSet); + } + + [Fact] + public void BatchSize_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + BatchSize = 50 + }; + + Assert.True(attr.IsBatchSizeSet); + Assert.Equal((uint)50, attr.BatchSize); + } + + // ===== StartingPosition Tests ===== + + [Fact] + public void StartingPosition_DefaultValue_IsLatest() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Equal(StartingPosition.LATEST, attr.StartingPosition); + } + + [Fact] + public void StartingPosition_CanBeSetToTrimHorizon() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + StartingPosition = StartingPosition.TRIM_HORIZON + }; + + Assert.Equal(StartingPosition.TRIM_HORIZON, attr.StartingPosition); + } + + // ===== MaximumBatchingWindowInSeconds Tests ===== + + [Fact] + public void MaximumBatchingWindowInSeconds_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + } + + [Fact] + public void MaximumBatchingWindowInSeconds_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + MaximumBatchingWindowInSeconds = 60 + }; + + Assert.True(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Equal((uint)60, attr.MaximumBatchingWindowInSeconds); + } + + // ===== Enabled Property Tests ===== + + [Fact] + public void Enabled_DefaultNotSet() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.False(attr.IsEnabledSet); + } + + [Fact] + public void Enabled_WhenSetToFalse_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Enabled = false + }; + + Assert.True(attr.IsEnabledSet); + Assert.False(attr.Enabled); + } + + [Fact] + public void Enabled_WhenSetToTrue_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Enabled = true + }; + + Assert.True(attr.IsEnabledSet); + Assert.True(attr.Enabled); + } + + // ===== Filters Tests ===== + + [Fact] + public void Filters_DefaultIsNull() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + } + + [Fact] + public void Filters_WhenSet_IsTracked() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + Filters = "{\"eventName\": [\"INSERT\"]}" + }; + + Assert.True(attr.IsFiltersSet); + Assert.Equal("{\"eventName\": [\"INSERT\"]}", attr.Filters); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidResourceReference_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("@MyTable"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidStreamArn_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable/stream/2024-01-01T00:00:00.000"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidStreamArn_ReturnsError() + { + var attr = new DynamoDBEventAttribute("not-a-valid-arn"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_BatchSizeTooLarge_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + BatchSize = 10001 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("BatchSize", errors[0]); + } + + [Fact] + public void Validate_MaxBatchingWindowTooLarge_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + MaximumBatchingWindowInSeconds = 301 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumBatchingWindowInSeconds", errors[0]); + } + + + [Fact] + public void Validate_AtSignOnly_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + Assert.Contains("'@' prefix must be followed by a non-empty resource or parameter name", errors[0]); + } + + [Fact] + public void Validate_AtSignWithWhitespace_ReturnsError() + { + var attr = new DynamoDBEventAttribute("@ "); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Stream", errors[0]); + Assert.Contains("'@' prefix must be followed by a non-empty resource or parameter name", errors[0]); + } + + [Fact] + public void Validate_MultipleErrors_ReturnsAll() + { + var attr = new DynamoDBEventAttribute("not-valid") + { + ResourceName = "invalid!", + BatchSize = 10001 + }; + + var errors = attr.Validate(); + Assert.Equal(3, errors.Count); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new DynamoDBEventAttribute("@MyTable") + { + ResourceName = "MyDynamoDBEvent", + BatchSize = 100, + StartingPosition = StartingPosition.TRIM_HORIZON, + MaximumBatchingWindowInSeconds = 60, + Filters = "{\"eventName\": [\"INSERT\"]}", + Enabled = true + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs new file mode 100644 index 000000000..bf57c36c0 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SQSEventAttributeTests.cs @@ -0,0 +1,366 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SQS; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class SQSEventAttributeTests + { + // ===== Constructor and Default Values ===== + + [Fact] + public void Constructor_SetsQueueProperty() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.Equal("@MyQueue", attr.Queue); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsBatchSizeSet); + Assert.False(attr.IsEnabledSet); + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.False(attr.IsMaximumConcurrencySet); + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyQueue", attr.ResourceName); + } + + // ===== ResourceName Tests ===== + + [Fact] + public void ResourceName_DerivedFromQueue_WithAtPrefix() + { + var attr = new SQSEventAttribute("@TestQueue"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("TestQueue", attr.ResourceName); + } + + [Fact] + public void ResourceName_DerivedFromQueueArn() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-1:123456789012:MyQueue"); + + Assert.False(attr.IsResourceNameSet); + Assert.Equal("MyQueue", attr.ResourceName); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "CustomEventName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("CustomEventName", attr.ResourceName); + } + + // ===== BatchSize Tests ===== + + [Fact] + public void BatchSize_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsBatchSizeSet); + } + + [Fact] + public void BatchSize_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 50 + }; + + Assert.True(attr.IsBatchSizeSet); + Assert.Equal((uint)50, attr.BatchSize); + } + + // ===== MaximumBatchingWindowInSeconds Tests ===== + + [Fact] + public void MaximumBatchingWindowInSeconds_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsMaximumBatchingWindowInSecondsSet); + } + + [Fact] + public void MaximumBatchingWindowInSeconds_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumBatchingWindowInSeconds = 60 + }; + + Assert.True(attr.IsMaximumBatchingWindowInSecondsSet); + Assert.Equal((uint)60, attr.MaximumBatchingWindowInSeconds); + } + + // ===== MaximumConcurrency Tests ===== + + [Fact] + public void MaximumConcurrency_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsMaximumConcurrencySet); + } + + [Fact] + public void MaximumConcurrency_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 10 + }; + + Assert.True(attr.IsMaximumConcurrencySet); + Assert.Equal((uint)10, attr.MaximumConcurrency); + } + + // ===== Enabled Property Tests ===== + + [Fact] + public void Enabled_DefaultNotSet() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.False(attr.IsEnabledSet); + } + + [Fact] + public void Enabled_WhenSetToFalse_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Enabled = false + }; + + Assert.True(attr.IsEnabledSet); + Assert.False(attr.Enabled); + } + + [Fact] + public void Enabled_WhenSetToTrue_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Enabled = true + }; + + Assert.True(attr.IsEnabledSet); + Assert.True(attr.Enabled); + } + + // ===== Filters Tests ===== + + [Fact] + public void Filters_DefaultIsNull() + { + var attr = new SQSEventAttribute("@MyQueue"); + + Assert.Null(attr.Filters); + Assert.False(attr.IsFiltersSet); + } + + [Fact] + public void Filters_WhenSet_IsTracked() + { + var attr = new SQSEventAttribute("@MyQueue") + { + Filters = "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" + }; + + Assert.True(attr.IsFiltersSet); + Assert.Equal("{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }", attr.Filters); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidResourceReference_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("@MyQueue"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidQueueArn_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-1:123456789012:MyQueue"); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidQueueArn_ReturnsError() + { + var attr = new SQSEventAttribute("not-a-valid-arn"); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Queue", errors[0]); + Assert.Contains("ARN", errors[0]); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_BatchSizeTooLarge_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 10001 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("BatchSize")); + } + + [Fact] + public void Validate_BatchSizeZero_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 0 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("BatchSize")); + } + + [Fact] + public void Validate_MaxBatchingWindowTooLarge_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumBatchingWindowInSeconds = 301 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumBatchingWindowInSeconds", errors[0]); + } + + [Fact] + public void Validate_MaximumConcurrencyTooLow_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 1 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumConcurrency", errors[0]); + } + + [Fact] + public void Validate_MaximumConcurrencyTooHigh_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + MaximumConcurrency = 1001 + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("MaximumConcurrency", errors[0]); + } + + [Fact] + public void Validate_BatchSizeGreaterThan10_RequiresMaximumBatchingWindow() + { + var attr = new SQSEventAttribute("@MyQueue") + { + BatchSize = 100 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("MaximumBatchingWindowInSeconds")); + } + + [Fact] + public void Validate_FifoQueue_MaximumBatchingWindowNotAllowed() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-2:444455556666:test-queue.fifo") + { + MaximumBatchingWindowInSeconds = 5 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("FIFO")); + } + + [Fact] + public void Validate_FifoQueue_BatchSizeGreaterThan10NotAllowed() + { + var attr = new SQSEventAttribute("arn:aws:sqs:us-east-2:444455556666:test-queue.fifo") + { + BatchSize = 100, + MaximumBatchingWindowInSeconds = 5 + }; + + var errors = attr.Validate(); + Assert.Contains(errors, e => e.Contains("FIFO") && e.Contains("BatchSize")); + } + + [Fact] + public void Validate_EmptyResourceName_ReturnsError() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new SQSEventAttribute("@MyQueue") + { + ResourceName = "MySQSEvent", + BatchSize = 5, + MaximumBatchingWindowInSeconds = 60, + MaximumConcurrency = 30, + Filters = "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }", + Enabled = true + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs new file mode 100644 index 000000000..986b51396 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs @@ -0,0 +1,57 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + public class ValidDynamoDBEvents_ProcessMessagesAsync_Generated + { + private readonly ValidDynamoDBEvents validDynamoDBEvents; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public ValidDynamoDBEvents_ProcessMessagesAsync_Generated() + { + SetExecutionEnvironment(); + validDynamoDBEvents = new ValidDynamoDBEvents(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The request object that will be processed by the Lambda function handler. + /// Result of the Lambda function execution + public async System.Threading.Tasks.Task ProcessMessagesAsync(Amazon.Lambda.DynamoDBEvents.DynamoDBEvent __evnt__) + { + await validDynamoDBEvents.ProcessMessagesAsync(__evnt__); + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs new file mode 100644 index 000000000..814090041 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/DynamoDB/ValidDynamoDBEvents_ProcessMessages_Generated.g.cs @@ -0,0 +1,57 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + public class ValidDynamoDBEvents_ProcessMessages_Generated + { + private readonly ValidDynamoDBEvents validDynamoDBEvents; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public ValidDynamoDBEvents_ProcessMessages_Generated() + { + SetExecutionEnvironment(); + validDynamoDBEvents = new ValidDynamoDBEvents(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The request object that will be processed by the Lambda function handler. + /// Result of the Lambda function execution + public void ProcessMessages(Amazon.Lambda.DynamoDBEvents.DynamoDBEvent __evnt__) + { + validDynamoDBEvents.ProcessMessages(__evnt__); + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template new file mode 100644 index 000000000..5fbcf8d48 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/dynamoDBEvents.template @@ -0,0 +1,129 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v{ANNOTATIONS_ASSEMBLY_VERSION}).", + "Resources": { + "TestServerlessAppDynamoDBEventExamplesValidDynamoDBEventsProcessMessagesGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "MyTable", + "MyTable2", + "testTableEvent" + ], + "SyncedEventProperties": { + "MyTable": [ + "Stream", + "StartingPosition", + "BatchSize", + "MaximumBatchingWindowInSeconds", + "FilterCriteria.Filters" + ], + "MyTable2": [ + "Stream", + "StartingPosition", + "Enabled" + ], + "testTableEvent": [ + "Stream.Fn::GetAtt", + "StartingPosition" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.DynamoDBEventExamples.ValidDynamoDBEvents_ProcessMessages_Generated::ProcessMessages" + ] + }, + "Events": { + "MyTable": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", + "StartingPosition": "LATEST", + "BatchSize": 50, + "MaximumBatchingWindowInSeconds": 2, + "FilterCriteria": { + "Filters": [ + { + "Pattern": "My-Filter-1" + }, + { + "Pattern": "My-Filter-2" + } + ] + } + } + }, + "MyTable2": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00", + "StartingPosition": "TRIM_HORIZON", + "Enabled": false + } + }, + "testTableEvent": { + "Type": "DynamoDB", + "Properties": { + "Stream": { + "Fn::GetAtt": [ + "testTable", + "StreamArn" + ] + }, + "StartingPosition": "LATEST" + } + } + } + } + }, + "TestServerlessAppDynamoDBEventExamplesValidDynamoDBEventsProcessMessagesAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "MyTable" + ], + "SyncedEventProperties": { + "MyTable": [ + "Stream", + "StartingPosition" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.DynamoDBEventExamples.ValidDynamoDBEvents_ProcessMessagesAsync_Generated::ProcessMessagesAsync" + ] + }, + "Events": { + "MyTable": { + "Type": "DynamoDB", + "Properties": { + "Stream": "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", + "StartingPosition": "LATEST" + } + } + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index 5824ed3de..a377eb5a7 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1227,59 +1227,59 @@ public async Task VerifyInvalidSQSEvents_ThrowsCompilationErrors() ExpectedDiagnostics = { DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("BatchSize = 0. It must be between 1 and 10000"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("MaximumBatchingWindowInSeconds = 302. It must be between 0 and 300"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 15, 9, 20, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 18, 9, 23, 10) .WithArguments("MaximumConcurrency = 1. It must be between 2 and 1000"), DiagnosticResult.CompilerError("AWSLambda0117") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 22, 9, 27, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 25, 9, 30, 10) .WithArguments("When using the SQSEventAttribute, the Lambda method can accept at most 2 parameters. " + "The first parameter is required and must be of type Amazon.Lambda.SQSEvents.SQSEvent. " + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), DiagnosticResult.CompilerError("AWSLambda0117") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 29, 9, 35, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 32, 9, 38, 10) .WithArguments("When using the SQSEventAttribute, the Lambda method can return either " + "void, System.Threading.Tasks.Task, Amazon.Lambda.SQSEvents.SQSBatchResponse or Task"), DiagnosticResult .CompilerError("AWSLambda0102") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 37, 9, 43, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 40, 9, 46, 10) .WithMessage("Multiple event attributes on LambdaFunction are not supported"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 45, 9, 50, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 48, 9, 53, 10) .WithArguments("Queue = test-queue. The SQS queue ARN is invalid. The ARN format is 'arn::sqs:::'"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 52, 9, 57, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 55, 9, 60, 10) .WithArguments("ResourceName = sqs-event-source. It must only contain alphanumeric characters and must not be an empty string"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 59, 9, 64, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 62, 9, 67, 10) .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 66, 9, 71, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 69, 9, 74, 10) .WithArguments("MaximumBatchingWindowInSeconds is not set or set to a value less than 1. It must be set to at least 1 when BatchSize is greater than 10"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 73, 9, 78, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 76, 9, 81, 10) .WithArguments("MaximumBatchingWindowInSeconds is not set or set to a value less than 1. It must be set to at least 1 when BatchSize is greater than 10"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 80, 9, 85, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 83, 9, 88, 10) .WithArguments("BatchSize = 100. It must be less than or equal to 10 when the event source mapping is for a FIFO queue"), DiagnosticResult.CompilerError("AWSLambda0116") - .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 80, 9, 85, 10) + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SQSEventExamples{Path.DirectorySeparatorChar}InvalidSQSEvents.cs", 83, 9, 88, 10) .WithArguments("MaximumBatchingWindowInSeconds must not be set when the event source mapping is for a FIFO queue") } } @@ -1391,6 +1391,173 @@ public async Task VerifyValidALBEvents() }.RunAsync(); } + [Fact] + public async Task VerifyInvalidDynamoDBEvents_ThrowsCompilationErrors() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "InvalidDynamoDBEvents.cs.error"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + ExpectedDiagnostics = + { + // ProcessMessageWithInvalidDynamoDBEventAttributes: BatchSize = 10001 + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) + .WithArguments("BatchSize = 10001. It must be between 1 and 10000"), + + // ProcessMessageWithInvalidDynamoDBEventAttributes: MaximumBatchingWindowInSeconds = 301 + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 17, 9, 22, 10) + .WithArguments("MaximumBatchingWindowInSeconds = 301. It must be between 0 and 300"), + + // ProcessMessageWithInvalidParameters: too many parameters + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 24, 9, 29, 10) + .WithArguments("When using the DynamoDBEventAttribute, the Lambda method can accept at most 2 parameters. " + + "The first parameter is required and must be of type Amazon.Lambda.DynamoDBEvents.DynamoDBEvent. " + + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), + + // ProcessMessageWithInvalidReturnType: returns bool + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 31, 9, 37, 10) + .WithArguments("When using the DynamoDBEventAttribute, the Lambda method can return either void or System.Threading.Tasks.Task"), + + // ProcessMessageWithMultipleEventTypes: multiple events + DiagnosticResult + .CompilerError("AWSLambda0102") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 39, 9, 45, 10) + .WithMessage("Multiple event attributes on LambdaFunction are not supported"), + + // ProcessMessageWithInvalidStreamArn: invalid ARN + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 47, 9, 52, 10) + .WithArguments("Stream = not-a-valid-arn. The DynamoDB stream ARN is invalid"), + + // ProcessMessageWithInvalidResourceName: invalid characters + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 54, 9, 59, 10) + .WithArguments("ResourceName = dynamo-event-source. It must only contain alphanumeric characters and must not be an empty string"), + + // ProcessMessageWithEmptyResourceName: empty string + DiagnosticResult.CompilerError("AWSLambda0137") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}DynamoDBEventExamples{Path.DirectorySeparatorChar}InvalidDynamoDBEvents.cs", 61, 9, 66, 10) + .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), + } + } + }.RunAsync(); + } + + [Fact] + public async Task VerifyValidDynamoDBEvents() + { + var expectedTemplateContent = await ReadSnapshotContent(Path.Combine("Snapshots", "ServerlessTemplates", "dynamoDBEvents.template")); + var validDynamoDBEventsProcessMessagesGeneratedContent = await ReadSnapshotContent(Path.Combine("Snapshots", "DynamoDB", "ValidDynamoDBEvents_ProcessMessages_Generated.g.cs")); + var validDynamoDBEventsProcessMessagesAsyncGeneratedContent = await ReadSnapshotContent(Path.Combine("Snapshots", "DynamoDB", "ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs")); + + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "DynamoDBEventExamples", "ValidDynamoDBEvents.cs.txt"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "DynamoDBEventAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "DynamoDB", "StartingPosition.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + GeneratedSources = + { + ( + typeof(SourceGenerator.Generator), + "ValidDynamoDBEvents_ProcessMessages_Generated.g.cs", + SourceText.From(validDynamoDBEventsProcessMessagesGeneratedContent, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ), + ( + typeof(SourceGenerator.Generator), + "ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs", + SourceText.From(validDynamoDBEventsProcessMessagesAsyncGeneratedContent, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ) + }, + ExpectedDiagnostics = + { + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments("ValidDynamoDBEvents_ProcessMessages_Generated.g.cs", validDynamoDBEventsProcessMessagesGeneratedContent), + + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments("ValidDynamoDBEvents_ProcessMessagesAsync_Generated.g.cs", validDynamoDBEventsProcessMessagesAsyncGeneratedContent), + + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info) + .WithArguments($"TestServerlessApp{Path.DirectorySeparatorChar}serverless.template", expectedTemplateContent) + } + } + }.RunAsync(); + } + + [Fact] + public async Task VerifyInvalidSNSEvents_ThrowsCompilationErrors() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "PlaceholderClass.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "PlaceholderClass.cs"))), + (Path.Combine("TestServerlessApp", "SNSEventExamples", "InvalidSNSEvents.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "SNSEventExamples", "InvalidSNSEvents.cs.error"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "SNS", "SNSEventAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "SNS", "SNSEventAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + ExpectedDiagnostics = + { + // ProcessMessageWithInvalidTopicArn: invalid ARN + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 17, 9, 22, 10) + .WithArguments("Topic = not-a-valid-arn. The SNS topic ARN is invalid. The ARN format is 'arn::sns:::'"), + + // ProcessMessageWithInvalidParameters: too many parameters + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 24, 9, 29, 10) + .WithArguments("When using the SNSEventAttribute, the Lambda method can accept at most 2 parameters. " + + "The first parameter is required and must be of type Amazon.Lambda.SNSEvents.SNSEvent. " + + "The second parameter is optional and must be of type Amazon.Lambda.Core.ILambdaContext."), + + // ProcessMessageWithInvalidReturnType: returns bool + DiagnosticResult.CompilerError("AWSLambda0117") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 31, 9, 37, 10) + .WithArguments("When using the SNSEventAttribute, the Lambda method can return either void or System.Threading.Tasks.Task"), + + // ProcessMessageWithMultipleEventTypes: multiple events + DiagnosticResult + .CompilerError("AWSLambda0102") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 39, 9, 45, 10) + .WithMessage("Multiple event attributes on LambdaFunction are not supported"), + + // ProcessMessageWithInvalidResourceName: invalid characters + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 47, 9, 52, 10) + .WithArguments("ResourceName = sns-event-source. It must only contain alphanumeric characters and must not be an empty string"), + + // ProcessMessageWithEmptyResourceName: empty string + DiagnosticResult.CompilerError("AWSLambda0138") + .WithSpan($"TestServerlessApp{Path.DirectorySeparatorChar}SNSEventExamples{Path.DirectorySeparatorChar}InvalidSNSEvents.cs", 54, 9, 59, 10) + .WithArguments("ResourceName = . It must only contain alphanumeric characters and must not be an empty string"), + } + } + }.RunAsync(); + } + [Fact] public async Task VerifyValidSNSEvents() { diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs new file mode 100644 index 000000000..065d04d95 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/DynamoDBEventsTests.cs @@ -0,0 +1,357 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SourceGenerator.Writers; +using Amazon.Lambda.Annotations.DynamoDB; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + const string streamArn1 = "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00"; + const string streamArn2 = "arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00"; + + [Theory] + [ClassData(typeof(DynamoDBEventsTestData))] + public void VerifyDynamoDBEventAttributes_AreCorrectlyApplied(CloudFormationTemplateFormat templateFormat, IEnumerable dynamoDBEventAttributes) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + lambdaFunctionModel.ReturnTypeFullName = "void"; + foreach (var att in dynamoDBEventAttributes) + { + lambdaFunctionModel.Attributes.Add(new AttributeModel { Data = att }); + } + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // ASSERT + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + foreach (var att in dynamoDBEventAttributes) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventName}"; + var eventPropertiesPath = $"{eventPath}.Properties"; + + Assert.True(templateWriter.Exists(eventPath)); + Assert.Equal("DynamoDB", templateWriter.GetToken($"{eventPath}.Type")); + + if (!att.Stream.StartsWith("@")) + { + Assert.Equal(att.Stream, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + } + else + { + Assert.Equal([att.Stream.Substring(1), "StreamArn"], templateWriter.GetToken>($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + } + + Assert.Equal(att.StartingPosition.ToString(), templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + + Assert.Equal(att.IsBatchSizeSet, templateWriter.Exists($"{eventPropertiesPath}.BatchSize")); + if (att.IsBatchSizeSet) + { + Assert.Equal(att.BatchSize, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + } + + Assert.Equal(att.IsEnabledSet, templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + if (att.IsEnabledSet) + { + Assert.Equal(att.Enabled, templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + } + + Assert.Equal(att.IsFiltersSet, templateWriter.Exists($"{eventPropertiesPath}.FilterCriteria")); + if (att.IsFiltersSet) + { + var filtersList = templateWriter.GetToken>>($"{eventPropertiesPath}.FilterCriteria.Filters"); + var index = 0; + foreach (var filter in att.Filters.Split(';').Select(x => x.Trim())) + { + Assert.Equal(filter, filtersList[index]["Pattern"]); + index++; + } + } + + Assert.Equal(att.IsMaximumBatchingWindowInSecondsSet, templateWriter.Exists($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + if (att.IsMaximumBatchingWindowInSecondsSet) + { + Assert.Equal(att.MaximumBatchingWindowInSeconds, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + } + } + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyDynamoDBEventProperties_AreSyncedCorrectly(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MyDynamoDBEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new DynamoDBEventAttribute(streamArn1) + { + ResourceName = eventResourceName, + MaximumBatchingWindowInSeconds = 15 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert initial properties + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(15, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal("LATEST", templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.BatchSize")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.FilterCriteria")); + + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(3, syncedEventProperties[eventResourceName].Count); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.Contains("StartingPosition", syncedEventProperties[eventResourceName]); + Assert.Contains("MaximumBatchingWindowInSeconds", syncedEventProperties[eventResourceName]); + + // Update attribute + var updatedAttribute = new DynamoDBEventAttribute(streamArn2) + { + ResourceName = eventResourceName, + BatchSize = 10 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = updatedAttribute }]; + report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(10, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn2, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal("LATEST", templateWriter.GetToken($"{eventPropertiesPath}.StartingPosition")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Enabled")); + + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Equal(3, syncedEventProperties[eventResourceName].Count); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.Contains("StartingPosition", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchBetweenArnAndRef_ForDynamoDBStream(CloudFormationTemplateFormat templateFormat) + { + // Arrange + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + var mockFileManager = GetMockFileManager(string.Empty); + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + + var lambdaFunctionModel = GetLambdaFunctionModel(); + var eventResourceName = "MyDynamoDBEvent"; + + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + + // Start with Stream ARN + var dynamoDBEventAttribute = new DynamoDBEventAttribute(streamArn1) { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + + // Act + var report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + // Assert - Stream as ARN + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + + // Switch to Stream reference + dynamoDBEventAttribute.Stream = "@MyTable"; + cloudFormationWriter.ApplyReport(report); + + // Assert - Stream as Ref + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + + Assert.Equal(["MyTable", "StreamArn"], templateWriter.GetToken>($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.Contains("Stream.Fn::GetAtt", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyStreamCanBeSet_FromCloudFormationParameter(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + const string jsonContent = @"{ + 'Parameters':{ + 'MyTable':{ + 'Type':'String', + 'Default':'arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00' + } + } + }"; + + const string yamlContent = @"Parameters: + MyTable: + Type: String + Default: arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00"; + + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + var content = templateFormat == CloudFormationTemplateFormat.Json ? jsonContent : yamlContent; + + var mockFileManager = GetMockFileManager(content); + var lambdaFunctionModel = GetLambdaFunctionModel(); + var eventResourceName = "MyDynamoDBEvent"; + var dynamoDBEventAttribute = new DynamoDBEventAttribute("@MyTable") { ResourceName = eventResourceName }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Verify Stream property exists as a Ref (when @name matches a CF Parameter, writer uses Ref) + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("MyTable", templateWriter.GetToken($"{eventPropertiesPath}.Stream.Ref")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + + // Verify the list of synced event properties + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Stream.Ref", syncedEventProperties[eventResourceName]); + + // Change the Stream property to be an ARN and re-generate the template + dynamoDBEventAttribute.Stream = streamArn1; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = dynamoDBEventAttribute }]; + report = GetAnnotationReport([lambdaFunctionModel]); + cloudFormationWriter.ApplyReport(report); + + // Verify Stream property exists as an ARN + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Fn::GetAtt")); + Assert.False(templateWriter.Exists($"{eventPropertiesPath}.Stream.Ref")); + + // Verify the list of synced event properties + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyManuallySetDynamoDBEventProperties_ArePreserved(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MyDynamoDBEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new DynamoDBEventAttribute(streamArn1) + { + ResourceName = eventResourceName, + BatchSize = 20 + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert that initial attributes properties are correctly set + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(20, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + + // Verify initial attribute properties are synced + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + + // Modify the serverless template by hand and add a new property + templateWriter.SetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds", 30); + mockFileManager.WriteAllText(ServerlessTemplateFilePath, templateWriter.GetContent()); + + // Perform another source generation + cloudFormationWriter.ApplyReport(report); + + // Assert that both the initial properties and the manually added property exists + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(20, templateWriter.GetToken($"{eventPropertiesPath}.BatchSize")); + Assert.Equal(streamArn1, templateWriter.GetToken($"{eventPropertiesPath}.Stream")); + Assert.Equal(30, templateWriter.GetToken($"{eventPropertiesPath}.MaximumBatchingWindowInSeconds")); + + // Assert that the synced event properties are still the same and the manually set property is not synced + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("BatchSize", syncedEventProperties[eventResourceName]); + Assert.Contains("Stream", syncedEventProperties[eventResourceName]); + Assert.DoesNotContain("MaximumBatchingWindowInSeconds", syncedEventProperties[eventResourceName]); + } + + public class DynamoDBEventsTestData : TheoryData> + { + public DynamoDBEventsTestData() + { + foreach (var templateFormat in new List { CloudFormationTemplateFormat.Json, CloudFormationTemplateFormat.Yaml }) + { + // Simple attribute + Add(templateFormat, [new(streamArn1)]); + + // Multiple DynamoDBEvent attributes + Add(templateFormat, [new(streamArn1), new(streamArn2)]); + + // Use table reference + Add(templateFormat, [new("@MyTable")]); + + // Specify filters + Add(templateFormat, [new(streamArn1) { Filters = "SOME-FILTER1; SOME-FILTER2" }]); + + // Explicitly specify all properties + Add(templateFormat, + [new(streamArn1) + { + BatchSize = 10, + Filters = "SOME-FILTER1; SOME-FILTER2", + MaximumBatchingWindowInSeconds = 15, + Enabled = false, + StartingPosition = StartingPosition.TRIM_HORIZON + }]); + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs index 59b94bee1..caf7f94fd 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs @@ -225,6 +225,63 @@ public void SwitchBetweenArnAndRef_ForTopic(CloudFormationTemplateFormat templat Assert.Equal("Topic.Ref", syncedEventProperties[eventResourceName][0]); } + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void VerifyManuallySetSNSEventProperties_ArePreserved(CloudFormationTemplateFormat templateFormat) + { + // ARRANGE + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel(); + lambdaFunctionModel.PackageType = LambdaPackageType.Zip; + var eventResourceName = "MySNSEvent"; + var eventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Properties.Events.{eventResourceName}.Properties"; + var syncedEventPropertiesPath = $"Resources.{lambdaFunctionModel.ResourceName}.Metadata.SyncedEventProperties"; + + var initialAttribute = new SNSEventAttribute(topicArn1) + { + ResourceName = eventResourceName, + FilterPolicy = "{ \"store\": [\"example_corp\"] }" + }; + lambdaFunctionModel.Attributes = [new AttributeModel { Data = initialAttribute }]; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport([lambdaFunctionModel]); + + // ACT + cloudFormationWriter.ApplyReport(report); + + // Assert that initial attributes properties are correctly set + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + Assert.Equal(topicArn1, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + Assert.Equal("{ \"store\": [\"example_corp\"] }", templateWriter.GetToken($"{eventPropertiesPath}.FilterPolicy")); + + // Verify initial attribute properties are synced + var syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("FilterPolicy", syncedEventProperties[eventResourceName]); + + // Modify the serverless template by hand and add a new property (Enabled) + templateWriter.SetToken($"{eventPropertiesPath}.Enabled", false); + mockFileManager.WriteAllText(ServerlessTemplateFilePath, templateWriter.GetContent()); + + // Perform another source generation + cloudFormationWriter.ApplyReport(report); + + // Assert that both the initial properties and the manually added property exists + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(topicArn1, templateWriter.GetToken($"{eventPropertiesPath}.Topic")); + Assert.Equal("{ \"store\": [\"example_corp\"] }", templateWriter.GetToken($"{eventPropertiesPath}.FilterPolicy")); + Assert.False(templateWriter.GetToken($"{eventPropertiesPath}.Enabled")); + + // Assert that the synced event properties are still the same and the manually set property is not synced + syncedEventProperties = templateWriter.GetToken>>($"{syncedEventPropertiesPath}"); + Assert.Contains("Topic", syncedEventProperties[eventResourceName]); + Assert.Contains("FilterPolicy", syncedEventProperties[eventResourceName]); + Assert.DoesNotContain("Enabled", syncedEventProperties[eventResourceName]); + } + public class SnsEventsTestData : TheoryData> { public SnsEventsTestData() diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs index 94b5bb2b9..4ec53e4e0 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SQSEventsTests.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.SourceGenerator; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; using Amazon.Lambda.Annotations.SourceGenerator.Writers; diff --git a/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template b/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template index 59a8256fb..ebce2bc30 100644 --- a/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/src/Function/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", @@ -11,7 +11,7 @@ "Properties": { "Auth": { "Authorizers": { - "HttpApiAuthorize": { + "CustomAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "CustomAuthorizer", @@ -28,7 +28,7 @@ "EnableFunctionDefaultPermissions": true, "AuthorizerResultTtlInSeconds": 0 }, - "HttpApiAuthorizeV1": { + "CustomAuthorizerV1": { "FunctionArn": { "Fn::GetAtt": [ "CustomAuthorizerV1", @@ -45,7 +45,7 @@ "EnableFunctionDefaultPermissions": true, "AuthorizerResultTtlInSeconds": 0 }, - "SimpleHttpApiAuthorize": { + "SimpleAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "SimpleAuthorizer", @@ -72,7 +72,7 @@ "StageName": "Prod", "Auth": { "Authorizers": { - "RestApiAuthorize": { + "RestApiAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "RestApiAuthorizer", @@ -85,7 +85,7 @@ "FunctionPayloadType": "TOKEN", "AuthorizerResultTtlInSeconds": 0 }, - "SimpleRestApiAuthorize": { + "SimpleRestAuthorizer": { "FunctionArn": { "Fn::GetAtt": [ "SimpleRestAuthorizer", @@ -223,7 +223,7 @@ "Path": "/api/protected", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -266,7 +266,7 @@ "Path": "/api/user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -348,7 +348,7 @@ "Path": "/api/rest-user-info", "Method": "GET", "Auth": { - "Authorizer": "RestApiAuthorize" + "Authorizer": "RestApiAuthorizer" }, "RestApiId": { "Ref": "AnnotationsRestApi" @@ -393,7 +393,7 @@ "Method": "GET", "PayloadFormatVersion": "1.0", "Auth": { - "Authorizer": "HttpApiAuthorizeV1" + "Authorizer": "CustomAuthorizerV1" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -436,7 +436,7 @@ "Path": "/api/ihttpresult-user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -479,7 +479,7 @@ "Path": "/api/simple-httpapi-user-info", "Method": "GET", "Auth": { - "Authorizer": "SimpleHttpApiAuthorize" + "Authorizer": "SimpleAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" @@ -522,7 +522,7 @@ "Path": "/api/simple-restapi-user-info", "Method": "GET", "Auth": { - "Authorizer": "SimpleRestApiAuthorize" + "Authorizer": "SimpleRestAuthorizer" }, "RestApiId": { "Ref": "AnnotationsRestApi" @@ -565,7 +565,7 @@ "Path": "/api/nonstring-user-info", "Method": "GET", "Auth": { - "Authorizer": "HttpApiAuthorize" + "Authorizer": "CustomAuthorizer" }, "ApiId": { "Ref": "AnnotationsHttpApi" diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 b/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 index c8a2147d1..bbff35b47 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 +++ b/Libraries/test/TestServerlessApp.IntegrationTests/DeploymentScript.ps1 @@ -86,6 +86,36 @@ try Write-Host "Added TestTopic resource to serverless.template" } + # Add TestTable resource to serverless.template for DynamoDB event integration testing + # The source generator creates a Fn::GetAtt reference to TestTable for StreamArn but doesn't define the resource itself + $template = Get-Content $templatePath | Out-String | ConvertFrom-Json + if (-not $template.Resources.PSObject.Properties['TestTable']) { + $testTableResource = @{ + Type = "AWS::DynamoDB::Table" + Properties = @{ + BillingMode = "PAY_PER_REQUEST" + AttributeDefinitions = @( + @{ + AttributeName = "Id" + AttributeType = "S" + } + ) + KeySchema = @( + @{ + AttributeName = "Id" + KeyType = "HASH" + } + ) + StreamSpecification = @{ + StreamViewType = "NEW_AND_OLD_IMAGES" + } + } + } + $template.Resources | Add-Member -NotePropertyName "TestTable" -NotePropertyValue $testTableResource -Force + $template | ConvertTo-Json -Depth 100 | Set-Content $templatePath + Write-Host "Added TestTable resource to serverless.template" + } + dotnet restore Write-Host "Creating CloudFormation Stack $identifier, Architecture $arch, Runtime $runtime" dotnet lambda deploy-serverless --template-parameters "ArchitectureTypeParameter=$arch" diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs new file mode 100644 index 000000000..cef6c76e4 --- /dev/null +++ b/Libraries/test/TestServerlessApp.IntegrationTests/DynamoDBEventSourceMapping.cs @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace TestServerlessApp.IntegrationTests +{ + [Collection("Integration Tests")] + public class DynamoDBEventSourceMapping + { + private readonly IntegrationTestContextFixture _fixture; + + public DynamoDBEventSourceMapping(IntegrationTestContextFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task VerifyDynamoDBEventSourceMappingConfiguration() + { + var lambdaFunctionName = _fixture.LambdaFunctions.FirstOrDefault(x => string.Equals(x.LogicalId, "DynamoDBStreamHandler"))?.Name; + Assert.NotNull(lambdaFunctionName); + + var testTableStreamArn = _fixture.TestTableStreamARN; + Assert.False(string.IsNullOrEmpty(testTableStreamArn), "TestTable stream ARN should not be empty"); + + var listEventSourceMappingResponse = await _fixture.LambdaHelper.ListEventSourceMappingsAsync(lambdaFunctionName, testTableStreamArn); + var eventSourceMappings = listEventSourceMappingResponse.EventSourceMappings; + + Assert.Single(eventSourceMappings); + + var dynamoDbEventSourceMapping = eventSourceMappings.First(); + + Assert.Equal(testTableStreamArn, dynamoDbEventSourceMapping.EventSourceArn); + Assert.Equal(100, dynamoDbEventSourceMapping.BatchSize); + Assert.Equal("TRIM_HORIZON", dynamoDbEventSourceMapping.StartingPosition); + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs index d2c1817cd..5199dae0b 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -35,6 +35,7 @@ public class IntegrationTestContextFixture : IAsyncLifetime public string HttpApiUrlPrefix; public string FunctionUrlPrefix; public string TestTopicARN; + public string TestTableStreamARN; public string TestQueueARN; public string TestS3BucketName; public List LambdaFunctions; @@ -90,6 +91,17 @@ public async Task InitializeAsync() TestTopicARN = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestTopic"); Console.WriteLine($"[IntegrationTest] TestTopic ARN: {TestTopicARN}"); + // Get the DynamoDB table stream ARN + var testTableName = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestTable"); + Console.WriteLine($"[IntegrationTest] TestTable: {testTableName}"); + if (!string.IsNullOrEmpty(testTableName)) + { + using var dynamoDbClient = new Amazon.DynamoDBv2.AmazonDynamoDBClient(Amazon.RegionEndpoint.USWest2); + var describeTableResponse = await dynamoDbClient.DescribeTableAsync(testTableName); + TestTableStreamARN = describeTableResponse.Table.LatestStreamArn; + Console.WriteLine($"[IntegrationTest] TestTable Stream ARN: {TestTableStreamARN}"); + } + // Get the S3 bucket name from the physical resource ID TestS3BucketName = await _cloudFormationHelper.GetResourcePhysicalIdAsync(_stackName, "TestS3Bucket"); Console.WriteLine($"[IntegrationTest] TestS3Bucket: {TestS3BucketName}"); @@ -99,7 +111,7 @@ public async Task InitializeAsync() Console.WriteLine($"[IntegrationTest] Found {LambdaFunctions.Count} Lambda functions: {string.Join(", ", LambdaFunctions.Select(f => f.Name ?? "(null)"))}"); Assert.True(await _s3Helper.BucketExistsAsync(_bucketName), $"S3 bucket {_bucketName} should exist"); - Assert.Equal(39, LambdaFunctions.Count); + Assert.Equal(40, LambdaFunctions.Count); Assert.False(string.IsNullOrEmpty(RestApiUrlPrefix), "RestApiUrlPrefix should not be empty"); Assert.False(string.IsNullOrEmpty(HttpApiUrlPrefix), "HttpApiUrlPrefix should not be empty"); diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs b/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs index e24169f0d..02f803074 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/SQSEventSourceMapping.cs @@ -1,4 +1,7 @@ -using System.Linq; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; using System.Threading.Tasks; using Xunit; diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj index 38aa5bd22..0c882dda8 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj +++ b/Libraries/test/TestServerlessApp.IntegrationTests/TestServerlessApp.IntegrationTests.csproj @@ -8,7 +8,8 @@ - + + diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error new file mode 100644 index 000000000..dda434652 --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/InvalidDynamoDBEvents.cs.error @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.DynamoDBEvents; +using System; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + // This file represents invalid usage of the DynamoDBEventAttribute. + // This file is sent as input to the source generator unit tests and we assert that compilation errors are thrown with the appropriate diagnostic message. + + public class InvalidDynamoDBEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", BatchSize = 10001, MaximumBatchingWindowInSeconds = 301)] + public void ProcessMessageWithInvalidDynamoDBEventAttributes(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable")] + public void ProcessMessageWithInvalidParameters(DynamoDBEvent evnt, bool invalidParameter1, int invalidParameter2) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable")] + public bool ProcessMessageWithInvalidReturnType(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + return true; + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [RestApi(LambdaHttpMethod.Get, "/")] + [DynamoDBEvent("@testTable")] + public void ProcessMessageWithMultipleEventTypes(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("not-a-valid-arn")] + public void ProcessMessageWithInvalidStreamArn(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", ResourceName = "dynamo-event-source")] + public void ProcessMessageWithInvalidResourceName(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@testTable", ResourceName = "")] + public void ProcessMessageWithEmptyResourceName(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt new file mode 100644 index 000000000..90bec559f --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDBEventExamples/ValidDynamoDBEvents.cs.txt @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.DynamoDBEvents; +using System; +using System.Threading.Tasks; + +namespace TestServerlessApp.DynamoDBEventExamples +{ + // This file represents valid usage of the DynamoDBEventAttribute. This is added as .txt file since we do not want to deploy these functions during our integration tests. + // This file is only sent as input to the source generator unit tests. + + public class ValidDynamoDBEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00", BatchSize = 50, MaximumBatchingWindowInSeconds = 2, Filters = "My-Filter-1; My-Filter-2")] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable2/stream/2024-01-01T00:00:00", StartingPosition = StartingPosition.TRIM_HORIZON, Enabled = false)] + [DynamoDBEvent("@testTable", ResourceName = "testTableEvent")] + public void ProcessMessages(DynamoDBEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("arn:aws:dynamodb:us-east-2:444455556666:table/MyTable/stream/2024-01-01T00:00:00")] + public async Task ProcessMessagesAsync(DynamoDBEvent evnt) + { + await Console.Out.WriteLineAsync($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs new file mode 100644 index 000000000..cd865c0f6 --- /dev/null +++ b/Libraries/test/TestServerlessApp/DynamoDbStreamProcessing.cs @@ -0,0 +1,17 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.DynamoDB; +using Amazon.Lambda.Core; +using Amazon.Lambda.DynamoDBEvents; + +namespace TestServerlessApp +{ + public class DynamoDbStreamProcessing + { + [LambdaFunction(ResourceName = "DynamoDBStreamHandler", Policies = "AWSLambdaDynamoDBExecutionRole", PackageType = LambdaPackageType.Image)] + [DynamoDBEvent("@TestTable", ResourceName = "TestTableStream", BatchSize = 100, StartingPosition = StartingPosition.TRIM_HORIZON)] + public void HandleStream(DynamoDBEvent evnt, ILambdaContext lambdaContext) + { + lambdaContext.Logger.Log($"Received {evnt.Records.Count} records"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error b/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error new file mode 100644 index 000000000..639c31441 --- /dev/null +++ b/Libraries/test/TestServerlessApp/SNSEventExamples/InvalidSNSEvents.cs.error @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.SNS; +using Amazon.Lambda.SNSEvents; +using System; + +namespace TestServerlessApp.SNSEventExamples +{ + // This file represents invalid usage of the SNSEventAttribute. + // This file is sent as input to the source generator unit tests and we assert that compilation errors are thrown with the appropriate diagnostic message. + + public class InvalidSNSEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("not-a-valid-arn")] + public void ProcessMessageWithInvalidTopicArn(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic")] + public void ProcessMessageWithInvalidParameters(SNSEvent evnt, bool invalidParameter1, int invalidParameter2) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic")] + public bool ProcessMessageWithInvalidReturnType(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + return true; + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [RestApi(LambdaHttpMethod.Get, "/")] + [SNSEvent("@testTopic")] + public void ProcessMessageWithMultipleEventTypes(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic", ResourceName = "sns-event-source")] + public void ProcessMessageWithInvalidResourceName(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("@testTopic", ResourceName = "")] + public void ProcessMessageWithEmptyResourceName(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error b/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error index 1603f121d..617c758f7 100644 --- a/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error +++ b/Libraries/test/TestServerlessApp/SQSEventExamples/InvalidSQSEvents.cs.error @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SQS; using Amazon.Lambda.SQSEvents; diff --git a/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt b/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt index 8bd12d68e..5c177b178 100644 --- a/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt +++ b/Libraries/test/TestServerlessApp/SQSEventExamples/ValidSQSEvents.cs.txt @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using Amazon.Lambda.Annotations; using Amazon.Lambda.Annotations.SQS; using Amazon.Lambda.SQSEvents; diff --git a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj index 9875acee9..a9ce36791 100644 --- a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj +++ b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj @@ -27,6 +27,7 @@ + diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 03fe9926f..0b96350ff 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", -"s3-bucket" : "test-serverless-app-784dfb1d", -"stack-name" : "test-serverless-app-784dfb1d", -"function-architecture" : "x86_64" -} + "s3-bucket": "test-serverless-app", + "stack-name": "test-serverless-app", + "function-architecture": "x86_64" +} \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 65fcfee31..fae491e28 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1463,6 +1463,72 @@ }, "TestTopic": { "Type": "AWS::SNS::Topic" + }, + "DynamoDBStreamHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestTableStream" + ], + "SyncedEventProperties": { + "TestTableStream": [ + "Stream.Fn::GetAtt", + "StartingPosition", + "BatchSize" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaDynamoDBExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamoDbStreamProcessing_HandleStream_Generated::HandleStream" + ] + }, + "Events": { + "TestTableStream": { + "Type": "DynamoDB", + "Properties": { + "StartingPosition": "TRIM_HORIZON", + "BatchSize": 100, + "Stream": { + "Fn::GetAtt": [ + "TestTable", + "StreamArn" + ] + } + } + } + } + } + }, + "TestTable": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "Id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "KeyType": "HASH", + "AttributeName": "Id" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + } } } } \ No newline at end of file