diff --git a/.autover/changes/apigateway-websocket-aspnetcore.json b/.autover/changes/apigateway-websocket-aspnetcore.json new file mode 100644 index 000000000..2c8bc7952 --- /dev/null +++ b/.autover/changes/apigateway-websocket-aspnetcore.json @@ -0,0 +1,19 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.AspNetCoreServer", + "Type": "Minor", + "ChangelogMessages": [ + "Add APIGatewayWebsocketApiProxyFunction (and TStartup variant) so API Gateway WebSocket APIs can be hosted via LambdaServer (DI, controllers). WebSocket events are dispatched as POST requests whose path is the RouteKey, allowing controller actions like [HttpPost(\"$default\")] to handle them.", + "Expose ParseHttpPath, ParseHttpMethod, and AddMissingRequestHeaders as protected virtual hooks on APIGatewayProxyFunction so subclasses can customize how the API Gateway request is mapped onto the ASP.NET Core request feature." + ] + }, + { + "Name": "Amazon.Lambda.AspNetCoreServer.Hosting", + "Type": "Minor", + "ChangelogMessages": [ + "Add LambdaEventSource.WebsocketApi so AddAWSLambdaHosting can wire ASP.NET Core minimal APIs and controllers up to API Gateway WebSocket events." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs index c19229a4a..b99d62a86 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs @@ -464,4 +464,70 @@ protected override void PostMarshallItemsFeatureFeature(IItemsFeature aspNetCore } } } + + /// + /// IServer for handling Lambda events from an API Gateway Websocket API. + /// + public class APIGatewayWebsocketApiLambdaRuntimeSupportServer : APIGatewayRestApiLambdaRuntimeSupportServer + { + /// + /// 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 APIGatewayWebsocketMinimalApi(serviceProvider); +#pragma warning disable CA2252 + var hostingOptions = serviceProvider.GetService(); + handler.EnableResponseStreaming = hostingOptions?.EnableResponseStreaming ?? false; +#pragma warning restore CA2252 + Func> bufferedHandler = handler.FunctionHandlerAsync; + return HandlerWrapper.GetHandlerWrapper(bufferedHandler, Serializer); + } + + /// + /// MinimalApi variant of APIGatewayWebsocketApiProxyFunction. Reuses the REST API MinimalApi plumbing + /// (snapshot collectors, hosting options, PostMarshall callbacks) and applies the websocket-specific + /// request transformations on top. + /// + public class APIGatewayWebsocketMinimalApi : APIGatewayRestApiLambdaRuntimeSupportServer.APIGatewayRestApiMinimalApi + { + /// + /// Create instances + /// + /// The IServiceProvider created for the ASP.NET Core application + public APIGatewayWebsocketMinimalApi(IServiceProvider serviceProvider) + : base(serviceProvider) + { + } + + /// + protected override string ParseHttpPath(APIGatewayEvents.APIGatewayProxyRequest apiGatewayRequest) + => "/" + System.Net.WebUtility.UrlDecode(apiGatewayRequest.RequestContext.RouteKey ?? string.Empty); + + /// + protected override string ParseHttpMethod(APIGatewayEvents.APIGatewayProxyRequest apiGatewayRequest) => "POST"; + + /// + protected override Microsoft.AspNetCore.Http.IHeaderDictionary AddMissingRequestHeaders(APIGatewayEvents.APIGatewayProxyRequest apiGatewayRequest, Microsoft.AspNetCore.Http.IHeaderDictionary headers) + { + headers = base.AddMissingRequestHeaders(apiGatewayRequest, headers); + if (!headers.ContainsKey("Content-Type")) + { + headers["Content-Type"] = "application/json"; + } + return headers; + } + } + } } diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs index ed8d8ccf1..7471b22dd 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 WebSocket API + /// + WebsocketApi } /// @@ -167,6 +172,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 7f2a8b3b7..1398239de 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; @@ -194,33 +195,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); @@ -257,13 +234,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)) @@ -299,6 +270,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. @@ -380,5 +352,67 @@ protected override APIGatewayProxyResponse MarshallResponse(IHttpResponseFeature return response; } + + /// + /// Determines the path that should be assigned to for this request. + /// The default implementation honors {proxy+} resource templates and falls back to . + /// Subclasses can override to derive the path from a different source (e.g. websocket route keys). + /// + 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; + } + + /// + /// Determines the HTTP method that should be assigned to . + /// The default returns ; subclasses can override to force + /// a fixed method (e.g. websocket events are always exposed as POST). + /// + protected virtual string ParseHttpMethod(APIGatewayProxyRequest apiGatewayRequest) + { + return apiGatewayRequest.HttpMethod; + } + + /// + /// Adds any headers that API Gateway did not include but ASP.NET Core needs in order to route or bind the request. + /// The default implementation adds a synthesized Host header. Subclasses can override to add additional defaults + /// (e.g. a default Content-Type for websocket payloads). + /// + 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..2c5024c38 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction.cs @@ -0,0 +1,78 @@ +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 invoked by API Gateway Websocket APIs. + /// + /// Websocket events are surfaced as POST requests whose path is the RouteKey, so the same Lambda + /// should be referenced by every websocket route that has a matching ASP.NET Core controller route + /// (e.g. [HttpPost("$default")], [HttpPost("$connect")]) for the ASP.NET Core IServer + /// to successfully dispatch requests. + /// + public abstract class APIGatewayWebsocketApiProxyFunction : APIGatewayProxyFunction + { + /// + /// Default constructor. The ASP.NET Core framework is initialized as part of construction. + /// + protected APIGatewayWebsocketApiProxyFunction() + : base() + { + } + + /// + /// Constructor that lets the caller defer ASP.NET Core framework initialization until the first request. + /// + /// Configures when the ASP.NET Core framework is 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. + /// + /// The service provider built by the ASP.NET Core host. + protected APIGatewayWebsocketApiProxyFunction(IServiceProvider hostedServices) + : base(hostedServices) + { + } + + /// + /// Maps the websocket event to a request path of /{RouteKey} so ASP.NET Core can dispatch to a controller + /// route declared with [HttpPost("{RouteKey}")]. + /// + protected override string ParseHttpPath(APIGatewayProxyRequest apiGatewayRequest) + { + return "/" + Utilities.DecodeResourcePath(apiGatewayRequest.RequestContext.RouteKey); + } + + /// + /// Always returns POST for websocket events. Combined with , this lets the same + /// Lambda route every websocket event into an ASP.NET Core controller action. + /// + protected override string ParseHttpMethod(APIGatewayProxyRequest apiGatewayRequest) + { + return "POST"; + } + + /// + /// Adds a default Content-Type of application/json when API Gateway did not supply one. + /// Websocket message payloads are typically JSON, but the gateway does not set headers automatically. + /// + protected override IHeaderDictionary AddMissingRequestHeaders(APIGatewayProxyRequest apiGatewayRequest, IHeaderDictionary headers) + { + headers = base.AddMissingRequestHeaders(apiGatewayRequest, headers); + if (!headers.ContainsKey("Content-Type")) + { + 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..c264483c4 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayWebsocketApiProxyFunction{TStartup}.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Hosting; +using System.Diagnostics.CodeAnalysis; + +namespace Amazon.Lambda.AspNetCoreServer +{ + /// + /// Strongly-typed variant of that wires up an ASP.NET Core Startup class. + /// The Lambda function handler should point at the inherited 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 is initialized as part of construction. + /// + protected APIGatewayWebsocketApiProxyFunction() + : base() + { + } + + /// + /// Constructor that lets the caller defer ASP.NET Core framework initialization until the first request. + /// + /// Configures when the ASP.NET Core framework is initialized. + protected APIGatewayWebsocketApiProxyFunction(StartupMode startupMode) + : base(startupMode) + { + } + + /// + protected override void Init(IWebHostBuilder builder) + { + builder.UseStartup(); + } + } +} 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..07d0551da --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayWebsocketApiCalls.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestUtilities; + +using TestWebApp; + +using Xunit; + + + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + public class TestApiGatewayWebsocketApiCalls + { + [Fact] + public async Task TestPostWithBody() + { + var response = await 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(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..20760653c --- /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