diff --git a/sam-dotnet-durablefunction-expenseapproval/.gitignore b/sam-dotnet-durablefunction-expenseapproval/.gitignore new file mode 100644 index 000000000..eb9697107 --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/.gitignore @@ -0,0 +1,16 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## SAM +.aws-sam/ +samconfig.toml + +## IDE +.idea/ +.vs/ +*.swp diff --git a/sam-dotnet-durablefunction-expenseapproval/README.md b/sam-dotnet-durablefunction-expenseapproval/README.md new file mode 100644 index 000000000..3dd10192c --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/README.md @@ -0,0 +1,225 @@ +# AWS Lambda Durable Function — Expense Approval (Human Interaction / Wait for Event) + +This sample demonstrates how to build an expense approval workflow using **AWS Lambda Durable Functions** for .NET with **AWS SAM**. The workflow submits an expense report, pauses execution to wait for a manager's approval via an external callback, then proceeds with reimbursement or rejection. + +## Architecture + +``` +┌────────────┐ ┌─────────────────────────────────────────────────────────────┐ +│ POST │ │ Lambda Durable Function (Expense Workflow) │ +│ /expenses │────▶│ │ +└────────────┘ │ ┌──────────────────────────────────────────────────┐ │ + │ │ Step: Create callback + save expense to DynamoDB │ │ + │ └──────────────────────┬───────────────────────────┘ │ + │ ▼ │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ WaitForCallback: Suspend up to 72 hours │ │ + │ │ (Lambda terminates — no compute charges) │ │ + │ └──────────────────────┬───────────────────────────┘ │ + │ │ │ + └─────────────────────────┼───────────────────────────────────┘ + │ + ┌───────────────────────────────┼───────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ + │ Manager approves │ │ Manager rejects │ │ 72h timeout │ + │ POST /approve │ │ POST /reject │ │ (auto-reject) │ + └─────────┬─────────┘ └──────────┬───────────┘ └────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ + │ Process │ │ Record rejection │ │ Record timeout │ + │ reimbursement │ │ in DynamoDB │ │ in DynamoDB │ + └───────────────────┘ └──────────────────────┘ └──────────────────┘ +``` + +## What It Demonstrates + +- **Callbacks (Wait for Event)** — The workflow creates a callback ID, stores it in DynamoDB, then suspends. The Lambda terminates with zero compute charges until the manager responds. +- **Human interaction** — A separate API endpoint allows the manager to approve or reject, sending the decision back to the suspended workflow. +- **Durable timers** — If no decision arrives within 72 hours, the callback times out and the expense is auto-rejected. +- **Automatic checkpointing** — Each step is checkpointed. If the Lambda is interrupted during reimbursement processing, it resumes from the last completed step. +- **AWS SAM** — Infrastructure defined in `template.yaml` with API Gateway, DynamoDB, and Lambda. + +## Project Structure + +``` +├── template.yaml # SAM template (API Gateway, DynamoDB, Lambda) +├── events/ +│ ├── submit-expense.json # Sample expense submission +│ ├── approve-expense.json # Sample approval event +│ └── reject-expense.json # Sample rejection event +└── src/ + ├── ExpenseWorkflowHandler.cs # Durable workflow (submit → wait → process) + ├── ApproverHandler.cs # Manager approval/rejection endpoint + ├── ExpenseRepository.cs # DynamoDB persistence + ├── Models.cs # Record types + └── ExpenseApproval.csproj +``` + +--- + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +- AWS account with credentials configured (`aws configure`) + +--- + +## Build & Deploy + +### Build + +```bash +sam build +``` + +### Deploy + +```bash +sam deploy --guided +``` + +Follow the prompts to configure stack name, region, and confirm IAM role creation. On subsequent deploys: + +```bash +sam deploy +``` + +--- + +## Testing + +### 1. Submit an expense report + +```bash +API_URL=$(aws cloudformation describe-stacks \ + --stack-name sam-dotnet-durablefunction-expenseapproval \ + --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ + --output text) + +curl -X POST "$API_URL/expenses" \ + -H "Content-Type: application/json" \ + -d '{ + "ExpenseId": "EXP-001", + "SubmittedBy": "jane.doe@example.com", + "Description": "Client dinner - Q3 planning", + "Amount": 142.50, + "Currency": "USD", + "ManagerEmail": "john.manager@example.com" + }' +``` + +The workflow starts, saves the expense, and suspends waiting for approval. The API returns HTTP 200 with the execution ARN. + +### 2. Check the pending expense in DynamoDB + +```bash +TABLE_NAME=$(aws cloudformation describe-stacks \ + --stack-name sam-dotnet-durablefunction-expenseapproval \ + --query "Stacks[0].Outputs[?OutputKey=='ExpenseTableName'].OutputValue" \ + --output text) + +aws dynamodb get-item \ + --table-name $TABLE_NAME \ + --key '{"ExpenseId":{"S":"EXP-001"}}' +``` + +You'll see `Status: "pending_approval"` and a `CallbackId`. + +### 3. Approve the expense (manager action) + +```bash +curl -X POST "$API_URL/expenses/EXP-001/approve" \ + -H "Content-Type: application/json" \ + -d '{"decidedBy": "john.manager@example.com"}' +``` + +This resumes the suspended workflow, which processes the reimbursement and updates DynamoDB. + +### 4. Or reject it + +```bash +curl -X POST "$API_URL/expenses/EXP-001/reject" \ + -H "Content-Type: application/json" \ + -d '{ + "decidedBy": "john.manager@example.com", + "reason": "Amount exceeds per-diem limit" + }' +``` + +### 5. Check execution status + +```bash +FUNCTION_ARN=$(aws cloudformation describe-stacks \ + --stack-name sam-dotnet-durablefunction-expenseapproval \ + --query "Stacks[0].Outputs[?OutputKey=='ExpenseWorkflowFunctionArn'].OutputValue" \ + --output text) + +aws lambda list-durable-executions-by-function \ + --function-name $FUNCTION_ARN +``` + +--- + +## How the Callback Pattern Works + +```csharp +// 1. Create a callback — allocates a unique ID +var callback = await ctx.CreateCallbackAsync( + name: "await-manager-approval", + config: new CallbackConfig { Timeout = TimeSpan.FromHours(72) }); + +// 2. Store the callback ID so the external system can find it +await ctx.StepAsync( + async (_, ct) => await _repository.SaveExpenseAsync(expense, callback.CallbackId, ct), + name: "save-expense"); + +// 3. Suspend execution — Lambda terminates, no compute charges +var decision = await callback.GetResultAsync(); +// Execution resumes here when the manager calls SendDurableExecutionCallbackSuccess +``` + +The approver endpoint resolves the callback: + +```csharp +await LambdaClient.SendDurableExecutionCallbackSuccessAsync( + new SendDurableExecutionCallbackSuccessRequest + { + CallbackId = callbackId, + Result = new MemoryStream(Encoding.UTF8.GetBytes(resultJson)) + }); +``` + +If 72 hours pass without a response, the callback times out and the workflow catches the timeout to auto-reject. + +--- + +## Cleanup + +```bash +sam delete +``` + +--- + +## Useful Commands + +| Command | Description | +|---------|-------------| +| `sam build` | Build the Lambda function | +| `sam deploy --guided` | Deploy with interactive prompts | +| `sam deploy` | Deploy with saved config | +| `sam logs --tail` | Tail CloudWatch logs | +| `sam delete` | Tear down the stack | +| `dotnet build src/` | Build locally without SAM | + +--- + +## References + +- [Amazon.Lambda.DurableExecution SDK](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) +- [Callbacks documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/callbacks.md) +- [AWS SAM Developer Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/) diff --git a/sam-dotnet-durablefunction-expenseapproval/events/approve-expense.json b/sam-dotnet-durablefunction-expenseapproval/events/approve-expense.json new file mode 100644 index 000000000..379c65762 --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/events/approve-expense.json @@ -0,0 +1,11 @@ +{ + "body": "{\"decidedBy\":\"john.manager@example.com\"}", + "httpMethod": "POST", + "path": "/expenses/EXP-001/approve", + "pathParameters": { + "expenseId": "EXP-001" + }, + "headers": { + "Content-Type": "application/json" + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/events/reject-expense.json b/sam-dotnet-durablefunction-expenseapproval/events/reject-expense.json new file mode 100644 index 000000000..8f8f78d3d --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/events/reject-expense.json @@ -0,0 +1,11 @@ +{ + "body": "{\"decidedBy\":\"john.manager@example.com\",\"reason\":\"Amount exceeds per-diem limit\"}", + "httpMethod": "POST", + "path": "/expenses/EXP-001/reject", + "pathParameters": { + "expenseId": "EXP-001" + }, + "headers": { + "Content-Type": "application/json" + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/events/submit-expense.json b/sam-dotnet-durablefunction-expenseapproval/events/submit-expense.json new file mode 100644 index 000000000..f3073149b --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/events/submit-expense.json @@ -0,0 +1,8 @@ +{ + "body": "{\"ExpenseId\":\"EXP-001\",\"SubmittedBy\":\"jane.doe@example.com\",\"Description\":\"Client dinner - Q3 planning\",\"Amount\":142.50,\"Currency\":\"USD\",\"ManagerEmail\":\"john.manager@example.com\"}", + "httpMethod": "POST", + "path": "/expenses", + "headers": { + "Content-Type": "application/json" + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/sam-dotnet-durablefunction-expenseapproval.json b/sam-dotnet-durablefunction-expenseapproval/sam-dotnet-durablefunction-expenseapproval.json new file mode 100644 index 000000000..74d07300c --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/sam-dotnet-durablefunction-expenseapproval.json @@ -0,0 +1,64 @@ +{ + "title": "Lambda Durable Function — Expense Approval with Human Interaction (.NET)", + "description": "A durable function expense approval workflow that pauses execution to wait for a manager's approval callback, with a 72-hour durable timer fallback.", + "language": ".NET", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys a Lambda durable function that implements an expense approval workflow. After an expense is submitted, the function creates a callback token and suspends execution — consuming zero compute while waiting for a manager's decision.", + "A manager can approve or reject the expense via a separate API endpoint that sends the callback. If no response arrives within 72 hours, a durable timer automatically rejects the expense.", + "This demonstrates the human-in-the-loop pattern with Lambda durable functions: WaitForCallback for external events, durable timers for deadlines, and DynamoDB for persisting expense state." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sam-dotnet-durablefunction-expenseapproval", + "templateURL": "serverless-patterns/sam-dotnet-durablefunction-expenseapproval", + "projectFolder": "sam-dotnet-durablefunction-expenseapproval", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Durable Functions Documentation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "Build multi-step applications and AI workflows with Lambda durable functions", + "link": "https://aws.amazon.com/blogs/aws/build-multi-step-applications-and-ai-workflows-with-aws-lambda-durable-functions/" + }, + { + "text": "Lambda Durable Functions .NET SDK", + "link": "https://github.com/aws/aws-lambda-dotnet" + } + ] + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Doug Perkes", + "image": "", + "bio": "Senior Solutions Architect at AWS, focused on .NET and serverless.", + "linkedin": "dougperkes", + "twitter": "" + } + ] +} diff --git a/sam-dotnet-durablefunction-expenseapproval/src/ApproverHandler.cs b/sam-dotnet-durablefunction-expenseapproval/src/ApproverHandler.cs new file mode 100644 index 000000000..b8b066a70 --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/ApproverHandler.cs @@ -0,0 +1,101 @@ +using System.Text; +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.Lambda; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using Amazon.Lambda.Model; + +namespace ExpenseApproval; + +/// +/// Handles manager approval/rejection requests. +/// Looks up the callback ID from DynamoDB and sends the decision +/// back to the waiting durable execution via SendDurableExecutionCallbackSuccess. +/// +public class ApproverHandler +{ + private static readonly IAmazonDynamoDB DynamoDbClient = new AmazonDynamoDBClient(); + private static readonly IAmazonLambda LambdaClient = new AmazonLambdaClient(); + + private static readonly string TableName = + System.Environment.GetEnvironmentVariable("EXPENSE_TABLE_NAME") + ?? throw new InvalidOperationException("EXPENSE_TABLE_NAME environment variable is not set."); + + private readonly ExpenseRepository _repository = new(DynamoDbClient, TableName); + + public async Task Handler(APIGatewayProxyRequest request, ILambdaContext context) + { + // Extract expense ID from path parameters + if (!request.PathParameters.TryGetValue("expenseId", out var expenseId)) + { + return new APIGatewayProxyResponse + { + StatusCode = 400, + Body = JsonSerializer.Serialize(new { message = "Missing expenseId in path" }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + + // Determine if this is an approval or rejection from the path + var isApproval = request.Path.EndsWith("/approve", StringComparison.OrdinalIgnoreCase); + var decisionType = isApproval ? "approved" : "rejected"; + + // Parse optional body for decidedBy and reason + string? decidedBy = null; + string? reason = null; + + if (!string.IsNullOrEmpty(request.Body)) + { + var body = JsonSerializer.Deserialize(request.Body); + if (body.TryGetProperty("decidedBy", out var db)) + decidedBy = db.GetString(); + if (body.TryGetProperty("reason", out var r)) + reason = r.GetString(); + } + + decidedBy ??= "manager"; + + // Look up the callback ID from DynamoDB + var callbackId = await _repository.GetCallbackIdAsync(expenseId, CancellationToken.None); + + if (callbackId is null) + { + return new APIGatewayProxyResponse + { + StatusCode = 404, + Body = JsonSerializer.Serialize(new { message = $"Expense '{expenseId}' not found or already processed" }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + + // Send the decision back to the durable execution via callback + var decision = new ApprovalDecision(expenseId, decisionType, decidedBy, reason); + var resultJson = JsonSerializer.Serialize(decision); + + await LambdaClient.SendDurableExecutionCallbackSuccessAsync( + new SendDurableExecutionCallbackSuccessRequest + { + CallbackId = callbackId, + Result = new MemoryStream(Encoding.UTF8.GetBytes(resultJson)) + }); + + // Update DynamoDB status + await _repository.UpdateStatusAsync(expenseId, decisionType, decidedBy, reason, CancellationToken.None); + + context.Logger.LogInformation($"Expense {expenseId} {decisionType} by {decidedBy}"); + + return new APIGatewayProxyResponse + { + StatusCode = 200, + Body = JsonSerializer.Serialize(new + { + message = $"Expense {expenseId} has been {decisionType}", + expenseId, + decision = decisionType, + decidedBy + }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/src/ExpenseApproval.csproj b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseApproval.csproj new file mode 100644 index 000000000..13928867e --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseApproval.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + Lambda + true + true + + + + + + + + + diff --git a/sam-dotnet-durablefunction-expenseapproval/src/ExpenseRepository.cs b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseRepository.cs new file mode 100644 index 000000000..80a07764e --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseRepository.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; + +namespace ExpenseApproval; + +/// +/// Persists expense reports and their status to DynamoDB. +/// +public sealed class ExpenseRepository(IAmazonDynamoDB dynamoDb, string tableName) +{ + private readonly IAmazonDynamoDB _dynamoDb = dynamoDb; + private readonly string _tableName = tableName; + + public async Task SaveExpenseAsync(ExpenseReport expense, string callbackId, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(expense); + + var item = new Dictionary + { + ["ExpenseId"] = new() { S = expense.ExpenseId }, + ["SubmittedBy"] = new() { S = expense.SubmittedBy }, + ["Description"] = new() { S = expense.Description }, + ["Amount"] = new() { N = expense.Amount.ToString(CultureInfo.InvariantCulture) }, + ["Currency"] = new() { S = expense.Currency }, + ["ManagerEmail"] = new() { S = expense.ManagerEmail }, + ["CallbackId"] = new() { S = callbackId }, + ["Status"] = new() { S = "pending_approval" }, + ["SubmittedAt"] = new() { S = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) } + }; + + await _dynamoDb.PutItemAsync(new PutItemRequest + { + TableName = _tableName, + Item = item + }, ct); + } + + public async Task GetCallbackIdAsync(string expenseId, CancellationToken ct) + { + var response = await _dynamoDb.GetItemAsync(new GetItemRequest + { + TableName = _tableName, + Key = new Dictionary + { + ["ExpenseId"] = new() { S = expenseId } + }, + ProjectionExpression = "CallbackId" + }, ct); + + return response.Item.TryGetValue("CallbackId", out var attr) ? attr.S : null; + } + + public async Task UpdateStatusAsync(string expenseId, string status, string? decidedBy, string? reason, CancellationToken ct) + { + var expressionValues = new Dictionary + { + [":status"] = new() { S = status }, + [":decidedAt"] = new() { S = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) } + }; + + var updateExpression = "SET #st = :status, DecidedAt = :decidedAt"; + + if (decidedBy is not null) + { + expressionValues[":decidedBy"] = new() { S = decidedBy }; + updateExpression += ", DecidedBy = :decidedBy"; + } + + if (reason is not null) + { + expressionValues[":reason"] = new() { S = reason }; + updateExpression += ", Reason = :reason"; + } + + await _dynamoDb.UpdateItemAsync(new UpdateItemRequest + { + TableName = _tableName, + Key = new Dictionary + { + ["ExpenseId"] = new() { S = expenseId } + }, + UpdateExpression = updateExpression, + ExpressionAttributeNames = new Dictionary + { + ["#st"] = "Status" + }, + ExpressionAttributeValues = expressionValues + }, ct); + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/src/ExpenseStarterHandler.cs b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseStarterHandler.cs new file mode 100644 index 000000000..8f6ccdfba --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseStarterHandler.cs @@ -0,0 +1,70 @@ +using System.Text; +using System.Text.Json; +using Amazon.Lambda; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using Amazon.Lambda.Model; + +namespace ExpenseApproval; + +/// +/// Thin API Gateway handler that starts the expense workflow durable execution. +/// Invokes the durable function asynchronously (InvocationType = Event) so the +/// caller gets an immediate response while the long-running workflow executes. +/// +public class ExpenseStarterHandler +{ + private static readonly IAmazonLambda LambdaClient = new AmazonLambdaClient(); + + private static readonly string WorkflowFunctionName = + System.Environment.GetEnvironmentVariable("WORKFLOW_FUNCTION_NAME") + ?? throw new InvalidOperationException("WORKFLOW_FUNCTION_NAME environment variable is not set."); + + public async Task Handler(APIGatewayProxyRequest request, ILambdaContext context) + { + // Validate the request body has a parseable expense report + ExpenseReport? expense = null; + if (!string.IsNullOrEmpty(request.Body)) + { + expense = JsonSerializer.Deserialize(request.Body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + + if (expense is null || string.IsNullOrEmpty(expense.ExpenseId) || expense.Amount <= 0) + { + return new APIGatewayProxyResponse + { + StatusCode = 400, + Body = JsonSerializer.Serialize(new { message = "Invalid expense report. Provide an ExpenseId and a positive Amount." }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + + // Invoke the durable function asynchronously + var invokeRequest = new InvokeRequest + { + FunctionName = WorkflowFunctionName, + InvocationType = InvocationType.Event, + Payload = JsonSerializer.Serialize(request) + }; + + var response = await LambdaClient.InvokeAsync(invokeRequest); + + context.Logger.LogInformation( + $"Started expense workflow for {expense.ExpenseId}, status code: {response.StatusCode}"); + + return new APIGatewayProxyResponse + { + StatusCode = 202, + Body = JsonSerializer.Serialize(new + { + message = "Expense submitted for approval", + expenseId = expense.ExpenseId, + status = "pending_approval" + }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/src/ExpenseWorkflowHandler.cs b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseWorkflowHandler.cs new file mode 100644 index 000000000..f6efdb635 --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/ExpenseWorkflowHandler.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; + +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace ExpenseApproval; + +/// +/// Durable Function workflow for expense approval. +/// +/// Flow: +/// 1. Receive expense submission via API Gateway. +/// 2. Save to DynamoDB with "pending_approval" status. +/// 3. Create a callback and store the callback ID (so the approver can find it). +/// 4. Wait for the manager to approve/reject (up to 72 hours). +/// 5. On approval → process reimbursement. On rejection/timeout → mark rejected. +/// +public class ExpenseWorkflowHandler +{ + private static readonly IAmazonDynamoDB DynamoDbClient = new AmazonDynamoDBClient(); + + private static readonly string TableName = + Environment.GetEnvironmentVariable("EXPENSE_TABLE_NAME") + ?? throw new InvalidOperationException("EXPENSE_TABLE_NAME environment variable is not set."); + + private readonly ExpenseRepository _repository = new(DynamoDbClient, TableName); + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(APIGatewayProxyRequest request, IDurableContext ctx) + { + // ────────────────────────────────────────────────────────────────── + // Parse the expense report from the request body + // ────────────────────────────────────────────────────────────────── + var expense = JsonSerializer.Deserialize(request.Body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (expense is null || expense.Amount <= 0) + { + return new APIGatewayProxyResponse + { + StatusCode = 400, + Body = JsonSerializer.Serialize(new { message = "Invalid expense report. Amount must be positive." }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + + // ────────────────────────────────────────────────────────────────── + // Step 1: Create a callback for the approval decision. + // The callback ID is stored in DynamoDB so the approver endpoint can find it. + // ────────────────────────────────────────────────────────────────── + var callback = await ctx.CreateCallbackAsync( + name: "await-manager-approval", + config: new CallbackConfig + { + Timeout = TimeSpan.FromHours(72) + }); + + // ────────────────────────────────────────────────────────────────── + // Step 2: Save the expense report with the callback ID. + // ────────────────────────────────────────────────────────────────── + await ctx.StepAsync( + async (_, ct) => await _repository.SaveExpenseAsync(expense, callback.CallbackId, ct), + name: "save-expense", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + // ────────────────────────────────────────────────────────────────── + // Step 3: Wait for the manager's decision (suspends execution). + // The Lambda terminates here. Execution resumes when the approver + // calls SendDurableExecutionCallbackSuccess/Failure. + // ────────────────────────────────────────────────────────────────── + ApprovalDecision? decision = null; + var timedOut = false; + + try + { + decision = await callback.GetResultAsync(); + } + catch (CallbackException ex) when (ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)) + { + timedOut = true; + } + + // ────────────────────────────────────────────────────────────────── + // Step 4: Process the decision + // ────────────────────────────────────────────────────────────────── + ExpenseResult result; + + if (timedOut) + { + // Approval timed out — auto-reject + await ctx.StepAsync( + async (_, ct) => await _repository.UpdateStatusAsync( + expense.ExpenseId, "timed_out", null, "Approval timed out after 72 hours", ct), + name: "handle-timeout", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + result = new ExpenseResult( + expense.ExpenseId, "timed_out", null, + "Approval timed out after 72 hours", null, DateTime.UtcNow); + } + else if (decision?.Decision == "approved") + { + // Process reimbursement + var reimbursementId = await ctx.StepAsync( + async (_, ct) => + { + await _repository.UpdateStatusAsync( + expense.ExpenseId, "approved", decision.DecidedBy, null, ct); + return $"REIMB-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + }, + name: "process-reimbursement", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + result = new ExpenseResult( + expense.ExpenseId, "approved", decision.DecidedBy, + null, reimbursementId, DateTime.UtcNow); + } + else + { + // Rejected + await ctx.StepAsync( + async (_, ct) => await _repository.UpdateStatusAsync( + expense.ExpenseId, "rejected", decision?.DecidedBy, decision?.Reason, ct), + name: "handle-rejection", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + result = new ExpenseResult( + expense.ExpenseId, "rejected", decision?.DecidedBy, + decision?.Reason, null, DateTime.UtcNow); + } + + return new APIGatewayProxyResponse + { + StatusCode = 200, + Body = JsonSerializer.Serialize(result), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } +} diff --git a/sam-dotnet-durablefunction-expenseapproval/src/Models.cs b/sam-dotnet-durablefunction-expenseapproval/src/Models.cs new file mode 100644 index 000000000..6d8757895 --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/src/Models.cs @@ -0,0 +1,32 @@ +namespace ExpenseApproval; + +/// +/// An expense report submitted for approval. +/// +public record ExpenseReport( + string ExpenseId, + string SubmittedBy, + string Description, + decimal Amount, + string Currency, + string ManagerEmail); + +/// +/// The decision made by a manager on an expense report. +/// +public record ApprovalDecision( + string ExpenseId, + string Decision, // "approved" or "rejected" + string DecidedBy, + string? Reason); + +/// +/// Final result of the expense workflow. +/// +public record ExpenseResult( + string ExpenseId, + string Status, // "approved", "rejected", or "timed_out" + string? ApprovedBy, + string? Reason, + string? ReimbursementId, + DateTime CompletedAt); diff --git a/sam-dotnet-durablefunction-expenseapproval/template.yaml b/sam-dotnet-durablefunction-expenseapproval/template.yaml new file mode 100644 index 000000000..f92c6ed7a --- /dev/null +++ b/sam-dotnet-durablefunction-expenseapproval/template.yaml @@ -0,0 +1,120 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Expense Approval workflow using Lambda Durable Functions. + Submits an expense report, waits for manager approval via callback, + then processes reimbursement or rejection. + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: dotnet10 + Architectures: + - x86_64 + +Resources: + # ───────────────────────────────────────────────────────────────────── + # DynamoDB Table — stores expense reports and their status + # ───────────────────────────────────────────────────────────────────── + ExpenseTable: + Type: AWS::DynamoDB::Table + Properties: + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: ExpenseId + AttributeType: S + KeySchema: + - AttributeName: ExpenseId + KeyType: HASH + DeletionPolicy: Delete + + # ───────────────────────────────────────────────────────────────────── + # Durable Function — expense approval workflow + # ───────────────────────────────────────────────────────────────────── + ExpenseWorkflowFunction: + Type: AWS::Serverless::Function + Properties: + Handler: ExpenseApproval::ExpenseApproval.ExpenseWorkflowHandler::Handler + CodeUri: ./src/ + Description: Durable Function - expense approval workflow with callback + DurableConfig: + ExecutionTimeout: 604800 # 7 days — allows time for human approval + RetentionPeriodInDays: 14 + AutoPublishAlias: live + Environment: + Variables: + EXPENSE_TABLE_NAME: !Ref ExpenseTable + Policies: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy + - DynamoDBCrudPolicy: + TableName: !Ref ExpenseTable + + # ───────────────────────────────────────────────────────────────────── + # Starter Function — thin API handler that starts the durable workflow + # ───────────────────────────────────────────────────────────────────── + ExpenseStarterFunction: + Type: AWS::Serverless::Function + Properties: + Handler: ExpenseApproval::ExpenseApproval.ExpenseStarterHandler::Handler + CodeUri: ./src/ + Description: Starts the expense workflow durable execution asynchronously + Environment: + Variables: + WORKFLOW_FUNCTION_NAME: !Ref ExpenseWorkflowFunctionAliaslive + Policies: + - LambdaInvokePolicy: + FunctionName: !Ref ExpenseWorkflowFunction + Events: + SubmitExpense: + Type: Api + Properties: + Path: /expenses + Method: post + + # ───────────────────────────────────────────────────────────────────── + # Approver Function — called by manager to approve/reject + # ───────────────────────────────────────────────────────────────────── + ApproverFunction: + Type: AWS::Serverless::Function + Properties: + Handler: ExpenseApproval::ExpenseApproval.ApproverHandler::Handler + CodeUri: ./src/ + Description: Handles manager approval/rejection of expense reports + Environment: + Variables: + EXPENSE_TABLE_NAME: !Ref ExpenseTable + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ExpenseTable + - LambdaInvokePolicy: + FunctionName: !Ref ExpenseWorkflowFunction + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - lambda:SendDurableExecutionCallbackSuccess + - lambda:SendDurableExecutionCallbackFailure + Resource: !Sub "${ExpenseWorkflowFunction.Arn}*" + Events: + ApproveExpense: + Type: Api + Properties: + Path: /expenses/{expenseId}/approve + Method: post + RejectExpense: + Type: Api + Properties: + Path: /expenses/{expenseId}/reject + Method: post + +Outputs: + ApiEndpoint: + Description: API Gateway endpoint URL + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" + ExpenseWorkflowFunctionArn: + Description: Expense workflow Lambda function ARN + Value: !GetAtt ExpenseWorkflowFunction.Arn + ExpenseTableName: + Description: DynamoDB table for expense reports + Value: !Ref ExpenseTable