├── .github └── workflows │ └── ci.yml ├── .gitignore ├── HelloWorld.sln ├── README.md ├── serverless.yml ├── src └── HelloWorld │ ├── HelloWorld.csproj │ ├── Program.cs │ ├── Startup.cs │ ├── ValuesController.cs │ ├── ValuesService.cs │ ├── appsettings.json │ └── aws-lambda-tools-defaults.json └── test └── HelloWorld.Tests ├── HelloWorld.Tests.csproj └── TestValuesController.cs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: checkout 13 | uses: actions/checkout@v2.0.0 14 | 15 | - name: setup .net core 16 | uses: actions/setup-dotnet@v1.4.0 17 | with: 18 | dotnet-version: 3.1.100 19 | 20 | - name: install lambda tools 21 | run: dotnet tool install -g Amazon.Lambda.Tools 22 | 23 | - run: dotnet test 24 | 25 | - name: build 26 | run: dotnet lambda package --msbuild-parameters "/p:PublishReadyToRun=true --self-contained false" 27 | working-directory: src/HelloWorld 28 | 29 | - name: aws login 30 | if: github.ref == 'refs/heads/master' 31 | uses: aws-actions/configure-aws-credentials@v1 32 | with: 33 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 34 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 35 | aws-region: us-east-1 36 | 37 | - name: Deploy 38 | if: github.ref == 'refs/heads/master' 39 | uses: docker://glassechidna/stackit 40 | with: 41 | args: stackit up --stack-name hello-world-app --template serverless.yml 42 | 43 | resharper: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: checkout 47 | uses: actions/checkout@v2 48 | 49 | - name: resharper 50 | uses: glassechidna/resharper-action@master 51 | with: 52 | solution: HelloWorld.sln 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/bin 2 | **/obj 3 | 4 | 5 | -------------------------------------------------------------------------------- /HelloWorld.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9A16544E-F5A7-4566-B239-1CC3DC3DD96E}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloWorld", "src\HelloWorld\HelloWorld.csproj", "{5BD04563-46D9-4D5B-BCE5-667789E2BBEA}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{114EAE3F-4A6E-4446-B4DE-73660768E258}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloWorld.Tests", "test\HelloWorld.Tests\HelloWorld.Tests.csproj", "{8251D2E8-2A62-4D65-89AD-F738437B91C0}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(SolutionProperties) = preSolution 24 | HideSolutionNode = FALSE 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|x64.ActiveCfg = Debug|Any CPU 30 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|x64.Build.0 = Debug|Any CPU 31 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|x86.ActiveCfg = Debug|Any CPU 32 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Debug|x86.Build.0 = Debug|Any CPU 33 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|x64.ActiveCfg = Release|Any CPU 36 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|x64.Build.0 = Release|Any CPU 37 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|x86.ActiveCfg = Release|Any CPU 38 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA}.Release|x86.Build.0 = Release|Any CPU 39 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|x64.Build.0 = Debug|Any CPU 43 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Debug|x86.Build.0 = Debug|Any CPU 45 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|x64.ActiveCfg = Release|Any CPU 48 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|x64.Build.0 = Release|Any CPU 49 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|x86.ActiveCfg = Release|Any CPU 50 | {8251D2E8-2A62-4D65-89AD-F738437B91C0}.Release|x86.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {5BD04563-46D9-4D5B-BCE5-667789E2BBEA} = {9A16544E-F5A7-4566-B239-1CC3DC3DD96E} 54 | {8251D2E8-2A62-4D65-89AD-F738437B91C0} = {114EAE3F-4A6E-4446-B4DE-73660768E258} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.Net Core 3.1 on AWS Lambda demo 2 | 3 |

4 | logo 5 |

