-
Notifications
You must be signed in to change notification settings - Fork 480
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement Lambda response transformation to API Gateway format (#1927)
- Loading branch information
1 parent
f8577af
commit 1d5ce54
Showing
4 changed files
with
462 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/InvokeResponseExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
using System.Text.Json; | ||
using Amazon.Lambda.APIGatewayEvents; | ||
using Amazon.Lambda.Model; | ||
using Amazon.Lambda.TestTool.Models; | ||
|
||
/// <summary> | ||
/// Provides extension methods for converting Lambda InvokeResponse to API Gateway response types. | ||
/// </summary> | ||
public static class InvokeResponseExtensions | ||
{ | ||
/// <summary> | ||
/// Converts an Amazon Lambda InvokeResponse to an APIGatewayProxyResponse. | ||
/// </summary> | ||
/// <param name="invokeResponse">The InvokeResponse from a Lambda function invocation.</param> | ||
/// <param name="emulatorMode">The API Gateway emulator mode (Rest or Http).</param> | ||
/// <returns>An APIGatewayProxyResponse object.</returns> | ||
/// <remarks> | ||
/// If the response cannot be deserialized as an APIGatewayProxyResponse, it returns an error response. | ||
/// The error response differs based on the emulator mode: | ||
/// - For Rest mode: StatusCode 502 with a generic error message. | ||
/// - For Http mode: StatusCode 500 with a generic error message. | ||
/// </remarks> | ||
public static APIGatewayProxyResponse ToApiGatewayProxyResponse(this InvokeResponse invokeResponse, ApiGatewayEmulatorMode emulatorMode) | ||
{ | ||
if (emulatorMode == ApiGatewayEmulatorMode.HttpV2) | ||
{ | ||
throw new NotSupportedException("This function should only be used with Rest and Httpv1 emulator modes"); | ||
} | ||
|
||
using var reader = new StreamReader(invokeResponse.Payload); | ||
string responseJson = reader.ReadToEnd(); | ||
try | ||
{ | ||
return JsonSerializer.Deserialize<APIGatewayProxyResponse>(responseJson); | ||
} | ||
catch | ||
{ | ||
if (emulatorMode == ApiGatewayEmulatorMode.Rest) | ||
{ | ||
return new APIGatewayProxyResponse | ||
{ | ||
StatusCode = 502, | ||
Body = "{\"message\":\"Internal server error\"}", | ||
Headers = new Dictionary<string, string> | ||
{ | ||
{ "Content-Type", "application/json" } | ||
}, | ||
IsBase64Encoded = false | ||
}; | ||
} | ||
else | ||
{ | ||
return new APIGatewayProxyResponse | ||
{ | ||
StatusCode = 500, | ||
Body = "{\"message\":\"Internal Server Error\"}", | ||
Headers = new Dictionary<string, string> | ||
{ | ||
{ "Content-Type", "application/json" } | ||
}, | ||
IsBase64Encoded = false | ||
}; | ||
} | ||
} | ||
} | ||
|
||
/// <summary> | ||
/// Converts an Amazon Lambda InvokeResponse to an APIGatewayHttpApiV2ProxyResponse. | ||
/// </summary> | ||
/// <param name="invokeResponse">The InvokeResponse from a Lambda function invocation.</param> | ||
/// <returns>An APIGatewayHttpApiV2ProxyResponse object.</returns> | ||
/// <remarks> | ||
/// This method reads the payload from the InvokeResponse and passes it to ToHttpApiV2Response | ||
/// for further processing and conversion. | ||
/// </remarks> | ||
public static APIGatewayHttpApiV2ProxyResponse ToApiGatewayHttpApiV2ProxyResponse(this InvokeResponse invokeResponse) | ||
{ | ||
using var reader = new StreamReader(invokeResponse.Payload); | ||
string responseJson = reader.ReadToEnd(); | ||
return ToHttpApiV2Response(responseJson); | ||
} | ||
|
||
/// <summary> | ||
/// Converts a response string to an APIGatewayHttpApiV2ProxyResponse. | ||
/// </summary> | ||
/// <param name="response">The response string to convert.</param> | ||
/// <returns>An APIGatewayHttpApiV2ProxyResponse object.</returns> | ||
/// <remarks> | ||
/// This method replicates the observed behavior of API Gateway's HTTP API | ||
/// with Lambda integrations using payload format version 2.0, which differs | ||
/// from the official documentation. | ||
/// | ||
/// Observed behavior: | ||
/// 1. If the response is a JSON object with a 'statusCode' property: | ||
/// - It attempts to deserialize it as a full APIGatewayHttpApiV2ProxyResponse. | ||
/// - If deserialization fails, it returns a 500 Internal Server Error. | ||
/// 2. For any other response (including non-JSON strings, invalid JSON, or partial JSON): | ||
/// - Sets statusCode to 200 | ||
/// - Uses the response as-is for the body | ||
/// - Sets Content-Type to application/json | ||
/// - Sets isBase64Encoded to false | ||
/// | ||
/// This behavior contradicts the official documentation, which states: | ||
/// "If your Lambda function returns valid JSON and doesn't return a statusCode, | ||
/// API Gateway assumes a 200 status code and treats the entire response as the body." | ||
/// | ||
/// In practice, API Gateway does not validate the JSON. It treats any response | ||
/// without a 'statusCode' property as a raw body, regardless of whether it's | ||
/// valid JSON or not. | ||
/// | ||
/// For example, if a Lambda function returns: | ||
/// '{"name": "John Doe", "age":' | ||
/// API Gateway will treat this as a raw string body in a 200 OK response, not attempting | ||
/// to parse or validate the JSON structure. | ||
/// | ||
/// This method replicates this observed behavior rather than the documented behavior. | ||
/// </remarks> | ||
private static APIGatewayHttpApiV2ProxyResponse ToHttpApiV2Response(string response) | ||
{ | ||
try | ||
{ | ||
// Try to deserialize as JsonElement first to inspect the structure | ||
var jsonElement = JsonSerializer.Deserialize<JsonElement>(response); | ||
|
||
// Check if it's an object that might represent a full response | ||
if (jsonElement.ValueKind == JsonValueKind.Object && | ||
jsonElement.TryGetProperty("statusCode", out _)) | ||
{ | ||
// It has a statusCode property, so try to deserialize as full response | ||
try | ||
{ | ||
return JsonSerializer.Deserialize<APIGatewayHttpApiV2ProxyResponse>(response); | ||
} | ||
catch | ||
{ | ||
// If deserialization fails, return Internal Server Error | ||
return new APIGatewayHttpApiV2ProxyResponse | ||
{ | ||
StatusCode = 500, | ||
Body = "{\"message\":\"Internal Server Error\"}", | ||
Headers = new Dictionary<string, string> | ||
{ | ||
{ "Content-Type", "application/json" } | ||
}, | ||
IsBase64Encoded = false | ||
}; | ||
} | ||
} | ||
} | ||
catch | ||
{ | ||
// If JSON parsing fails, fall through to default behavior | ||
} | ||
|
||
// Default behavior: return the response as-is | ||
return new APIGatewayHttpApiV2ProxyResponse | ||
{ | ||
StatusCode = 200, | ||
Body = response, | ||
Headers = new Dictionary<string, string> | ||
{ | ||
{ "Content-Type", "application/json" } | ||
}, | ||
IsBase64Encoded = false | ||
}; | ||
} | ||
|
||
} |
196 changes: 196 additions & 0 deletions
196
...tests/Amazon.Lambda.TestTool.IntegrationTests/InvokeResponseExtensionsIntegrationTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,196 @@ | ||
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
using Amazon.Lambda.APIGatewayEvents; | ||
using Amazon.Lambda.Model; | ||
using Amazon.Lambda.TestTool.Models; | ||
using System.Text; | ||
using System.Text.Json; | ||
|
||
namespace Amazon.Lambda.TestTool.IntegrationTests; | ||
|
||
/// <summary> | ||
/// Integration tests for InvokeResponseExtensions. | ||
/// </summary> | ||
/// <remarks> | ||
/// Developer's Note: | ||
/// These tests don't have direct access to the intermediate result of the Lambda to API Gateway conversion. | ||
/// Instead, we test the final API Gateway response object to ensure our conversion methods produce results | ||
/// that match the actual API Gateway behavior. This approach allows us to verify the correctness of our | ||
/// conversion methods within the constraints of not having access to AWS's internal conversion process. | ||
/// </remarks> | ||
[Collection("ApiGateway Integration Tests")] | ||
public class InvokeResponseExtensionsIntegrationTests | ||
{ | ||
private readonly ApiGatewayIntegrationTestFixture _fixture; | ||
|
||
public InvokeResponseExtensionsIntegrationTests(ApiGatewayIntegrationTestFixture fixture) | ||
{ | ||
_fixture = fixture; | ||
} | ||
|
||
[Theory] | ||
[InlineData(ApiGatewayEmulatorMode.Rest)] | ||
[InlineData(ApiGatewayEmulatorMode.HttpV1)] | ||
public async Task ToApiGatewayProxyResponse_ValidResponse_MatchesDirectConversion(ApiGatewayEmulatorMode emulatorMode) | ||
{ | ||
// Arrange | ||
var testResponse = new APIGatewayProxyResponse | ||
{ | ||
StatusCode = 200, | ||
Body = JsonSerializer.Serialize(new { message = "Hello, World!" }), | ||
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } } | ||
}; | ||
var invokeResponse = new InvokeResponse | ||
{ | ||
Payload = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testResponse))) | ||
}; | ||
|
||
// Act | ||
var convertedResponse = invokeResponse.ToApiGatewayProxyResponse(emulatorMode); | ||
|
||
// Assert | ||
var apiUrl = emulatorMode == ApiGatewayEmulatorMode.Rest | ||
? _fixture.ParseAndReturnBodyRestApiUrl | ||
: _fixture.ParseAndReturnBodyHttpApiV1Url; | ||
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, apiUrl, emulatorMode); | ||
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); | ||
} | ||
|
||
[Fact] | ||
public async Task ToApiGatewayHttpApiV2ProxyResponse_ValidResponse_MatchesDirectConversion() | ||
{ | ||
// Arrange | ||
var testResponse = new APIGatewayHttpApiV2ProxyResponse | ||
{ | ||
StatusCode = 200, | ||
Body = JsonSerializer.Serialize(new { message = "Hello, World!" }), | ||
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } } | ||
}; | ||
var invokeResponse = new InvokeResponse | ||
{ | ||
Payload = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testResponse))) | ||
}; | ||
|
||
// Act | ||
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse(); | ||
|
||
// Assert | ||
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url); | ||
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); | ||
} | ||
|
||
[Theory] | ||
[InlineData(ApiGatewayEmulatorMode.Rest, 502, "Internal server error")] | ||
[InlineData(ApiGatewayEmulatorMode.HttpV1, 500, "Internal Server Error")] | ||
public async Task ToApiGatewayProxyResponse_InvalidJson_ReturnsErrorResponse(ApiGatewayEmulatorMode emulatorMode, int expectedStatusCode, string expectedErrorMessage) | ||
{ | ||
// Arrange | ||
var invokeResponse = new InvokeResponse | ||
{ | ||
Payload = new MemoryStream(Encoding.UTF8.GetBytes("Not a valid proxy response object")) | ||
}; | ||
|
||
// Act | ||
var convertedResponse = invokeResponse.ToApiGatewayProxyResponse(emulatorMode); | ||
|
||
// Assert | ||
Assert.Equal(expectedStatusCode, convertedResponse.StatusCode); | ||
Assert.Contains(expectedErrorMessage, convertedResponse.Body); | ||
|
||
var apiUrl = emulatorMode == ApiGatewayEmulatorMode.Rest | ||
? _fixture.ParseAndReturnBodyRestApiUrl | ||
: _fixture.ParseAndReturnBodyHttpApiV1Url; | ||
var (actualResponse, _) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, apiUrl, emulatorMode); | ||
Assert.Equal(expectedStatusCode, (int)actualResponse.StatusCode); | ||
var content = await actualResponse.Content.ReadAsStringAsync(); | ||
Assert.Contains(expectedErrorMessage, content); | ||
} | ||
|
||
/// <summary> | ||
/// Tests various Lambda return values to verify API Gateway's handling of responses. | ||
/// </summary> | ||
/// <param name="responsePayload">The payload returned by the Lambda function.</param> | ||
/// <remarks> | ||
/// This test demonstrates a discrepancy between the official AWS documentation | ||
/// and the actual observed behavior of API Gateway HTTP API v2 with Lambda | ||
/// proxy integrations (payload format version 2.0). | ||
/// | ||
/// Official documentation states: | ||
/// "If your Lambda function returns valid JSON and doesn't return a statusCode, | ||
/// API Gateway assumes a 200 status code and treats the entire response as the body." | ||
/// | ||
/// However, the observed behavior (which this test verifies) is: | ||
/// - API Gateway does not validate whether the returned data is valid JSON. | ||
/// - Any response from the Lambda function that is not a properly formatted | ||
/// API Gateway response object (i.e., an object with a 'statusCode' property) | ||
/// is treated as a raw body in a 200 OK response. | ||
/// - This includes valid JSON objects without a statusCode, JSON arrays, | ||
/// primitive values, and invalid JSON strings. | ||
/// | ||
/// This test ensures that our ToApiGatewayHttpApiV2ProxyResponse method | ||
/// correctly replicates this observed behavior, rather than the documented behavior. | ||
/// </remarks> | ||
[Theory] | ||
[InlineData("{\"name\": \"John Doe\", \"age\":")] // Invalid JSON (partial object) | ||
[InlineData("{\"name\": \"John Doe\", \"age\": 30}")] // Valid JSON object without statusCode | ||
[InlineData("[1, 2, 3, 4, 5]")] // JSON array | ||
[InlineData("\"Hello, World!\"")] // String primitive | ||
[InlineData("42")] // Number primitive | ||
[InlineData("true")] // Boolean primitive | ||
public async Task ToApiGatewayHttpApiV2ProxyResponse_VariousPayloads_ReturnsAsRawBody(string responsePayload) | ||
{ | ||
// Arrange | ||
var invokeResponse = new InvokeResponse | ||
{ | ||
Payload = new MemoryStream(Encoding.UTF8.GetBytes(responsePayload)) | ||
}; | ||
|
||
// Act | ||
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse(); | ||
|
||
// Assert | ||
Assert.Equal(200, convertedResponse.StatusCode); | ||
Assert.Equal(responsePayload, convertedResponse.Body); | ||
Assert.Equal("application/json", convertedResponse.Headers["Content-Type"]); | ||
|
||
// Verify against actual API Gateway behavior | ||
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url); | ||
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); | ||
|
||
// Additional checks for API Gateway specific behavior | ||
Assert.Equal(200, (int)actualResponse.StatusCode); | ||
var content = await actualResponse.Content.ReadAsStringAsync(); | ||
Assert.Equal(responsePayload, content); | ||
Assert.Equal("application/json", actualResponse.Content.Headers.ContentType?.ToString()); | ||
} | ||
|
||
[Fact] | ||
public async Task ToApiGatewayHttpApiV2ProxyResponse_StatusCodeAsFloat_ReturnsInternalServerError() | ||
{ | ||
// Arrange | ||
var responsePayload = "{\"statusCode\": 200.5, \"body\": \"Hello\", \"headers\": {\"Content-Type\": \"text/plain\"}}"; | ||
var invokeResponse = new InvokeResponse | ||
{ | ||
Payload = new MemoryStream(Encoding.UTF8.GetBytes(responsePayload)) | ||
}; | ||
|
||
// Act | ||
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse(); | ||
|
||
// Assert | ||
Assert.Equal(500, convertedResponse.StatusCode); | ||
Assert.Equal("{\"message\":\"Internal Server Error\"}", convertedResponse.Body); | ||
Assert.Equal("application/json", convertedResponse.Headers["Content-Type"]); | ||
|
||
// Verify against actual API Gateway behavior | ||
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url); | ||
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); | ||
|
||
// Additional checks for API Gateway specific behavior | ||
Assert.Equal(500, (int)actualResponse.StatusCode); | ||
var content = await actualResponse.Content.ReadAsStringAsync(); | ||
Assert.Equal("{\"message\":\"Internal Server Error\"}", content); | ||
Assert.Equal("application/json", actualResponse.Content.Headers.ContentType?.ToString()); | ||
} | ||
} |
Oops, something went wrong.