Skip to content

Latest commit

 

History

History
345 lines (299 loc) · 11.6 KB

CommandQuery.AWSLambda.md

File metadata and controls

345 lines (299 loc) · 11.6 KB

CommandQuery.AWSLambda ⚡

build CodeFactor

Command Query Separation for AWS Lambda

  • Provides generic function support for commands and queries with Amazon API Gateway
  • Enables APIs based on HTTP POST and GET

Get Started

  1. Install AWS Toolkit for Visual Studio
  2. Create a new AWS Serverless Application (.NET Core) project
  3. Install the CommandQuery.AWSLambda package from NuGet
    • PM> Install-Package CommandQuery.AWSLambda
  4. Create functions
    • Preferably named Command and Query
  5. Create commands and command handlers
    • Implement ICommand and ICommandHandler<in TCommand>
    • Or ICommand<TResult> and ICommandHandler<in TCommand, TResult>
  6. Create queries and query handlers
    • Implement IQuery<TResult> and IQueryHandler<in TQuery, TResult>
  7. Configure the serverless template

New Project - AWS Serverless Application (.NET Core - C#)

Choose:

  • AWS Serverless Application (.NET Core - C#)

New AWS Serverless Application - Empty Serverless Application

Choose:

  • Empty Serverless Application

Commands

using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;

namespace CommandQuery.Sample.AWSLambda;

public class Command(ICommandFunction commandFunction)
{
    [LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
    [RestApi(LambdaHttpMethod.Post, "/command/{commandName}")]
    public async Task<APIGatewayProxyResponse> Post(
        APIGatewayProxyRequest request,
        ILambdaContext context,
        string commandName) =>
        await commandFunction.HandleAsync(commandName, request, context.Logger);
}
  • The function is requested via HTTP POST with the Content-Type application/json in the header.
  • The name of the command is the slug of the URL.
  • The command itself is provided as JSON in the body.
  • If the command succeeds; the response is empty with the HTTP status code 200.
  • If the command fails; the response is an error message with the HTTP status code 400 or 500.

Commands with result:

  • If the command succeeds; the response is the result as JSON with the HTTP status code 200.

Queries

using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;

namespace CommandQuery.Sample.AWSLambda
{
    public class Query(IQueryFunction queryFunction)
    {
        [LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
        [RestApi(LambdaHttpMethod.Get, "/query/{queryName}")]
        public async Task<APIGatewayProxyResponse> Get(
            APIGatewayProxyRequest request,
            ILambdaContext context,
            string queryName) =>
            await queryFunction.HandleAsync(queryName, request, context.Logger);

        [LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
        [RestApi(LambdaHttpMethod.Post, "/query/{queryName}")]
        public async Task<APIGatewayProxyResponse> Post(
            APIGatewayProxyRequest request,
            ILambdaContext context,
            string queryName) =>
            await queryFunction.HandleAsync(queryName, request, context.Logger);
    }
}
  • The function is requested via:
    • HTTP POST with the Content-Type application/json in the header and the query itself as JSON in the body
    • HTTP GET and the query itself as query string parameters in the URL
  • The name of the query is the slug of the URL.
  • If the query succeeds; the response is the result as JSON with the HTTP status code 200.
  • If the query fails; the response is an error message with the HTTP status code 400 or 500.

Configuration

Configuration in Startup.cs:

using Amazon.Lambda.Annotations;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;
using CommandQuery.Sample.Contracts.Commands;
using CommandQuery.Sample.Contracts.Queries;
using CommandQuery.Sample.Handlers;
using CommandQuery.Sample.Handlers.Commands;
using CommandQuery.Sample.Handlers.Queries;
using Microsoft.Extensions.DependencyInjection;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace CommandQuery.Sample.AWSLambda;

[LambdaStartup]
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        //services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.Web));

        // Add commands and queries
        services.AddCommandFunction(typeof(FooCommandHandler).Assembly, typeof(FooCommand).Assembly);
        services.AddQueryFunction(typeof(BarQueryHandler).Assembly, typeof(BarQuery).Assembly);

        // Add handler dependencies
        services.AddTransient<IDateTimeProxy, DateTimeProxy>();
        services.AddTransient<ICultureService, CultureService>();

        // Validation
        var serviceProvider = services.BuildServiceProvider();
        serviceProvider.GetService<ICommandProcessor>()!.AssertConfigurationIsValid();
        serviceProvider.GetService<IQueryProcessor>()!.AssertConfigurationIsValid();
    }
}

The extension methods AddCommandFunction and AddQueryFunction will add functions and all command/query handlers in the given assemblies to the IoC container. You can pass in a params array of Assembly arguments if your handlers are located in different projects. If you only have one project you can use typeof(Program).Assembly as a single argument.

Configuration in serverless.template:

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.5.0.0).",
  "Resources": {
    "CommandQuerySampleAWSLambdaCommandPostGenerated": {
      "Type": "AWS::Serverless::Function",
      "Metadata": {
        "Tool": "Amazon.Lambda.Annotations",
        "SyncedEvents": [
          "RootPost"
        ],
        "SyncedEventProperties": {
          "RootPost": [
            "Path",
            "Method"
          ]
        }
      },
      "Properties": {
        "Architectures": [
          "x86_64"
        ],
        "Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Command_Post_Generated::Post",
        "Runtime": "dotnet8",
        "CodeUri": ".",
        "MemorySize": 256,
        "Timeout": 30,
        "Policies": [
          "AWSLambdaBasicExecutionRole"
        ],
        "PackageType": "Zip",
        "Events": {
          "RootPost": {
            "Type": "Api",
            "Properties": {
              "Path": "/command/{commandName}",
              "Method": "POST"
            }
          }
        }
      }
    },
    "CommandQuerySampleAWSLambdaQueryGetGenerated": {
      "Type": "AWS::Serverless::Function",
      "Metadata": {
        "Tool": "Amazon.Lambda.Annotations",
        "SyncedEvents": [
          "RootGet"
        ],
        "SyncedEventProperties": {
          "RootGet": [
            "Path",
            "Method"
          ]
        }
      },
      "Properties": {
        "Architectures": [
          "x86_64"
        ],
        "Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Query_Get_Generated::Get",
        "Runtime": "dotnet8",
        "CodeUri": ".",
        "MemorySize": 256,
        "Timeout": 30,
        "Policies": [
          "AWSLambdaBasicExecutionRole"
        ],
        "PackageType": "Zip",
        "Events": {
          "RootGet": {
            "Type": "Api",
            "Properties": {
              "Path": "/query/{queryName}",
              "Method": "GET"
            }
          }
        }
      }
    },
    "CommandQuerySampleAWSLambdaQueryPostGenerated": {
      "Type": "AWS::Serverless::Function",
      "Metadata": {
        "Tool": "Amazon.Lambda.Annotations",
        "SyncedEvents": [
          "RootPost"
        ],
        "SyncedEventProperties": {
          "RootPost": [
            "Path",
            "Method"
          ]
        }
      },
      "Properties": {
        "Architectures": [
          "x86_64"
        ],
        "Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Query_Post_Generated::Post",
        "Runtime": "dotnet8",
        "CodeUri": ".",
        "MemorySize": 256,
        "Timeout": 30,
        "Policies": [
          "AWSLambdaBasicExecutionRole"
        ],
        "PackageType": "Zip",
        "Events": {
          "RootPost": {
            "Type": "Api",
            "Properties": {
              "Path": "/query/{queryName}",
              "Method": "POST"
            }
          }
        }
      }
    }
  },
  "Outputs": {
    "ApiURL": {
      "Description": "API endpoint URL for Prod environment",
      "Value": {
        "Fn::Sub": "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
      }
    }
  }
}

Testing

You can test your lambdas with the Amazon.Lambda.TestUtilities package.

using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.TestUtilities;
using CommandQuery.AWSLambda;
using CommandQuery.Sample.Contracts.Commands;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;

namespace CommandQuery.Sample.AWSLambda.Tests
{
    public class CommandTests
    {
        [SetUp]
        public void SetUp()
        {
            var serviceCollection = new ServiceCollection();
            new Startup().ConfigureServices(serviceCollection);
            var serviceProvider = serviceCollection.BuildServiceProvider();

            Subject = new Command(serviceProvider.GetRequiredService<ICommandFunction>());
            Context = new TestLambdaContext();
        }

        [Test]
        public async Task should_handle_command()
        {
            var response = await Subject.Post(GetRequest(new FooCommand { Value = "Foo" }), Context, "FooCommand");
            response.StatusCode.Should().Be(200);
        }

        [Test]
        public async Task should_handle_errors()
        {
            var response = await Subject.Post(GetRequest(new FooCommand()), Context, "FooCommand");
            response.ShouldBeError("Value cannot be null or empty");
        }

        static APIGatewayProxyRequest GetRequest(object body) => new() { Body = JsonSerializer.Serialize(body) };

        Command Subject = null!;
        TestLambdaContext Context = null!;
    }
}

Samples