C# and AWS Lambdas, Part 5 – Updating the Zip in S3 and Updating the Running Lambda, with Pulumi IaC

Full source code available here.

This post pulls together a few threads I’ve been working on – creation of lambda to run .NET, storing the zip in S3, and updating the .NET lambda when the zip in S3 is updated.

This one took quite while to put together -the permissions, roles and policies were not obvious and I hope it will be of help to you.
This is not a blog post on CI/CD, I am cutting corners by using Pulumi to upload the zip files initially, and then use the AWS command line to send zips to S3. In a future set of posts I will show how to use GitHub Actions to build the infrastructure, and to compile and deploy the .NET lambda directly to S3 from GitHub.

The idea
I want to have a lambda that runs .NET code stored in a zip file in S3. I want to be able to update the zip, and have the .NET lambda run the code in the new zip. I had hoped this would be a little tick box on the lambda, but sadly there is no such box.

Instead I have a second lambda (referred to as the updater lambda) that is triggered by an update on specified bucket in S3. This updater lambda in turn calls an update on the .NET lambda and within a few seconds the .NET lambda will be running the new code. Doesn’t sound easy, but I didn’t think it would be too hard, but take a look at the number of resources needed.

What’s needed
A role to run .NET lambda.
A role to run lambda that updates the .NET lambda, I’m calling this the updater.
A policy to give the updater permissions to update the .NET lambda and S3.
A policy attachment for the .NET lambda.
A policy attachment for the updater lambda.
An S3 bucket.
An S3 bucket object.
The .NET lambda pointing at the bucket and bucket object.
The zip file for for the .NET lambda.
The updater lambda with variables passed in to verify the update of the .NET lambda.
The zip file for the updater lambda – Node.js.
Permission for the bucket to call the updated lambda.
A bucket notification with attached permissions.
Reduce the bucket accessible to the public (no necessary, but good).

That’s a lot more than the tick box I was hoping for.

The code

using Pulumi;
using S3 = Pulumi.Aws.S3;
using Aws = Pulumi.Aws;

class MyStack : Stack
{
    public MyStack()
    {
        string resource_prefix = "PulumiHelloWorldAutoUpdate";

        var lambdaHelloWorldRole = new Aws.Iam.Role($"{resource_prefix}_LambdaRole", new Aws.Iam.RoleArgs
        {
            AssumeRolePolicy =
@"{
    ""Version"": ""2012-10-17"",
    ""Statement"": [
        {
        ""Action"": ""sts:AssumeRole"",
        ""Principal"": {
            ""Service"": ""lambda.amazonaws.com""
        },
        ""Effect"": ""Allow"",
        ""Sid"": """"
        }
    ]
}",
        });

        var lambdaUpdateRole = new Aws.Iam.Role($"{resource_prefix}_LambdaUpdateRole", new Aws.Iam.RoleArgs
        {
            AssumeRolePolicy =
@"{
    ""Version"": ""2012-10-17"",
    ""Statement"": [
        {
        ""Action"": ""sts:AssumeRole"",
        ""Principal"": {
            ""Service"": ""lambda.amazonaws.com""
        },
        ""Effect"": ""Allow"",
        ""Sid"": """"
        }
    ]
}",
        });

