Skip to content

Commit 8cfbc0a

Browse files
committed
Add api gateway extensions
1 parent 8b90fa5 commit 8cfbc0a

File tree

9 files changed

+1735
-3
lines changed

9 files changed

+1735
-3
lines changed
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
<Solution>
1+
<Solution>
22
<Folder Name="/src/">
3-
<Project Path="src\Amazon.Lambda.TestTool\Amazon.Lambda.TestTool.csproj" Type="Classic C#" />
3+
<Project Path="src\Amazon.Lambda.TestTool\Amazon.Lambda.TestTool.csproj" />
44
</Folder>
55
<Folder Name="/tests/">
6-
<Project Path="tests\Amazon.Lambda.TestTool.UnitTests\Amazon.Lambda.TestTool.UnitTests.csproj" Type="Classic C#" />
6+
<Project Path="tests\Amazon.Lambda.TestTool.UnitTests\Amazon.Lambda.TestTool.UnitTests.csproj" />
7+
<Project Path="tests\Amazon.Lambda.TestTool.IntegrationTests\Amazon.Lambda.TestTool.IntegrationTests.csproj" />
78
</Folder>
89
</Solution>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<ItemGroup>
1818
<PackageReference Include="Spectre.Console" Version="0.49.1" />
1919
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
20+
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
2021
<PackageReference Include="Blazored.Modal" Version="7.3.1" />
2122
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.11" />
2223
</ItemGroup>
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
namespace Amazon.Lambda.TestTool.Extensions;
2+
3+
using Amazon.Lambda.APIGatewayEvents;
4+
using Amazon.Lambda.TestTool.Models;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.Primitives;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.IO;
10+
using System.Text;
11+
using System.Text.Json;
12+
13+
/// <summary>
14+
/// Provides extension methods for converting API Gateway responses to HttpResponse objects.
15+
/// </summary>
16+
public static class ApiGatewayResponseExtensions
17+
{
18+
19+
private const string InternalServerErrorMessage = "{\"message\":\"Internal Server Error\"}";
20+
21+
/// <summary>
22+
/// Converts an APIGatewayProxyResponse to an HttpResponse.
23+
/// </summary>
24+
/// <param name="apiResponse">The API Gateway proxy response to convert.</param>
25+
/// <returns>An HttpResponse representing the API Gateway response.</returns>
26+
public static HttpResponse ToHttpResponse(this APIGatewayProxyResponse apiResponse, ApiGatewayEmulatorMode emulatorMode)
27+
{
28+
var httpContext = new DefaultHttpContext();
29+
var response = httpContext.Response;
30+
31+
SetResponseHeaders(response, apiResponse.Headers, apiResponse.MultiValueHeaders, emulatorMode);
32+
SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded, emulatorMode);
33+
SetContentTypeAndStatusCode(response, apiResponse.Headers, apiResponse.MultiValueHeaders, apiResponse.StatusCode, emulatorMode);
34+
35+
return response;
36+
}
37+
38+
/// <summary>
39+
/// Converts an APIGatewayHttpApiV2ProxyResponse to an HttpResponse.
40+
/// </summary>
41+
/// <param name="apiResponse">The API Gateway HTTP API v2 proxy response to convert.</param>
42+
/// <returns>An HttpResponse representing the API Gateway response.</returns>
43+
public static HttpResponse ToHttpResponse(this APIGatewayHttpApiV2ProxyResponse apiResponse)
44+
{
45+
var httpContext = new DefaultHttpContext();
46+
var response = httpContext.Response;
47+
48+
SetResponseHeaders(response, apiResponse.Headers, emulatorMode: ApiGatewayEmulatorMode.HttpV2);
49+
SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded, ApiGatewayEmulatorMode.HttpV2);
50+
SetContentTypeAndStatusCodeV2(response, apiResponse.Headers, apiResponse.Body, apiResponse.StatusCode);
51+
52+
return response;
53+
}
54+
/// <summary>
55+
/// Sets the response headers on the HttpResponse, including default API Gateway headers based on the emulator mode.
56+
/// </summary>
57+
/// <param name="response">The HttpResponse to set headers on.</param>
58+
/// <param name="headers">The single-value headers to set.</param>
59+
/// <param name="multiValueHeaders">The multi-value headers to set.</param>
60+
/// <param name="emulatorMode">The API Gateway emulator mode determining which default headers to include.</param>
61+
private static void SetResponseHeaders(HttpResponse response, IDictionary<string, string>? headers, IDictionary<string, IList<string>>? multiValueHeaders = null, ApiGatewayEmulatorMode emulatorMode = ApiGatewayEmulatorMode.HttpV2)
62+
{
63+
var processedHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
64+
65+
// Add default API Gateway headers
66+
var defaultHeaders = GetDefaultApiGatewayHeaders(emulatorMode);
67+
foreach (var header in defaultHeaders)
68+
{
69+
response.Headers[header.Key] = header.Value;
70+
processedHeaders.Add(header.Key);
71+
}
72+
73+
if (multiValueHeaders != null)
74+
{
75+
foreach (var header in multiValueHeaders)
76+
{
77+
response.Headers[header.Key] = new StringValues(header.Value.ToArray());
78+
processedHeaders.Add(header.Key);
79+
}
80+
}
81+
82+
if (headers != null)
83+
{
84+
foreach (var header in headers)
85+
{
86+
if (!processedHeaders.Contains(header.Key))
87+
{
88+
response.Headers[header.Key] = header.Value;
89+
}
90+
else
91+
{
92+
response.Headers.Append(header.Key, header.Value);
93+
}
94+
}
95+
}
96+
}
97+
98+
/// <summary>
99+
/// Generates default API Gateway headers based on the specified emulator mode.
100+
/// </summary>
101+
/// <param name="emulatorMode">The API Gateway emulator mode determining which headers to generate.</param>
102+
/// <returns>A dictionary of default headers appropriate for the specified emulator mode.</returns>
103+
private static Dictionary<string, string> GetDefaultApiGatewayHeaders(ApiGatewayEmulatorMode emulatorMode)
104+
{
105+
var headers = new Dictionary<string, string>
106+
{
107+
{ "Date", DateTime.UtcNow.ToString("r") },
108+
{ "Connection", "keep-alive" }
109+
};
110+
111+
switch (emulatorMode)
112+
{
113+
case ApiGatewayEmulatorMode.Rest:
114+
headers.Add("x-amzn-RequestId", Guid.NewGuid().ToString("D"));
115+
headers.Add("x-amz-apigw-id", GenerateApiGwId());
116+
headers.Add("X-Amzn-Trace-Id", GenerateTraceId());
117+
break;
118+
case ApiGatewayEmulatorMode.HttpV1:
119+
case ApiGatewayEmulatorMode.HttpV2:
120+
headers.Add("Apigw-Requestid", GenerateRequestId());
121+
break;
122+
}
123+
124+
return headers;
125+
}
126+
127+
/// <summary>
128+
/// Generates a random API Gateway ID for REST API mode.
129+
/// </summary>
130+
/// <returns>A string representing a random API Gateway ID in the format used by API Gateway for REST APIs.</returns>
131+
/// <remarks>
132+
/// The generated ID is a 12-character string where digits are replaced by letters (A-J), followed by an equals sign.
133+
private static string GenerateApiGwId()
134+
{
135+
return new string(Guid.NewGuid().ToString("N").Take(12).Select(c => char.IsDigit(c) ? (char)(c + 17) : c).ToArray()) + "=";
136+
}
137+
138+
139+
/// <summary>
140+
/// Generates a random X-Amzn-Trace-Id for REST API mode.
141+
/// </summary>
142+
/// <returns>A string representing a random X-Amzn-Trace-Id in the format used by API Gateway for REST APIs.</returns>
143+
/// <remarks>
144+
/// The generated trace ID includes:
145+
/// - A root ID with a timestamp and two partial GUIDs
146+
/// - A parent ID
147+
/// - A sampling decision (always set to 0 in this implementation)
148+
/// - A lineage identifier
149+
/// </remarks>
150+
private static string GenerateTraceId()
151+
{
152+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString("x");
153+
var guid1 = Guid.NewGuid().ToString("N");
154+
var guid2 = Guid.NewGuid().ToString("N");
155+
return $"Root=1-{timestamp}-{guid1.Substring(0, 12)}{guid2.Substring(0, 12)};Parent={Guid.NewGuid().ToString("N").Substring(0, 16)};Sampled=0;Lineage=1:{Guid.NewGuid().ToString("N").Substring(0, 8)}:0";
156+
}
157+
158+
159+
/// <summary>
160+
/// Generates a random API Gateway request ID for HTTP API v1 and v2.
161+
/// </summary>
162+
/// <returns>A string representing a random request ID in the format used by API Gateway for HTTP APIs.</returns>
163+
/// <remarks>
164+
/// The generated ID is a 14-character string consisting of lowercase letters and numbers, followed by an equals sign.
165+
private static string GenerateRequestId()
166+
{
167+
return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 6) + "=";
168+
}
169+
170+
/// <summary>
171+
/// Sets the response body on the HttpResponse.
172+
/// </summary>
173+
/// <param name="response">The HttpResponse to set the body on.</param>
174+
/// <param name="body">The body content.</param>
175+
/// <param name="isBase64Encoded">Whether the body is Base64 encoded.</param>
176+
private static void SetResponseBody(HttpResponse response, string? body, bool isBase64Encoded, ApiGatewayEmulatorMode apiGatewayEmulator)
177+
{
178+
if (!string.IsNullOrEmpty(body))
179+
{
180+
byte[] bodyBytes;
181+
if (isBase64Encoded && ApiGatewayEmulatorMode.Rest != apiGatewayEmulator)
182+
{
183+
bodyBytes = Convert.FromBase64String(body);
184+
}
185+
else
186+
{
187+
bodyBytes = Encoding.UTF8.GetBytes(body);
188+
}
189+
190+
response.Body = new MemoryStream(bodyBytes);
191+
response.ContentLength = bodyBytes.Length;
192+
}
193+
}
194+
195+
/// <summary>
196+
/// Sets the content type and status code for API Gateway v1 responses.
197+
/// </summary>
198+
/// <param name="response">The HttpResponse to set the content type and status code on.</param>
199+
/// <param name="headers">The single-value headers.</param>
200+
/// <param name="multiValueHeaders">The multi-value headers.</param>
201+
/// <param name="statusCode">The status code to set.</param>
202+
private static void SetContentTypeAndStatusCode(HttpResponse response, IDictionary<string, string>? headers, IDictionary<string, IList<string>>? multiValueHeaders, int statusCode, ApiGatewayEmulatorMode emulatorMode)
203+
{
204+
string? contentType = null;
205+
206+
if (headers != null && headers.TryGetValue("Content-Type", out var headerContentType))
207+
{
208+
contentType = headerContentType;
209+
}
210+
else if (multiValueHeaders != null && multiValueHeaders.TryGetValue("Content-Type", out var multiValueContentType))
211+
{
212+
contentType = multiValueContentType[0];
213+
}
214+
215+
if (contentType != null)
216+
{
217+
response.ContentType = contentType;
218+
}
219+
else
220+
{
221+
if (emulatorMode == ApiGatewayEmulatorMode.HttpV1)
222+
{
223+
response.ContentType = "text/plain; charset=utf-8";
224+
}
225+
else if (emulatorMode == ApiGatewayEmulatorMode.Rest)
226+
{
227+
response.ContentType = "application/json";
228+
}
229+
}
230+
231+
if (statusCode != 0)
232+
{
233+
response.StatusCode = statusCode;
234+
}
235+
else
236+
{
237+
if (emulatorMode == ApiGatewayEmulatorMode.Rest) // rest api text for this message/error code is slightly different
238+
{
239+
response.StatusCode = 502;
240+
response.ContentType = "application/json";
241+
var errorBytes = Encoding.UTF8.GetBytes("{\"message\": \"Internal server error\"}");
242+
response.Body = new MemoryStream(errorBytes);
243+
response.ContentLength = errorBytes.Length;
244+
} else
245+
{
246+
SetInternalServerError(response);
247+
}
248+
}
249+
}
250+
251+
/// <summary>
252+
/// Sets the content type and status code for API Gateway v2 responses.
253+
/// </summary>
254+
/// <param name="response">The HttpResponse to set the content type and status code on.</param>
255+
/// <param name="headers">The headers.</param>
256+
/// <param name="body">The response body.</param>
257+
/// <param name="statusCode">The status code to set.</param>
258+
private static void SetContentTypeAndStatusCodeV2(HttpResponse response, IDictionary<string, string>? headers, string? body, int statusCode)
259+
{
260+
if (headers != null && headers.TryGetValue("Content-Type", out var contentType))
261+
{
262+
response.ContentType = contentType;
263+
}
264+
else
265+
{
266+
response.ContentType = "text/plain; charset=utf-8"; // api gateway v2 defaults to this content type if none is provided
267+
}
268+
269+
if (statusCode != 0)
270+
{
271+
response.StatusCode = statusCode;
272+
}
273+
// v2 tries to automatically make some assumptions if the body is valid json
274+
else if (IsValidJson(body))
275+
{
276+
// API Gateway 2.0 format version assumptions
277+
response.StatusCode = 200;
278+
response.ContentType = "application/json";
279+
// Note: IsBase64Encoded is assumed to be false, which is already the default behavior
280+
}
281+
else
282+
{
283+
// if all else fails, v2 will error out
284+
SetInternalServerError(response);
285+
}
286+
}
287+
288+
/// <summary>
289+
/// Checks if the given string is valid JSON.
290+
/// </summary>
291+
/// <param name="strInput">The string to check.</param>
292+
/// <returns>True if the string is valid JSON, false otherwise.</returns>
293+
private static bool IsValidJson(string? strInput)
294+
{
295+
if (string.IsNullOrWhiteSpace(strInput)) { return false; }
296+
strInput = strInput.Trim();
297+
if ((strInput.StartsWith("{") && strInput.EndsWith("}")) ||
298+
(strInput.StartsWith("[") && strInput.EndsWith("]")))
299+
{
300+
try
301+
{
302+
var obj = JsonSerializer.Deserialize<object>(strInput);
303+
return true;
304+
}
305+
catch (JsonException)
306+
{
307+
return false;
308+
}
309+
}
310+
// a regular string is consisered json in api gateway.
311+
return true;
312+
}
313+
314+
/// <summary>
315+
/// Sets the response to an Internal Server Error (500) with a JSON error message.
316+
/// </summary>
317+
/// <param name="response">The HttpResponse to set the error on.</param>
318+
private static void SetInternalServerError(HttpResponse response)
319+
{
320+
response.StatusCode = 500;
321+
response.ContentType = "application/json";
322+
var errorBytes = Encoding.UTF8.GetBytes(InternalServerErrorMessage);
323+
response.Body = new MemoryStream(errorBytes);
324+
response.ContentLength = errorBytes.Length;
325+
}
326+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
14+
<PackageReference Include="AWSSDK.APIGateway" Version="3.7.401.7" />
15+
<PackageReference Include="AWSSDK.IdentityManagement" Version="3.7.401.7" />
16+
<PackageReference Include="AWSSDK.ApiGatewayV2" Version="3.7.400.63" />
17+
<PackageReference Include="AWSSDK.Lambda" Version="3.7.408.1" />
18+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
19+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
20+
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.0" />
21+
<PackageReference Include="xunit" Version="2.9.2" />
22+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
23+
</ItemGroup>
24+
25+
26+
<ItemGroup>
27+
<ProjectReference Include="..\..\src\Amazon.Lambda.TestTool\Amazon.Lambda.TestTool.csproj" />
28+
<ProjectReference Include="..\Amazon.Lambda.TestTool.UnitTests\Amazon.Lambda.TestTool.UnitTests.csproj" />
29+
</ItemGroup>
30+
31+
<ItemGroup>
32+
<Using Include="Xunit" />
33+
</ItemGroup>
34+
35+
</Project>

0 commit comments

Comments
 (0)