Skip to content

Commit 1d5ce54

Browse files
Implement Lambda response transformation to API Gateway format (#1927)
1 parent f8577af commit 1d5ce54

File tree

4 files changed

+462
-1
lines changed

4 files changed

+462
-1
lines changed

Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<Description>A tool to help debug and test your .NET AWS Lambda functions locally.</Description>
@@ -15,6 +15,7 @@
1515
</PropertyGroup>
1616

1717
<ItemGroup>
18+
<PackageReference Include="AWSSDK.Lambda" Version="3.7.408.1" />
1819
<PackageReference Include="Spectre.Console" Version="0.49.1" />
1920
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
2021
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System.Text.Json;
5+
using Amazon.Lambda.APIGatewayEvents;
6+
using Amazon.Lambda.Model;
7+
using Amazon.Lambda.TestTool.Models;
8+
9+
/// <summary>
10+
/// Provides extension methods for converting Lambda InvokeResponse to API Gateway response types.
11+
/// </summary>
12+
public static class InvokeResponseExtensions
13+
{
14+
/// <summary>
15+
/// Converts an Amazon Lambda InvokeResponse to an APIGatewayProxyResponse.
16+
/// </summary>
17+
/// <param name="invokeResponse">The InvokeResponse from a Lambda function invocation.</param>
18+
/// <param name="emulatorMode">The API Gateway emulator mode (Rest or Http).</param>
19+
/// <returns>An APIGatewayProxyResponse object.</returns>
20+
/// <remarks>
21+
/// If the response cannot be deserialized as an APIGatewayProxyResponse, it returns an error response.
22+
/// The error response differs based on the emulator mode:
23+
/// - For Rest mode: StatusCode 502 with a generic error message.
24+
/// - For Http mode: StatusCode 500 with a generic error message.
25+
/// </remarks>
26+
public static APIGatewayProxyResponse ToApiGatewayProxyResponse(this InvokeResponse invokeResponse, ApiGatewayEmulatorMode emulatorMode)
27+
{
28+
if (emulatorMode == ApiGatewayEmulatorMode.HttpV2)
29+
{
30+
throw new NotSupportedException("This function should only be used with Rest and Httpv1 emulator modes");
31+
}
32+
33+
using var reader = new StreamReader(invokeResponse.Payload);
34+
string responseJson = reader.ReadToEnd();
35+
try
36+
{
37+
return JsonSerializer.Deserialize<APIGatewayProxyResponse>(responseJson);
38+
}
39+
catch
40+
{
41+
if (emulatorMode == ApiGatewayEmulatorMode.Rest)
42+
{
43+
return new APIGatewayProxyResponse
44+
{
45+
StatusCode = 502,
46+
Body = "{\"message\":\"Internal server error\"}",
47+
Headers = new Dictionary<string, string>
48+
{
49+
{ "Content-Type", "application/json" }
50+
},
51+
IsBase64Encoded = false
52+
};
53+
}
54+
else
55+
{
56+
return new APIGatewayProxyResponse
57+
{
58+
StatusCode = 500,
59+
Body = "{\"message\":\"Internal Server Error\"}",
60+
Headers = new Dictionary<string, string>
61+
{
62+
{ "Content-Type", "application/json" }
63+
},
64+
IsBase64Encoded = false
65+
};
66+
}
67+
}
68+
}
69+
70+
/// <summary>
71+
/// Converts an Amazon Lambda InvokeResponse to an APIGatewayHttpApiV2ProxyResponse.
72+
/// </summary>
73+
/// <param name="invokeResponse">The InvokeResponse from a Lambda function invocation.</param>
74+
/// <returns>An APIGatewayHttpApiV2ProxyResponse object.</returns>
75+
/// <remarks>
76+
/// This method reads the payload from the InvokeResponse and passes it to ToHttpApiV2Response
77+
/// for further processing and conversion.
78+
/// </remarks>
79+
public static APIGatewayHttpApiV2ProxyResponse ToApiGatewayHttpApiV2ProxyResponse(this InvokeResponse invokeResponse)
80+
{
81+
using var reader = new StreamReader(invokeResponse.Payload);
82+
string responseJson = reader.ReadToEnd();
83+
return ToHttpApiV2Response(responseJson);
84+
}
85+
86+
/// <summary>
87+
/// Converts a response string to an APIGatewayHttpApiV2ProxyResponse.
88+
/// </summary>
89+
/// <param name="response">The response string to convert.</param>
90+
/// <returns>An APIGatewayHttpApiV2ProxyResponse object.</returns>
91+
/// <remarks>
92+
/// This method replicates the observed behavior of API Gateway's HTTP API
93+
/// with Lambda integrations using payload format version 2.0, which differs
94+
/// from the official documentation.
95+
///
96+
/// Observed behavior:
97+
/// 1. If the response is a JSON object with a 'statusCode' property:
98+
/// - It attempts to deserialize it as a full APIGatewayHttpApiV2ProxyResponse.
99+
/// - If deserialization fails, it returns a 500 Internal Server Error.
100+
/// 2. For any other response (including non-JSON strings, invalid JSON, or partial JSON):
101+
/// - Sets statusCode to 200
102+
/// - Uses the response as-is for the body
103+
/// - Sets Content-Type to application/json
104+
/// - Sets isBase64Encoded to false
105+
///
106+
/// This behavior contradicts the official documentation, which states:
107+
/// "If your Lambda function returns valid JSON and doesn't return a statusCode,
108+
/// API Gateway assumes a 200 status code and treats the entire response as the body."
109+
///
110+
/// In practice, API Gateway does not validate the JSON. It treats any response
111+
/// without a 'statusCode' property as a raw body, regardless of whether it's
112+
/// valid JSON or not.
113+
///
114+
/// For example, if a Lambda function returns:
115+
/// '{"name": "John Doe", "age":'
116+
/// API Gateway will treat this as a raw string body in a 200 OK response, not attempting
117+
/// to parse or validate the JSON structure.
118+
///
119+
/// This method replicates this observed behavior rather than the documented behavior.
120+
/// </remarks>
121+
private static APIGatewayHttpApiV2ProxyResponse ToHttpApiV2Response(string response)
122+
{
123+
try
124+
{
125+
// Try to deserialize as JsonElement first to inspect the structure
126+
var jsonElement = JsonSerializer.Deserialize<JsonElement>(response);
127+
128+
// Check if it's an object that might represent a full response
129+
if (jsonElement.ValueKind == JsonValueKind.Object &&
130+
jsonElement.TryGetProperty("statusCode", out _))
131+
{
132+
// It has a statusCode property, so try to deserialize as full response
133+
try
134+
{
135+
return JsonSerializer.Deserialize<APIGatewayHttpApiV2ProxyResponse>(response);
136+
}
137+
catch
138+
{
139+
// If deserialization fails, return Internal Server Error
140+
return new APIGatewayHttpApiV2ProxyResponse
141+
{
142+
StatusCode = 500,
143+
Body = "{\"message\":\"Internal Server Error\"}",
144+
Headers = new Dictionary<string, string>
145+
{
146+
{ "Content-Type", "application/json" }
147+
},
148+
IsBase64Encoded = false
149+
};
150+
}
151+
}
152+
}
153+
catch
154+
{
155+
// If JSON parsing fails, fall through to default behavior
156+
}
157+
158+
// Default behavior: return the response as-is
159+
return new APIGatewayHttpApiV2ProxyResponse
160+
{
161+
StatusCode = 200,
162+
Body = response,
163+
Headers = new Dictionary<string, string>
164+
{
165+
{ "Content-Type", "application/json" }
166+
},
167+
IsBase64Encoded = false
168+
};
169+
}
170+
171+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using Amazon.Lambda.APIGatewayEvents;
5+
using Amazon.Lambda.Model;
6+
using Amazon.Lambda.TestTool.Models;
7+
using System.Text;
8+
using System.Text.Json;
9+
10+
namespace Amazon.Lambda.TestTool.IntegrationTests;
11+
12+
/// <summary>
13+
/// Integration tests for InvokeResponseExtensions.
14+
/// </summary>
15+
/// <remarks>
16+
/// Developer's Note:
17+
/// These tests don't have direct access to the intermediate result of the Lambda to API Gateway conversion.
18+
/// Instead, we test the final API Gateway response object to ensure our conversion methods produce results
19+
/// that match the actual API Gateway behavior. This approach allows us to verify the correctness of our
20+
/// conversion methods within the constraints of not having access to AWS's internal conversion process.
21+
/// </remarks>
22+
[Collection("ApiGateway Integration Tests")]
23+
public class InvokeResponseExtensionsIntegrationTests
24+
{
25+
private readonly ApiGatewayIntegrationTestFixture _fixture;
26+
27+
public InvokeResponseExtensionsIntegrationTests(ApiGatewayIntegrationTestFixture fixture)
28+
{
29+
_fixture = fixture;
30+
}
31+
32+
[Theory]
33+
[InlineData(ApiGatewayEmulatorMode.Rest)]
34+
[InlineData(ApiGatewayEmulatorMode.HttpV1)]
35+
public async Task ToApiGatewayProxyResponse_ValidResponse_MatchesDirectConversion(ApiGatewayEmulatorMode emulatorMode)
36+
{
37+
// Arrange
38+
var testResponse = new APIGatewayProxyResponse
39+
{
40+
StatusCode = 200,
41+
Body = JsonSerializer.Serialize(new { message = "Hello, World!" }),
42+
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
43+
};
44+
var invokeResponse = new InvokeResponse
45+
{
46+
Payload = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testResponse)))
47+
};
48+
49+
// Act
50+
var convertedResponse = invokeResponse.ToApiGatewayProxyResponse(emulatorMode);
51+
52+
// Assert
53+
var apiUrl = emulatorMode == ApiGatewayEmulatorMode.Rest
54+
? _fixture.ParseAndReturnBodyRestApiUrl
55+
: _fixture.ParseAndReturnBodyHttpApiV1Url;
56+
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, apiUrl, emulatorMode);
57+
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse);
58+
}
59+
60+
[Fact]
61+
public async Task ToApiGatewayHttpApiV2ProxyResponse_ValidResponse_MatchesDirectConversion()
62+
{
63+
// Arrange
64+
var testResponse = new APIGatewayHttpApiV2ProxyResponse
65+
{
66+
StatusCode = 200,
67+
Body = JsonSerializer.Serialize(new { message = "Hello, World!" }),
68+
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
69+
};
70+
var invokeResponse = new InvokeResponse
71+
{
72+
Payload = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testResponse)))
73+
};
74+
75+
// Act
76+
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse();
77+
78+
// Assert
79+
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url);
80+
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse);
81+
}
82+
83+
[Theory]
84+
[InlineData(ApiGatewayEmulatorMode.Rest, 502, "Internal server error")]
85+
[InlineData(ApiGatewayEmulatorMode.HttpV1, 500, "Internal Server Error")]
86+
public async Task ToApiGatewayProxyResponse_InvalidJson_ReturnsErrorResponse(ApiGatewayEmulatorMode emulatorMode, int expectedStatusCode, string expectedErrorMessage)
87+
{
88+
// Arrange
89+
var invokeResponse = new InvokeResponse
90+
{
91+
Payload = new MemoryStream(Encoding.UTF8.GetBytes("Not a valid proxy response object"))
92+
};
93+
94+
// Act
95+
var convertedResponse = invokeResponse.ToApiGatewayProxyResponse(emulatorMode);
96+
97+
// Assert
98+
Assert.Equal(expectedStatusCode, convertedResponse.StatusCode);
99+
Assert.Contains(expectedErrorMessage, convertedResponse.Body);
100+
101+
var apiUrl = emulatorMode == ApiGatewayEmulatorMode.Rest
102+
? _fixture.ParseAndReturnBodyRestApiUrl
103+
: _fixture.ParseAndReturnBodyHttpApiV1Url;
104+
var (actualResponse, _) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, apiUrl, emulatorMode);
105+
Assert.Equal(expectedStatusCode, (int)actualResponse.StatusCode);
106+
var content = await actualResponse.Content.ReadAsStringAsync();
107+
Assert.Contains(expectedErrorMessage, content);
108+
}
109+
110+
/// <summary>
111+
/// Tests various Lambda return values to verify API Gateway's handling of responses.
112+
/// </summary>
113+
/// <param name="responsePayload">The payload returned by the Lambda function.</param>
114+
/// <remarks>
115+
/// This test demonstrates a discrepancy between the official AWS documentation
116+
/// and the actual observed behavior of API Gateway HTTP API v2 with Lambda
117+
/// proxy integrations (payload format version 2.0).
118+
///
119+
/// Official documentation states:
120+
/// "If your Lambda function returns valid JSON and doesn't return a statusCode,
121+
/// API Gateway assumes a 200 status code and treats the entire response as the body."
122+
///
123+
/// However, the observed behavior (which this test verifies) is:
124+
/// - API Gateway does not validate whether the returned data is valid JSON.
125+
/// - Any response from the Lambda function that is not a properly formatted
126+
/// API Gateway response object (i.e., an object with a 'statusCode' property)
127+
/// is treated as a raw body in a 200 OK response.
128+
/// - This includes valid JSON objects without a statusCode, JSON arrays,
129+
/// primitive values, and invalid JSON strings.
130+
///
131+
/// This test ensures that our ToApiGatewayHttpApiV2ProxyResponse method
132+
/// correctly replicates this observed behavior, rather than the documented behavior.
133+
/// </remarks>
134+
[Theory]
135+
[InlineData("{\"name\": \"John Doe\", \"age\":")] // Invalid JSON (partial object)
136+
[InlineData("{\"name\": \"John Doe\", \"age\": 30}")] // Valid JSON object without statusCode
137+
[InlineData("[1, 2, 3, 4, 5]")] // JSON array
138+
[InlineData("\"Hello, World!\"")] // String primitive
139+
[InlineData("42")] // Number primitive
140+
[InlineData("true")] // Boolean primitive
141+
public async Task ToApiGatewayHttpApiV2ProxyResponse_VariousPayloads_ReturnsAsRawBody(string responsePayload)
142+
{
143+
// Arrange
144+
var invokeResponse = new InvokeResponse
145+
{
146+
Payload = new MemoryStream(Encoding.UTF8.GetBytes(responsePayload))
147+
};
148+
149+
// Act
150+
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse();
151+
152+
// Assert
153+
Assert.Equal(200, convertedResponse.StatusCode);
154+
Assert.Equal(responsePayload, convertedResponse.Body);
155+
Assert.Equal("application/json", convertedResponse.Headers["Content-Type"]);
156+
157+
// Verify against actual API Gateway behavior
158+
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url);
159+
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse);
160+
161+
// Additional checks for API Gateway specific behavior
162+
Assert.Equal(200, (int)actualResponse.StatusCode);
163+
var content = await actualResponse.Content.ReadAsStringAsync();
164+
Assert.Equal(responsePayload, content);
165+
Assert.Equal("application/json", actualResponse.Content.Headers.ContentType?.ToString());
166+
}
167+
168+
[Fact]
169+
public async Task ToApiGatewayHttpApiV2ProxyResponse_StatusCodeAsFloat_ReturnsInternalServerError()
170+
{
171+
// Arrange
172+
var responsePayload = "{\"statusCode\": 200.5, \"body\": \"Hello\", \"headers\": {\"Content-Type\": \"text/plain\"}}";
173+
var invokeResponse = new InvokeResponse
174+
{
175+
Payload = new MemoryStream(Encoding.UTF8.GetBytes(responsePayload))
176+
};
177+
178+
// Act
179+
var convertedResponse = invokeResponse.ToApiGatewayHttpApiV2ProxyResponse();
180+
181+
// Assert
182+
Assert.Equal(500, convertedResponse.StatusCode);
183+
Assert.Equal("{\"message\":\"Internal Server Error\"}", convertedResponse.Body);
184+
Assert.Equal("application/json", convertedResponse.Headers["Content-Type"]);
185+
186+
// Verify against actual API Gateway behavior
187+
var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(convertedResponse, _fixture.ParseAndReturnBodyHttpApiV2Url);
188+
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse);
189+
190+
// Additional checks for API Gateway specific behavior
191+
Assert.Equal(500, (int)actualResponse.StatusCode);
192+
var content = await actualResponse.Content.ReadAsStringAsync();
193+
Assert.Equal("{\"message\":\"Internal Server Error\"}", content);
194+
Assert.Equal("application/json", actualResponse.Content.Headers.ContentType?.ToString());
195+
}
196+
}

0 commit comments

Comments
 (0)