        // gives the lamaba permissions to other lambdas and s3 - too many permissions, but this is a demo.
        var lambdaUpdatePolicy = new Aws.Iam.Policy($"{resource_prefix}_S3_Lambda_Policy", new Aws.Iam.PolicyArgs{
            PolicyDocument = 
@"{
    ""Version"": ""2012-10-17"",
    ""Statement"": [
        {
            ""Sid"": """",
            ""Effect"": ""Allow"",
            ""Action"": [
                ""s3:*"",
                ""logs:*"",
                ""lambda:*""
            ],
            ""Resource"": ""*""
        }
    ]
}"
        });

        // attach a simple policy to the hello world lambda.
        var lambdaHelloWorldAttachment = new Aws.Iam.PolicyAttachment($"{resource_prefix}_LambdaHelloWorldPolicyAttachment", new Aws.Iam.PolicyAttachmentArgs
        {
            Roles =
            {
                lambdaHelloWorldRole.Name
            },
            PolicyArn = Aws.Iam.ManagedPolicy.AWSLambdaBasicExecutionRole.ToString(),
        });

        // attach the custom policy to the role that runs the update lambda.
        var lambdaUpdateAttachment = new Aws.Iam.PolicyAttachment($"{resource_prefix}_LambdaUpdatePolicyAttachment", new Aws.Iam.PolicyAttachmentArgs
        {
            Roles =
            {
                lambdaUpdateRole.Name
            },
            PolicyArn = lambdaUpdatePolicy.Arn,
        });

        var s3Bucket = new S3.Bucket($"{resource_prefix}_S3Bucket", new S3.BucketArgs
        {
            BucketName = "pulumi-hello-world-auto-update-s3-bucket",
            Versioning = new Aws.S3.Inputs.BucketVersioningArgs
            {
                Enabled = true,
            },
            Acl = "private"
        });

        var s3BucketObject = new S3.BucketObject($"{resource_prefix}_ZipFile", new S3.BucketObjectArgs
        {
            Bucket = s3Bucket.BucketName.Apply(name => name),
            Acl = "private",
            Source = new FileArchive("./Lambdas/helloworld_no_date/helloworld.zip"),
            Key = "helloworld.zip"
        });

        // this is the lambda that runs .NET code
        var lambdaHelloWorldFunction = new Aws.Lambda.Function($"{resource_prefix}_LambdaHelloWorldFunction", new Aws.Lambda.FunctionArgs
        {
            Handler = "HelloWorldLambda::HelloWorldLambda.Function::FunctionHandler",
            MemorySize = 128,
            Publish = false,
            ReservedConcurrentExecutions = -1,
            Role = lambdaHelloWorldRole.Arn,
            Runtime = Aws.Lambda.Runtime.DotnetCore3d1,
            Timeout = 4,
            S3Bucket = s3Bucket.BucketName,
            S3Key = s3BucketObject.Key
        });

        // this is the lambda triggered by an upload to S3 and replaces the zip in the above lambda
        var lambdaUpdateFunction = new Aws.Lambda.Function($"{resource_prefix}_LambdaUpdateFunction", new Aws.Lambda.FunctionArgs
        {
            Handler = "index.handler",
            MemorySize = 128,
            Publish = false,
            ReservedConcurrentExecutions = -1,
            Role = lambdaUpdateRole.Arn,
            Runtime = Aws.Lambda.Runtime.NodeJS14dX,
            Timeout = 4,
            Code = new FileArchive("./Lambdas/LambdaUpdater/index.zip"),
            Environment = new Aws.Lambda.Inputs.FunctionEnvironmentArgs
            {
                Variables = new InputMap<string> {{"s3Bucket", s3Bucket.BucketName}, {"s3Key", "helloworld.zip"}, {"functionToUpdate", lambdaHelloWorldFunction.Name}}
            }
        });

        var s3BucketPermissionToCallLambda = new Aws.Lambda.Permission($"{resource_prefix}_S3BucketPermissionToCallLambda", new Aws.Lambda.PermissionArgs
        {
            Action = "lambda:InvokeFunction",
            Function = lambdaUpdateFunction.Arn,
            Principal = "s3.amazonaws.com",
            SourceArn = s3Bucket.Arn,
        });

        var bucketNotification = new S3.BucketNotification($"{resource_prefix}_S3BucketNotification", new Aws.S3.BucketNotificationArgs
        {
            Bucket = s3Bucket.Id,
            LambdaFunctions = 
            {
                new Aws.S3.Inputs.BucketNotificationLambdaFunctionArgs
                {
                    LambdaFunctionArn = lambdaUpdateFunction.Arn,
                    Events = 
                    {
                        "s3:ObjectCreated:*",
                    },
                }
            },
        }, new CustomResourceOptions
        {
            DependsOn = 
            {
                s3BucketPermissionToCallLambda,
            },
        });

        // keep the contents bucket private
        var bucketPublicAccessBlock = new S3.BucketPublicAccessBlock($"{resource_prefix}_PublicAccessBlock", new S3.BucketPublicAccessBlockArgs
        {
            Bucket = s3Bucket.Id,
            BlockPublicAcls = false,  // leaving these two false because I need them this way 
            IgnorePublicAcls = false, // for a post about GitHub Actions that I'm working on
            BlockPublicPolicy = true,
            RestrictPublicBuckets = true
        });

        this.LambdaUpdateFunctionName = lambdaUpdateFunction.Name;
        this.LambdaHelloWorldFunctionName = lambdaHelloWorldFunction.Name;
        this.S3Bucket = s3Bucket.BucketName;
        this.S3Key = s3BucketObject.Key;
    }

    [Output]
    public Output<string> LambdaUpdateFunctionName { get; set; }

    [Output]
    public Output<string> LambdaHelloWorldFunctionName { get; set; }

    [Output]
    public Output<string> S3Bucket {get;set;}

    [Output]
    public Output<string> S3Key {get;set;}
}

Below is the code of the updater lambda. The if checks to make sure that the lambda.updateFunctionCode(..) runs only if the expected file in S3 is updated. The environmental variables were passed in via the Pulumi code above.

const AWS = require('aws-sdk');
const lambda = new AWS.Lambda();

exports.handler = (event) => {
    
    if (event.Records[0].s3.bucket.name == process.env.s3Bucket && event.Records[0].s3.object.key == process.env.s3Key)
    {
        var params = {
            FunctionName: process.env.functionToUpdate,
            S3Bucket: event.Records[0].s3.bucket.name, 
            S3Key: event.Records[0].s3.object.key
        };
        
        // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#updateFunctionCode-property
        lambda.updateFunctionCode(params, function(err, data) {
            if (err) // an error occurred
            {
                console.log(err, err.stack);
            }
            else
            {   
                console.log(data);  
            }
        });
    }
    else
    {
        console.log("bucket name or s3 key did not match expected values.");
        console.log("expected bucket name: " + process.env.s3Bucket + " actual: " + event.Records[0].s3.bucket.name);
        console.log("expected s3 key: " + process.env.s3Key + " actual: " + event.Records[0].s3.object.key);
    }
    console.log("Exiting");
};

The zip attached to this blog post has all the source code needed, you don’t have to add or change anything.

Running it

From the console, run –

pulumi up 

At the end you should see something like this –

Note the outputs. These are the name of your lambdas and the s3 bucket and key –

Outputs:
    LambdaHelloWorldFunctionName: "PulumiHelloWorldAutoUpdate_LambdaHelloWorldFunction-???????"
    LambdaUpdateFunctionName    : "PulumiHelloWorldAutoUpdate_LambdaUpdateFunction-???????"
    S3Bucket                    : "pulumi-hello-world-auto-update-s3-bucket"
    S3Key                       : "helloworld.zip"

Go the aws console, and test the lambda as shown in part 1 of this blog series.

You should get output like this – “HELLO WORLD”.

Updating the zip in S3
Now to try out the real functionality, updating the zip in S3 and see if it runs in the .NET lambda.

In the attached source there is a Lambdas directory with two subdirectories – helloworld_no_date and helloworld_with_date. They contain two variations of the .NET application. The first converts the input text to uppercase, the second converts the input text to uppercase and adds the current date and time.

You can run the below commands to upload each zip file and try out the lambda. A few seconds after you upload, the .NET lambda will use that zip.

// no date
aws s3 cp ./Lambdas/helloworld_no_date/helloworld.zip s3://pulumi-hello-world-auto-update-s3-bucket/helloworld.zip
// with date
aws s3 cp ./Lambdas/helloworld_with_date/helloworld.zip s3://pulumi-hello-world-auto-update-s3-bucket/helloworld.zip

If you don’t want to go into the AWS UI console to try out the lambda you can invoke it from the command line, but you need to swap the function name below for the one in the output of the pulumi up command –

aws lambda invoke --function-name PulumiHelloWorldAutoUpdate_LambdaHelloWorldFunction-??????? --payload '"hello world"' /dev/stdout

This was long tough one, but I’ve learned a lot about AWS, Pulumi, and even GitHub Actions (more on that soon).

Full source code available here.

C# and AWS Lambdas, Part 4 – Storing the Zip in S3, Setup with Pulumi IaC

Full source code available here.

In the previous post I showed how to use Pulumi to create a lambda, API gateway and upload a zip of Web API application directly to the lambda.

In the post I’m going to use S3 to store the zip of the of a simple .NET application (not a Web API app) and point the lambda at it, brining all the resources up using Pulumi. One drawback of using S3 to store the zip for a lambda is that when you update the zip in S3, AWS doesn’t deploy the new zip to the lambda, but I will show a way of handling that in the next blog post.

Every time I write these posts it feels like a lot of work to set up the infrastructure, learn about the AWS components, policies, etc. But then when I run pulumi destroy -y and pulumi up -y as I make changes I appreciate the speed and predictability.

What’s needed

A lambda role.
A policy to execute the lambda.
An S3 bucket.
Block all public access to the bucket – optional.
Put zip in the bucket.
The lambda function pointing to the zip in the bucket.

A few of these are the same as in the previous post, but the S3 resources are new.

Note that I included BucketPublicAccessBlock section, this makes the S3 bucket more secure than it would be under the default settings.

The code
Here is the code that sets it all up. I’ve included a helloworld.zip file in the attached source code zip. This is NOT how you would normally upload the zip to S3, but it’s easy for demonstration purposes. In a more realistic scenario, you would a CI/CD pipeline to compile the code and drop it in S3.

using Pulumi;
using S3 = Pulumi.Aws.S3;
using Aws = Pulumi.Aws;
class MyStack : Stack
{
    public MyStack()
    {

        var lambdaRole = new Aws.Iam.Role("PulumiHelloWorld_LambdaRole", new Aws.Iam.RoleArgs
        {
            AssumeRolePolicy =
@"{
    ""Version"": ""2012-10-17"",
    ""Statement"": [
        {
        ""Action"": ""sts:AssumeRole"",
        ""Principal"": {
            ""Service"": ""lambda.amazonaws.com""
        },
        ""Effect"": ""Allow"",
        ""Sid"": """"
        }
    ]
}",
        });

        var lambdaPolicyAttachment = new Aws.Iam.PolicyAttachment("PulumiHelloWorld_LambdaPolicyAttachment", new Aws.Iam.PolicyAttachmentArgs
        {
            Roles =
            {
                lambdaRole.Name
            },
            PolicyArn = Aws.Iam.ManagedPolicy.AWSLambdaBasicExecutionRole.ToString(),
        });

        var bucket = new S3.Bucket("PulumiHelloWorld_S3Bucket", new S3.BucketArgs
        {
            BucketName = "pulumi-hello-world-s3-bucket",
            Acl = "private"
        });

        var bucketPublicAccessBlock = new S3.BucketPublicAccessBlock("PulumiHelloWorld_PublicAccessBlock", new S3.BucketPublicAccessBlockArgs
        {
            Bucket = bucket.Id,
            BlockPublicAcls = true,
            BlockPublicPolicy = true,
            RestrictPublicBuckets = true,
            IgnorePublicAcls = true
        });

        var bucketObject = new S3.BucketObject("PulumiHelloWorld_ZipFile", new S3.BucketObjectArgs
        {
            Bucket = bucket.BucketName.Apply(name => name),
            Acl = "private",
            Source = new FileArchive("helloworld.zip")
        });

        var lambdaFunction = new Aws.Lambda.Function("PulumiHelloWorld_LambdaFunction", new Aws.Lambda.FunctionArgs
        {
            Handler = "HelloWorldLambda::HelloWorldLambda.Function::FunctionHandler",
            MemorySize = 128,
            Publish = false,
            ReservedConcurrentExecutions = -1,
            Role = lambdaRole.Arn,
            Runtime = Aws.Lambda.Runtime.DotnetCore3d1,
            Timeout = 4,
            S3Bucket = bucket.BucketName,
            S3Key = bucketObject.Key
        });

        this.LambdaFunctionName = lambdaFunction.Name;
    }

    [Output]
    public Output<string> LambdaFunctionName { get; set; }
}

