From e5b40dc5a80dbe627ca0c1aaa5de5cee01c62818 Mon Sep 17 00:00:00 2001 From: Yancey Date: Fri, 27 Jan 2017 11:55:05 -0800 Subject: [PATCH] Modified APIGatewayProxyFunction to enhance error output for AggregateExceptions and TypeLoadExceptions. Refactored TestCallingWebAPI tests to use TestLambdaContext instead of null so that the APIGatewayProxyFunction class can assume ILambdaContext is never null. Also moved commonly called code into a single method. Created CustomAuthorizerContextOutput class for use by custom authorizers (since the serializer will serialize a null value). Modified ErrorTestsController to throw different exceptions based on the query string for testing custom error handling. --- ...APIGatewayCustomAuthorizerContextOutput.cs | 29 +++++ .../APIGatewayCustomAuthorizerResponse.cs | 2 +- .../APIGatewayProxyFunction.cs | 105 ++++++++++++------ .../TestCallingWebAPI.cs | 67 ++++++----- .../project.json | 7 +- ...-get-aggregateerror-apigatway-request.json | 36 ++++++ ...s-get-typeloaderror-apigatway-request.json | 36 ++++++ .../Controllers/ErrorTestsController.cs | 18 ++- 8 files changed, 227 insertions(+), 73 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerContextOutput.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-aggregateerror-apigatway-request.json create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-typeloaderror-apigatway-request.json diff --git a/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerContextOutput.cs b/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerContextOutput.cs new file mode 100644 index 000000000..d7dac18ed --- /dev/null +++ b/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerContextOutput.cs @@ -0,0 +1,29 @@ +namespace Amazon.Lambda.APIGatewayEvents +{ + using System.Runtime.Serialization; + + /// + /// An object representing the expected format of an API Gateway custom authorizer response. + /// + [DataContract] + public class APIGatewayCustomAuthorizerContextOutput + { + /// + /// Gets or sets the 'stringKey' property. + /// + [DataMember(Name = "stringKey", IsRequired = false)] + public string StringKey { get; set; } + + /// + /// Gets or sets the 'numKey' property. + /// + [DataMember(Name = "numKey", IsRequired = false)] + public int? NumKey { get; set; } + + /// + /// Gets or sets the 'boolKey' property. + /// + [DataMember(Name = "boolKey", IsRequired = false)] + public bool? BoolKey { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerResponse.cs b/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerResponse.cs index 51218d842..589e5ffd3 100644 --- a/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerResponse.cs +++ b/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayCustomAuthorizerResponse.cs @@ -24,6 +24,6 @@ public class APIGatewayCustomAuthorizerResponse /// Gets or sets the property. /// [DataMember(Name = "context")] - public APIGatewayCustomAuthorizerContext Context { get; set; } + public APIGatewayCustomAuthorizerContextOutput Context { get; set; } } } diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs index b894c25f1..abb50337c 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs @@ -1,19 +1,16 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using System.Text.Encodings.Web; - -using Amazon.Lambda.Core; -using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.AspNetCoreServer.Internal; - +using Amazon.Lambda.Core; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Internal; using Microsoft.AspNetCore.Http.Features; - -using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; namespace Amazon.Lambda.AspNetCoreServer { @@ -71,7 +68,7 @@ protected APIGatewayProxyFunction() [LambdaSerializerAttribute(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))] public virtual async Task FunctionHandlerAsync(APIGatewayProxyRequest request, ILambdaContext lambdaContext) { - lambdaContext?.Logger.Log($"Incoming {request.HttpMethod} requests to {request.Path}"); + lambdaContext.Logger.Log($"Incoming {request.HttpMethod} requests to {request.Path}"); InvokeFeatures features = new InvokeFeatures(); MarshallRequest(features, request); var context = this.CreateContext(features); @@ -93,41 +90,86 @@ protected HostingApplication.Context CreateContext(IFeatureCollection features) /// implementation. /// The hosting application request context object. /// An instance. - protected async Task ProcessRequest(ILambdaContext lambdaContext, HostingApplication.Context context, InvokeFeatures features) + /// + /// If specified, an unhandled exception will be rethrown for custom error handling. + /// Ensure that the error handling code calls 'this.MarshallResponse(features, 500);' after handling the error to return a to the user. + /// + protected async Task ProcessRequest(ILambdaContext lambdaContext, HostingApplication.Context context, InvokeFeatures features, bool rethrowUnhandledError = false) { var defaultStatusCode = 200; + Exception ex = null; try { await this._server.Application.ProcessRequestAsync(context); - this._server.Application.DisposeContext(context, null); + } + catch (AggregateException agex) + { + ex = agex; + lambdaContext.Logger.Log($"Caught AggregateException: '{agex}'"); + var sb = new StringBuilder(); + foreach (var newEx in agex.InnerExceptions) + { + sb.AppendLine(this.ErrorReport(newEx)); + } + + lambdaContext.Logger.Log(sb.ToString()); + defaultStatusCode = 500; + } + catch (ReflectionTypeLoadException rex) + { + ex = rex; + lambdaContext.Logger.Log($"Caught ReflectionTypeLoadException: '{rex}'"); + var sb = new StringBuilder(); + foreach (var loaderException in rex.LoaderExceptions) + { + var fileNotFoundException = loaderException as FileNotFoundException; + if (fileNotFoundException != null && !string.IsNullOrEmpty(fileNotFoundException.FileName)) + { + sb.AppendLine($"Missing file: {fileNotFoundException.FileName}"); + } + else + { + sb.AppendLine(this.ErrorReport(loaderException)); + } + } + + lambdaContext.Logger.Log(sb.ToString()); + defaultStatusCode = 500; } catch (Exception e) { - lambdaContext?.Logger.Log($"Unknown error responding to request: {this.ErrorReport(e)}"); - this._server.Application.DisposeContext(context, e); + ex = e; + if (rethrowUnhandledError) throw; + lambdaContext.Logger.Log($"Unknown error responding to request: {this.ErrorReport(e)}"); defaultStatusCode = 500; } + finally + { + this._server.Application.DisposeContext(context, ex); + } - var response = this.MarshallResponse(features); + var response = this.MarshallResponse(features, defaultStatusCode); - // ASP.NET Core Web API does not always set the status code if the request was - // successful - if (response.StatusCode == 0) - response.StatusCode = defaultStatusCode; + if (ex != null) + response.Headers.Add(new KeyValuePair("ErrorType", ex.GetType().Name)); return response; } - private string ErrorReport(Exception e) + /// + /// Formats an Exception into a string, including all inner exceptions. + /// + /// instance. + protected string ErrorReport(Exception e) { - StringBuilder sb = new StringBuilder(); + var sb = new StringBuilder(); + sb.AppendLine($"{e.GetType().Name}:\n{e}"); Exception inner = e; - while(inner != null) + while (inner != null) { - Console.WriteLine(inner.Message); - Console.WriteLine(inner.StackTrace); - + // Append the messages to the StringBuilder. + sb.AppendLine($"{inner.GetType().Name}:\n{inner}"); inner = inner.InnerException; } @@ -201,12 +243,13 @@ protected void MarshallRequest(IHttpRequestFeature requestFeatures, APIGatewayPr /// serialized into the JSON object that API Gateway expects. /// /// - /// - private APIGatewayProxyResponse MarshallResponse(IHttpResponseFeature responseFeatures) + /// Sometimes the ASP.NET server doesn't set the status code correctly when successful, so this parameter will be used when the value is 0. + /// + protected APIGatewayProxyResponse MarshallResponse(IHttpResponseFeature responseFeatures, int statusCodeIfNotSet = 200) { var response = new APIGatewayProxyResponse { - StatusCode = responseFeatures.StatusCode + StatusCode = responseFeatures.StatusCode != 0 ? responseFeatures.StatusCode : statusCodeIfNotSet }; if(responseFeatures.Headers != null) diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestCallingWebAPI.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestCallingWebAPI.cs index 9187fd53b..96c23f0a3 100644 --- a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestCallingWebAPI.cs +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestCallingWebAPI.cs @@ -1,15 +1,12 @@ -using System; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestUtilities; +using Newtonsoft.Json; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading.Tasks; - -using Amazon.Lambda.APIGatewayEvents; using TestWebApp; using Xunit; -using Newtonsoft.Json; - namespace Amazon.Lambda.AspNetCoreServer.Test { public class TestCallingWebAPI @@ -21,11 +18,7 @@ public TestCallingWebAPI() [Fact] public async Task TestGetAllValues() { - var lambdaFunction = new LambdaFunction(); - - var requestStr = File.ReadAllText("values-get-all-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + var response = await this.InvokeAPIGatewayRequest("values-get-all-apigatway-request.json"); Assert.Equal(response.StatusCode, 200); Assert.Equal("[\"value1\",\"value2\"]", response.Body); @@ -36,11 +29,7 @@ public async Task TestGetAllValues() [Fact] public async Task TestGetSingleValue() { - var lambdaFunction = new LambdaFunction(); - - var requestStr = File.ReadAllText("values-get-single-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + var response = await this.InvokeAPIGatewayRequest("values-get-single-apigatway-request.json"); Assert.Equal("value=5", response.Body); Assert.True(response.Headers.ContainsKey("Content-Type")); @@ -50,11 +39,7 @@ public async Task TestGetSingleValue() [Fact] public async Task TestGetQueryStringValue() { - var lambdaFunction = new LambdaFunction(); - - var requestStr = File.ReadAllText("values-get-querystring-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + var response = await this.InvokeAPIGatewayRequest("values-get-querystring-apigatway-request.json"); Assert.Equal("Lewis, Meriwether", response.Body); Assert.True(response.Headers.ContainsKey("Content-Type")); @@ -64,11 +49,7 @@ public async Task TestGetQueryStringValue() [Fact] public async Task TestPutWithBody() { - var lambdaFunction = new LambdaFunction(); - - var requestStr = File.ReadAllText("values-put-withbody-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + var response = await this.InvokeAPIGatewayRequest("values-put-withbody-apigatway-request.json"); Assert.Equal(200, response.StatusCode); Assert.Equal("Agent, Smith", response.Body); @@ -79,24 +60,29 @@ public async Task TestPutWithBody() [Fact] public async Task TestDefaultResponseErrorCode() { - var lambdaFunction = new LambdaFunction(); + var response = await this.InvokeAPIGatewayRequest("values-get-error-apigatway-request.json"); - var requestStr = File.ReadAllText("values-get-error-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + Assert.Equal(response.StatusCode, 500); + Assert.Equal(string.Empty, response.Body); + } + + [Theory] + [InlineData("values-get-aggregateerror-apigatway-request.json", "AggregateException")] + [InlineData("values-get-typeloaderror-apigatway-request.json", "ReflectionTypeLoadException")] + public async Task TestEnhancedExceptions(string requestFileName, string expectedExceptionType) + { + var response = await this.InvokeAPIGatewayRequest(requestFileName); Assert.Equal(response.StatusCode, 500); Assert.Equal(string.Empty, response.Body); + Assert.True(response.Headers.ContainsKey("ErrorType")); + Assert.Equal(expectedExceptionType, response.Headers["ErrorType"]); } [Fact] public async Task TestGettingSwaggerDefinition() { - var lambdaFunction = new LambdaFunction(); - - var requestStr = File.ReadAllText("swagger-get-apigatway-request.json"); - var request = JsonConvert.DeserializeObject(requestStr); - var response = await lambdaFunction.FunctionHandlerAsync(request, null); + var response = await this.InvokeAPIGatewayRequest("swagger-get-apigatway-request.json"); Assert.Equal(response.StatusCode, 200); Assert.True(response.Body.Length > 0); @@ -120,7 +106,7 @@ public void TestCustomAuthorizerSerialization() var response = new APIGatewayCustomAuthorizerResponse { PrincipalID = "com.amazon.someuser", - Context = new APIGatewayCustomAuthorizerContext + Context = new APIGatewayCustomAuthorizerContextOutput { StringKey = "Hey I'm a string", BoolKey = true, @@ -145,5 +131,14 @@ public void TestCustomAuthorizerSerialization() var expected = "{\"principalId\":\"com.amazon.someuser\",\"policyDocument\":{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"execute-api:Invoke\"],\"Resource\":[\"arn:aws:execute-api:us-west-2:1234567890:apit123d45/Prod/GET/*\"]}]},\"context\":{\"stringKey\":\"Hey I'm a string\",\"numKey\":9,\"boolKey\":true}}"; Assert.Equal(expected, json); } + + private async Task InvokeAPIGatewayRequest(string fileName) + { + var context = new TestLambdaContext(); + var lambdaFunction = new LambdaFunction(); + var requestStr = File.ReadAllText(fileName); + var request = JsonConvert.DeserializeObject(requestStr); + return await lambdaFunction.FunctionHandlerAsync(request, context); + } } } diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/project.json b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/project.json index c36ea99ff..93d4615b0 100644 --- a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/project.json +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/project.json @@ -1,4 +1,4 @@ -{ +{ "version": "1.0.0-*", "buildOptions": { "emitEntryPoint": false @@ -9,11 +9,10 @@ "type": "platform", "version": "1.0.1" }, - "TestWebApp": { "target": "project" }, - "xunit": "2.1.0-*", - "dotnet-test-xunit": "2.2.0-*" + "dotnet-test-xunit": "2.2.0-*", + "Amazon.Lambda.TestUtilities": "1.0.0" }, "testRunner": "xunit", diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-aggregateerror-apigatway-request.json b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-aggregateerror-apigatway-request.json new file mode 100644 index 000000000..811a992ae --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-aggregateerror-apigatway-request.json @@ -0,0 +1,36 @@ +{ + "resource": "/{proxy+}", + "path": "/api/errortests", + "httpMethod": "GET", + "headers": null, + "queryStringParameters": { + "id": "aggregate-test" + }, + "pathParameters": { + "proxy": "api/values" + }, + "stageVariables": null, + "requestContext": { + "accountId": "AAAAAAAAAAAA", + "resourceId": "5agfss", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": "AAAAAAAAAAAA", + "cognitoIdentityId": null, + "caller": "BBBBBBBBBBBB", + "apiKey": "test-invoke-api-key", + "sourceIp": "test-invoke-source-ip", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": "arn:aws:iam::AAAAAAAAAAAA:root", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "AAAAAAAAAAAA" + }, + "resourcePath": "/{proxy+}", + "httpMethod": "GET", + "apiId": "t2yh6sjnmk" + }, + "body": null +} \ No newline at end of file diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-typeloaderror-apigatway-request.json b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-typeloaderror-apigatway-request.json new file mode 100644 index 000000000..0b0c57256 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/values-get-typeloaderror-apigatway-request.json @@ -0,0 +1,36 @@ +{ + "resource": "/{proxy+}", + "path": "/api/errortests", + "httpMethod": "GET", + "headers": null, + "queryStringParameters": { + "id": "typeload-test" + }, + "pathParameters": { + "proxy": "api/values" + }, + "stageVariables": null, + "requestContext": { + "accountId": "AAAAAAAAAAAA", + "resourceId": "5agfss", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": "AAAAAAAAAAAA", + "cognitoIdentityId": null, + "caller": "BBBBBBBBBBBB", + "apiKey": "test-invoke-api-key", + "sourceIp": "test-invoke-source-ip", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": "arn:aws:iam::AAAAAAAAAAAA:root", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "AAAAAAAAAAAA" + }, + "resourcePath": "/{proxy+}", + "httpMethod": "GET", + "apiId": "t2yh6sjnmk" + }, + "body": null +} \ No newline at end of file diff --git a/Libraries/test/TestWebApp/Controllers/ErrorTestsController.cs b/Libraries/test/TestWebApp/Controllers/ErrorTestsController.cs index 6a01dde94..e1c1bd993 100644 --- a/Libraries/test/TestWebApp/Controllers/ErrorTestsController.cs +++ b/Libraries/test/TestWebApp/Controllers/ErrorTestsController.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Reflection; using Microsoft.AspNetCore.Mvc; namespace TestWebApp.Controllers @@ -9,7 +11,21 @@ public class ErrorTestsController [HttpGet] public string Get([FromQuery]string id) { - throw new Exception("Unit test exception, for test conditions."); + if (id == "typeload-test") + { + var fnfEx = new FileNotFoundException("Couldn't find file", "System.String.dll"); + throw new ReflectionTypeLoadException(new[] { typeof(String) }, new[] { fnfEx }); + } + + var ex = new Exception("Unit test exception, for test conditions."); + if (id == "aggregate-test") + { + throw new AggregateException(ex); + } + else + { + throw ex; + } } } }