6 | 7 | As of the end of March 2020, AWS Lambda [supports ASP.Net Core 3.1][lambda-support]. 8 | As of mid-March 2020, API Gateway [HTTP APIs become generally available][http-api-ga]. 9 | The combination of these two releases means that the best way (in my opinion!) of 10 | writing, deploying and running serverless web apps in the cloud is now even better. 11 | 12 | My favourite pattern for architecting a serverless .Net website is to put a regular 13 | ASP.Net Core website into a Lambda function wholesale. This means that developers 14 | can do local development, unit tests, integration tests the exact same way they 15 | know and love **and** take advantage of serverless infrastructure. 16 | 17 | This repo contains everything you need to take the standard ASP.Net Core "web API" 18 | template and continuously deploy it to AWS Lambda. Here's what's been added: 19 | 20 | ## Additions to standard template 21 | 22 | * [`.github/workflows/ci.yml`](.github/workflows/ci.yml): This is the [GitHub Actions][actions] 23 | pipeline for building and deploying this project to AWS Lambda. The steps are: 24 | 25 | * Setting up .Net SDK and AWS Lambda CLI 26 | * Run unit and integration tests 27 | * Run [ReSharper checks][resharper-action] and reports on PRs 28 | * Build and package app into a zip file suitable for upload to AWS 29 | * Log into AWS (this requires you to [configure AWS creds in GitHub][aws-action]) 30 | * Use CloudFormation to deploy the Lambda function and HTTP API 31 | 32 | * [`src/HelloWorld/Program.cs`](src/HelloWorld/Program.cs): This file has been 33 | refactored to support the slightly different way that an ASP.Net Core app is 34 | started in Lambda. You shouldn't need to touch this file at all, except for 35 | changing logging. 36 | 37 | * [`src/HelloWorld/Startup.cs`](src/HelloWorld/Startup.cs): The only change to 38 | this file is to add a (trivial) dependency-injected `IValuesService` to demonstrate 39 | integration testing in the test project. 40 | 41 | * [`test/HelloWorld.Tests/TestValuesController.cs`](test/HelloWorld.Tests/TestValuesController.cs): 42 | This file demonstrates [ASP.Net Core integration tests][anc-tests] in the style 43 | made possible by `Microsoft.AspNetCore.Mvc.Testing`. A mock `IValuesService` 44 | is injected. This shows that tests don't have to be written any differently 45 | just because the app is hosted in Lambda. 46 | 47 | * [`serverless.yml`](serverless.yml): This file contains the entirety of the 48 | serverless infrastructure needed to host the website. The key to the file's 49 | conciseness is the [`AWS::Serverless::Function`][sam-function] that can magic up 50 | an API. 51 | 52 | ## So what should I do? 53 | 54 | First, you'll want to create your own copy of this template repo by clicking 55 | this button on the top right of this page: 56 | 57 | Use this template 58 | 59 | Once your repo has been created, the first run in GitHub Actions will unfortunately 60 | fail because you haven't yet setup secrets. You'll want to follow [this AWS guide][aws-action] 61 | to setup your secrets in GitHub. You'll know it's done correctly when your secrets 62 | look like this: 63 | 64 | Example of well-configured secrets 65 | 66 | Finally, once your secrets are configured correctly your pipeline will run 67 | successfully. PRs have will run unit tests and building, but only the `master` 68 | branch will get deployed. To access your website, go to your Action's logs, 69 | click the arrow next to the _Deploy_ step and look for the `ApiUrl` output. It 70 | should look something like this: 71 | 72 | Example output 73 | 74 | You can then navigate to that URL in your browser - and add `/api/values` onto 75 | the end of the URL to see the fruits of your labour! 76 | 77 | [lambda-support]: https://aws.amazon.com/blogs/compute/announcing-aws-lambda-supports-for-net-core-3-1/ 78 | [http-api-ga]: https://aws.amazon.com/blogs/compute/building-better-apis-http-apis-now-generally-available/ 79 | [actions]: https://github.com/features/actions 80 | [aws-action]: https://github.com/aws-actions/configure-aws-credentials 81 | [anc-tests]: https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-3.1 82 | [sam-function]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html 83 | [resharper-action]: https://github.com/glassechidna/resharper-action 84 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | Transform: AWS::Serverless-2016-10-31 2 | 3 | Resources: 4 | Function: 5 | Type: AWS::Serverless::Function 6 | Properties: 7 | Handler: HelloWorld::HelloWorld.LambdaHandler::FunctionHandlerAsync 8 | CodeUri: src/HelloWorld/bin/Release/netcoreapp3.1/HelloWorld.zip 9 | Runtime: dotnetcore3.1 10 | MemorySize: 512 11 | Timeout: 20 12 | AutoPublishAlias: live 13 | Events: 14 | Api: 15 | Type: HttpApi 16 | Properties: 17 | TimeoutInMillis: 20000 18 | 19 | Outputs: 20 | ApiUrl: 21 | Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com 22 | Function: 23 | Value: !Ref Function 24 | -------------------------------------------------------------------------------- /src/HelloWorld/HelloWorld.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | true 5 | Lambda 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/HelloWorld/Program.cs: -------------------------------------------------------------------------------- 1 | using Amazon.Lambda.AspNetCoreServer; 2 | using Microsoft.AspNetCore; 3 | using Microsoft.AspNetCore.Hosting; 4 | 5 | namespace HelloWorld 6 | { 7 | public class Program 8 | { 9 | public static void Main(string[] args) 10 | { 11 | CreateWebHostBuilder(args).Build().Run(); 12 | } 13 | 14 | // Microsoft.AspNetCore.Mvc.Testing wants the method below to have this exact name 15 | public static IWebHostBuilder CreateWebHostBuilder(string[] args) 16 | { 17 | return WebHost. 18 | CreateDefaultBuilder(args). 19 | // maybe you'll want to add your own logging configuration here (e.g. Serilog, etc) 20 | UseStartup(); 21 | } 22 | } 23 | 24 | // On Lambda, Program.Main is **not** executed. Instead, Lambda loads this DLL 25 | // into its own app and uses the following class to translate from the Lambda 26 | // protocol to the standard ASP.Net Core web host and middleware pipeline. 27 | public class LambdaHandler : APIGatewayHttpApiV2ProxyFunction 28 | { 29 | protected override IWebHostBuilder CreateWebHostBuilder() 30 | { 31 | return Program.CreateWebHostBuilder(null); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/HelloWorld/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace HelloWorld 8 | { 9 | public class Startup 10 | { 11 | public Startup(IConfiguration configuration) 12 | { 13 | Configuration = configuration; 14 | } 15 | 16 | public static IConfiguration Configuration { get; private set; } 17 | 18 | public void ConfigureServices(IServiceCollection services) 19 | { 20 | services.AddControllers(); 21 | 22 | services.AddSingleton(); 23 | } 24 | 25 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 26 | { 27 | if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); 28 | 29 | app.UseHttpsRedirection(); 30 | app.UseRouting(); 31 | app.UseAuthorization(); 32 | 33 | app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/HelloWorld/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace HelloWorld 6 | { 7 | [Route("api/[controller]")] 8 | public class ValuesController : ControllerBase 9 | { 10 | private readonly IValuesService _values; 11 | 12 | public ValuesController(IValuesService values) 13 | { 14 | _values = values; 15 | } 16 | 17 | // GET api/values 18 | [HttpGet] 19 | public IEnumerable Get() 20 | { 21 | return _values.GetValues(); 22 | } 23 | 24 | // GET api/values/5 25 | [HttpGet("{id}")] 26 | public string Get(int id) 27 | { 28 | return _values.GetValues().FirstOrDefault(); 29 | } 30 | 31 | // POST api/values 32 | [HttpPost] 33 | public void Post([FromBody] string value) 34 | { 35 | } 36 | 37 | // PUT api/values/5 38 | [HttpPut("{id}")] 39 | public void Put(int id, [FromBody] string value) 40 | { 41 | } 42 | 43 | // DELETE api/values/5 44 | [HttpDelete("{id}")] 45 | public void Delete(int id) 46 | { 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/HelloWorld/ValuesService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace HelloWorld 4 | { 5 | public interface IValuesService 6 | { 7 | IEnumerable GetValues(); 8 | } 9 | 10 | public class ValuesService : IValuesService 11 | { 12 | public IEnumerable GetValues() 13 | { 14 | return new List 15 | { 16 | "value1", 17 | "value2" 18 | }; 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/HelloWorld/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/HelloWorld/aws-lambda-tools-defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "configuration": "Release", 3 | "framework": "netcoreapp3.1" 4 | } -------------------------------------------------------------------------------- /test/HelloWorld.Tests/HelloWorld.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | False 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/HelloWorld.Tests/TestValuesController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc.Testing; 4 | using Microsoft.AspNetCore.TestHost; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Xunit; 7 | 8 | namespace HelloWorld.Tests 9 | { 10 | public class TestValuesController : IClassFixture> 11 | { 12 | public TestValuesController(WebApplicationFactory factory) 13 | { 14 | _factory = factory; 15 | } 16 | 17 | private readonly WebApplicationFactory _factory; 18 | 19 | [Fact] 20 | public async Task TestSomeMoo() 21 | { 22 | using var client = _factory.WithWebHostBuilder(builder => 23 | { 24 | builder.ConfigureTestServices(services => 25 | { 26 | services.AddSingleton(); 27 | }); 28 | }).CreateClient(); 29 | 30 | var index = await client.GetStringAsync("/api/values"); 31 | Assert.Equal("[\"value1\"]", index); 32 | } 33 | } 34 | 35 | public class TestValuesService : IValuesService 36 | { 37 | public IEnumerable GetValues() 38 | { 39 | return new List {"value1"}; 40 | } 41 | } 42 | } --------------------------------------------------------------------------------