diff --git a/AwsLambdaTestServer.sln b/AwsLambdaTestServer.sln index 408d7bb6..c5ae2924 100644 --- a/AwsLambdaTestServer.sln +++ b/AwsLambdaTestServer.sln @@ -65,6 +65,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\update-dotnet-sdk.yml = .github\workflows\update-dotnet-sdk.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalApi", "samples\MinimalApi\MinimalApi.csproj", "{D27E75B3-DAFF-485C-8D91-8ACF1190822A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalApi.Tests", "samples\MinimalApi.Tests\MinimalApi.Tests.csproj", "{9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -87,6 +91,14 @@ Global {AAEB2F8D-2B12-4253-801D-F19CBA89C905}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAEB2F8D-2B12-4253-801D-F19CBA89C905}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAEB2F8D-2B12-4253-801D-F19CBA89C905}.Release|Any CPU.Build.0 = Release|Any CPU + {D27E75B3-DAFF-485C-8D91-8ACF1190822A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D27E75B3-DAFF-485C-8D91-8ACF1190822A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D27E75B3-DAFF-485C-8D91-8ACF1190822A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D27E75B3-DAFF-485C-8D91-8ACF1190822A}.Release|Any CPU.Build.0 = Release|Any CPU + {9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -100,6 +112,8 @@ Global {50943DAF-B42F-433E-A60B-32DD432A803C} = {93C9B9F8-CDE1-4E8D-BBFD-D2EFEC0F209A} {AAEB2F8D-2B12-4253-801D-F19CBA89C905} = {93C9B9F8-CDE1-4E8D-BBFD-D2EFEC0F209A} {63888346-CEF4-442A-84BB-A07EA5E02FCC} = {331A4CDC-50D5-498B-AD21-3F6D60DBC4D1} + {D27E75B3-DAFF-485C-8D91-8ACF1190822A} = {93C9B9F8-CDE1-4E8D-BBFD-D2EFEC0F209A} + {9D598E1A-EA93-4B4B-BC08-E6C78A10B9F4} = {93C9B9F8-CDE1-4E8D-BBFD-D2EFEC0F209A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E88F7204-A559-4B27-8795-7CFE2500F0D3} diff --git a/Directory.Build.props b/Directory.Build.props index c0506ac3..b4088c93 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -38,8 +38,8 @@ snupkg true false - 0.5.0.0 - 0.5.1 + 0.6.0.0 + 0.6.0 beta$([System.Convert]::ToInt32(`$(GITHUB_RUN_NUMBER)`).ToString(`0000`)) $(GITHUB_REF.Replace('refs/tags/v', '')) diff --git a/Directory.Packages.props b/Directory.Packages.props index 66879bd5..41eaff46 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index c473b889..17e58f6d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AWS Lambda Test Server for .NET Core +# AWS Lambda Test Server for .NET [![NuGet](https://buildstats.info/nuget/MartinCostello.Testing.AwsLambdaTestServer?includePreReleases=true)](http://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer "Download MartinCostello.Testing.AwsLambdaTestServer from NuGet") @@ -8,7 +8,7 @@ A NuGet package that builds on top of the `TestServer` class in the [Microsoft.AspNetCore.TestHost](https://www.nuget.org/packages/Microsoft.AspNetCore.TestHost) NuGet package to provide infrastructure to use with end-to-end/integration tests of .NET Core 3.1 and .NET 6.0 AWS Lambda Functions using a custom runtime with the `LambdaBootstrap` class from the [Amazon.Lambda.RuntimeSupport](https://www.nuget.org/packages/Amazon.Lambda.RuntimeSupport/) NuGet package. -[_.NET Core 3.0 on Lambda with AWS Lambda’s Custom Runtime_](https://aws.amazon.com/blogs/developer/net-core-3-0-on-lambda-with-aws-lambdas-custom-runtime/ ".NET Core 3.0 on Lambda with AWS Lambda’s Custom Runtime on the AWS Developer Blog") +[_.NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime_](https://aws.amazon.com/blogs/developer/net-core-3-0-on-lambda-with-aws-lambdas-custom-runtime/ ".NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime on the AWS Developer Blog") ### Installation @@ -199,7 +199,7 @@ note over Test Method:Assert You can find examples of how to factor your Lambda function and how to test it: - 1. In the [samples](https://github.com/martincostello/lambda-test-server/tree/main/samples "Sample function and tests"); + 1. In the [samples](https://github.com/martincostello/lambda-test-server/tree/main/samples "Sample functions and tests"); 1. In the [unit tests](https://github.com/martincostello/lambda-test-server/blob/main/tests/AwsLambdaTestServer.Tests/Examples.cs "Unit test examples") for this project; 1. How I use the library in the tests for my own [Alexa skill](https://github.com/martincostello/alexa-london-travel/blob/e363ff77a1368e9da694c37fff33a1102ea6accf/test/LondonTravel.Skill.Tests/EndToEndTests.cs#L22 "Alexa London Travel's end-to-end tests"). @@ -419,6 +419,14 @@ Result StandardOutput: Request finished in 26.6306ms 204 ``` +#### Custom Lambda Server + +It is also possible to use `LambdaTestServer` with a custom [`IServer`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.hosting.server.iserver "IServer Interface on docs.microsoft.com") implementation by overriding the [`CreateServer()`](https://github.com/martincostello/lambda-test-server/blob/cd5e038660d6e607d06833c03a4a0e8740d643a2/src/AwsLambdaTestServer/LambdaTestServer.cs#L209-L217 "LambdaTestServer.CreateServer() method") method in a derived class. + +This can be used, for example, to host the Lambda test server in a real HTTP server that can be accessed remotely instead of being hosted in-memory with the [`TestServer`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.testhost.testserver "TestServer Class on docs.microsoft.com") class. + +For examples of this use case, see the `MinimalApi` example project and its test project in the [samples](https://github.com/martincostello/lambda-test-server/tree/main/samples "Sample functions and tests"). + ## Feedback Any feedback or issues can be added to the issues for this project in [GitHub](https://github.com/martincostello/lambda-test-server/issues "Issues for this project on GitHub.com"). @@ -433,7 +441,7 @@ This project is licensed under the [Apache 2.0](http://www.apache.org/licenses/L ## Building and Testing -Compiling the library yourself requires Git and the [.NET Core SDK](https://www.microsoft.com/net/download/core "Download the .NET Core SDK") to be installed (version `3.1.201` or later). +Compiling the library yourself requires Git and the [.NET SDK](https://dotnet.microsoft.com/en-us/download "Download the .NET SDK") to be installed (version `3.1.201` or later). To build and test the library locally from a terminal/command-line, run one of the following set of commands: diff --git a/build.ps1 b/build.ps1 index 09a34891..72c8a3c2 100755 --- a/build.ps1 +++ b/build.ps1 @@ -21,7 +21,8 @@ $libraryProject = Join-Path $solutionPath "src\AwsLambdaTestServer\MartinCostell $testProjects = @( (Join-Path $solutionPath "tests\AwsLambdaTestServer.Tests\MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj"), - (Join-Path $solutionPath "samples\MathsFunctions.Tests\MathsFunctions.Tests.csproj") + (Join-Path $solutionPath "samples\MathsFunctions.Tests\MathsFunctions.Tests.csproj"), + (Join-Path $solutionPath "samples\MinimalApi.Tests\MinimalApi.Tests.csproj") ) $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version diff --git a/samples/MathsFunctions.Tests/MathsFunctions.Tests.csproj b/samples/MathsFunctions.Tests/MathsFunctions.Tests.csproj index a56508f4..bea0627c 100644 --- a/samples/MathsFunctions.Tests/MathsFunctions.Tests.csproj +++ b/samples/MathsFunctions.Tests/MathsFunctions.Tests.csproj @@ -3,7 +3,7 @@ false $(NoWarn);CA1062;CA1707;CA2007;CA2234;SA1600 MathsFunctions - net6.0 + net6.0 diff --git a/samples/MinimalApi.Tests/ApiTests.cs b/samples/MinimalApi.Tests/ApiTests.cs new file mode 100644 index 00000000..a4e35380 --- /dev/null +++ b/samples/MinimalApi.Tests/ApiTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +using System.Net.Sockets; +using System.Reflection; +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; +using MartinCostello.Testing.AwsLambdaTestServer; +using Microsoft.AspNetCore.Http; + +namespace MinimalApi; + +public class ApiTests : IAsyncLifetime +{ + private readonly HttpLambdaTestServer _server; + + public ApiTests(ITestOutputHelper outputHelper) + { + _server = new() { OutputHelper = outputHelper }; + } + + public async Task DisposeAsync() + => await _server.DisposeAsync(); + + public async Task InitializeAsync() + => await _server.InitializeAsync(); + + [Fact(Timeout = 5_000)] + public async Task Can_Hash_String() + { + // Arrange + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + var body = new + { + algorithm = "sha256", + format = "base64", + plaintext = "ASP.NET Core", + }; + + var request = new APIGatewayProxyRequest() + { + Body = JsonSerializer.Serialize(body), + Headers = new Dictionary() + { + ["content-type"] = "application/json", + }, + HttpMethod = HttpMethods.Post, + Path = "/hash", + }; + + // Arrange + string json = JsonSerializer.Serialize(request, options); + + LambdaTestContext context = await _server.EnqueueAsync(json); + + using var cts = GetCancellationTokenSourceForResponseAvailable(context); + + // Act + _ = Task.Run( + () => + { + try + { + typeof(HashRequest).Assembly.EntryPoint!.Invoke(null, new[] { Array.Empty() }); + } + catch (Exception ex) when (LambdaServerWasShutDown(ex)) + { + // The Lambda runtime server was shut down + } + }, + cts.Token); + + // Assert + await context.Response.WaitToReadAsync(cts.IsCancellationRequested ? default : cts.Token); + + context.Response.TryRead(out LambdaTestResponse? response).ShouldBeTrue(); + response.IsSuccessful.ShouldBeTrue($"Failed to process request: {await response.ReadAsStringAsync()}"); + response.Duration.ShouldBeInRange(TimeSpan.Zero, TimeSpan.FromSeconds(2)); + response.Content.ShouldNotBeEmpty(); + + // Assert + var actual = JsonSerializer.Deserialize(response.Content, options); + + actual.ShouldNotBeNull(); + + actual.ShouldNotBeNull(); + actual.StatusCode.ShouldBe(StatusCodes.Status200OK); + actual.MultiValueHeaders.ShouldContainKey("Content-Type"); + actual.MultiValueHeaders["Content-Type"].ShouldBe(new[] { "application/json; charset=utf-8" }); + + var hash = JsonSerializer.Deserialize(actual.Body, options); + + hash.ShouldNotBeNull(); + hash.Hash.ShouldBe("XXE/IcKhlw/yjLTH7cCWPSr7JfOw5LuYXeBuE5skNfA="); + } + + private static CancellationTokenSource GetCancellationTokenSourceForResponseAvailable( + LambdaTestContext context, + TimeSpan? timeout = null) + { + if (timeout == null) + { + timeout = System.Diagnostics.Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(3); + } + + var cts = new CancellationTokenSource(timeout.Value); + + // Queue a task to stop the test server from listening as soon as the response is available + _ = Task.Run( + async () => + { + await context.Response.WaitToReadAsync(cts.Token); + + if (!cts.IsCancellationRequested) + { + cts.Cancel(); + } + }, + cts.Token); + + return cts; + } + + private static bool LambdaServerWasShutDown(Exception exception) + { + if (exception is not TargetInvocationException targetException || + targetException.InnerException is not HttpRequestException httpException || + httpException.InnerException is not SocketException socketException) + { + return false; + } + + return socketException.SocketErrorCode == SocketError.ConnectionRefused; + } +} diff --git a/samples/MinimalApi.Tests/HttpLambdaTestServer.cs b/samples/MinimalApi.Tests/HttpLambdaTestServer.cs new file mode 100644 index 00000000..d278cb14 --- /dev/null +++ b/samples/MinimalApi.Tests/HttpLambdaTestServer.cs @@ -0,0 +1,69 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +using MartinCostello.Logging.XUnit; +using MartinCostello.Testing.AwsLambdaTestServer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MinimalApi; + +internal sealed class HttpLambdaTestServer : LambdaTestServer, IAsyncLifetime, ITestOutputHelperAccessor +{ + private readonly CancellationTokenSource _cts = new(); + private bool _disposed; + private IWebHost? _webHost; + + public HttpLambdaTestServer() + : base() + { + } + + public ITestOutputHelper? OutputHelper { get; set; } + + public async Task DisposeAsync() + { + if (_webHost is not null) + { + await _webHost.StopAsync(); + } + + Dispose(); + } + + public async Task InitializeAsync() + => await StartAsync(_cts.Token); + + protected override IServer CreateServer(WebHostBuilder builder) + { + _webHost = builder + .UseKestrel() + .ConfigureServices((services) => services.AddLogging((builder) => builder.AddXUnit(this))) + .UseUrls("http://127.0.0.1:0") + .Build(); + + _webHost.Start(); + + return _webHost.Services.GetRequiredService(); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _webHost?.Dispose(); + + _cts.Cancel(); + _cts.Dispose(); + } + + _disposed = true; + } + + base.Dispose(disposing); + } +} diff --git a/samples/MinimalApi.Tests/MinimalApi.Tests.csproj b/samples/MinimalApi.Tests/MinimalApi.Tests.csproj new file mode 100644 index 00000000..baaf9a20 --- /dev/null +++ b/samples/MinimalApi.Tests/MinimalApi.Tests.csproj @@ -0,0 +1,22 @@ + + + false + $(NoWarn);CA1062;CA1707;CA2007;CA2234;SA1600 + MinimalApi + net6.0 + + + + + + + + + + + + + + + + diff --git a/samples/MinimalApi.Tests/xunit.runner.json b/samples/MinimalApi.Tests/xunit.runner.json new file mode 100644 index 00000000..1d280220 --- /dev/null +++ b/samples/MinimalApi.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "methodDisplay": "method" +} diff --git a/samples/MinimalApi/HashRequest.cs b/samples/MinimalApi/HashRequest.cs new file mode 100644 index 00000000..39368afe --- /dev/null +++ b/samples/MinimalApi/HashRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +namespace MinimalApi; + +public class HashRequest +{ + public string Algorithm { get; set; } = string.Empty; + + public string Format { get; set; } = string.Empty; + + public string Plaintext { get; set; } = string.Empty; +} diff --git a/samples/MinimalApi/HashResponse.cs b/samples/MinimalApi/HashResponse.cs new file mode 100644 index 00000000..4b1c0561 --- /dev/null +++ b/samples/MinimalApi/HashResponse.cs @@ -0,0 +1,9 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +namespace MinimalApi; + +public class HashResponse +{ + public string Hash { get; set; } = string.Empty; +} diff --git a/samples/MinimalApi/MinimalApi.csproj b/samples/MinimalApi/MinimalApi.csproj new file mode 100644 index 00000000..d3a6abef --- /dev/null +++ b/samples/MinimalApi/MinimalApi.csproj @@ -0,0 +1,10 @@ + + + $(NoWarn);CA1050;CA1812;CA2007;CA5350;CA5351;SA1600 + MinimalApi + net6.0 + + + + + diff --git a/samples/MinimalApi/Program.cs b/samples/MinimalApi/Program.cs new file mode 100644 index 00000000..452693c6 --- /dev/null +++ b/samples/MinimalApi/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Text; +using MinimalApi; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + +var app = builder.Build(); + +app.MapGet("/", () => "Hello World!"); + +app.MapPost("/hash", async (HttpRequest httpRequest) => +{ + var request = await httpRequest.ReadFromJsonAsync(); + + if (string.IsNullOrWhiteSpace(request?.Algorithm)) + { + return Results.Problem( + "No hash algorithm name specified.", + statusCode: StatusCodes.Status400BadRequest); + } + + if (string.IsNullOrWhiteSpace((string)request.Format)) + { + return Results.Problem( + "No hash output format specified.", + statusCode: StatusCodes.Status400BadRequest); + } + + bool? formatAsBase64 = request.Format.ToUpperInvariant() switch + { + "BASE64" => true, + "HEXADECIMAL" => false, + _ => null, + }; + + if (formatAsBase64 is null) + { + return Results.Problem( + $"The specified hash format '{request.Format}' is invalid.", + statusCode: StatusCodes.Status400BadRequest); + } + + const int MaxPlaintextLength = 4096; + + if (request.Plaintext?.Length > MaxPlaintextLength) + { + return Results.Problem( + $"The plaintext to hash cannot be more than {MaxPlaintextLength} characters in length.", + statusCode: StatusCodes.Status400BadRequest); + } + + byte[] buffer = Encoding.UTF8.GetBytes((string)(request.Plaintext ?? string.Empty)); + byte[] hash = request.Algorithm.ToUpperInvariant() switch + { + "MD5" => MD5.HashData(buffer), + "SHA1" => SHA1.HashData(buffer), + "SHA256" => SHA256.HashData(buffer), + "SHA384" => SHA384.HashData(buffer), + "SHA512" => SHA512.HashData(buffer), + _ => Array.Empty(), + }; + + if (hash.Length == 0) + { + return Results.Problem( + $"The specified hash algorithm '{request.Algorithm}' is not supported.", + statusCode: StatusCodes.Status400BadRequest); + } + + var result = new HashResponse() + { + Hash = formatAsBase64 == true ? Convert.ToBase64String(hash) : Convert.ToHexString(hash), + }; + + return Results.Json(result); +}); + +app.Run(); diff --git a/samples/MinimalApi/Properties/launchSettings.json b/samples/MinimalApi/Properties/launchSettings.json new file mode 100644 index 00000000..21f76ab2 --- /dev/null +++ b/samples/MinimalApi/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "profiles": { + "MinimalApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "MinimalApi.Lambda": { + "commandName": "Project", + "commandLineArgs": "", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AWS_LAMBDA_FUNCTION_NAME": "MinimalApi", + "AWS_LAMBDA_RUNTIME_API": "localhost:5050", + "AWS_PROFILE": "default", + "AWS_REGION": "eu-west-1" + }, + "applicationUrl": "http://localhost:5050/runtime" + }, + "Lambda Test Tool": { + "commandName": "Executable", + "commandLineArgs": "--port 5050", + "workingDirectory": ".\\bin\\$(Configuration)\\net6.0", + "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-6.0.exe" + } + } +} diff --git a/samples/MinimalApi/appsettings.Development.json b/samples/MinimalApi/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/MinimalApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/MinimalApi/appsettings.json b/samples/MinimalApi/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/MinimalApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AwsLambdaTestServer/LambdaTestServer.cs b/src/AwsLambdaTestServer/LambdaTestServer.cs index 81eee541..31729e3e 100644 --- a/src/AwsLambdaTestServer/LambdaTestServer.cs +++ b/src/AwsLambdaTestServer/LambdaTestServer.cs @@ -4,6 +4,8 @@ using System.Threading.Channels; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -20,7 +22,7 @@ public class LambdaTestServer : IDisposable private bool _disposed; private RuntimeHandler? _handler; private bool _isStarted; - private TestServer? _server; + private IServer? _server; private CancellationTokenSource? _onStopped; /// @@ -104,12 +106,19 @@ public void Dispose() /// /// The instance has been disposed. /// - public HttpClient CreateClient() + public virtual HttpClient CreateClient() { ThrowIfDisposed(); ThrowIfNotStarted(); - return _server!.CreateClient(); + if (_server is TestServer testServer) + { + return testServer.CreateClient(); + } + + var baseAddress = GetServerBaseAddress(); + + return new() { BaseAddress = baseAddress }; } /// @@ -132,7 +141,7 @@ public HttpClient CreateClient() /// public async Task EnqueueAsync(LambdaTestRequest request) { - if (request == null) + if (request is null) { throw new ArgumentNullException(nameof(request)); } @@ -176,17 +185,37 @@ public virtual Task StartAsync(CancellationToken cancellationToken = default) ConfigureWebHost(builder); - _server = new TestServer(builder); + _server = CreateServer(builder) ?? throw new InvalidOperationException($"No {nameof(IServer)} was returned by the {nameof(CreateServer)}() method."); - _handler.Logger = _server.Services.GetRequiredService>(); + Uri baseAddress; - SetLambdaEnvironmentVariables(_server.BaseAddress); + if (_server is TestServer testServer) + { + _handler.Logger = testServer.Services.GetRequiredService>(); + baseAddress = testServer.BaseAddress; + } + else + { + baseAddress = GetServerBaseAddress(); + } + + SetLambdaEnvironmentVariables(baseAddress); _isStarted = true; return Task.CompletedTask; } + /// + /// Creates the server to use for the Lambda runtime. + /// + /// The to use to create the server. + /// + /// The to use. + /// + protected virtual IServer CreateServer(WebHostBuilder builder) + => new TestServer(builder); + /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -257,7 +286,7 @@ protected virtual void ConfigureServices(IServiceCollection services) /// protected virtual void ConfigureWebHost(IWebHostBuilder builder) { - if (builder == null) + if (builder is null) { throw new ArgumentNullException(nameof(builder)); } @@ -290,11 +319,27 @@ private void ThrowIfDisposed() } } +#if NET5_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_server))] +#endif private void ThrowIfNotStarted() { - if (_server == null) + if (_server is null) { throw new InvalidOperationException("The test server has not been started."); } } + + private Uri GetServerBaseAddress() + { + var serverAddresses = _server!.Features.Get(); + var serverUrl = serverAddresses?.Addresses?.FirstOrDefault(); + + if (serverUrl is null) + { + throw new InvalidOperationException("No server addresses are available."); + } + + return new Uri(serverUrl, UriKind.Absolute); + } } diff --git a/src/AwsLambdaTestServer/PublicAPI.Shipped.txt b/src/AwsLambdaTestServer/PublicAPI.Shipped.txt index 2eeda6eb..88724d24 100644 --- a/src/AwsLambdaTestServer/PublicAPI.Shipped.txt +++ b/src/AwsLambdaTestServer/PublicAPI.Shipped.txt @@ -16,7 +16,6 @@ MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse.Duration.get -> Sy MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse.IsSuccessful.get -> bool MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponseExtensions MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer -MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.CreateClient() -> System.Net.Http.HttpClient! MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Dispose() -> void MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.EnqueueAsync(MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest! request) -> System.Threading.Tasks.Task! MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.IsStarted.get -> bool @@ -53,5 +52,7 @@ static MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerExtensions.Enq virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Configure(Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> void virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> void virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder! builder) -> void +virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.CreateClient() -> System.Net.Http.HttpClient! +virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.CreateServer(Microsoft.AspNetCore.Hosting.WebHostBuilder! builder) -> Microsoft.AspNetCore.Hosting.Server.IServer! virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Dispose(bool disposing) -> void virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.StartAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServer.cs b/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServer.cs new file mode 100644 index 00000000..f63e0259 --- /dev/null +++ b/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServer.cs @@ -0,0 +1,78 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +using MartinCostello.Logging.XUnit; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MartinCostello.Testing.AwsLambdaTestServer; + +internal sealed class HttpLambdaTestServer : LambdaTestServer, IAsyncLifetime, ITestOutputHelperAccessor +{ + private readonly CancellationTokenSource _cts = new(); + private bool _disposed; + private IWebHost? _webHost; + + public HttpLambdaTestServer() + : base() + { + } + + public HttpLambdaTestServer(Action configure) + : base(configure) + { + } + + public ITestOutputHelper? OutputHelper { get; set; } + + public async Task DisposeAsync() + { + if (_webHost is not null) + { + await _webHost.StopAsync(); + } + + Dispose(); + } + + public async Task InitializeAsync() + { + Options.Configure = (services) => + services.AddLogging((builder) => builder.AddXUnit(this)); + + await StartAsync(_cts.Token); + } + + protected override IServer CreateServer(WebHostBuilder builder) + { + _webHost = builder + .UseKestrel() + .ConfigureServices((services) => services.AddLogging((builder) => builder.AddXUnit(this))) + .UseUrls("http://127.0.0.1:0") + .Build(); + + _webHost.Start(); + + return _webHost.Services.GetRequiredService(); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _webHost?.Dispose(); + + _cts.Cancel(); + _cts.Dispose(); + } + + _disposed = true; + } + + base.Dispose(disposing); + } +} diff --git a/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServerTests.cs b/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServerTests.cs new file mode 100644 index 00000000..5055460d --- /dev/null +++ b/tests/AwsLambdaTestServer.Tests/HttpLambdaTestServerTests.cs @@ -0,0 +1,158 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +using System.Text; +using MartinCostello.Logging.XUnit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MartinCostello.Testing.AwsLambdaTestServer; + +[Collection(nameof(LambdaTestServerCollection))] +public class HttpLambdaTestServerTests : ITestOutputHelperAccessor +{ + public HttpLambdaTestServerTests(ITestOutputHelper outputHelper) + { + OutputHelper = outputHelper; + } + + public ITestOutputHelper? OutputHelper { get; set; } + + [Fact] + public async Task Function_Can_Process_Request() + { + // Arrange + void Configure(IServiceCollection services) + { + services.AddLogging((builder) => builder.AddXUnit(this)); + } + + using var server = new HttpLambdaTestServer(Configure); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + await server.StartAsync(cts.Token); + + var context = await server.EnqueueAsync(@"{""Values"": [ 1, 2, 3 ]}"); + + _ = Task.Run(async () => + { + await context.Response.WaitToReadAsync(cts.Token); + + if (!cts.IsCancellationRequested) + { + cts.Cancel(); + } + }); + + using var httpClient = server.CreateClient(); + + // Act + await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); + + // Assert + context.Response.TryRead(out var response).ShouldBeTrue(); + + response.ShouldNotBeNull(); + response!.IsSuccessful.ShouldBeTrue(); + response.Content.ShouldNotBeNull(); + response.Duration.ShouldBeGreaterThan(TimeSpan.Zero); + Encoding.UTF8.GetString(response.Content).ShouldBe(@"{""Sum"":6}"); + } + + [Fact] + public async Task Function_Can_Handle_Failed_Request() + { + // Arrange + void Configure(IServiceCollection services) + { + services.AddLogging((builder) => builder.AddXUnit(this)); + } + + using var server = new LambdaTestServer(Configure); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + await server.StartAsync(cts.Token); + + var context = await server.EnqueueAsync(@"{""Values"": null}"); + + _ = Task.Run(async () => + { + await context.Response.WaitToReadAsync(cts.Token); + + if (!cts.IsCancellationRequested) + { + cts.Cancel(); + } + }); + + using var httpClient = server.CreateClient(); + + // Act + await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); + + // Assert + context.Response.TryRead(out var response).ShouldBeTrue(); + + response.ShouldNotBeNull(); + response!.IsSuccessful.ShouldBeFalse(); + response.Content.ShouldNotBeNull(); + } + + [Fact] + public async Task Function_Can_Process_Multiple_Requests() + { + // Arrange + void Configure(IServiceCollection services) + { + services.AddLogging((builder) => builder.AddXUnit(this)); + } + + using var server = new LambdaTestServer(Configure); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + await server.StartAsync(cts.Token); + + var channels = new List<(int Expected, LambdaTestContext Context)>(); + + for (int i = 0; i < 10; i++) + { + var request = new MyRequest() + { + Values = Enumerable.Range(1, i + 1).ToArray(), + }; + + channels.Add((request.Values.Sum(), await server.EnqueueAsync(request))); + } + + _ = Task.Run(async () => + { + foreach ((var _, var context) in channels) + { + await context.Response.WaitToReadAsync(cts.Token); + } + + if (!cts.IsCancellationRequested) + { + cts.Cancel(); + } + }); + + using var httpClient = server.CreateClient(); + + // Act + await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); + + // Assert + foreach ((int expected, var context) in channels) + { + context.Response.TryRead(out var response).ShouldBeTrue(); + + response.ShouldNotBeNull(); + response!.IsSuccessful.ShouldBeTrue(); + response.Content.ShouldNotBeNull(); + + var deserialized = response.ReadAs(); + deserialized.Sum.ShouldBe(expected); + } + } +} diff --git a/tests/AwsLambdaTestServer.Tests/LambdaTestServerCollection.cs b/tests/AwsLambdaTestServer.Tests/LambdaTestServerCollection.cs new file mode 100644 index 00000000..91efdc26 --- /dev/null +++ b/tests/AwsLambdaTestServer.Tests/LambdaTestServerCollection.cs @@ -0,0 +1,9 @@ +// Copyright (c) Martin Costello, 2019. All rights reserved. +// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. + +namespace MartinCostello.Testing.AwsLambdaTestServer; + +[CollectionDefinition(nameof(LambdaTestServerCollection), DisableParallelization = true)] +public static class LambdaTestServerCollection +{ +} diff --git a/tests/AwsLambdaTestServer.Tests/LambdaTestServerTests.cs b/tests/AwsLambdaTestServer.Tests/LambdaTestServerTests.cs index 7f7dbc09..d7e74678 100644 --- a/tests/AwsLambdaTestServer.Tests/LambdaTestServerTests.cs +++ b/tests/AwsLambdaTestServer.Tests/LambdaTestServerTests.cs @@ -6,11 +6,16 @@ using Amazon.Lambda.RuntimeSupport; using MartinCostello.Logging.XUnit; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Moq; namespace MartinCostello.Testing.AwsLambdaTestServer; +[Collection(nameof(LambdaTestServerCollection))] public class LambdaTestServerTests : ITestOutputHelperAccessor { public LambdaTestServerTests(ITestOutputHelper outputHelper) @@ -125,6 +130,32 @@ public async Task StartAsync_Throws_If_Already_Started() await Assert.ThrowsAsync(async () => await target.StartAsync()); } + [Fact] + public async Task StartAsync_Throws_If_Server_Null() + { + // Arrange + using var target = new NullServerLambdaTestServer(); + + // Act and Assert + var exception = await Assert.ThrowsAsync(() => target.StartAsync()); + + target.IsStarted.ShouldBeFalse(); + exception.Message.ShouldBe("No IServer was returned by the CreateServer() method."); + } + + [Fact] + public async Task StartAsync_Throws_If_Server_Has_No_Addresses() + { + // Arrange + using var target = new NullServerAddressesLambdaTestServer(); + + // Act and Assert + var exception = await Assert.ThrowsAsync(() => target.StartAsync()); + + target.IsStarted.ShouldBeFalse(); + exception.Message.ShouldBe("No server addresses are available."); + } + [Fact] public async Task StartAsync_Throws_If_WebHostBuilder_Null() { @@ -509,4 +540,31 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) base.ConfigureWebHost(null!); } } + + private sealed class NullServerLambdaTestServer : LambdaTestServer + { + protected override IServer CreateServer(WebHostBuilder builder) => null!; + } + + private sealed class NullServerAddressesLambdaTestServer : LambdaTestServer + { + protected override IServer CreateServer(WebHostBuilder builder) + { + var serverAddresses = new Mock(); + + serverAddresses + .Setup((p) => p.Addresses) + .Returns(Array.Empty()); + + var server = new Mock(); + + var featureCollection = new FeatureCollection(); + + server + .Setup((p) => p.Features) + .Returns(featureCollection); + + return server.Object; + } + } } diff --git a/tests/AwsLambdaTestServer.Tests/MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj b/tests/AwsLambdaTestServer.Tests/MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj index 40e894fb..3858bc48 100644 --- a/tests/AwsLambdaTestServer.Tests/MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj +++ b/tests/AwsLambdaTestServer.Tests/MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj @@ -3,7 +3,7 @@ Tests for MartinCostello.Testing.AwsLambdaTestServer. false true - $(NoWarn);CA1062;CA1707;CA2007;CA2234;SA1600 + $(NoWarn);CA1062;CA1707;CA1711;CA2007;CA2234;SA1600 MartinCostello.Testing.AwsLambdaTestServer $(Description) net6.0 @@ -31,7 +31,7 @@ $(OutputPath)/ $(MSBuildThisFileDirectory) cobertura,json - [Amazon.Lambda*]*,[MathsFunctions*]*,[xunit.*]* + [Amazon.Lambda*]*,[MathsFunctions*]*,[MinimalApi*]*,[xunit.*]* System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute 87