diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs index 05493e244..400c60cdb 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs @@ -175,4 +175,45 @@ public ApplicationLoadBalancerMinimalApi(IServiceProvider serviceProvider) } } } + + /// + /// IServer for handlying Lambda events from an API Gateway Websocket API. + /// + public class APIGatewayWebsocketApiLambdaRuntimeSupportServer : LambdaRuntimeSupportServer + { + /// + /// Create instances + /// + /// The IServiceProvider created for the ASP.NET Core application + public APIGatewayWebsocketApiLambdaRuntimeSupportServer(IServiceProvider serviceProvider) + : base(serviceProvider) + { + } + + /// + /// Creates HandlerWrapper for processing events from API Gateway Websocket API + /// + /// + /// + protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider) + { + var handler = new APIGatewayWebsocketApiMinimalApi(serviceProvider).FunctionHandlerAsync; + return HandlerWrapper.GetHandlerWrapper(handler, this.Serializer); + } + + /// + /// Create the APIGatewayWebsocketApiV2ProxyFunction passing in the ASP.NET Core application's IServiceProvider + /// + public class APIGatewayWebsocketApiMinimalApi : APIGatewayWebsocketApiProxyFunction + { + /// + /// Create instances + /// + /// The IServiceProvider created for the ASP.NET Core application + public APIGatewayWebsocketApiMinimalApi(IServiceProvider serviceProvider) + : base(serviceProvider) + { + } + } + } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs index 82fa10376..697becb43 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs @@ -27,7 +27,12 @@ public enum LambdaEventSource /// /// ELB Application Load Balancer /// - ApplicationLoadBalancer + ApplicationLoadBalancer, + + /// + /// API Gateway HTTP API + /// + WebsocketApi } /// @@ -106,6 +111,7 @@ private static bool TryLambdaSetup(IServiceCollection services, LambdaEventSourc LambdaEventSource.HttpApi => typeof(APIGatewayHttpApiV2LambdaRuntimeSupportServer), LambdaEventSource.RestApi => typeof(APIGatewayRestApiLambdaRuntimeSupportServer), LambdaEventSource.ApplicationLoadBalancer => typeof(ApplicationLoadBalancerLambdaRuntimeSupportServer), + LambdaEventSource.WebsocketApi => typeof(APIGatewayWebsocketApiLambdaRuntimeSupportServer), _ => throw new ArgumentException($"Event source type {eventSource} unknown") }; diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs index 841b3b1d5..3b897f921 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using System.Text; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; @@ -151,33 +152,9 @@ protected override void MarshallRequest(InvokeFeatures features, APIGatewayProxy { var requestFeatures = (IHttpRequestFeature)features; requestFeatures.Scheme = "https"; - requestFeatures.Method = apiGatewayRequest.HttpMethod; + requestFeatures.Method = this.ParseHttpMethod(apiGatewayRequest); - string path = null; - - // Replaces {proxy+} in path, if exists - if (apiGatewayRequest.PathParameters != null && apiGatewayRequest.PathParameters.TryGetValue("proxy", out var proxy) && - !string.IsNullOrEmpty(apiGatewayRequest.Resource)) - { - var proxyPath = proxy; - path = apiGatewayRequest.Resource.Replace("{proxy+}", proxyPath); - - // Adds all the rest of non greedy parameters in apiGateway.Resource to the path - foreach (var pathParameter in apiGatewayRequest.PathParameters.Where(pp => pp.Key != "proxy")) - { - path = path.Replace($"{{{pathParameter.Key}}}", pathParameter.Value); - } - } - - if (string.IsNullOrEmpty(path)) - { - path = apiGatewayRequest.Path; - } - - if (!path.StartsWith("/")) - { - path = "/" + path; - } + string path = this.ParseHttpPath(apiGatewayRequest); var rawQueryString = Utilities.CreateQueryStringParameters( apiGatewayRequest.QueryStringParameters, apiGatewayRequest.MultiValueQueryStringParameters, true); @@ -214,13 +191,7 @@ protected override void MarshallRequest(InvokeFeatures features, APIGatewayProxy Utilities.SetHeadersCollection(requestFeatures.Headers, apiGatewayRequest.Headers, apiGatewayRequest.MultiValueHeaders); - if (!requestFeatures.Headers.ContainsKey("Host")) - { - var apiId = apiGatewayRequest?.RequestContext?.ApiId ?? ""; - var stage = apiGatewayRequest?.RequestContext?.Stage ?? ""; - - requestFeatures.Headers["Host"] = $"apigateway-{apiId}-{stage}"; - } + requestFeatures.Headers = this.AddMissingRequestHeaders(apiGatewayRequest, requestFeatures.Headers); if (!string.IsNullOrEmpty(apiGatewayRequest.Body)) @@ -256,6 +227,7 @@ protected override void MarshallRequest(InvokeFeatures features, APIGatewayProxy { connectionFeatures.RemotePort = int.Parse(forwardedPort, CultureInfo.InvariantCulture); } + connectionFeatures.ConnectionId = apiGatewayRequest.RequestContext?.ConnectionId; // Call consumers customize method in case they want to change how API Gateway's request // was marshalled into ASP.NET Core request. @@ -335,5 +307,66 @@ protected override APIGatewayProxyResponse MarshallResponse(IHttpResponseFeature return response; } + + /// + /// Get the http path from the request. + /// + /// + /// string + protected virtual string ParseHttpPath(APIGatewayProxyRequest apiGatewayRequest) + { + string path = null; + + // Replaces {proxy+} in path, if exists + if (apiGatewayRequest.PathParameters != null && apiGatewayRequest.PathParameters.TryGetValue("proxy", out var proxy) && + !string.IsNullOrEmpty(apiGatewayRequest.Resource)) + { + var proxyPath = proxy; + path = apiGatewayRequest.Resource.Replace("{proxy+}", proxyPath); + + // Adds all the rest of non greedy parameters in apiGateway.Resource to the path + foreach (var pathParameter in apiGatewayRequest.PathParameters.Where(pp => pp.Key != "proxy")) + { + path = path.Replace($"{{{pathParameter.Key}}}", pathParameter.Value); + } + } + + if (string.IsNullOrEmpty(path)) + { + path = apiGatewayRequest.Path; + } + + if (!path.StartsWith("/")) + { + path = "/" + path; + } + return path; + } + + /// + /// Get the http method from the request. + /// + /// + /// string + protected virtual string ParseHttpMethod(APIGatewayProxyRequest apiGatewayRequest) + { + return apiGatewayRequest.HttpMethod; + } + + /// + /// Add missing headers to request. + /// + /// IHeaderDictionary + protected virtual IHeaderDictionary AddMissingRequestHeaders(APIGatewayProxyRequest apiGatewayRequest, IHeaderDictionary headers) + { + if (!headers.ContainsKey("Host")) + { + var apiId = apiGatewayRequest?.RequestContext?.ApiId ?? ""; + var stage = apiGatewayRequest?.RequestContext?.Stage ?? ""; + + headers["Host"] = $"apigateway-{apiId}-{stage}"; + } + return headers; + } } } diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction.cs new file mode 100644 index 000000000..0771c7866 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction.cs @@ -0,0 +1,70 @@ +using System; + +using Microsoft.AspNetCore.Http; + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.AspNetCoreServer.Internal; + +namespace Amazon.Lambda.AspNetCoreServer +{ + /// + /// Base class for ASP.NET Core Lambda functions that are getting request from API Gateway Websocket API V2 payload format. + /// + /// The http method is fixed as POST. Requests are handled using the RouteKey, so the same lambda should be referenced by multiple API Gateway routes for the ASP.NET Core IServer to successfully route requests. + /// + public abstract class APIGatewayWebsocketApiProxyFunction : APIGatewayProxyFunction + { + /// + /// Default constructor + /// + protected APIGatewayWebsocketApiProxyFunction() + : base() + { + + } + + /// + /// Configure when the ASP.NET Core framework will be initialized + protected APIGatewayWebsocketApiProxyFunction(StartupMode startupMode) + : base(startupMode) + { + + } + + /// + /// Constructor used by Amazon.Lambda.AspNetCoreServer.Hosting to support ASP.NET Core projects using the Minimal API style. + /// + /// + protected APIGatewayWebsocketApiProxyFunction(IServiceProvider hostedServices) + : base(hostedServices) + { + _hostServices = hostedServices; + } + + /// + /// + /// string + protected override string ParseHttpPath(APIGatewayProxyRequest apiGatewayRequest) + { + var path = "/" + Utilities.DecodeResourcePath(apiGatewayRequest.RequestContext.RouteKey); + return path; + } + + /// + /// + /// string + protected override string ParseHttpMethod(APIGatewayProxyRequest apiGatewayRequest) + { + return "POST"; + } + + /// + /// IHeaderDictionary + protected override IHeaderDictionary AddMissingRequestHeaders(APIGatewayProxyRequest apiGatewayRequest, IHeaderDictionary headers) + { + headers = base.AddMissingRequestHeaders(apiGatewayRequest, headers); + headers["Content-Type"] = "application/json"; + return headers; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction{TStartup}.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction{TStartup}.cs new file mode 100644 index 000000000..5d3e1b3a1 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction{TStartup}.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Hosting; +using System.Diagnostics.CodeAnalysis; + +namespace Amazon.Lambda.AspNetCoreServer +{ + /// + /// APIGatewayWebsocketApiV2ProxyFunction is the base class that is implemented in a ASP.NET Core Web API. The derived class implements + /// the Init method similar to Main function in the ASP.NET Core and provides typed Startup. The function handler for + /// the Lambda function will point to this base class FunctionHandlerAsync method. + /// + /// The type containing the startup methods for the application. + public abstract class APIGatewayWebsocketApiProxyFunction<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] TStartup> : APIGatewayWebsocketApiProxyFunction where TStartup : class + { + /// + /// Default Constructor. The ASP.NET Core Framework will be initialized as part of the construction. + /// + protected APIGatewayWebsocketApiProxyFunction() + : base() + { + + } + + + /// + /// + /// + /// Configure when the ASP.NET Core framework will be initialized + protected APIGatewayWebsocketApiProxyFunction(StartupMode startupMode) + : base(startupMode) + { + + } + + /// + protected override void Init(IWebHostBuilder builder) + { + builder.UseStartup(); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.sln b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.sln new file mode 100644 index 000000000..a183b9830 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Amazon.Lambda.AspNetCoreServer.Test", "Amazon.Lambda.AspNetCoreServer.Test.csproj", "{AE614E81-1148-41E0-9CA7-B1F3EB34B65E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE614E81-1148-41E0-9CA7-B1F3EB34B65E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE614E81-1148-41E0-9CA7-B1F3EB34B65E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE614E81-1148-41E0-9CA7-B1F3EB34B65E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE614E81-1148-41E0-9CA7-B1F3EB34B65E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FDFB946B-997E-4736-A826-7461752C8DF4} + EndGlobalSection +EndGlobal diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayWebsocketApiCalls.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayWebsocketApiCalls.cs new file mode 100644 index 000000000..e24860a0f --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayWebsocketApiCalls.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestUtilities; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using TestWebApp; + +using Xunit; + + + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + public class TestApiGatewayWebsocketApiCalls + { + [Fact] + public async Task TestPostWithBody() + { + var response = await this.InvokeAPIGatewayRequest("values-post-withbody-websocketapi-request.json"); + + Assert.Equal(200, response.StatusCode); + Assert.Equal("Agent, Smith", response.Body); + Assert.True(response.MultiValueHeaders.ContainsKey("Content-Type")); + Assert.Equal("text/plain; charset=utf-8", response.MultiValueHeaders["Content-Type"][0]); + } + + private async Task InvokeAPIGatewayRequest(string fileName, bool configureApiToReturnExceptionDetail = false) + { + return await InvokeAPIGatewayRequestWithContent(new TestLambdaContext(), GetRequestContent(fileName), configureApiToReturnExceptionDetail); + } + + private async Task InvokeAPIGatewayRequest(TestLambdaContext context, string fileName, bool configureApiToReturnExceptionDetail = false) + { + return await InvokeAPIGatewayRequestWithContent(context, GetRequestContent(fileName), configureApiToReturnExceptionDetail); + } + + private async Task InvokeAPIGatewayRequestWithContent(TestLambdaContext context, string requestContent, bool configureApiToReturnExceptionDetail = false) + { + var lambdaFunction = new TestWebApp.WebsocketLambdaFunction(); + if (configureApiToReturnExceptionDetail) + lambdaFunction.IncludeUnhandledExceptionDetailInResponse = true; + var requestStream = new MemoryStream(System.Text.UTF8Encoding.UTF8.GetBytes(requestContent)); + var request = new Amazon.Lambda.Serialization.SystemTextJson.LambdaJsonSerializer().Deserialize(requestStream); + + return await lambdaFunction.FunctionHandlerAsync(request, context); + } + + private string GetRequestContent(string fileName) + { + var filePath = Path.Combine(Path.GetDirectoryName(this.GetType().GetTypeInfo().Assembly.Location), fileName); + var requestStr = File.ReadAllText(filePath); + return requestStr; + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-post-withbody-websocketapi-request.json b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-post-withbody-websocketapi-request.json new file mode 100644 index 000000000..16ac75489 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-post-withbody-websocketapi-request.json @@ -0,0 +1,50 @@ +{ + "resource": null, + "path": null, + "httpMethod": null, + "headers": null, + "queryStringParameters": null, + "stageVariables": null, + "requestContext": { + "path": null, + "accountId": null, + "resourceId": null, + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "apiKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": null, + "user": null, + "ClientCert": null + }, + "resourcePath": null, + "httpMethod": null, + "apiId": "t2yh6sjnmk", + "extendedRequestId": "amJGnGBlIBMFiTw=", + "connectionId": "amJTNfeGLAMCLCQ=", + "ConnectedAt": 1725479956267, + "DomainName": "8d611s53xy.execute-api.us-east-1.amazonaws.com", + "DomainPrefix": null, + "EventType": "MESSAGE", + "MessageId": "amJTNfeGLAMCLCQ=", + "RouteKey": "$default", + "Authorizer": null, + "OperationName": null, + "Error": null, + "IntegrationLatency": null, + "MessageDirection": "IN", + "RequestTime": "04/Sep/2024:19:59:18 +0000", + "RequestTimeEpoch": 1725479958896, + "Status": null + }, + "body": "{\"firstName\":\"Smith\",\"lastName\": \"Agent\"}", + "isBase64Encoded": false +} \ No newline at end of file diff --git a/Libraries/test/TestWebApp/Controllers/RouteKeyController.cs b/Libraries/test/TestWebApp/Controllers/RouteKeyController.cs new file mode 100644 index 000000000..3328efa9b --- /dev/null +++ b/Libraries/test/TestWebApp/Controllers/RouteKeyController.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + + +namespace TestWebApp.Controllers +{ + [Route("/")] + public class RouteKeyController : Controller + { + [HttpPost("$default")] + public string PostBody([FromBody] Person body) + { + return $"{body.LastName}, {body.FirstName}"; + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + } + } +} diff --git a/Libraries/test/TestWebApp/WebsocketApiLambdaFunction.cs b/Libraries/test/TestWebApp/WebsocketApiLambdaFunction.cs new file mode 100644 index 000000000..105302c7f --- /dev/null +++ b/Libraries/test/TestWebApp/WebsocketApiLambdaFunction.cs @@ -0,0 +1,7 @@ +using Amazon.Lambda.AspNetCoreServer; +namespace TestWebApp +{ + public class WebsocketLambdaFunction : APIGatewayWebsocketApiProxyFunction + { + } +} \ No newline at end of file