To test out the lambda, open it in the AWS console, navigate to the lambda and add a test as described in part 1 of this series of posts.

Full source code available here.

C# and AWS Lambdas, Part 3 – Pulumi IaC for Web API and an API Gateway

Full source code available here.

In this the third in a series of posts on using .NET in AWS lambdas I build on the previous where I connected a Http Gateway to a lambda running a Web API application. In that post I built the infrastructure by hand, i.e. via the GUI, pointing and clicking.

In this post I will show how to build everything with Pulumi. I’ve written a few posts on Pulimi before, you can find them here.

As with all IaC, the hard part isn’t the IaC itself, it’s knowing what to do on the platform you are deploying to.
In the previous post I worked out all the required steps and components and got everything working. But the UI takes care of some things that you have to explicitly do in IaC and this is where the challenge is as you get familiar with the platform you are working on.

What’s needed
When you add up all the components needed, it feels like a lot, but eighty lines of C# code sets it all up.

IAM role for the lambda.
Policy attachment for the above role letting it execute the lambda.
The lambda function. This where the Web API code runs.
A Http Gateway.
An integration between the Http Gateway and the lambda.
A route on the Http Gateway that forwards to the integration.
A stage on the Http Gateway.
Permissions for the Http Gateway to execute the lambda.

That last one was not obvious, thank you to Piers Karsenbarg at Pulumi for his assistance.

The code
You need to have Pulumi installed, check their site for instructions.

Here is the stack to setup all the components listed above.

using Pulumi;
using Aws = Pulumi.Aws;

class MyStack : Stack
{
    public MyStack()
    {

        var lambdaRole = new Aws.Iam.Role("PulumiWebApiGateway_LambdaRole", new Aws.Iam.RoleArgs
        {
            AssumeRolePolicy = 
@"{
    ""Version"": ""2012-10-17"",
    ""Statement"": [
        {
        ""Action"": ""sts:AssumeRole"",
        ""Principal"": {
            ""Service"": ""lambda.amazonaws.com""
        },
        ""Effect"": ""Allow"",
        ""Sid"": """"
        }
    ]
}",
        });

        var lambdaPolicyAttachment = new Aws.Iam.PolicyAttachment("PulumiWebApiGateway_LambdaPolicyAttachment", new Aws.Iam.PolicyAttachmentArgs
        {
            Roles =
            {
                lambdaRole.Name
            },
            PolicyArn = Aws.Iam.ManagedPolicy.AWSLambdaBasicExecutionRole.ToString(), 
        });

        var lambdaFunction = new Aws.Lambda.Function("PulumiWebApiGateway_LambdaFunction", new Aws.Lambda.FunctionArgs
        {
            Handler = "WebAPILambda::WebAPILambda.LambdaEntryPoint::FunctionHandlerAsync",
            MemorySize = 128,
            Publish = false,
            ReservedConcurrentExecutions = -1,
            Role = lambdaRole.Arn,
            Runtime = Aws.Lambda.Runtime.DotnetCore3d1,
            Timeout = 4,
            Code = new FileArchive("WebAPILambda.zip"), // I put the zip file in the same dir as this code for this demo, but you should not do this.
        });
        System.Console.WriteLine(Aws.Iam.ManagedPolicy.AWSLambdaBasicExecutionRole.ToString());

        var httpApiGateway = new Pulumi.Aws.ApiGatewayV2.Api("PulumiWebApiGateway_ApiGateway", new Pulumi.Aws.ApiGatewayV2.ApiArgs
        {
            ProtocolType = "HTTP",
            RouteSelectionExpression = "${request.method} ${request.path}",
        });

        var httpApiGateway_LambdaIntegration = new Pulumi.Aws.ApiGatewayV2.Integration("PulumiWebApiGateway_ApiGatewayIntegration", new Pulumi.Aws.ApiGatewayV2.IntegrationArgs
        {
            ApiId = httpApiGateway.Id,
            IntegrationType = "AWS_PROXY",
            IntegrationMethod = "POST",
            IntegrationUri = lambdaFunction.Arn,
            PayloadFormatVersion = "2.0",
            TimeoutMilliseconds = 30000,
        });

        var httpApiGatewayRoute = new Pulumi.Aws.ApiGatewayV2.Route("PulumiWebApiGateway_ApiGatewayRoute", new Pulumi.Aws.ApiGatewayV2.RouteArgs
        {
            ApiId = httpApiGateway.Id,
            RouteKey = "$default",
            Target = httpApiGateway_LambdaIntegration.Id.Apply(id => $"integrations/{id}"),
        });

        var httpApiGatewayStage = new Pulumi.Aws.ApiGatewayV2.Stage("PulumiWebApiGateway_ApiGatewayStage", new Pulumi.Aws.ApiGatewayV2.StageArgs
        {
            ApiId = httpApiGateway.Id,
            AutoDeploy = true,
            Name = "$default",
        });

        var lambdaPermissionsForApiGateway = new Aws.Lambda.Permission("PulumiWebApiGateway_LambdaPermission", new Aws.Lambda.PermissionArgs
        {
            Action = "lambda:InvokeFunction",
            Function = lambdaFunction.Name,
            Principal = "apigateway.amazonaws.com",
            SourceArn = Output.Format($"{httpApiGateway.ExecutionArn}/*") // note it's the ExecutionArn.
            // SourceArn = httpApiGateway.ExecutionArn.Apply(arn => $"{arn}/*") // this is another way of doing the same thing
        });

        this.ApiEndpoint = httpApiGateway.ApiEndpoint.Apply(endpoint =>  $"{endpoint}/api/values");
    }

    [Output]
    public Output<string> ApiEndpoint { get; set; }
}

Run –

pulumi up

This is will show you what is going to be deployed, if it looks ok select yes.

After a short wait you should see something like this the below image indicating that everything has been setup

Now you can click on the APIEndpoint url and it should execute your lambda and return the hello world message.

Full source code available here.

C# and AWS Lambdas, Part 2 – Web API and an API Gateway

Full source code available here.

In the previous post I created a lambda that executed a C# console application. In this post I walkthrough creating a .NET 3.1 Web API application inside a lambda and making it reachable from the web, just like a normal Web API application.

First, update the lambda templates, they have fixed a bug in the csproj file of the template, that I described in the in first post, and added some new templates.

dotnet new -i Amazon.Lambda.Templates::5.1.0

Another useful thing to install are the Amazon.Lambda.Tools package, but I don’t use them in this post.

dotnet tool install -g Amazon.Lambda.Tools 

Or update it if you have it installed already.

dotnet tool update -g Amazon.Lambda.Tools

Create a new application.

dotnet new serverless.AspNetCoreWebAPI --name WebAPILambda

In LambdaEntryPoint.cs change the class it inherits from to Amazon.Lambda.AspNetCoreServer.APIGatewayHttpApiV2ProxyFunction.

For the sake of a demo I’m going to change very little, and show a GET and a POST call.

Add a Person class.

namespace WebAPILambda.Models
{
    public class Person
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }

        public override string ToString()
        {
            return $"{FirstName} {LastName}";
        }
    }
}

Replace the ValuesController class with this –

[Route("api/[controller]")]
public class ValuesController : ControllerBase
{

    [HttpGet]
    public string Get()
    {
        return $"Hello World from inside a lambda {DateTime.Now}";
    }

    [HttpGet("{id}")]
    public string Get(int id)
    {
        return $"You asked for {id}";
    }

    [HttpPost]
    public IActionResult Post([FromBody] Person person)
    {
        return Ok($"You sent {person.ToString()}");
    }
}

Build it. You can try it by starting the application locally and use like a normal Web API application.

Zip the contents of the WebAPILambda/bin/Debug/netcoreapp3.1 directory in a file named WebAPILambda.zip.

We’re done with the code.

Create the Lambda

I’m not going to show the detailed steps because they are described in part 1 of this series of posts.

But there is one difference – make this the handler WebAPILambda::WebAPILambda.LambdaEntryPoint::FunctionHandlerAsync.

Upload the zip as shown in part 1.

You should be able to paste in the content of the test/WebAPILambda.Tests/SampleRequests/ValuesController-Get.json from the source code into the lambda test feature, but the Json generated by the template does NOT work with a LambdaEntryPoint that inherits from APIGatewayHttpApiV2ProxyFunction.

That’s the lambda setup.

Now we need to send some web requests to it.

API Gateway
There are a few choices here, I am going to show how to use the Http API because it is simpler.

Click Create API.

Top of the list should be HTTP API, click Build.

Hit Add integration, choose Lambda, and pick the lambda function defined in the previous step.
Select Version 2.0.
Give the API a name.
Hit next.

For route configuration leave the METHOD as ANY.
Change resource path to /api/values – this matches the route in Web API controller in the application.

In Configure Stages don’t change anything for this simple example.
Hit next.

In Review and Create confirm that it looks correct and hit Create in the bottom right.

You should now have an API Gateway setup that looks something like –

Click on the Invoke URL and you should get a response like –

{
message: “Not Found”
}

Change the url to include the route of the controller and by adding /api/values and you will get –

That is hitting the GET method. In the attached source code there is file named test.http, it has an example of a POST method.

There you go, an API inside a lambda reachable from the web! Not so hard after you’ve done it once.

Full source code available here.

C# and AWS Lambdas, Part 1 – Hello World

Full source code available here.

This is the first in a series of posts on using .NET with AWS Lambdas. It will start with the simplest example that converts a lowercase string to an uppercase string, but by the end you will be running a .NET Web API powered by lambda, fronted by an API gateway where all the infrastructure is setup by Pulumi – this will take a few posts over the next while.

Getting Started
Install the AWS Lambda Templates.

dotnet new -i Amazon.Lambda.Templates

Now you can create projects based on these templates.

Here is the full list of available templates.

Templates                                                 Short Name                                        Language          Tags                  
----------------------------------------------------      --------------------------------------------      ------------      ----------------------
Order Flowers Chatbot Tutorial                            lambda.OrderFlowersChatbot                        [C#]              AWS/Lambda/Function   
Lambda Custom Runtime Function (.NET 5.0)                 lambda.CustomRuntimeFunction                      [C#], F#          AWS/Lambda/Function   
Lambda Detect Image Labels                                lambda.DetectImageLabels                          [C#], F#          AWS/Lambda/Function   
Lambda Empty Function                                     lambda.EmptyFunction                              [C#], F#          AWS/Lambda/Function   
Lex Book Trip Sample                                      lambda.LexBookTripSample                          [C#]              AWS/Lambda/Function   
Lambda Simple Application Load Balancer Function          lambda.SimpleApplicationLoadBalancerFunction      [C#]              AWS/Lambda/Function   
Lambda Simple DynamoDB Function                           lambda.DynamoDB                                   [C#], F#          AWS/Lambda/Function   
Lambda Simple Kinesis Firehose Function                   lambda.KinesisFirehose                            [C#]              AWS/Lambda/Function   
Lambda Simple Kinesis Function                            lambda.Kinesis                                    [C#], F#          AWS/Lambda/Function   
Lambda Simple S3 Function                                 lambda.S3                                         [C#], F#          AWS/Lambda/Function   
Lambda Simple SNS Function                                lambda.SNS                                        [C#]              AWS/Lambda/Function   
Lambda Simple SQS Function                                lambda.SQS                                        [C#]              AWS/Lambda/Function   
Lambda ASP.NET Core Web API                               serverless.AspNetCoreWebAPI                       [C#], F#          AWS/Lambda/Serverless 
Lambda ASP.NET Core Web Application with Razor Pages      serverless.AspNetCoreWebApp                       [C#]              AWS/Lambda/Serverless 
Serverless Detect Image Labels                            serverless.DetectImageLabels                      [C#], F#          AWS/Lambda/Serverless 
Lambda DynamoDB Blog API                                  serverless.DynamoDBBlogAPI                        [C#]              AWS/Lambda/Serverless 
Lambda Empty Serverless                                   serverless.EmptyServerless                        [C#], F#          AWS/Lambda/Serverless 
Lambda Giraffe Web App                                    serverless.Giraffe                                F#                AWS/Lambda/Serverless 
Serverless Simple S3 Function                             serverless.S3                                     [C#], F#          AWS/Lambda/Serverless 
Step Functions Hello World                                serverless.StepFunctionsHelloWorld                [C#], F#          AWS/Lambda/Serverless 
Serverless WebSocket API                                  serverless.WebSocketAPI                           [C#]              AWS/Lambda/Serverless 

The Application
For this example we are going to use lambda.EmptyFunction.

dotnet new lambda.EmptyFunction --name HelloWorldLambda

This sets up two new projects, one for the lambda, and one for the tests.

There seems to be a bug in the template as it is missing line 6, this is very important. Without it, you will get errors like – An assembly specified in the application dependencies manifest (HelloWorldLambda.deps.json) was not found: package: ‘Amazon.Lambda.Core’, version: ‘1.1.0’

Be sure to add

<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

to HelloWorldLambda.csproj.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
    <AWSProjectType>Lambda</AWSProjectType>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Amazon.Lambda.Core" Version="1.1.0" />
    <PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.0.1" />
  </ItemGroup>
</Project>

The code of the lambda can stay the same, it takes an input string and converts it to uppercase and returns a string.

using Amazon.Lambda.Core;

// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace HelloWorldLambda
{
    public class Function
    {
        
        /// <summary>
        /// A simple function that takes a string and does a ToUpper
        /// </summary>
        /// <param name="input"></param>
        /// <param name="context"></param>
        /// <returns></returns>
        public string FunctionHandler(string input, ILambdaContext context)
        {
            return input?.ToUpper();
        }
    }
}

Build this, and zip up the all the files in the bin/debug/netcoreapp3.1 directory. This zip will be uploaded to the AWS soon.

The Lambda Function
For this post I’m going to show how to create the lambda using the AWS UI. In later post I’ll create it with Pulumi.

Open the AWS Lambada page – https://console.aws.amazon.com/lambda.

Click Create Function.

Give the function a name – HelloWorld is a good choice.
Change the runtime to .NET Core 3.1 (C#/PowerShell).

Click Create Function in the bottom right (no shown in the image above).

Upload the zip created above by clicking Action in the Function Code section of the screen.

Edit the Handler to read

HelloWorldLambda::HelloWorldLambda.Function::FunctionHandler 

This matches the namespace in the application we created.

Open the test tool in the top right of the screen and click “Configure test events”.

Set the event name, and replace the body with this “hello world”.

That’s everything setup. Let’s run it.

Running the Lambda
Hit the “Test” button in the top right.

You should see something like – “Execution result: succeeded(logs)”. Expand the Details and you will see “HELLO WORLD” and bunch of other information about the execution of the lambda.

That’s it, a C# .NET Core 3.1 Hello World lambda up and running.

In the next post I’ll do something a little more interesting, I’ll put an API Gateway in front of a lambda, so the lambda can be called from the web.

Full source code available here.

Indexing the Works of Shakespeare in ElasticSearch – Part 4, Searching via Web API in .NET 5

Full source code available here.

This is part four of my four part series on indexing the works of Shakespeare in ElasticSearch.

In this I’ll show how to use the ElasticSearch “low level client” to perform the search. In the past I wrote a blog showing how to use a HttpClient to perform the search using Json, and this works fine, but Steve Gordon suggested I try to the Elastic client as it supports things like connection pooling and still lets me use Json directly with ElasticSearch.

To go along with the Elastic “low level client” there is a “high level client” called NEST, I have tried both and prefer to stick with Json, but you may find them more useful.

Because I develop on a few languages, Json is the natural choice for me. I use it when querying from Node.js, inside a HTTP client (Fiddler, Rest Client, etc) when figuring out my queries and I want to use it in .NET.

But Json and C# don’t go together very well, you have to jump through hoops to make it work with escaping. Or, as I have doe, use a creative approach to deserializing via dynamic objects (I know some people won’t like this), I find this much more convenient than converting my Json queries to the Elastic client syntaxes.

This examples shows how to use the a Web API application to search for a piece of text in isolation or within specific play.

The setup
There is very little to do here.

In Startup.cs add the following to the ConfigureServices(..) method –

services.AddSingleton<ElasticLowLevelClient>(new ElasticLowLevelClient(new ConnectionConfiguration(new Uri("http://localhost:9200"))));

In the SearchController add the following to pass the ElasticSearch client in via dependency injection –

public class SearchController : ControllerBase
{
    private readonly ElasticLowLevelClient _lowLevelClient;
    public SearchController(ElasticLowLevelClient lowLevelClient)
    {
        _lowLevelClient = lowLevelClient;
    }
//snip ..

I have two action methods, one to search for a play and line, and one to search for a line across all plays (I know they could be combined into a single action method, I want keep things simple) –

[HttpGet("Line")]
public ActionResult Line(string text)
{
    string queryWithParams = GetLineQuery(text);
    var lines = PerformQuery(queryWithParams);
    
    return Ok(lines);
}

[HttpGet("PlayAndLine")]
public ActionResult PlayAndLine(string play, string text)
{
    string queryWithParams = GetPlayAndLineQuery(play, text);
    var lines = PerformQuery(queryWithParams);

    return Ok(lines);
}

All very straightforward so far, but now comes the “creative” approach to handling the Json problems.

I put my ElasticSearch queries into their own files. The first is Line.Json

{
    "query": {
        "match_phrase_prefix" :{
            "Line": ""
        }
    }
} 

And the second is PlayAndLine.Json

{
    "query":{
        "bool": {
            "must": [
                { "match": { "Play": "" } }
               ,{ "match_phrase_prefix": { "Line": "" } }
            ]
        }
    }
}

These Json queries are loaded into dynamic objects and the relevant values are set in C#.
See lines 5 and 14 & 15.

private string GetLineQuery(string text)
{
    string elasticSearchQuery = System.IO.File.ReadAllText($"Queries/Line.json");
    dynamic workableElasticSearchQuery = JsonConvert.DeserializeObject(elasticSearchQuery);
    workableElasticSearchQuery.query.match_phrase_prefix.Line = text;

    return workableElasticSearchQuery.ToString();
}

private string GetPlayAndLineQuery(string play, string text)
{
    string elasticSearchQuery = System.IO.File.ReadAllText($"Queries/PlayAndLine.json");
    dynamic workableElasticSearchQuery = JsonConvert.DeserializeObject(elasticSearchQuery);
    workableElasticSearchQuery.query.@bool.must[0].match.Play = play;
    workableElasticSearchQuery.query.@bool.must[1].match_phrase_prefix.Line = text;

    return workableElasticSearchQuery.ToString();
}

The strings the above methods return are the queries that will be sent to ElasticSearch.

The below method makes the request, and deserializes the response into the ESResponse class. That class was generated by https://json2csharp.com/.

private ESResponse PerformQuery(string queryWithParams)
{
    var response = _lowLevelClient.Search<StringResponse>("shakespeare", queryWithParams);
    ESResponse esResponse = System.Text.Json.JsonSerializer.Deserialize<ESResponse>(response.Body);
    return esResponse;
}

You might have noticed that I use System.Text.Json and Newtonsoft Json, this is because System.Text.Json does not support dynamic deserialization, see this discussion – https://github.com/dotnet/runtime/issues/29690.

That’s it, searching, and parsing of ElasticSearch results via a Web API application, feels a bit messy, but hope it helps.

Full source code available here.

Working with JSON in .NET, Infrastructure as Code with Pulumi

Full source code available here.

This is a follow up to my previous post where I used dynamic and JSON files to make querying ElasticSearch with a HttpClient much easier.

To deploy my ElasticSearch domain on AWS I used Pulumi. ElasticSearch requires a JSON policy to define the permissions. In the post linked above, I have a heavily escaped that This policy can be complex and needs values substituted into it. In the example below I need to pass in the region, account id, domain name and allowed IP address.

Here is a very simple policy with four substitutions –

"{{
""Version"": ""2012-10-17"",
""Statement"": [
    {{
        ""Action"": ""es:*"",
        ""Principal"": {{
            ""AWS"": ""*""
        }},
        ""Effect"": ""Allow"",
        ""Resource"": ""arn:aws:es:{currentRegion.Name}:{currentCallerIdentity.AccountId}:domain/{esDomainName}/*"",
        ""Condition"": {{
            ""IpAddress"": {{""aws:SourceIp"": [""{myIPAddress}""]}}
        }}
    }}
]
}}"

Just escaping this is not easy, and very prone to error. A more realistic policy would be significantly longer and would need more substitutions.

Using a JSON file
Here is what I think is an easier way. As in the previous post, the JSON file becomes part of my source code. It is deserialized into a dynamic object and the required values are set.

Here is the AWS policy as it appears in my JSON file. The resource (made up of region, account, and domain name) and IpAddress are left blank, but the structure of the policy is the same as you would paste into the AWS console.

{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "AWS": "*"
        },
        "Action": "es:*",
        "Resource": "",
        "Condition": {
          "IpAddress": {
            "aws:SourceIp": ""
          }
        }
      }
    ]
}

In my C# I read the file, deserialize, and set the values with simple C#.

Here is an example –

private string GetAWSElasticSearchPolicy(string region, string account, string elasticSearchDomainName, string allowedIPAddress)
{
    string blankPolicy = File.ReadAllText("AWSPolicy.json");
    dynamic awsElasticSearchPolicy = JsonConvert.DeserializeObject(blankPolicy);

    awsElasticSearchPolicy.Statement[0].Resource = $"arn:aws:es:{region}:{account}:domain/{elasticSearchDomainName}/*";
    awsElasticSearchPolicy.Statement[0].Condition.IpAddress = new JObject(new JProperty("aws:SourceIp", allowedIPAddress));

    return awsElasticSearchPolicy.ToString(); // this is correctly formatted JSON that can be used with Pulumi.
}

Line 3 reads the JSON file into a string.
Line 4 turns the string into a dynamic object.
Lines 6 & 7 set the values I want.
Line 9 returns a nice JSON string that can be used with Pulumi.

This is much cleaner than the heavily escaped version in this post.

Full source code available here.

Working with JSON in .NET, a better way?

Full source code available here.

Two recent experiences with C# and JSON frustrated me with how difficult it is to work JSON inside an application. I have also been learning Node.js and contrasting the ease of use there with C# is, shocking. In C# the developer is generally expected to create class structures that represent the JSON they want to produce or consume and for most of my career that has been fine, I usually had to work on quite fixed JSON, with quite fixed classes.

An example might be JSON that represents customers, orders and order items. Easy enough to make C# classes that represent them, and it having classes means its is easy to work with the customer, order or order item inside your code.

But more recently I have been working with ElasticSearch and Pulumi.

In the case of ElasticSearch, querying it is done through HTTP requests with complex JSON that can change significantly between requests. The JSON can be many layers deep and combine searching across multiple fields, sorting, paging, specifying fields to return, and other functionality.

Here is a simple query, I built this using Visual Studio Rest Client. To use this inside a C# application I have to escape all the “, {, and } characters and I have do it such a way that allows me substitute in the values I want.

This is the raw JSON –

{
    "query": {
        "match_phrase_prefix": {
            "fullName" : "Joe"
        }
    },
    "from": 0,
    "size": 2
}

Escaping and getting it to work with a request from HttpClient took a while, and to my mind it looks awful –

string query = @"
                {{
                    ""query"": {{
                        ""match_phrase_prefix"": {{
                            ""fullName"" : ""{0}""
                        }}
                    }},
                    ""from"": {1},
                    ""size"": {2}
                }}";

Here is a more realistic and not so complicated query with ElasticSearch, now try to escape that support substitutions for each value!

{
    "query":{
        "bool": {
            "must": [
                { "match": { "address.city": "New York" } }
               ,{ "match_phrase_prefix": { "lastName": "Sanders" } }
            ]
            ,"must_not": [
                {"range": {"dateOfBirth" : {"gte": "1980-01-01", "lte": "2000-01-01" }}}
            ]
        }
    }
    ,"sort": { "customerId" : {"order": "asc"} }
    ,"size": 100
    ,"from": 0 
    ,"_source": ["firstName", "lastName"]
}

You might rightly ask why I don’t use the provided libraries from the Elastic company. Well, I am working on a system that uses multiple languages, I do my experiments and testing with a HTTP client, and the last thing I want to do is convert everything from JSON to a significantly different formats for each programming language. JSON is also the first class citizen of ElasticSearch, I don’t want to find out later that the .NET client has not kept up with features provided by ElasticSearch. JSON is also very easy to share with colleagues.

What To Do
I am going to store my JSON in a file that becomes part of my source code, deserialize it into a dynamic object, set the values on the fields I want to change, serialize it back to a string and use that string in my requests. It is not as complicated as that might sound and way better than escaping the JSON.

Let’s take the first ElasticSearch query, here again is the raw JSON, I save it to file named ElasticSearchQuery.json.

{
  "query": {
      "match_phrase_prefix": {
          "fullName" : ""
      }
  },
  "from": 0,
  "size": 0
}

And here is how I read, set values and serialize it again –

private string GetElasticSearchQuery(string fullName, int from, int size)
{
    string elasticSearchQuery = File.ReadAllText("ElasticSearchQuery.json");
    dynamic workableElasticSearchQuery = JsonConvert.DeserializeObject(elasticSearchQuery);

    workableElasticSearchQuery.query.match_phrase_prefix.fullName = fullName;
    workableElasticSearchQuery.from = from;
    workableElasticSearchQuery.size = size;

    return workableElasticSearchQuery.ToString();
}

Line 3 reads the JSON file into a string.
Line 4 turns the string into a dynamic object.
Lines 6,7,8 set the values I want.
Line 10 returns a nice JSON string that can be used with a HttpClient to make request to ElasticSearch.

But some ElasticSearch queries are a little harder to work with because a query can include a bool. This example is in the file ElasticSearchQuery.json.

{
    "query": {
        "bool": {
            "must": [
                {"match_phrase_prefix": { "lastName" : "" } }
                ,{"match": { "address.state" : ""} } 
            ]
        }
    }
}

The dynamic object will not allow us to use “bool” because it is reserved word in C#, but you can put an “@” in front of it, and now it will work –

private string GetElasticSearchQuery2(string lastName, string state)
{
    string elasticSearchQuery2 = File.ReadAllText("ElasticSearchQuery2.json");
    dynamic workableElasticSearchQuery2 = JsonConvert.DeserializeObject(elasticSearchQuery2);

    workableElasticSearchQuery2.query.@bool.must[0].match_phrase_prefix.lastName = lastName;
    workableElasticSearchQuery2.query.@bool.must[1].match = new JObject(new JProperty("address.state", state));

    return workableElasticSearchQuery2.ToString();
}

And again the string produced can be used with a HttpClient.

Full source code available here.

Getting Started with ElasticSearch, Part 3 – Deploying to AWS with Pulumi

Full source code available here.

This is part 3 of my short introduction to ElasticSearch. In the first part I showed how to create an ElasticSearch index, mapping, and seeded it with data. In the second I used HttpClientFactory and a typed client to query the index. In this part I going to show you how to setup ElasticSearch in AWS using infrastructure as code. Be careful, AWS charges for these things.

A few months ago Pulumi added C# to their list of supported languages. If you haven’t heard of them, they are building a tool that lets you create the IaC in a familiar programming language, at the time of writing they support TypeScript, JavaScript, Python, Go and C#. Writing in a programming language makes it easy to work with things like loops and conditionals, if you are unfamiliar with IaC, those two simple things can be extremely challenging or impossible with other tools.

I’m going to write my IaC in C#.

I’m not going to walk you through installing Pulumi, their site has all the info you need for that.

The IaC Project
Once you have installed Pulimi and tested that the command works, create a new directory called ElasticSearchDeploy.

Change to that directory and run –

pulumi new aws-csharp

Follow the instructions and open the project in VS Code or Visual Studio.

Delete the MyStack.cs file.
Create a file named MyElasticSearchStack.cs.

Paste in the below code –

using Pulumi;
using ElasticSearch = Pulumi.Aws.ElasticSearch;
using Aws = Pulumi.Aws;
using Pulumi.Aws.ElasticSearch.Inputs;

class MyElasticSearchStack : Stack
{
    public MyElasticSearchStack()
    {
        string myIPAddress = "x.x.x.x" you need to put your IP address here;
        string esDomainName = "myelasticesearch";
        var config = new Config();
        var currentRegion = Output.Create(Aws.GetRegion.InvokeAsync());
        var currentCallerIdentity = Output.Create(Aws.GetCallerIdentity.InvokeAsync());
        var esDomain = new ElasticSearch.Domain(esDomainName, new ElasticSearch.DomainArgs
        {
            DomainName = esDomainName,
            ClusterConfig = new ElasticSearch.Inputs.DomainClusterConfigArgs
            {
                InstanceType = "t2.small.elasticsearch",
            },
            EbsOptions = new DomainEbsOptionsArgs()
            {
                EbsEnabled = true,
                VolumeSize = 10,
                VolumeType = "gp2"
            },
            ElasticsearchVersion = "7.7",
            AccessPolicies = Output.Tuple(currentRegion, currentCallerIdentity).Apply(values =>
            {
                var currentRegion = values.Item1;
                var currentCallerIdentity = values.Item2;
                return @$"
                {{
                    ""Version"": ""2012-10-17"",
                    ""Statement"": [
                        {{
                            ""Action"": ""es:*"",
                            ""Principal"": {{
                                ""AWS"": ""*""
                            }},
                            ""Effect"": ""Allow"",
                            ""Resource"": ""arn:aws:es:{currentRegion.Name}:{currentCallerIdentity.AccountId}:domain/{esDomainName}/*"",
                            ""Condition"": {{
                                ""IpAddress"": {{""aws:SourceIp"": [""{myIPAddress}""]}}
                            }}
                        }}
                    ]
                    }}
                ";
            }),
        });
        this.ESDomainEndpoint =  esDomain.Endpoint;
    }
    [Output]
    public Output<string> ESDomainEndpoint { get; set; }
}

Note on line 10, you need to put in the IP address you are using. Checking this with a site like https://ipstack.com/.

In Program.cs change the reference my MyStack to MyElasticSearchStack.

That’s it.

Deploying
Go to the command line, run –

pulumi up

Select ‘yes’ and then wait about 10 to 15 minutes as AWS gets your ElasticSearch domain up and running. In the output of the command you willsee the url of the ElasticSearch domain you just created, use that in the scripts from part 1 of this series.

You can also go to the AWS console, you should see something like –

There you go – ElasticSearch index creating, seeding, querying, and infrastructure as code.

In a follow up post I’ll show you how to deploy ElasticSearch with Terraform.

The JSON Problem
For those of you that dislike horribly escaped blocks of JSON inside C#, as I do, I am working on a post that will make this much nicer to look at, and to work with.

Full source code available here.

Dependency Inject a Service from Startup back to Program in .Net Core 3.1

Full source code available here.

Over the past couple of years I wrote a few posts about Dependency Injection in .Net Core 2.1, and this week I received comments from a reader telling me that some of the changes in .Net Core 3.1 mean that some of the approaches no longer work. There have been breaking changes https://docs.microsoft.com/en-us/dotnet/core/compatibility/2.2-3.1#hosting-generic-host-restricts-startup-constructor-injection

I wanted to see what would still work so I tried a few things.

You can no longer DI from Program in Startup.

But you can add a transient service and/or a singleton service to the ServiceCollection and use it within Program, and the rest of the application. You can also add a scoped service to the ServiceCollection and use it within the Program, it’s a little different from using transient and singleton so I’ll cover it in the next post.
Here’s how to use transient and singletons inside Program.
Create two services, creatively named ServiceOne and ServiceTwo and have them implement interfaces.

public class ServiceOne : IServiceOne
{
	public static int staticCounter;

	public ServiceOne()
	{
		staticCounter++;
	}
   //snip…

public class ServiceTwo : IServiceTwo
{
	public static int staticCounter;

	public ServiceTwo()
	{
		staticCounter++;
	}
	//snip…

I added a static counter to make it easy to see how many times the constructor is called.
For the transient one I expect it to increment every time the service is injected, for the singleton I expect it to remain at 1 for the lifetime of the application.

In Program.cs I split up CreateHostBuilder(args).Build call from the subsequent .Run().

public static void Main(string[] args)
{
	IHost host = CreateHostBuilder(args).Build();
	DoSomethingWithTheTransientService(host.Services);
	DoSomethingWithTheSingletonService(host.Services);
	host.Run();
}

The CreateHostBuilder() method is not changed –

public static IHostBuilder CreateHostBuilder(string[] args) =>
	Host.CreateDefaultBuilder(args)
	.ConfigureWebHostDefaults(webBuilder =>
	{
		webBuilder.UseStartup<Startup>();
	});

And I have two methods that use the ServiceCollection to access the registered services –

private static void DoSomethingWithTheTransientService(IServiceProvider serviceProvider)
{
	Console.WriteLine("Calling the transient service");

	var serviceOne = serviceProvider.GetService<IServiceOne>();
	Console.WriteLine(serviceOne.StaticCounter());
	Console.WriteLine(serviceOne.GetHashCode());
}

private static void DoSomethingWithTheSingletonService(IServiceProvider serviceProvider)
{
	Console.WriteLine("Calling the singleton service");

	var serviceTwo = serviceProvider.GetService<IServiceTwo>();
	Console.WriteLine(serviceTwo.StaticCounter());
	Console.WriteLine(serviceTwo.GetHashCode());
}

To make what’s happening more obvious I added the services to the constructor call of the WeatherForecastController() and added the counter and hash codes to the data returned by the action method.

public class WeatherForecastController : ControllerBase
{
	private IServiceOne _serviceOne;
	private IServiceTwo _serviceTwo;
	public WeatherForecastController(IServiceOne serviceOne, IServiceTwo serviceTwo)
	{
		_serviceOne = serviceOne;
		_serviceTwo = serviceTwo;
	}

For completeness, here is the ConfigureServices method in Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<IServiceOne, ServiceOne>();
    services.AddSingleton<IServiceTwo, ServiceTwo>();
    services.AddControllers();
}

Put some breakpoints in Program Main(), DoSomethingWithTheTransientService(), DoSomethingWithTheSingletonService() and in the WeatherForecastController.Get().

Start the application and browse to http://localhost:5000/weatherforecast to see what happens.

I’m going to follow up on this post with a version that shows how to use scoped dependencies in Startup .NET 5.

Full source code available here.