@kensio/yulin 1.20.15 → 1.20.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +13 -0
  2. package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
  3. package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
  4. package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
  5. package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
  6. package/dist/sdk/send-patch.d.ts +8 -0
  7. package/dist/sdk/send-patch.js +11 -0
  8. package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +28 -6
  9. package/dist/service/aws/caller/sim-aws-caller-resolver.js +18 -11
  10. package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
  11. package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
  12. package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
  13. package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +6 -0
  14. package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +3 -0
  15. package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +6 -0
  16. package/dist/service/aws/factory/sim-aws-account-service-cache.js +5 -0
  17. package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
  18. package/dist/service/aws/factory/sim-aws-service-factory.js +6 -0
  19. package/dist/service/aws/sim-aws-properties.d.ts +15 -0
  20. package/dist/service/aws/sim-aws.js +2 -1
  21. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +30 -7
  22. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +20 -11
  23. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +2 -1
  24. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +5 -1
  25. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +10 -0
  26. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +15 -3
  27. package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +11 -4
  28. package/dist/service/iam/authorize/sim-iam-account-auth-z.js +5 -3
  29. package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +2 -1
  30. package/dist/service/iam/authorize/sim-iam-authorizer.js +1 -0
  31. package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +9 -0
  32. package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
  33. package/dist/service/iam/sim-iam-account-parts.d.ts +8 -0
  34. package/dist/service/iam/sim-iam-account-parts.js +1 -0
  35. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
  36. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
  37. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
  38. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
  39. package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
  40. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
  41. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
  42. package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +5 -1
  43. package/dist/service/sts/command/assume-role/assume-role.handler.js +5 -2
  44. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +5 -1
  45. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +4 -1
  46. package/dist/service/sts/service-role/sim-service-role.js +5 -1
  47. package/dist/service/sts/sim-sts.d.ts +9 -1
  48. package/dist/service/sts/sim-sts.js +4 -0
  49. package/docs/README.md +54 -0
  50. package/docs/ai-skill/README.md +69 -0
  51. package/docs/cli/README.md +284 -0
  52. package/docs/factories/README.md +140 -0
  53. package/docs/lint/README.md +176 -0
  54. package/docs/non-aws-dependencies/README.md +261 -0
  55. package/docs/sdk/README.md +272 -0
  56. package/docs/serve/README.md +1126 -0
  57. package/docs/services/acm/README.md +823 -0
  58. package/docs/services/apigateway/README.md +1919 -0
  59. package/docs/services/apigatewayv2/README.md +2678 -0
  60. package/docs/services/athena/README.md +1044 -0
  61. package/docs/services/bedrock/README.md +350 -0
  62. package/docs/services/cloudformation/README.md +3440 -0
  63. package/docs/services/cloudfront/README.md +2874 -0
  64. package/docs/services/cloudwatch/README.md +420 -0
  65. package/docs/services/cognito/README.md +4923 -0
  66. package/docs/services/dynamodb/README.md +3784 -0
  67. package/docs/services/ecr/README.md +302 -0
  68. package/docs/services/ecs/README.md +2010 -0
  69. package/docs/services/elbv2/README.md +1897 -0
  70. package/docs/services/eventbridge/README.md +1110 -0
  71. package/docs/services/firehose/README.md +805 -0
  72. package/docs/services/glue/README.md +501 -0
  73. package/docs/services/iam/README.md +1283 -0
  74. package/docs/services/kinesis/README.md +454 -0
  75. package/docs/services/kms/README.md +659 -0
  76. package/docs/services/lambda/README.md +3645 -0
  77. package/docs/services/logs/README.md +868 -0
  78. package/docs/services/organizations/README.md +574 -0
  79. package/docs/services/personalize/README.md +835 -0
  80. package/docs/services/rekognition/README.md +1070 -0
  81. package/docs/services/route53/README.md +1783 -0
  82. package/docs/services/s3/README.md +3024 -0
  83. package/docs/services/scheduler/README.md +806 -0
  84. package/docs/services/secretsmanager/README.md +667 -0
  85. package/docs/services/ses/README.md +1011 -0
  86. package/docs/services/sns/README.md +1904 -0
  87. package/docs/services/sqs/README.md +1185 -0
  88. package/docs/services/ssm/README.md +1072 -0
  89. package/docs/services/stepfunctions/README.md +1332 -0
  90. package/docs/services/sts/README.md +245 -0
  91. package/docs/services/wafv2/README.md +1378 -0
  92. package/docs/terraform/README.md +248 -0
  93. package/docs/time/README.md +284 -0
  94. package/llms.txt +59 -0
  95. package/package.json +7 -4
@@ -0,0 +1,2678 @@
1
+ # Simulated API Gateway HTTP APIs
2
+
3
+ Yulin includes a simulated API Gateway v2 service, reachable as `simAws.apiGatewayV2()`. It covers
4
+ HTTP APIs with a Lambda proxy integration. A handler that runs behind an HTTP API can be tested
5
+ against a real HTTP request, with no hand-built event to keep in step.
6
+
7
+ This is HTTP APIs only. WebSocket APIs and REST APIs (the older API Gateway v1 service, on a
8
+ separate SDK client) are outside it.
9
+
10
+ ## Creating an API
11
+
12
+ `CreateApiCommand` creates an HTTP API and returns the endpoint API Gateway generates for it.
13
+
14
+ ```typescript sim-apigatewayv2-create-api
15
+ /**
16
+ * Creating a simulated API Gateway HTTP API.
17
+ */
18
+
19
+ import { CreateApiCommand, GetApiCommand } from "@aws-sdk/client-apigatewayv2";
20
+
21
+ import { SimAws } from "@kensio/yulin";
22
+
23
+ const simAws = new SimAws();
24
+ const apiGateway = simAws
25
+ .account("555555555555")
26
+ .region("eu-west-1")
27
+ .apiGatewayV2();
28
+
29
+ const created = await apiGateway.createApi(
30
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
31
+ );
32
+
33
+ console.log(created.ApiId);
34
+ console.log(created.ApiEndpoint);
35
+
36
+ const fetched = await apiGateway.getApi(
37
+ new GetApiCommand({ ApiId: created.ApiId }),
38
+ );
39
+
40
+ console.log(fetched.Name);
41
+ ```
42
+
43
+ The endpoint names the API id and the region, as a real one does:
44
+
45
+ ```text
46
+ https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com
47
+ ```
48
+
49
+ A name is a label here, as it is on real AWS. Two APIs in one Account and Region may share a name,
50
+ and only the id tells them apart.
51
+
52
+ ## Routing requests to a Lambda function
53
+
54
+ An API needs three more resources before it serves anything. Those are an integration naming the
55
+ function, a route pointing at that integration, and a stage to serve it from. The function also has to allow API
56
+ Gateway to invoke it. That grant is a permission on the function, not on the API. See
57
+ [Granting the API permission to invoke the function](#granting-the-api-permission-to-invoke-the-function).
58
+ Once all of that exists, `serveSimAws` answers requests to the generated endpoint by invoking the
59
+ function.
60
+
61
+ Pass the API endpoint through `srv.localUrl(...)`. It keeps the endpoint's hostname and sends the
62
+ request to the local server, the way it adapts simulated S3 website and Lambda Function URL
63
+ endpoints.
64
+
65
+ ```typescript sim-apigatewayv2-lambda-proxy
66
+ /**
67
+ * Serving a simulated HTTP API that proxies to a simulated Lambda function.
68
+ */
69
+
70
+ import {
71
+ CreateApiCommand,
72
+ CreateIntegrationCommand,
73
+ CreateRouteCommand,
74
+ CreateStageCommand,
75
+ } from "@aws-sdk/client-apigatewayv2";
76
+ import {
77
+ AddPermissionCommand,
78
+ CreateFunctionCommand,
79
+ } from "@aws-sdk/client-lambda";
80
+
81
+ import { SimAws } from "@kensio/yulin";
82
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
83
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
84
+ import { serveSimAws } from "@kensio/yulin/serve";
85
+
86
+ const simAws = new SimAws();
87
+
88
+ const { FunctionArn } = await simAws.lambda().createFunction(
89
+ new CreateFunctionCommand({
90
+ FunctionName: "orders",
91
+ Role: "arn:aws:iam::111111111111:role/OrdersRole",
92
+ Code: {
93
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
94
+ statusCode: 200,
95
+ headers: { "content-type": "text/plain" },
96
+ body: `orders limit ${event.queryStringParameters?.["limit"] ?? "none"}`,
97
+ })),
98
+ },
99
+ }),
100
+ );
101
+
102
+ const apiGateway = simAws.apiGatewayV2();
103
+
104
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
105
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
106
+ );
107
+
108
+ const { IntegrationId } = await apiGateway.createIntegration(
109
+ new CreateIntegrationCommand({
110
+ ApiId,
111
+ IntegrationType: "AWS_PROXY",
112
+ IntegrationUri: FunctionArn,
113
+ PayloadFormatVersion: "2.0",
114
+ }),
115
+ );
116
+
117
+ await apiGateway.createRoute(
118
+ new CreateRouteCommand({
119
+ ApiId,
120
+ RouteKey: "$default",
121
+ Target: `integrations/${IntegrationId}`,
122
+ }),
123
+ );
124
+
125
+ await apiGateway.createStage(
126
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
127
+ );
128
+
129
+ await simAws.lambda().addPermission(
130
+ new AddPermissionCommand({
131
+ FunctionName: "orders",
132
+ StatementId: "api-gateway-invoke",
133
+ Action: "lambda:InvokeFunction",
134
+ Principal: "apigateway.amazonaws.com",
135
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
136
+ }),
137
+ );
138
+
139
+ const srv = await serveSimAws({ simAws });
140
+
141
+ const response = await fetch(srv.localUrl(`${ApiEndpoint}/orders?limit=10`));
142
+
143
+ console.log(response.status);
144
+ console.log(await response.text());
145
+
146
+ await srv.close();
147
+ ```
148
+
149
+ The `$default` route matches any method and path. Every request to the endpoint reaches the
150
+ function. The integration URI is the function's ARN, and the function may be in another Account or
151
+ Region. It is looked up where its ARN says it is.
152
+
153
+ ## Granting the API permission to invoke the function
154
+
155
+ A Lambda proxy integration works once the function's resource policy allows
156
+ `apigateway.amazonaws.com` to invoke it. The console adds that permission for you. An integration
157
+ created through CloudFormation, the CLI or an SDK leaves it to you. That is what
158
+ `AddPermissionCommand` in the example above does. Without it the request is answered with a 500 and
159
+ `{"message":"Internal Server Error"}`, and the handler never runs. CDK's `HttpLambdaIntegration`
160
+ emits the same grant as an `AWS::Lambda::Permission`.
161
+
162
+ Each request is authorized as `lambda:InvokeFunction` on the function ARN, with the caller being the
163
+ service principal `apigateway.amazonaws.com`. The function's own resource policy is the whole
164
+ decision. A service principal has no identity policies of its own. The route that matched is supplied
165
+ as `AWS:SourceArn`. A permission may be granted for one route and withheld from another:
166
+
167
+ ```text
168
+ arn:aws:execute-api:<region>:<account>:<apiId>/<stage>/<METHOD>/<route path>
169
+ ```
170
+
171
+ - The Account and Region are the API's, not the function's.
172
+ - The stage is the one that served the request, so `$default` for the default stage.
173
+ - The method is the request's own. A `GET` reaching a route keyed `ANY /orders` gives `GET`.
174
+ - The path is the matched route key's template with its parameter braces intact. A request to
175
+ `/orders/42` on the route `GET /orders/{orderId}` gives `orders/{orderId}`. A `SourceArn` is
176
+ written against route keys, and IAM treats a brace as an ordinary character.
177
+ - The `$default` route has no method and no path of its own, so both collapse into one `$default`
178
+ segment: `<apiId>/<stage>/$default`.
179
+
180
+ A `SourceArn` may wildcard any part of that, and the usual grant does. `<apiId>/*/*` allows every
181
+ route of the API on every stage.
182
+
183
+ The API's own Account is supplied as `AWS:SourceAccount`. A permission carrying a `SourceAccount`
184
+ matches when it names the Account the API belongs to. That is the Account the source ARN names, not
185
+ the one owning the function, and the difference shows up on an integration reaching across Accounts.
186
+ CDK writes both keys for some grants, and a permission carrying either or both is evaluated on what
187
+ it says.
188
+
189
+ `AWS:SourceArn` and `AWS:SourceAccount` are the only condition keys supplied here. A permission that
190
+ also carries `PrincipalOrgID` or `InvokedViaFunctionUrl` never matches, since those keys have no
191
+ value at request time. The request is refused with the same 500.
192
+
193
+ AWS documents neither the method nor the path as the value API Gateway supplies. Both are inferred
194
+ from the permission patterns AWS and CDK write. The code that builds the ARN records that.
195
+
196
+ ## Routing to a published version or an alias
197
+
198
+ An integration URI may end in a version number or an alias name, in either of the two forms:
199
+
200
+ ```text
201
+ arn:aws:lambda:eu-west-2:111111111111:function:orders:live
202
+ arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:111111111111:function:orders:live/invocations
203
+ ```
204
+
205
+ A Lambda `REQUEST` authorizer's `AuthorizerUri` takes the same qualifier.
206
+
207
+ The qualifier is read when the request arrives. A route built on the alias `live` runs whichever
208
+ version `live` points at now, and `UpdateAliasCommand` carries the route to another version with the
209
+ API untouched. (That is how a deployment behind an HTTP API usually moves on AWS.)
210
+
211
+ The invoke permission is granted on the qualifier as well. An alias holds a resource policy of its
212
+ own, and a grant made on the function admits an unqualified call and says nothing about `live`. Pass
213
+ `Qualifier` to `AddPermissionCommand` with the same `SourceArn` an unqualified grant carries.
214
+
215
+ ```typescript sim-apigatewayv2-lambda-alias
216
+ /**
217
+ * Serving an HTTP API route from a Lambda alias, and moving the alias.
218
+ */
219
+
220
+ import {
221
+ CreateApiCommand,
222
+ CreateIntegrationCommand,
223
+ CreateRouteCommand,
224
+ CreateStageCommand,
225
+ } from "@aws-sdk/client-apigatewayv2";
226
+ import {
227
+ AddPermissionCommand,
228
+ CreateAliasCommand,
229
+ CreateFunctionCommand,
230
+ PublishVersionCommand,
231
+ UpdateAliasCommand,
232
+ } from "@aws-sdk/client-lambda";
233
+
234
+ import { SimAws } from "@kensio/yulin";
235
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
236
+ import { serveSimAws } from "@kensio/yulin/serve";
237
+
238
+ const simAws = new SimAws();
239
+ const lambda = simAws.lambda();
240
+
241
+ const { FunctionArn } = await lambda.createFunction(
242
+ new CreateFunctionCommand({
243
+ FunctionName: "orders",
244
+ Role: "arn:aws:iam::111111111111:role/OrdersRole",
245
+ Code: {
246
+ ZipFile: makeLambdaZipFileInput((_event, context) => ({
247
+ statusCode: 200,
248
+ headers: { "content-type": "text/plain" },
249
+ body: `served by version ${context.functionVersion}`,
250
+ })),
251
+ },
252
+ }),
253
+ );
254
+
255
+ await lambda.publishVersion(
256
+ new PublishVersionCommand({ FunctionName: "orders" }),
257
+ );
258
+ await lambda.publishVersion(
259
+ new PublishVersionCommand({ FunctionName: "orders" }),
260
+ );
261
+
262
+ await lambda.createAlias(
263
+ new CreateAliasCommand({
264
+ FunctionName: "orders",
265
+ Name: "live",
266
+ FunctionVersion: "1",
267
+ }),
268
+ );
269
+
270
+ const apiGateway = simAws.apiGatewayV2();
271
+
272
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
273
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
274
+ );
275
+
276
+ const { IntegrationId } = await apiGateway.createIntegration(
277
+ new CreateIntegrationCommand({
278
+ ApiId,
279
+ IntegrationType: "AWS_PROXY",
280
+ IntegrationUri: `${FunctionArn}:live`,
281
+ PayloadFormatVersion: "2.0",
282
+ }),
283
+ );
284
+
285
+ await apiGateway.createRoute(
286
+ new CreateRouteCommand({
287
+ ApiId,
288
+ RouteKey: "$default",
289
+ Target: `integrations/${IntegrationId}`,
290
+ }),
291
+ );
292
+
293
+ await apiGateway.createStage(
294
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
295
+ );
296
+
297
+ // The grant names the alias. One made on the function alone leaves this call
298
+ // refused with a 500.
299
+ await lambda.addPermission(
300
+ new AddPermissionCommand({
301
+ FunctionName: "orders",
302
+ Qualifier: "live",
303
+ StatementId: "api-gateway-invoke",
304
+ Action: "lambda:InvokeFunction",
305
+ Principal: "apigateway.amazonaws.com",
306
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
307
+ }),
308
+ );
309
+
310
+ const srv = await serveSimAws({ simAws });
311
+
312
+ const first = await fetch(srv.localUrl(ApiEndpoint));
313
+ console.log(await first.text());
314
+
315
+ await lambda.updateAlias(
316
+ new UpdateAliasCommand({
317
+ FunctionName: "orders",
318
+ Name: "live",
319
+ FunctionVersion: "2",
320
+ }),
321
+ );
322
+
323
+ const second = await fetch(srv.localUrl(ApiEndpoint));
324
+ console.log(await second.text());
325
+
326
+ await srv.close();
327
+ ```
328
+
329
+ ```text
330
+ served by version 1
331
+ served by version 2
332
+ ```
333
+
334
+ A qualifier belonging to no version and no alias surfaces at the request, the way a function that
335
+ was never created does. The endpoint answers 500 and the handler never runs.
336
+
337
+ ## Route keys
338
+
339
+ A route key is either the literal `$default` or an upper-case HTTP method and a path separated by one
340
+ space:
341
+
342
+ ```text
343
+ GET /pets
344
+ GET /pets/{petId}
345
+ ANY /admin/{proxy+}
346
+ $default
347
+ ```
348
+
349
+ The method is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` or `ANY`, where `ANY`
350
+ matches whatever method the request used.
351
+
352
+ A path is made of three kinds of segment:
353
+
354
+ - A literal, such as `pets`, matching that one segment.
355
+ - A parameter, such as `{petId}`, matching exactly one segment, whatever is in it.
356
+ - A greedy parameter, such as `{proxy+}`, matching everything left of the path. It is only ever the
357
+ last segment, and it needs at least one segment to match, so `GET /pets/{proxy+}` matches
358
+ `/pets/cat/1` but not `/pets`.
359
+
360
+ A route key that cannot be read is refused by `CreateRoute` with a `BadRequestException`, the same
361
+ place real API Gateway refuses it. That covers a lower-case method, an unbalanced brace, and a greedy
362
+ parameter anywhere but the end.
363
+
364
+ A parameter name falls outside a route's identity, so `GET /pets/{id}` and `GET /pets/{petId}` are
365
+ the same route key, and creating the second gives a `ConflictException`.
366
+
367
+ One path may not name the same parameter twice, so `GET /pets/{id}/toys/{id}` is refused. A handler
368
+ reads path parameters off one object, and only one of the two captures could arrive. Whether real API
369
+ Gateway refuses it too is unestablished. This is stricter than AWS is known to be.
370
+
371
+ ## Which route serves a request
372
+
373
+ More than one route may match a request, and the most specific one takes it. In order:
374
+
375
+ 1. A route matching the whole path beats a route ending in a greedy parameter, which beats
376
+ `$default`.
377
+ 2. An exact method beats `ANY`.
378
+ 3. The path decides, segment by segment from the left, with a literal beating a `{name}` parameter
379
+ and a `{name}` beating a `{name+}`. Comparing left to right is also what makes the longest literal
380
+ prefix win between two greedy routes, so `GET /pets/dog/{proxy+}` takes `/pets/dog/collars/1`
381
+ ahead of `GET /pets/{proxy+}`.
382
+
383
+ AWS's worked example, encoded as a test here:
384
+
385
+ | Request | Route selected |
386
+ | ----------------- | -------------------- |
387
+ | `GET /pets/dog/1` | `GET /pets/dog/1` |
388
+ | `GET /pets/dog/2` | `GET /pets/dog/{id}` |
389
+ | `GET /pets/cat/1` | `GET /pets/{proxy+}` |
390
+ | `POST /test/5` | `ANY /{proxy+}` |
391
+
392
+ from the routes `GET /pets/dog/1`, `GET /pets/dog/{id}`, `GET /pets/{proxy+}`, `ANY /{proxy+}` and
393
+ `$default`.
394
+
395
+ Rule 1 is documented by AWS, as is the literal-beating-parameter part of rule 3. Three things here
396
+ are observed rather than documented. They are rule 2, the longest-literal-prefix part of rule 3, and
397
+ the placement of the method comparison above the path comparison. Each is marked in the code next to the
398
+ rule it governs.
399
+
400
+ A request whose path matches a route with a different method matches no route at all. An API with no
401
+ `ANY` route, no greedy route and no `$default` route to catch it answers 404, not 405.
402
+
403
+ ## Path parameters and named stages
404
+
405
+ What a route captured reaches the handler as `event.pathParameters`. A named stage is served under
406
+ its own path segment. Stage selection runs before route selection, and the routes never see the stage
407
+ name.
408
+
409
+ ```typescript sim-apigatewayv2-routes
410
+ /**
411
+ * Matching a simulated HTTP API request by route key, path parameter and stage.
412
+ */
413
+
414
+ import {
415
+ CreateApiCommand,
416
+ CreateIntegrationCommand,
417
+ CreateRouteCommand,
418
+ CreateStageCommand,
419
+ } from "@aws-sdk/client-apigatewayv2";
420
+ import {
421
+ AddPermissionCommand,
422
+ CreateFunctionCommand,
423
+ } from "@aws-sdk/client-lambda";
424
+
425
+ import { SimAws } from "@kensio/yulin";
426
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
427
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
428
+ import { serveSimAws } from "@kensio/yulin/serve";
429
+
430
+ const simAws = new SimAws();
431
+
432
+ const { FunctionArn } = await simAws.lambda().createFunction(
433
+ new CreateFunctionCommand({
434
+ FunctionName: "pets",
435
+ Role: "arn:aws:iam::111111111111:role/PetsRole",
436
+ Code: {
437
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
438
+ statusCode: 200,
439
+ headers: { "content-type": "application/json" },
440
+ body: JSON.stringify({
441
+ routeKey: event.routeKey,
442
+ rawPath: event.rawPath,
443
+ stage: event.requestContext.stage,
444
+ petId: event.pathParameters?.["petId"],
445
+ }),
446
+ })),
447
+ },
448
+ }),
449
+ );
450
+
451
+ const apiGateway = simAws.apiGatewayV2();
452
+
453
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
454
+ new CreateApiCommand({ Name: "pets", ProtocolType: "HTTP" }),
455
+ );
456
+
457
+ const { IntegrationId } = await apiGateway.createIntegration(
458
+ new CreateIntegrationCommand({
459
+ ApiId,
460
+ IntegrationType: "AWS_PROXY",
461
+ IntegrationUri: FunctionArn,
462
+ PayloadFormatVersion: "2.0",
463
+ }),
464
+ );
465
+
466
+ for (const RouteKey of [
467
+ "GET /pets",
468
+ "GET /pets/{petId}",
469
+ "ANY /admin/{proxy+}",
470
+ "$default",
471
+ ]) {
472
+ await apiGateway.createRoute(
473
+ new CreateRouteCommand({
474
+ ApiId,
475
+ RouteKey,
476
+ Target: `integrations/${IntegrationId}`,
477
+ }),
478
+ );
479
+ }
480
+
481
+ await apiGateway.createStage(
482
+ new CreateStageCommand({ ApiId, StageName: "dev", AutoDeploy: true }),
483
+ );
484
+
485
+ await simAws.lambda().addPermission(
486
+ new AddPermissionCommand({
487
+ FunctionName: "pets",
488
+ StatementId: "api-gateway-invoke",
489
+ Action: "lambda:InvokeFunction",
490
+ Principal: "apigateway.amazonaws.com",
491
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
492
+ }),
493
+ );
494
+
495
+ const srv = await serveSimAws({ simAws });
496
+
497
+ const response = await fetch(srv.localUrl(`${ApiEndpoint}/dev/pets/6`));
498
+
499
+ console.log(await response.json());
500
+
501
+ await srv.close();
502
+ ```
503
+
504
+ That handler reports four fields of its event. The response body it produces is:
505
+
506
+ ```json
507
+ {
508
+ "routeKey": "GET /pets/{petId}",
509
+ "rawPath": "/dev/pets/6",
510
+ "stage": "dev",
511
+ "petId": "6"
512
+ }
513
+ ```
514
+
515
+ `rawPath` and `requestContext.http.path` keep the stage segment. `routeKey` and `pathParameters` come
516
+ from the path the routes matched, `/pets/6`. The stage prefix in the path is corroborated by the
517
+ documented `$context.path` access log variable ("the request path, for example
518
+ `/{stage}/root/child`"). The payload format page says nothing about it.
519
+
520
+ A stage name is `$default`, or up to 128 alphanumerics, hyphens and underscores. The `$default` stage
521
+ is served at the root of the endpoint, and a named stage under its own segment. Both kinds can exist
522
+ on one API at once. An explicit stage match wins over any route match. With a `$default` stage and a
523
+ stage named `pets`, a request for `/pets/dog` is served by the stage `pets` on the route path `/dog`.
524
+ A request reaching an API with no stage for it, and no `$default` stage, is a 404.
525
+
526
+ `pathParameters` is left out of the event entirely when the matched route captured nothing, including
527
+ on a `$default` match. `StageVariables` set on the stage arrive as `event.stageVariables`, and are
528
+ left out the same way when the stage has none.
529
+
530
+ ## Throttling a stage and a route
531
+
532
+ A stage holds a token bucket for every route it serves. `DefaultRouteSettings` sets the rate and the
533
+ burst those buckets start with, and a `RouteSettings` entry keyed by route key overrides them for the
534
+ route it names. `ThrottlingRateLimit` is requests per second, and `ThrottlingBurstLimit` is how many
535
+ requests a route will take at once.
536
+
537
+ A request that finds an empty bucket is answered 429 with `{"message":"Too Many Requests"}`. The
538
+ route's authorizer and its integration are both skipped.
539
+
540
+ The buckets refill against the simulated clock. Freeze it, spend a route's burst, assert on the 429,
541
+ then move a second on and watch the route serve again.
542
+
543
+ ```typescript sim-apigatewayv2-throttling
544
+ /**
545
+ * Throttling a simulated HTTP API stage and one of its routes.
546
+ */
547
+
548
+ import {
549
+ CreateApiCommand,
550
+ CreateIntegrationCommand,
551
+ CreateRouteCommand,
552
+ CreateStageCommand,
553
+ } from "@aws-sdk/client-apigatewayv2";
554
+ import {
555
+ AddPermissionCommand,
556
+ CreateFunctionCommand,
557
+ } from "@aws-sdk/client-lambda";
558
+
559
+ import { SimAws } from "@kensio/yulin";
560
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
561
+ import { serveSimAws } from "@kensio/yulin/serve";
562
+
563
+ const simAws = new SimAws();
564
+
565
+ const { FunctionArn } = await simAws.lambda().createFunction(
566
+ new CreateFunctionCommand({
567
+ FunctionName: "users",
568
+ Role: "arn:aws:iam::111111111111:role/UsersRole",
569
+ Code: {
570
+ ZipFile: makeLambdaZipFileInput(() => ({
571
+ statusCode: 200,
572
+ headers: { "content-type": "text/plain" },
573
+ body: "ok",
574
+ })),
575
+ },
576
+ }),
577
+ );
578
+
579
+ const apiGateway = simAws.apiGatewayV2();
580
+
581
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
582
+ new CreateApiCommand({ Name: "users", ProtocolType: "HTTP" }),
583
+ );
584
+
585
+ const { IntegrationId } = await apiGateway.createIntegration(
586
+ new CreateIntegrationCommand({
587
+ ApiId,
588
+ IntegrationType: "AWS_PROXY",
589
+ IntegrationUri: FunctionArn,
590
+ PayloadFormatVersion: "2.0",
591
+ }),
592
+ );
593
+
594
+ for (const RouteKey of ["POST /user/password-reset", "GET /user/profile"]) {
595
+ await apiGateway.createRoute(
596
+ new CreateRouteCommand({
597
+ ApiId,
598
+ RouteKey,
599
+ Target: `integrations/${IntegrationId}`,
600
+ }),
601
+ );
602
+ }
603
+
604
+ await apiGateway.createStage(
605
+ new CreateStageCommand({
606
+ ApiId,
607
+ StageName: "$default",
608
+ AutoDeploy: true,
609
+ DefaultRouteSettings: { ThrottlingRateLimit: 10, ThrottlingBurstLimit: 5 },
610
+ RouteSettings: {
611
+ "POST /user/password-reset": {
612
+ ThrottlingRateLimit: 1,
613
+ ThrottlingBurstLimit: 2,
614
+ },
615
+ },
616
+ }),
617
+ );
618
+
619
+ await simAws.lambda().addPermission(
620
+ new AddPermissionCommand({
621
+ FunctionName: "users",
622
+ StatementId: "api-gateway-invoke",
623
+ Action: "lambda:InvokeFunction",
624
+ Principal: "apigateway.amazonaws.com",
625
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
626
+ }),
627
+ );
628
+
629
+ const srv = await serveSimAws({ simAws });
630
+
631
+ // Stop simulated time. A bucket now refills only when this example moves it.
632
+ simAws.clock().freeze();
633
+
634
+ const passwordReset = async (): Promise<Response> =>
635
+ await fetch(srv.localUrl(`${ApiEndpoint}/user/password-reset`), {
636
+ method: "POST",
637
+ });
638
+
639
+ const first = await passwordReset();
640
+ const second = await passwordReset();
641
+ const third = await passwordReset();
642
+
643
+ console.log(first.status, second.status, third.status);
644
+ console.log(await third.text());
645
+
646
+ // Another route, drawing on the stage default and a bucket of its own.
647
+ const profile = await fetch(srv.localUrl(`${ApiEndpoint}/user/profile`));
648
+ console.log(profile.status);
649
+
650
+ // One second at a rate limit of one is one token back.
651
+ await simAws.clock().advanceBy({ seconds: 1 });
652
+ const afterASecond = await passwordReset();
653
+ console.log(afterASecond.status);
654
+
655
+ await srv.close();
656
+ ```
657
+
658
+ The burst of two is served, the third password reset is refused, and the profile route is untouched
659
+ by any of it:
660
+
661
+ ```text
662
+ 200 200 429
663
+ {"message":"Too Many Requests"}
664
+ 200
665
+ 200
666
+ ```
667
+
668
+ Every client of a route draws on the same bucket. Two callers sending one request each spend two
669
+ tokens between them. A WAFv2 `RateBasedStatement` counts each client on its own (see
670
+ [Rate limiting](https://yulinsim.dev/services/wafv2/#rate-limiting)), and a stack often carries both.
671
+
672
+ A route is throttled here only where the settings reaching it name both limits. Naming one alone
673
+ leaves the other at the account limit on real AWS. Account limits are outside this simulation, and a
674
+ route configured that way is served unthrottled.
675
+
676
+ `GetStages` answers with the settings a stage was created with. `AWS::ApiGatewayV2::Stage` deploys
677
+ both properties as well (see [CloudFormation](#cloudformation)). The three members of `RouteSettings`
678
+ that say nothing about throttling, `DetailedMetricsEnabled`, `LoggingLevel` and `DataTraceEnabled`,
679
+ are refused by `CreateStage`. A template carrying one deploys, and the member it named is recorded on
680
+ `stack.ignoredProperties`.
681
+
682
+ ## Protecting a route with a Cognito user pool
683
+
684
+ A JWT authorizer verifies a signed token before the integration is invoked. `CreateAuthorizerCommand`
685
+ creates one, and a route asks for it with `AuthorizationType: "JWT"` and the authorizer's id.
686
+
687
+ The issuer is a URL. Point it at a [simulated Cognito user pool](https://yulinsim.dev/services/cognito/ "Simulated Cognito docs")
688
+ and the pool's own signing key verifies the token. A token from `InitiateAuthCommand` or
689
+ `AdminInitiateAuthCommand` reaches the route, and anything else is turned away. The audience is the
690
+ app client ids the authorizer admits.
691
+
692
+ ```typescript sim-apigatewayv2-jwt-authorizer
693
+ /**
694
+ * Protecting a simulated HTTP API route with a Cognito user pool.
695
+ */
696
+
697
+ import {
698
+ CreateApiCommand,
699
+ CreateAuthorizerCommand,
700
+ CreateIntegrationCommand,
701
+ CreateRouteCommand,
702
+ CreateStageCommand,
703
+ } from "@aws-sdk/client-apigatewayv2";
704
+ import {
705
+ AdminCreateUserCommand,
706
+ AdminInitiateAuthCommand,
707
+ AdminSetUserPasswordCommand,
708
+ CreateUserPoolClientCommand,
709
+ CreateUserPoolCommand,
710
+ } from "@aws-sdk/client-cognito-identity-provider";
711
+ import {
712
+ AddPermissionCommand,
713
+ CreateFunctionCommand,
714
+ } from "@aws-sdk/client-lambda";
715
+
716
+ import { SimAws } from "@kensio/yulin";
717
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
718
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
719
+ import { serveSimAws } from "@kensio/yulin/serve";
720
+
721
+ const simAws = new SimAws();
722
+ const cognito = simAws.cognitoIdentityProvider();
723
+
724
+ const pool = await cognito.createUserPool(
725
+ new CreateUserPoolCommand({ PoolName: "myapp-users" }),
726
+ );
727
+ const UserPoolId = pool.UserPool!.Id!;
728
+
729
+ const appClient = await cognito.createUserPoolClient(
730
+ new CreateUserPoolClientCommand({
731
+ UserPoolId,
732
+ ClientName: "web",
733
+ ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"],
734
+ }),
735
+ );
736
+ const ClientId = appClient.UserPoolClient!.ClientId!;
737
+
738
+ await cognito.adminCreateUser(
739
+ new AdminCreateUserCommand({ UserPoolId, Username: "ada" }),
740
+ );
741
+ await cognito.adminSetUserPassword(
742
+ new AdminSetUserPasswordCommand({
743
+ UserPoolId,
744
+ Username: "ada",
745
+ Password: "Correct-horse-1",
746
+ Permanent: true,
747
+ }),
748
+ );
749
+
750
+ const { FunctionArn } = await simAws.lambda().createFunction(
751
+ new CreateFunctionCommand({
752
+ FunctionName: "orders",
753
+ Role: "arn:aws:iam::111111111111:role/OrdersRole",
754
+ Code: {
755
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
756
+ statusCode: 200,
757
+ headers: { "content-type": "text/plain" },
758
+ body: `orders for ${
759
+ event.requestContext.authorizer?.jwt?.claims["username"] ?? "nobody"
760
+ }`,
761
+ })),
762
+ },
763
+ }),
764
+ );
765
+
766
+ const apiGateway = simAws.apiGatewayV2();
767
+
768
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
769
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
770
+ );
771
+
772
+ const { IntegrationId } = await apiGateway.createIntegration(
773
+ new CreateIntegrationCommand({
774
+ ApiId,
775
+ IntegrationType: "AWS_PROXY",
776
+ IntegrationUri: FunctionArn,
777
+ PayloadFormatVersion: "2.0",
778
+ }),
779
+ );
780
+
781
+ const { AuthorizerId } = await apiGateway.createAuthorizer(
782
+ new CreateAuthorizerCommand({
783
+ ApiId,
784
+ Name: "pool-authorizer",
785
+ AuthorizerType: "JWT",
786
+ IdentitySource: ["$request.header.Authorization"],
787
+ JwtConfiguration: {
788
+ Issuer: `https://cognito-idp.us-east-1.amazonaws.com/${UserPoolId}`,
789
+ Audience: [ClientId],
790
+ },
791
+ }),
792
+ );
793
+
794
+ await apiGateway.createRoute(
795
+ new CreateRouteCommand({
796
+ ApiId,
797
+ RouteKey: "GET /orders",
798
+ Target: `integrations/${IntegrationId}`,
799
+ AuthorizationType: "JWT",
800
+ AuthorizerId,
801
+ }),
802
+ );
803
+
804
+ await apiGateway.createStage(
805
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
806
+ );
807
+
808
+ await simAws.lambda().addPermission(
809
+ new AddPermissionCommand({
810
+ FunctionName: "orders",
811
+ StatementId: "api-gateway-invoke",
812
+ Action: "lambda:InvokeFunction",
813
+ Principal: "apigateway.amazonaws.com",
814
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
815
+ }),
816
+ );
817
+
818
+ const signedIn = await cognito.adminInitiateAuth(
819
+ new AdminInitiateAuthCommand({
820
+ UserPoolId,
821
+ ClientId,
822
+ AuthFlow: "ADMIN_USER_PASSWORD_AUTH",
823
+ AuthParameters: { USERNAME: "ada", PASSWORD: "Correct-horse-1" },
824
+ }),
825
+ );
826
+ const accessToken = signedIn.AuthenticationResult!.AccessToken!;
827
+
828
+ const srv = await serveSimAws({ simAws });
829
+ const url = srv.localUrl(`${ApiEndpoint}/orders`);
830
+
831
+ const anonymous = await fetch(url);
832
+
833
+ console.log(anonymous.status); // 401
834
+ console.log(anonymous.headers.get("www-authenticate")); // "Bearer"
835
+
836
+ const authorized = await fetch(url, {
837
+ headers: { authorization: `Bearer ${accessToken}` },
838
+ });
839
+
840
+ console.log(await authorized.text()); // "orders for ada"
841
+
842
+ // Advancing the simulation's clock past the token's expiry closes the route
843
+ // to the same token, with nothing reissued.
844
+ await simAws.clock().advanceBy({ hours: 2 });
845
+
846
+ const expired = await fetch(url, {
847
+ headers: { authorization: `Bearer ${accessToken}` },
848
+ });
849
+
850
+ console.log(expired.status); // 401
851
+
852
+ await srv.close();
853
+ ```
854
+
855
+ The verification is real. The token is parsed, its `alg` has to be `RS256`, its `kid` has to name a
856
+ key the issuer publishes, and the signature is checked against that key with `node:crypto`. The whole
857
+ check runs in process, with no network fetch and no verification library.
858
+
859
+ ### What a refused request gets back
860
+
861
+ A token that is missing, unreadable, signed with an unsupported algorithm, signed by an unknown key,
862
+ or carrying a claim that fails is answered with a 401, `{"message":"Unauthorized"}` and a
863
+ `www-authenticate: Bearer` header. The integration is never invoked. Which check failed stays
864
+ undisclosed, here and on real API Gateway. A mismatched audience is the exception. That one carries
865
+ `error_description="the token does not have a valid audience"`, the one description AWS publishes.
866
+
867
+ The claims are checked in the order AWS documents. The issuer comes first, then the audience, then
868
+ `exp`, `nbf` and `iat`. There is no allowance for clock skew, and every timestamp comes from the simulation's
869
+ clock. `simAws.clock().advanceBy(...)` expires a token that was accepted a moment before.
870
+
871
+ ### Identity source
872
+
873
+ `IdentitySource` takes one entry, either `$request.header.<name>` or `$request.querystring.<name>`. A
874
+ `Bearer` prefix on the value, followed by whitespace, is stripped case-insensitively and is optional.
875
+ Anything else is refused by `CreateAuthorizer`. An authorizer looking for the token where no client
876
+ puts it refuses every request, for a reason that reads like a signing problem.
877
+
878
+ ### Route scopes, and access tokens versus ID tokens
879
+
880
+ `AuthorizationScopes` on a route is checked against the token's `scope` claim, split on whitespace.
881
+ The check is any-of, so one matching scope is enough. A verified token matching none of them is
882
+ answered with a 403 and `{"message":"Forbidden"}`.
883
+
884
+ ```typescript
885
+ await apiGateway.createRoute(
886
+ new CreateRouteCommand({
887
+ ApiId,
888
+ RouteKey: "GET /orders",
889
+ Target: `integrations/${IntegrationId}`,
890
+ AuthorizationType: "JWT",
891
+ AuthorizerId,
892
+ AuthorizationScopes: ["aws.cognito.signin.user.admin"],
893
+ }),
894
+ );
895
+ ```
896
+
897
+ An ID token passes an authorizer that configures only an audience. `token_use` goes unchecked here, as
898
+ it does on real API Gateway. An ID token's `aud` is the app client id, and matches. AWS documents this
899
+ and recommends route scopes as the way to tell the two apart. A Cognito ID token has no `scope` claim
900
+ at all, so any route scope refuses it.
901
+
902
+ Sign-in through the user pool API issues one scope, `aws.cognito.signin.user.admin`, and that is the
903
+ only scope a simulated flow can put in a token. Resource servers, custom scopes and the client
904
+ credentials grant are outside the simulation. No other route scope is satisfiable.
905
+
906
+ ### The claims the handler receives
907
+
908
+ An accepted token arrives as `event.requestContext.authorizer.jwt`:
909
+
910
+ ```json
911
+ {
912
+ "claims": {
913
+ "sub": "0a1b2c3d-...",
914
+ "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123",
915
+ "client_id": "1h57kf5cpparf3m47el34md5m9",
916
+ "token_use": "access",
917
+ "scope": "aws.cognito.signin.user.admin",
918
+ "username": "ada",
919
+ "cognito:groups": "[Admins Readers]",
920
+ "exp": "1785675600"
921
+ },
922
+ "scopes": ["aws.cognito.signin.user.admin"]
923
+ }
924
+ ```
925
+
926
+ Every claim value is a string, whatever type it was signed as. A list claim such as `cognito:groups`
927
+ is rendered the way Go prints a slice, so two groups arrive as `[Admins Readers]`, and not as JSON or
928
+ a comma-separated list. `scopes` is `null`, not an empty list, when the token carries no `scope` claim.
929
+ AWS publishes none of that. All of it is what the real endpoint was observed to send.
930
+
931
+ A route with `AuthorizationType: "NONE"` has no caller to describe, so `requestContext.authorizer` is
932
+ left out of its events entirely.
933
+
934
+ ## Protecting a route with IAM
935
+
936
+ A route declared `AuthorizationType: "AWS_IAM"` reaches its integration only when the caller is
937
+ allowed `execute-api:Invoke` on the ARN of the route being called. IAM itself decides. The route takes
938
+ no authorizer, and naming one is refused.
939
+
940
+ The caller comes from the request, through either a SigV4 signature or an `x-sim-aws-caller` header
941
+ naming a principal directly. A request offering neither is anonymous, owns no policies, and is
942
+ refused. See [callers of HTTP requests](https://yulinsim.dev/services/iam/#callers-of-http-requests) in the IAM docs for how that
943
+ resolution works and how to sign a served request.
944
+
945
+ The ARN a request is authorized against is:
946
+
947
+ ```text
948
+ arn:aws:execute-api:<region>:<account>:<apiId>/<stage>/<METHOD>/<path>
949
+ ```
950
+
951
+ - The Account and Region are the API's own, not the caller's.
952
+ - The stage is the one that served the request, so `$default` for the default stage.
953
+ - The method is the one the client sent, upper case. A `GET` reaching a route keyed `ANY /orders`
954
+ gives `GET`.
955
+ - The path is the request path with the stage segment and the leading slash taken off, so
956
+ `/dev/orders/42` served from stage `dev` gives `orders/42`. This is the path asked for, not the route
957
+ key, because a policy is written against it by hand. A request to the API root gives an ARN ending
958
+ `/GET/`.
959
+
960
+ An identity policy may wildcard any part of that. `<apiId>/*`, `<apiId>/$default/*` and
961
+ `<apiId>/*/GET/orders/*` all allow a `GET` of `/orders/42` on the default stage.
962
+
963
+ ```typescript sim-apigatewayv2-iam-authorizer
964
+ /**
965
+ * Protecting a simulated HTTP API route with IAM.
966
+ */
967
+
968
+ import {
969
+ CreateApiCommand,
970
+ CreateIntegrationCommand,
971
+ CreateRouteCommand,
972
+ CreateStageCommand,
973
+ } from "@aws-sdk/client-apigatewayv2";
974
+ import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
975
+ import {
976
+ AddPermissionCommand,
977
+ CreateFunctionCommand,
978
+ } from "@aws-sdk/client-lambda";
979
+
980
+ import { SimAws } from "@kensio/yulin";
981
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
982
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
983
+ import { serveSimAws } from "@kensio/yulin/serve";
984
+
985
+ const simAws = new SimAws();
986
+
987
+ const { FunctionArn } = await simAws.lambda().createFunction(
988
+ new CreateFunctionCommand({
989
+ FunctionName: "orders",
990
+ Role: "arn:aws:iam::888888888888:role/OrdersRole",
991
+ Code: {
992
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
993
+ statusCode: 200,
994
+ headers: { "content-type": "text/plain" },
995
+ body: `orders for ${
996
+ event.requestContext.authorizer?.iam?.userArn ?? "nobody"
997
+ }`,
998
+ })),
999
+ },
1000
+ }),
1001
+ );
1002
+
1003
+ const apiGateway = simAws.apiGatewayV2();
1004
+
1005
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
1006
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
1007
+ );
1008
+
1009
+ const { IntegrationId } = await apiGateway.createIntegration(
1010
+ new CreateIntegrationCommand({
1011
+ ApiId,
1012
+ IntegrationType: "AWS_PROXY",
1013
+ IntegrationUri: FunctionArn,
1014
+ PayloadFormatVersion: "2.0",
1015
+ }),
1016
+ );
1017
+
1018
+ await apiGateway.createRoute(
1019
+ new CreateRouteCommand({
1020
+ ApiId,
1021
+ RouteKey: "GET /orders/{orderId}",
1022
+ Target: `integrations/${IntegrationId}`,
1023
+ AuthorizationType: "AWS_IAM",
1024
+ }),
1025
+ );
1026
+
1027
+ await apiGateway.createStage(
1028
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
1029
+ );
1030
+
1031
+ await simAws.lambda().addPermission(
1032
+ new AddPermissionCommand({
1033
+ FunctionName: "orders",
1034
+ StatementId: "api-gateway-invoke",
1035
+ Action: "lambda:InvokeFunction",
1036
+ Principal: "apigateway.amazonaws.com",
1037
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
1038
+ }),
1039
+ );
1040
+
1041
+ // A Role of the API's own Account, allowed to call the orders routes of this
1042
+ // API on the default stage.
1043
+ await simAws.iam().createRole(
1044
+ new CreateRoleCommand({
1045
+ RoleName: "Reporter",
1046
+ AssumeRolePolicyDocument: JSON.stringify({
1047
+ Version: "2012-10-17",
1048
+ Statement: [
1049
+ {
1050
+ Effect: "Allow",
1051
+ Principal: { AWS: "arn:aws:iam::888888888888:root" },
1052
+ Action: "sts:AssumeRole",
1053
+ },
1054
+ ],
1055
+ }),
1056
+ }),
1057
+ );
1058
+
1059
+ await simAws.iam().putRolePolicy(
1060
+ new PutRolePolicyCommand({
1061
+ RoleName: "Reporter",
1062
+ PolicyName: "InvokeOrders",
1063
+ PolicyDocument: JSON.stringify({
1064
+ Version: "2012-10-17",
1065
+ Statement: [
1066
+ {
1067
+ Effect: "Allow",
1068
+ Action: "execute-api:Invoke",
1069
+ Resource: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/$default/GET/orders/*`,
1070
+ },
1071
+ ],
1072
+ }),
1073
+ }),
1074
+ );
1075
+
1076
+ const srv = await serveSimAws({ simAws });
1077
+ const url = srv.localUrl(`${ApiEndpoint}/orders/42`);
1078
+
1079
+ const anonymous = await fetch(url);
1080
+
1081
+ console.log(anonymous.status); // 403
1082
+ console.log(await anonymous.text()); // '{"message":"Forbidden"}'
1083
+
1084
+ const reporter = await fetch(url, {
1085
+ headers: { "x-sim-aws-caller": "arn:aws:iam::888888888888:role/Reporter" },
1086
+ });
1087
+
1088
+ console.log(await reporter.text()); // "orders for arn:aws:iam::888888888888:role/Reporter"
1089
+
1090
+ await srv.close();
1091
+ ```
1092
+
1093
+ ### What a refused request gets back
1094
+
1095
+ A caller IAM refuses is answered with a 403 and `{"message":"Forbidden"}`, and the integration is
1096
+ never invoked. An unsigned request gets the same answer. The serving boundary resolves it to an
1097
+ anonymous caller, and an anonymous caller is allowed nothing. An explicit `Deny` beats an `Allow`, as
1098
+ it does in any IAM evaluation.
1099
+
1100
+ A request whose signature is malformed, or is scoped to another service, never reaches the route. The
1101
+ serving boundary refuses it first, with `{"Message":"Forbidden"}` and a capital `M`.
1102
+
1103
+ ### The caller the handler receives
1104
+
1105
+ An admitted request carries its caller into the event as `requestContext.authorizer.iam`:
1106
+
1107
+ ```json
1108
+ {
1109
+ "accessKey": "",
1110
+ "accountId": "888888888888",
1111
+ "callerId": "arn:aws:iam::888888888888:role/Reporter",
1112
+ "cognitoIdentity": null,
1113
+ "principalOrgId": null,
1114
+ "userArn": "arn:aws:iam::888888888888:role/Reporter",
1115
+ "userId": "arn:aws:iam::888888888888:role/Reporter"
1116
+ }
1117
+ ```
1118
+
1119
+ `accountId` and `userArn` come from the resolved principal's ARN. `requestContext.accountId` carries
1120
+ that Account too, in place of `anonymous`. The block is the same one a Lambda Function URL produces, so
1121
+ handler code reading it behaves the same behind either.
1122
+
1123
+ ### Callers from another Account
1124
+
1125
+ A principal of another Account is refused, whatever its own Account allows it. A cross-Account request
1126
+ needs an Allow from the resource side as well, and an HTTP API has nowhere to put one. An HTTP API has
1127
+ no resource policy at all, where a REST API does. Real AWS behaves the same way.
1128
+
1129
+ The way through, here and on AWS, is for that principal to assume a Role in the API's Account through
1130
+ STS and sign with the session credentials. The request is then made by a principal of the API's own
1131
+ Account.
1132
+
1133
+ ## Protecting a route with a Lambda authorizer
1134
+
1135
+ A Lambda `REQUEST` authorizer runs a function of its own before the integration is invoked, and that
1136
+ function decides. `CreateAuthorizerCommand` with `AuthorizerType: "REQUEST"` creates one, and a route
1137
+ asks for it with `AuthorizationType: "CUSTOM"` and the authorizer's id.
1138
+
1139
+ `IdentitySource` is what the request has to carry before the function is invoked at all. Each entry
1140
+ is `$request.header.<name>` or `$request.querystring.<name>`, and a request missing any one of them is
1141
+ refused without the function running.
1142
+
1143
+ `EnableSimpleResponses: true` asks the function for `{ isAuthorized, context }`. With it off, the
1144
+ function answers a `principalId` and an IAM `policyDocument` instead. Either way, the `context` it
1145
+ returns reaches the integration handler as `event.requestContext.authorizer.lambda`.
1146
+
1147
+ ```typescript sim-apigatewayv2-lambda-authorizer
1148
+ /**
1149
+ * Protecting a simulated HTTP API route with a Lambda REQUEST authorizer.
1150
+ */
1151
+
1152
+ import {
1153
+ CreateApiCommand,
1154
+ CreateAuthorizerCommand,
1155
+ CreateIntegrationCommand,
1156
+ CreateRouteCommand,
1157
+ CreateStageCommand,
1158
+ } from "@aws-sdk/client-apigatewayv2";
1159
+ import {
1160
+ AddPermissionCommand,
1161
+ CreateFunctionCommand,
1162
+ } from "@aws-sdk/client-lambda";
1163
+
1164
+ import { SimAws } from "@kensio/yulin";
1165
+ import type {
1166
+ SimHttpApiAuthorizerEvent,
1167
+ SimPayload2Event,
1168
+ } from "@kensio/yulin/apigatewayv2";
1169
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
1170
+ import { serveSimAws } from "@kensio/yulin/serve";
1171
+
1172
+ const simAws = new SimAws();
1173
+ const lambda = simAws.lambda();
1174
+
1175
+ const { FunctionArn: AuthorizerFunctionArn } = await lambda.createFunction(
1176
+ new CreateFunctionCommand({
1177
+ FunctionName: "session-authorizer",
1178
+ Role: "arn:aws:iam::888888888888:role/AuthorizerRole",
1179
+ Code: {
1180
+ ZipFile: makeLambdaZipFileInput((event: SimHttpApiAuthorizerEvent) => ({
1181
+ isAuthorized: event.identitySource[0] === "session=valid",
1182
+ context: { tenant: "acme" },
1183
+ })),
1184
+ },
1185
+ }),
1186
+ );
1187
+
1188
+ const { FunctionArn } = await lambda.createFunction(
1189
+ new CreateFunctionCommand({
1190
+ FunctionName: "account",
1191
+ Role: "arn:aws:iam::888888888888:role/AccountRole",
1192
+ Code: {
1193
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
1194
+ statusCode: 200,
1195
+ headers: { "content-type": "application/json" },
1196
+ body: JSON.stringify(event.requestContext.authorizer?.lambda),
1197
+ })),
1198
+ },
1199
+ }),
1200
+ );
1201
+
1202
+ const apiGateway = simAws.apiGatewayV2();
1203
+
1204
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
1205
+ new CreateApiCommand({ Name: "account", ProtocolType: "HTTP" }),
1206
+ );
1207
+
1208
+ const { IntegrationId } = await apiGateway.createIntegration(
1209
+ new CreateIntegrationCommand({
1210
+ ApiId,
1211
+ IntegrationType: "AWS_PROXY",
1212
+ IntegrationUri: FunctionArn,
1213
+ PayloadFormatVersion: "2.0",
1214
+ }),
1215
+ );
1216
+
1217
+ const { AuthorizerId } = await apiGateway.createAuthorizer(
1218
+ new CreateAuthorizerCommand({
1219
+ ApiId,
1220
+ Name: "session-cookie",
1221
+ AuthorizerType: "REQUEST",
1222
+ AuthorizerUri: AuthorizerFunctionArn,
1223
+ AuthorizerPayloadFormatVersion: "2.0",
1224
+ EnableSimpleResponses: true,
1225
+ IdentitySource: ["$request.header.cookie"],
1226
+ }),
1227
+ );
1228
+
1229
+ await apiGateway.createRoute(
1230
+ new CreateRouteCommand({
1231
+ ApiId,
1232
+ RouteKey: "GET /account",
1233
+ Target: `integrations/${IntegrationId}`,
1234
+ AuthorizationType: "CUSTOM",
1235
+ AuthorizerId,
1236
+ }),
1237
+ );
1238
+
1239
+ await apiGateway.createStage(
1240
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
1241
+ );
1242
+
1243
+ // Each function needs its own grant: the integration is invoked under the ARN
1244
+ // of the route, and the authorizer under an ARN naming the authorizer.
1245
+ await lambda.addPermission(
1246
+ new AddPermissionCommand({
1247
+ FunctionName: "account",
1248
+ StatementId: "api-gateway-invoke",
1249
+ Action: "lambda:InvokeFunction",
1250
+ Principal: "apigateway.amazonaws.com",
1251
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
1252
+ }),
1253
+ );
1254
+
1255
+ await lambda.addPermission(
1256
+ new AddPermissionCommand({
1257
+ FunctionName: "session-authorizer",
1258
+ StatementId: "api-gateway-invoke-authorizer",
1259
+ Action: "lambda:InvokeFunction",
1260
+ Principal: "apigateway.amazonaws.com",
1261
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/authorizers/${AuthorizerId}`,
1262
+ }),
1263
+ );
1264
+
1265
+ const srv = await serveSimAws({ simAws });
1266
+ const url = srv.localUrl(`${ApiEndpoint}/account`);
1267
+
1268
+ const refused = await fetch(url, { headers: { cookie: "session=expired" } });
1269
+
1270
+ console.log(refused.status); // 403
1271
+
1272
+ const admitted = await fetch(url, { headers: { cookie: "session=valid" } });
1273
+
1274
+ console.log(await admitted.text()); // '{"tenant":"acme"}'
1275
+
1276
+ await srv.close();
1277
+ ```
1278
+
1279
+ The authorizer function is invoked once per request reaching the route. Caching starts once the
1280
+ authorizer is given an `AuthorizerResultTtlInSeconds`, and AWS defaults it off the same way. See
1281
+ [Caching the authorizer's decision](#caching-the-authorizers-decision).
1282
+
1283
+ ### The event the authorizer receives
1284
+
1285
+ The function is invoked with the payload format 2.0 request event, plus three members of its own,
1286
+ exported as `SimHttpApiAuthorizerEvent`:
1287
+
1288
+ ```json
1289
+ {
1290
+ "version": "2.0",
1291
+ "type": "REQUEST",
1292
+ "routeArn": "arn:aws:execute-api:us-east-1:888888888888:a1b2c3d4e5/$default/GET/account",
1293
+ "identitySource": ["session=valid"],
1294
+ "routeKey": "GET /account",
1295
+ "rawPath": "/account",
1296
+ "rawQueryString": "",
1297
+ "headers": { "cookie": "session=valid" },
1298
+ "requestContext": { "...": "as the integration receives it" }
1299
+ }
1300
+ ```
1301
+
1302
+ `identitySource` carries the values found at the authorizer's identity sources, in the order they were
1303
+ configured. The expressions that found them stay out of the event. `routeArn` names the route the
1304
+ request matched, carrying the route key's path template. `GET /orders/{orderId}` is
1305
+ `<apiId>/<stage>/GET/orders/{orderId}` whichever order was asked for.
1306
+
1307
+ There is no `body` and no `isBase64Encoded`. AWS's published example of this event carries neither, and
1308
+ an authorizer cannot read the request body here or on AWS.
1309
+
1310
+ ### Answering with a policy
1311
+
1312
+ With `EnableSimpleResponses` left off, the function answers a `principalId` and an IAM policy
1313
+ document, and simulated IAM evaluates it for `execute-api:Invoke` against the route ARN. That is the
1314
+ same evaluation an [`AWS_IAM` route](#protecting-a-route-with-iam) goes through, with one difference.
1315
+ The request has no IAM principal behind it, and the returned document is the whole decision.
1316
+ `principalId` is a name the function chose for the caller, and grants no access.
1317
+
1318
+ ```typescript
1319
+ makeLambdaZipFileInput((event: SimHttpApiAuthorizerEvent) => ({
1320
+ principalId: "user-1",
1321
+ policyDocument: {
1322
+ Version: "2012-10-17",
1323
+ Statement: [
1324
+ {
1325
+ Effect: event.identitySource[0] === "session=valid" ? "Allow" : "Deny",
1326
+ Action: "execute-api:Invoke",
1327
+ Resource: event.routeArn,
1328
+ },
1329
+ ],
1330
+ },
1331
+ context: { tenant: "acme" },
1332
+ }));
1333
+ ```
1334
+
1335
+ An explicit `Deny` beats an `Allow`, and a document with no relevant `Allow` refuses the request.
1336
+
1337
+ ### What a refused request gets back
1338
+
1339
+ - A request missing any configured identity source is a 401 and `{"message":"Unauthorized"}`, and the
1340
+ authorizer function is never invoked.
1341
+ - `isAuthorized: false`, or a policy that refuses `execute-api:Invoke` on the route ARN, is a 403 and
1342
+ `{"message":"Forbidden"}`.
1343
+ - Returning `{ "errorMessage": "Unauthorized" }` is a 401. That is the only way the authorizer
1344
+ function produces one, and it is read whichever response format the authorizer is configured for.
1345
+ - A function that throws, returns a shape matching no response format, returns a policy document
1346
+ IAM cannot read, or has no invoke permission, is a 500 and `{"message":"Internal Server Error"}`.
1347
+ The caller hears no more, the way it hears no detail about a failed integration.
1348
+
1349
+ The integration is never invoked in any of these cases.
1350
+
1351
+ ### The authorizer's invoke permission
1352
+
1353
+ The authorizer's function is invoked under
1354
+ `arn:aws:execute-api:<region>:<account>:<apiId>/authorizers/<authorizerId>`. That is the `SourceArn`
1355
+ AWS documents for granting API Gateway permission to invoke one. It names no stage and no route. That
1356
+ is a different grant from the integration's, and a function used for both needs both.
1357
+
1358
+ ### Caching the authorizer's decision
1359
+
1360
+ `AuthorizerResultTtlInSeconds` holds a decision for that many seconds, and a request presenting the
1361
+ same identity source values within it is served from that decision without the function running
1362
+ again. AWS accepts up to 3600. The default, 0, holds no decision at all.
1363
+
1364
+ The key is the identity source values and nothing else, so one decision covers every route of the API
1365
+ that uses the authorizer. Adding `$context.routeKey` as an identity source puts the route in the key.
1366
+ AWS documents that for caching per route.
1367
+
1368
+ ```typescript sim-apigatewayv2-lambda-authorizer-cache
1369
+ /**
1370
+ * Caching a simulated HTTP API Lambda authorizer's decision.
1371
+ */
1372
+
1373
+ import {
1374
+ CreateApiCommand,
1375
+ CreateAuthorizerCommand,
1376
+ CreateIntegrationCommand,
1377
+ CreateRouteCommand,
1378
+ CreateStageCommand,
1379
+ } from "@aws-sdk/client-apigatewayv2";
1380
+ import {
1381
+ AddPermissionCommand,
1382
+ CreateFunctionCommand,
1383
+ } from "@aws-sdk/client-lambda";
1384
+
1385
+ import { SimAws } from "@kensio/yulin";
1386
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
1387
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
1388
+ import { serveSimAws } from "@kensio/yulin/serve";
1389
+
1390
+ const simAws = new SimAws();
1391
+ const lambda = simAws.lambda();
1392
+
1393
+ // An authorizer counting its own invocations, so the caller can see which
1394
+ // decision served each request.
1395
+ const counter = { invocations: 0 };
1396
+
1397
+ const { FunctionArn: AuthorizerFunctionArn } = await lambda.createFunction(
1398
+ new CreateFunctionCommand({
1399
+ FunctionName: "session-authorizer",
1400
+ Role: "arn:aws:iam::888888888888:role/AuthorizerRole",
1401
+ Code: {
1402
+ ZipFile: makeLambdaZipFileInput(() => {
1403
+ counter.invocations += 1;
1404
+
1405
+ return { isAuthorized: true, context: { ...counter } };
1406
+ }),
1407
+ },
1408
+ }),
1409
+ );
1410
+
1411
+ const { FunctionArn } = await lambda.createFunction(
1412
+ new CreateFunctionCommand({
1413
+ FunctionName: "account",
1414
+ Role: "arn:aws:iam::888888888888:role/AccountRole",
1415
+ Code: {
1416
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
1417
+ statusCode: 200,
1418
+ headers: { "content-type": "application/json" },
1419
+ body: JSON.stringify(event.requestContext.authorizer?.lambda),
1420
+ })),
1421
+ },
1422
+ }),
1423
+ );
1424
+
1425
+ const apiGateway = simAws.apiGatewayV2();
1426
+
1427
+ const { ApiId, ApiEndpoint } = await apiGateway.createApi(
1428
+ new CreateApiCommand({ Name: "account", ProtocolType: "HTTP" }),
1429
+ );
1430
+
1431
+ const { IntegrationId } = await apiGateway.createIntegration(
1432
+ new CreateIntegrationCommand({
1433
+ ApiId,
1434
+ IntegrationType: "AWS_PROXY",
1435
+ IntegrationUri: FunctionArn,
1436
+ PayloadFormatVersion: "2.0",
1437
+ }),
1438
+ );
1439
+
1440
+ const { AuthorizerId } = await apiGateway.createAuthorizer(
1441
+ new CreateAuthorizerCommand({
1442
+ ApiId,
1443
+ Name: "session-cookie",
1444
+ AuthorizerType: "REQUEST",
1445
+ AuthorizerUri: AuthorizerFunctionArn,
1446
+ AuthorizerPayloadFormatVersion: "2.0",
1447
+ EnableSimpleResponses: true,
1448
+ IdentitySource: ["$request.header.cookie"],
1449
+ AuthorizerResultTtlInSeconds: 300,
1450
+ }),
1451
+ );
1452
+
1453
+ await apiGateway.createRoute(
1454
+ new CreateRouteCommand({
1455
+ ApiId,
1456
+ RouteKey: "GET /account",
1457
+ Target: `integrations/${IntegrationId}`,
1458
+ AuthorizationType: "CUSTOM",
1459
+ AuthorizerId,
1460
+ }),
1461
+ );
1462
+
1463
+ await apiGateway.createStage(
1464
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
1465
+ );
1466
+
1467
+ for (const [FunctionName, SourceArn] of [
1468
+ ["account", `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`],
1469
+ [
1470
+ "session-authorizer",
1471
+ `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/authorizers/${AuthorizerId}`,
1472
+ ],
1473
+ ]) {
1474
+ await lambda.addPermission(
1475
+ new AddPermissionCommand({
1476
+ FunctionName,
1477
+ StatementId: "api-gateway-invoke",
1478
+ Action: "lambda:InvokeFunction",
1479
+ Principal: "apigateway.amazonaws.com",
1480
+ SourceArn,
1481
+ }),
1482
+ );
1483
+ }
1484
+
1485
+ const srv = await serveSimAws({ simAws });
1486
+ const url = srv.localUrl(`${ApiEndpoint}/account`);
1487
+ const call = async (): Promise<unknown> => {
1488
+ const response = await fetch(url, { headers: { cookie: "session=valid" } });
1489
+
1490
+ return await response.json();
1491
+ };
1492
+
1493
+ console.log(await call()); // { invocations: 1 }
1494
+ console.log(await call()); // { invocations: 1 }, held rather than asked again
1495
+
1496
+ // Simulated time passing the TTL drops the decision.
1497
+ await simAws.clock().advanceBy({ minutes: 6 });
1498
+
1499
+ console.log(await call()); // { invocations: 2 }
1500
+
1501
+ await srv.close();
1502
+ ```
1503
+
1504
+ A refusal is held the same way an admission is, so a session the authorizer rejected stays rejected
1505
+ until the TTL expires. An authorizer that could not answer at all is the exception. A function that
1506
+ threw, or replied in neither format, is asked again on the next request.
1507
+
1508
+ Expiry is checked against the simulation's clock. `simAws.clock().advanceBy(...)` expires a decision
1509
+ that was being reused a moment before, and no test has to wait.
1510
+
1511
+ ### The context the handler receives
1512
+
1513
+ The `context` the authorizer returned arrives as `event.requestContext.authorizer.lambda`, with its
1514
+ values as the function returned them and no stringifying on the way. An authorizer that allowed the
1515
+ request and returned no context leaves `null` there, and the block still says which kind of authorizer
1516
+ ran.
1517
+
1518
+ ## The event the handler receives
1519
+
1520
+ The handler is invoked with the API Gateway HTTP API payload format 2.0 event, exported as
1521
+ `SimPayload2Event`:
1522
+
1523
+ ```json
1524
+ {
1525
+ "version": "2.0",
1526
+ "routeKey": "$default",
1527
+ "rawPath": "/orders",
1528
+ "rawQueryString": "limit=10",
1529
+ "headers": { "host": "...", "x-forwarded-proto": "https" },
1530
+ "queryStringParameters": { "limit": "10" },
1531
+ "cookies": ["session=abc"],
1532
+ "requestContext": {
1533
+ "accountId": "anonymous",
1534
+ "apiId": "a1b2c3d4e5",
1535
+ "domainName": "a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com",
1536
+ "domainPrefix": "a1b2c3d4e5",
1537
+ "http": {
1538
+ "method": "GET",
1539
+ "path": "/orders",
1540
+ "protocol": "HTTP/1.1",
1541
+ "sourceIp": "127.0.0.1",
1542
+ "userAgent": "..."
1543
+ },
1544
+ "requestId": "...",
1545
+ "routeKey": "$default",
1546
+ "stage": "$default",
1547
+ "time": "02/Aug/2026:11:00:00 +0000",
1548
+ "timeEpoch": 1785668400000
1549
+ },
1550
+ "isBase64Encoded": false
1551
+ }
1552
+ ```
1553
+
1554
+ An empty field is left out of the event, as real API Gateway leaves it out. `cookies`,
1555
+ `queryStringParameters`, `body`, `pathParameters` and `stageVariables` are absent when the request has
1556
+ nothing for them. `rawQueryString` is the exception and is always present, as an empty string when
1557
+ there was no query.
1558
+
1559
+ Repeated query parameters are joined with commas. Cookies travel in `cookies`, with no `cookie`
1560
+ header. A body is passed through as text for a text content type and base64-encoded otherwise, with
1561
+ `isBase64Encoded` saying which happened.
1562
+
1563
+ The headers API Gateway sets itself replace whatever the client sent under those names. `host` is the
1564
+ API's own hostname, in place of the localhost one the request arrived at. `x-forwarded-proto` is
1565
+ `https`, `x-forwarded-port` is `443`, `x-forwarded-for` and `requestContext.http.sourceIp` are
1566
+ `127.0.0.1`, and `x-amzn-trace-id` carries an X-Ray-shaped id that no trace exists for.
1567
+
1568
+ The stage's variables, when it has any, reach the handler as `event.stageVariables`.
1569
+
1570
+ ### Making one without an API
1571
+
1572
+ A test of the integration handler on its own, with no API in front of it, still has to pass it a
1573
+ whole event. `httpApiProxyEventFactory` makes one, so such a test says what the request was and
1574
+ nothing else:
1575
+
1576
+ ```typescript sim-http-api-proxy-event-factory
1577
+ /**
1578
+ * Making an HTTP API invocation event to call an integration handler with.
1579
+ */
1580
+
1581
+ import { VariantFactory } from "@kensio/part-factory";
1582
+
1583
+ import {
1584
+ httpApiProxyEventFactory,
1585
+ type SimPayload2Event,
1586
+ } from "@kensio/yulin/apigatewayv2";
1587
+
1588
+ function ordersHandler(event: SimPayload2Event): string {
1589
+ return `${event.requestContext.http.method} ${event.pathParameters?.["orderId"] ?? "all"}`;
1590
+ }
1591
+
1592
+ // A request naming only its route: the method and path come from the route key.
1593
+ const listing = httpApiProxyEventFactory.make({ routeKey: "GET /orders" });
1594
+
1595
+ // GET all
1596
+ console.log(ordersHandler(listing));
1597
+
1598
+ // A request to a parameterised route says the concrete path and what the route
1599
+ // captured from it.
1600
+ const orderRequestFactory = new VariantFactory(httpApiProxyEventFactory, {
1601
+ routeKey: "GET /orders/{orderId}",
1602
+ });
1603
+
1604
+ const order = orderRequestFactory.make({
1605
+ rawPath: "/orders/YL-1",
1606
+ pathParameters: { orderId: "YL-1" },
1607
+ });
1608
+
1609
+ // GET YL-1
1610
+ console.log(ordersHandler(order));
1611
+ ```
1612
+
1613
+ The defaults describe an unauthorized `GET /` reaching the API's default stage, down to the headers
1614
+ API Gateway sets itself. The route key and the request agree whichever a test gives. An event for
1615
+ `rawPath: "/orders"` is one for the `GET /orders` route, and an event for `routeKey: "POST /orders"`
1616
+ is a POST to `/orders`. A route key whose path is a template captures nothing on its own. An event for
1617
+ a parameterised route says the concrete path and its `pathParameters` itself, as above.
1618
+
1619
+ `requestContext.authorizer` is absent, as it is for a route with no authorizer. Adding it is how a
1620
+ test describes a request that has been through one: `{ jwt: { claims, scopes } }` for a
1621
+ [Cognito user pool](#protecting-a-route-with-a-cognito-user-pool), `{ iam: { ... } }` for
1622
+ [IAM](#protecting-a-route-with-iam), and `{ lambda: { ... } }` for a
1623
+ [Lambda authorizer](#protecting-a-route-with-a-lambda-authorizer). A Lambda authorizer's own event
1624
+ is a different shape, `SimHttpApiAuthorizerEvent`, and has no factory.
1625
+
1626
+ The [event factories page](https://yulinsim.dev/factories/ "Test factories for AWS event shapes usage docs")
1627
+ covers what the factories have in common. A Function URL invocation is the same event from a different
1628
+ endpoint, and has
1629
+ [its own factory](https://yulinsim.dev/services/lambda/#making-an-invocation-event-without-a-request "Simulated Lambda usage docs").
1630
+
1631
+ ## The response the handler returns
1632
+
1633
+ A handler returning an object with a `statusCode` produces that HTTP response. Its `headers` are
1634
+ sent as headers, its `cookies` become `set-cookie` headers, and an empty body is sent as no body,
1635
+ leaving a 204 a valid response.
1636
+
1637
+ A handler returning anything else produces a 200 whose body is that value as JSON. That includes an
1638
+ object with no `statusCode` in it. A handler returning `{ body: "hi" }` produces a 200 whose body is
1639
+ the JSON `{"body":"hi"}` with `content-type: application/json`. Real API Gateway does the same.
1640
+
1641
+ ## Reading an API back
1642
+
1643
+ `GetApisCommand`, `GetIntegrationsCommand`, `GetRoutesCommand` and `GetStagesCommand` list what an
1644
+ API has. Each answers in full, as paging is outside the simulation.
1645
+
1646
+ ```typescript sim-apigatewayv2-list-resources
1647
+ /**
1648
+ * Listing what a simulated HTTP API has.
1649
+ */
1650
+
1651
+ import {
1652
+ CreateApiCommand,
1653
+ CreateStageCommand,
1654
+ GetApisCommand,
1655
+ GetStagesCommand,
1656
+ } from "@aws-sdk/client-apigatewayv2";
1657
+
1658
+ import { SimAws } from "@kensio/yulin";
1659
+
1660
+ const simAws = new SimAws();
1661
+ const apiGateway = simAws.apiGatewayV2();
1662
+
1663
+ const { ApiId } = await apiGateway.createApi(
1664
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
1665
+ );
1666
+
1667
+ await apiGateway.createStage(
1668
+ new CreateStageCommand({
1669
+ ApiId,
1670
+ StageName: "$default",
1671
+ AutoDeploy: true,
1672
+ StageVariables: { catalogue: "v2" },
1673
+ }),
1674
+ );
1675
+
1676
+ const apis = await apiGateway.getApis(new GetApisCommand({}));
1677
+
1678
+ console.log(apis.Items.map((api) => api.Name));
1679
+
1680
+ const stages = await apiGateway.getStages(new GetStagesCommand({ ApiId }));
1681
+
1682
+ console.log(stages.Items[0]?.StageVariables);
1683
+ ```
1684
+
1685
+ ## Deleting what an API has
1686
+
1687
+ `DeleteRouteCommand`, `DeleteIntegrationCommand` and `DeleteStageCommand` each take one resource off
1688
+ an API and leave the rest of it in place. A deleted route stops matching, and a request that used to
1689
+ reach it is answered the way any unmatched request is. A deleted stage stops resolving. A request
1690
+ addressed to it finds no stage, while the routes it served are still served by the API's other stages.
1691
+
1692
+ An integration a route still points at cannot be deleted. That is a `BadRequestException` naming the
1693
+ routes in the way, as it is on real AWS, and an API comes apart routes first and then the integrations
1694
+ behind them. Deleting a route leaves its integration in place, since an integration outlives the
1695
+ routes pointing at it.
1696
+
1697
+ ```typescript sim-apigatewayv2-delete-resources
1698
+ /**
1699
+ * Deleting a route, an integration and a stage from a simulated HTTP API.
1700
+ */
1701
+
1702
+ import {
1703
+ CreateApiCommand,
1704
+ CreateIntegrationCommand,
1705
+ CreateRouteCommand,
1706
+ CreateStageCommand,
1707
+ DeleteIntegrationCommand,
1708
+ DeleteRouteCommand,
1709
+ DeleteStageCommand,
1710
+ GetIntegrationsCommand,
1711
+ GetRoutesCommand,
1712
+ } from "@aws-sdk/client-apigatewayv2";
1713
+
1714
+ import { SimAws } from "@kensio/yulin";
1715
+
1716
+ const simAws = new SimAws();
1717
+ const apiGateway = simAws.apiGatewayV2();
1718
+
1719
+ const { ApiId } = await apiGateway.createApi(
1720
+ new CreateApiCommand({ Name: "orders", ProtocolType: "HTTP" }),
1721
+ );
1722
+
1723
+ const { IntegrationId } = await apiGateway.createIntegration(
1724
+ new CreateIntegrationCommand({
1725
+ ApiId,
1726
+ IntegrationType: "AWS_PROXY",
1727
+ IntegrationUri: "arn:aws:lambda:eu-west-2:111111111111:function:orders",
1728
+ PayloadFormatVersion: "2.0",
1729
+ }),
1730
+ );
1731
+
1732
+ const { RouteId } = await apiGateway.createRoute(
1733
+ new CreateRouteCommand({
1734
+ ApiId,
1735
+ RouteKey: "GET /orders",
1736
+ Target: `integrations/${IntegrationId}`,
1737
+ }),
1738
+ );
1739
+
1740
+ await apiGateway.createStage(
1741
+ new CreateStageCommand({ ApiId, StageName: "dev", AutoDeploy: true }),
1742
+ );
1743
+
1744
+ // The route comes off first, since the integration cannot be deleted while
1745
+ // anything still targets it.
1746
+ await apiGateway.deleteRoute(new DeleteRouteCommand({ ApiId, RouteId }));
1747
+
1748
+ await apiGateway.deleteIntegration(
1749
+ new DeleteIntegrationCommand({ ApiId, IntegrationId }),
1750
+ );
1751
+
1752
+ await apiGateway.deleteStage(
1753
+ new DeleteStageCommand({ ApiId, StageName: "dev" }),
1754
+ );
1755
+
1756
+ const routes = await apiGateway.getRoutes(new GetRoutesCommand({ ApiId }));
1757
+ const integrations = await apiGateway.getIntegrations(
1758
+ new GetIntegrationsCommand({ ApiId }),
1759
+ );
1760
+
1761
+ console.log(routes.Items.length, integrations.Items.length);
1762
+ ```
1763
+
1764
+ `DeleteApiCommand` deletes the API and everything under it, so taking a whole API away needs none of
1765
+ these.
1766
+
1767
+ ## Turning the generated endpoint off
1768
+
1769
+ `DisableExecuteApiEndpoint: true` stops the generated endpoint serving. That is how an API reachable
1770
+ only through a custom domain is configured. It takes the generated hostname out of the set of
1771
+ hostnames the API answers on, and a request to it gets the same 403 any unserved `Host` gets (see
1772
+ [Which hostnames an API answers on](#which-hostnames-an-api-answers-on)). AWS publishes neither the
1773
+ status nor the body for that case, so both are what a disabled endpoint was observed to answer.
1774
+
1775
+ ## Serving an API on a custom domain name
1776
+
1777
+ `CreateDomainNameCommand` creates a domain that answers on a hostname the project owns.
1778
+ `CreateApiMappingCommand` points a base path of that domain at one API and one of its stages. A
1779
+ domain with no mapping answers 404 to everything.
1780
+
1781
+ An empty `ApiMappingKey` maps the root of the domain. Every request reaching the domain goes to that
1782
+ API, with the path as the client sent it. A non-empty key is one or more path segments (`orders`,
1783
+ `orders/v1`), and those segments come off the front of the path before route selection sees it. A
1784
+ request to `/orders/pets/6` under the key `orders` matches the route `GET /pets/{petId}`.
1785
+
1786
+ The mapping names the stage. A request through a custom domain never goes through stage selection,
1787
+ so an API whose only stage is `dev` is served at the root of its domain with no `dev` segment in the
1788
+ path. The generated endpoint still wants that segment.
1789
+
1790
+ Where two mappings both match, the longest base path wins. A domain mapping both its root and
1791
+ `orders` serves `/orders/6` from the `orders` mapping and everything else from the root one.
1792
+
1793
+ The base path does not reach the handler. AWS documents `rawPath` in a payload format 2.0 event as
1794
+ not carrying the API mapping value, and points a handler that needs the whole path at payload format
1795
+ 1.0 and its `path` field. A request to `/orders/pets/6` under the key `orders` reports `/pets/6`, and
1796
+ a request to `/orders` itself reports `/`. A named stage's own segment behaves the other way and is
1797
+ reported, so the two are not the same rule.
1798
+
1799
+ `requestContext.domainName` is the custom domain and `domainPrefix` is its first label, so a handler
1800
+ behind `api.example.com` reads `api` where one behind the generated endpoint reads the API id.
1801
+
1802
+ ```typescript sim-apigatewayv2-custom-domain
1803
+ /**
1804
+ * Serving a simulated HTTP API on a custom domain name and API mapping.
1805
+ */
1806
+
1807
+ import {
1808
+ CreateApiCommand,
1809
+ CreateApiMappingCommand,
1810
+ CreateDomainNameCommand,
1811
+ CreateIntegrationCommand,
1812
+ CreateRouteCommand,
1813
+ CreateStageCommand,
1814
+ } from "@aws-sdk/client-apigatewayv2";
1815
+ import {
1816
+ AddPermissionCommand,
1817
+ CreateFunctionCommand,
1818
+ } from "@aws-sdk/client-lambda";
1819
+
1820
+ import { SimAws } from "@kensio/yulin";
1821
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
1822
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
1823
+ import { serveSimAws } from "@kensio/yulin/serve";
1824
+
1825
+ const simAws = new SimAws();
1826
+
1827
+ const { FunctionArn } = await simAws.lambda().createFunction(
1828
+ new CreateFunctionCommand({
1829
+ FunctionName: "pets",
1830
+ Role: "arn:aws:iam::111111111111:role/PetsRole",
1831
+ Code: {
1832
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
1833
+ statusCode: 200,
1834
+ headers: { "content-type": "application/json" },
1835
+ body: JSON.stringify({
1836
+ rawPath: event.rawPath,
1837
+ routeKey: event.routeKey,
1838
+ stage: event.requestContext.stage,
1839
+ domainName: event.requestContext.domainName,
1840
+ }),
1841
+ })),
1842
+ },
1843
+ }),
1844
+ );
1845
+
1846
+ const apiGateway = simAws.apiGatewayV2();
1847
+
1848
+ const { ApiId } = await apiGateway.createApi(
1849
+ new CreateApiCommand({ Name: "pets", ProtocolType: "HTTP" }),
1850
+ );
1851
+
1852
+ const { IntegrationId } = await apiGateway.createIntegration(
1853
+ new CreateIntegrationCommand({
1854
+ ApiId,
1855
+ IntegrationType: "AWS_PROXY",
1856
+ IntegrationUri: FunctionArn,
1857
+ PayloadFormatVersion: "2.0",
1858
+ }),
1859
+ );
1860
+
1861
+ await apiGateway.createRoute(
1862
+ new CreateRouteCommand({
1863
+ ApiId,
1864
+ RouteKey: "GET /pets/{petId}",
1865
+ Target: `integrations/${IntegrationId}`,
1866
+ }),
1867
+ );
1868
+
1869
+ await apiGateway.createStage(
1870
+ new CreateStageCommand({ ApiId, StageName: "dev", AutoDeploy: true }),
1871
+ );
1872
+
1873
+ await simAws.lambda().addPermission(
1874
+ new AddPermissionCommand({
1875
+ FunctionName: "pets",
1876
+ StatementId: "api-gateway-invoke",
1877
+ Action: "lambda:InvokeFunction",
1878
+ Principal: "apigateway.amazonaws.com",
1879
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
1880
+ }),
1881
+ );
1882
+
1883
+ await apiGateway.createDomainName(
1884
+ new CreateDomainNameCommand({ DomainName: "api.example.test" }),
1885
+ );
1886
+
1887
+ await apiGateway.createApiMapping(
1888
+ new CreateApiMappingCommand({
1889
+ DomainName: "api.example.test",
1890
+ ApiId,
1891
+ Stage: "dev",
1892
+ ApiMappingKey: "orders",
1893
+ }),
1894
+ );
1895
+
1896
+ const srv = await serveSimAws({ simAws });
1897
+
1898
+ const response = await fetch(
1899
+ srv.localUrl("https://api.example.test/orders/pets/6"),
1900
+ );
1901
+
1902
+ console.log(await response.json());
1903
+
1904
+ await srv.close();
1905
+ ```
1906
+
1907
+ ### The two names a domain has
1908
+
1909
+ API Gateway gives the domain a regional endpoint of its own alongside the name it was created with.
1910
+ `DomainNameConfigurations[0].ApiGatewayDomainName` reports it, in the shape
1911
+ `d-<id>.execute-api.<region>.amazonaws.com`, and the domain answers there too. That is the published
1912
+ address. A CloudFront Origin points at it directly, and a Route53 record points the custom domain
1913
+ name at it.
1914
+
1915
+ A record for the custom domain name decides where that name goes, as it does on AWS. A distribution
1916
+ serving `www.example.com` through an alias record keeps it after an API is given `www.example.com` as
1917
+ a custom domain, and requests to the domain's regional endpoint carry on reaching the API. Where a
1918
+ simulated hosted zone holds no record for the custom domain name, the name reaches the domain. That
1919
+ is how a test that creates only a domain reaches it.
1920
+
1921
+ ### Which hostnames an API answers on
1922
+
1923
+ An API answers on the endpoint API Gateway generated for it and on the domains mapped to it. A
1924
+ request carrying any other `Host` gets a 403 and `{"message":"Forbidden"}`, before the route's
1925
+ authorizer runs.
1926
+
1927
+ That refusal is what a CloudFront behaviour whose cache key holds `host` produces on real AWS.
1928
+ Everything in a cache key is forwarded to the origin, and the API is then handed the viewer's
1929
+ hostname. The 403 carries `x-amzn-errortype: ForbiddenException` and `x-amzn-requestid`. CloudFront's
1930
+ own refusal to reach an origin carries `apigw-requestid` instead, and those two headers are how the
1931
+ pair were told apart against real AWS. Simulated CloudFront always sends the Origin's own domain
1932
+ (see the [CloudFront limitations](https://yulinsim.dev/services/cloudfront/#limitations)), so
1933
+ reaching this refusal here means calling the API on a hostname of your own. A Route53 CNAME pointing
1934
+ at the generated endpoint does it.
1935
+
1936
+ A custom domain of that hostname makes no difference to it. The record still decides where the name
1937
+ goes, and a record pointing at the generated endpoint sends the request to an API answering on its
1938
+ own hostname. Point the record at the domain's regional endpoint to reach the mappings behind it.
1939
+
1940
+ ### Deleting a domain name and its mappings
1941
+
1942
+ `DeleteDomainNameCommand` takes the domain and its mappings away, and its hostname stops resolving.
1943
+ The APIs it mapped carry on serving their generated endpoints.
1944
+
1945
+ `DeleteApiCommand` takes the mappings pointing at the deleted API with it. A base path that used to
1946
+ reach it answers 404, and the domain keeps whatever else it maps.
1947
+
1948
+ ## Importing an OpenAPI definition
1949
+
1950
+ `ImportApiCommand` takes a serialised OpenAPI 3.0 document and creates the API, one route and one
1951
+ integration per operation, and one authorizer per security scheme an operation names. The route
1952
+ key is the operation key uppercased and the path taken unchanged, since OpenAPI path templating is
1953
+ already API Gateway's path parameter syntax.
1954
+
1955
+ An import creates no stage. `CreateStageCommand` or an `AWS::ApiGatewayV2::Stage` is still declared
1956
+ separately, and an imported API with no stage answers 404.
1957
+
1958
+ ```typescript sim-apigatewayv2-openapi
1959
+ /**
1960
+ * Creating a simulated HTTP API from an OpenAPI 3 definition.
1961
+ */
1962
+
1963
+ import {
1964
+ CreateStageCommand,
1965
+ GetRoutesCommand,
1966
+ ImportApiCommand,
1967
+ } from "@aws-sdk/client-apigatewayv2";
1968
+ import {
1969
+ AddPermissionCommand,
1970
+ CreateFunctionCommand,
1971
+ } from "@aws-sdk/client-lambda";
1972
+
1973
+ import { SimAws } from "@kensio/yulin";
1974
+ import type { SimPayload2Event } from "@kensio/yulin/apigatewayv2";
1975
+ import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
1976
+ import { serveSimAws } from "@kensio/yulin/serve";
1977
+
1978
+ const simAws = new SimAws();
1979
+
1980
+ const { FunctionArn } = await simAws.lambda().createFunction(
1981
+ new CreateFunctionCommand({
1982
+ FunctionName: "orders",
1983
+ Role: "arn:aws:iam::111111111111:role/OrdersRole",
1984
+ Code: {
1985
+ ZipFile: makeLambdaZipFileInput((event: SimPayload2Event) => ({
1986
+ statusCode: 200,
1987
+ headers: { "content-type": "text/plain" },
1988
+ body: `order ${event.pathParameters?.["orderId"] ?? "none"}`,
1989
+ })),
1990
+ },
1991
+ }),
1992
+ );
1993
+
1994
+ const openApi = {
1995
+ openapi: "3.0.1",
1996
+ info: { title: "orders", version: "1.0" },
1997
+ paths: {
1998
+ "/orders/{orderId}": {
1999
+ get: {
2000
+ // Ignored, as on AWS: HTTP APIs do no request validation.
2001
+ responses: { "200": { description: "200 response" } },
2002
+ "x-amazon-apigateway-integration": {
2003
+ type: "aws_proxy",
2004
+ httpMethod: "POST",
2005
+ uri:
2006
+ `arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/` +
2007
+ `${FunctionArn}/invocations`,
2008
+ payloadFormatVersion: "2.0",
2009
+ },
2010
+ },
2011
+ },
2012
+ },
2013
+ };
2014
+
2015
+ const apiGateway = simAws.apiGatewayV2();
2016
+
2017
+ const { ApiId, ApiEndpoint } = await apiGateway.importApi(
2018
+ new ImportApiCommand({ Body: JSON.stringify(openApi) }),
2019
+ );
2020
+
2021
+ const routes = await apiGateway.getRoutes(new GetRoutesCommand({ ApiId }));
2022
+
2023
+ console.log(routes.Items[0]?.RouteKey); // "GET /orders/{orderId}"
2024
+
2025
+ // An import creates no stage, so the API answers 404 until one is created.
2026
+ await apiGateway.createStage(
2027
+ new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
2028
+ );
2029
+
2030
+ await simAws.lambda().addPermission(
2031
+ new AddPermissionCommand({
2032
+ FunctionName: "orders",
2033
+ StatementId: "api-gateway-invoke",
2034
+ Action: "lambda:InvokeFunction",
2035
+ Principal: "apigateway.amazonaws.com",
2036
+ SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
2037
+ }),
2038
+ );
2039
+
2040
+ const srv = await serveSimAws({ simAws });
2041
+
2042
+ const response = await fetch(srv.localUrl(`${ApiEndpoint}/orders/42`));
2043
+
2044
+ console.log(await response.text()); // "order 42"
2045
+
2046
+ await srv.close();
2047
+ ```
2048
+
2049
+ The API is named by `info.title`. `uri` is read as either the long
2050
+ `arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations` form above
2051
+ or as the bare function ARN.
2052
+
2053
+ ### Members that are ignored
2054
+
2055
+ AWS sorts what an import finds into three categories, and the third is valid OpenAPI that HTTP APIs
2056
+ leave unsupported. AWS ignores it silently, and so does this: `requestBody`, the content schemas under
2057
+ `responses`, `components.schemas`, and an operation's `parameters`, `summary`, `description` and
2058
+ `tags`. HTTP APIs perform no request validation, so a request whose body contradicts a declared
2059
+ schema still reaches the handler.
2060
+
2061
+ Everything else the document carries and this simulation cannot apply is refused, naming the JSON
2062
+ pointer of the member, such as
2063
+ `#/paths/~1orders~1{orderId}/get/x-amazon-apigateway-integration/payloadFormatVersion`.
2064
+
2065
+ ### A shared integration or authorizer
2066
+
2067
+ An operation's `x-amazon-apigateway-integration` may be a reference into
2068
+ `components.x-amazon-apigateway-integrations`:
2069
+
2070
+ ```json
2071
+ {
2072
+ "get": {
2073
+ "x-amazon-apigateway-integration": {
2074
+ "$ref": "#/components/x-amazon-apigateway-integrations/orders"
2075
+ }
2076
+ }
2077
+ }
2078
+ ```
2079
+
2080
+ The referenced definition is created once and shared by every operation naming it. A `$ref` anywhere
2081
+ else is refused, naming the pointer it holds.
2082
+
2083
+ ### Protecting an imported route
2084
+
2085
+ A security scheme of type `oauth2` carrying an `x-amazon-apigateway-authorizer` with `type: "jwt"`
2086
+ becomes a JWT authorizer. The scheme key is the authorizer's name, one authorizer is created per
2087
+ scheme, and an operation naming it gets `AuthorizationType: "JWT"` with the requirement's scope list
2088
+ as its `AuthorizationScopes`. An operation with no `security` is open.
2089
+
2090
+ ```json
2091
+ {
2092
+ "components": {
2093
+ "securitySchemes": {
2094
+ "pool-authorizer": {
2095
+ "type": "oauth2",
2096
+ "x-amazon-apigateway-authorizer": {
2097
+ "type": "jwt",
2098
+ "identitySource": "$request.header.Authorization",
2099
+ "jwtConfiguration": {
2100
+ "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc",
2101
+ "audience": ["3n4b5..."]
2102
+ }
2103
+ }
2104
+ }
2105
+ }
2106
+ }
2107
+ }
2108
+ ```
2109
+
2110
+ A security scheme of type `apiKey` carrying an `x-amazon-apigateway-authorizer` with
2111
+ `type: "request"` becomes a Lambda `REQUEST` authorizer, and an operation naming it gets
2112
+ `AuthorizationType: "CUSTOM"`. The scheme's own `name` and `in` are read by AWS for the OpenAPI
2113
+ document to be valid, and `identitySource` is what decides where the values come from, here as there.
2114
+
2115
+ ```json
2116
+ {
2117
+ "components": {
2118
+ "securitySchemes": {
2119
+ "session-authorizer": {
2120
+ "type": "apiKey",
2121
+ "name": "cookie",
2122
+ "in": "header",
2123
+ "x-amazon-apigateway-authorizer": {
2124
+ "type": "request",
2125
+ "identitySource": "$request.header.cookie",
2126
+ "authorizerUri": "arn:aws:lambda:us-east-1:111111111111:function:session-authorizer",
2127
+ "authorizerPayloadFormatVersion": "2.0",
2128
+ "enableSimpleResponses": true,
2129
+ "authorizerResultTtlInSeconds": 300
2130
+ }
2131
+ }
2132
+ }
2133
+ }
2134
+ }
2135
+ ```
2136
+
2137
+ A scheme whose type and whose authorizer disagree, such as a `jwt` authorizer under `apiKey`, is
2138
+ refused naming the extension's own `type`.
2139
+
2140
+ `identitySource` is the comma-separated string a document writes. A JWT authorizer takes one entry
2141
+ and a value carrying more is refused, as more than one `IdentitySource` is on `CreateAuthorizer`. A
2142
+ `REQUEST` authorizer takes every entry the string names, and requires a request to carry all of them.
2143
+
2144
+ ## CloudFormation
2145
+
2146
+ [Simulated CloudFormation](https://yulinsim.dev/services/cloudformation/ "Simulated CloudFormation docs") deploys
2147
+ `AWS::ApiGatewayV2::Api`, `AWS::ApiGatewayV2::Authorizer`, `AWS::ApiGatewayV2::Integration`,
2148
+ `AWS::ApiGatewayV2::Route`, `AWS::ApiGatewayV2::Stage`, `AWS::ApiGatewayV2::DomainName` and
2149
+ `AWS::ApiGatewayV2::ApiMapping`. A synthesized or hand-written template produces an API that serves
2150
+ requests.
2151
+
2152
+ `Ref` and `Fn::GetAtt` return what real CloudFormation returns for each type:
2153
+
2154
+ | Resource type | `Ref` | `Fn::GetAtt` |
2155
+ | ------------- | ------------------ | ------------------------------------------------------------- |
2156
+ | `Api` | the API id | `ApiId`, `ApiEndpoint` |
2157
+ | `Authorizer` | the authorizer id | `AuthorizerId` |
2158
+ | `Integration` | the integration id | `IntegrationId` |
2159
+ | `Route` | the route id | `RouteId` |
2160
+ | `Stage` | the stage name | none, as AWS documents none |
2161
+ | `DomainName` | the domain name | `RegionalDomainName`, `RegionalHostedZoneId`, `DomainNameArn` |
2162
+ | `ApiMapping` | the mapping id | `ApiMappingId` |
2163
+
2164
+ `Fn::GetAtt: ["Api", "ApiEndpoint"]` is the generated endpoint with no trailing slash and no stage
2165
+ segment, on the real `amazonaws.com` hostname. CDK's `httpApi.url` is built from `AWS::URLSuffix`
2166
+ instead, which resolves to the local `sim-aws.localhost` form. Both reach the same served API.
2167
+
2168
+ An integration's `IntegrationUri` is accepted as the bare Lambda function ARN CDK emits, and as the
2169
+ `arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations` form. A
2170
+ route's `Target` is the `integrations/<integration-id>` string, which CDK builds with `Fn::Join` over
2171
+ a `Ref` to the integration.
2172
+
2173
+ ```typescript sim-apigatewayv2-cloudformation
2174
+ /**
2175
+ * Deploying a simulated HTTP API from a CloudFormation template.
2176
+ */
2177
+
2178
+ import { SimAws } from "@kensio/yulin";
2179
+ import { serveSimAws } from "@kensio/yulin/serve";
2180
+
2181
+ const simAws = new SimAws();
2182
+
2183
+ const stack = await simAws.cloudFormation().deployTemplate({
2184
+ stackName: "orders-stack",
2185
+ template: {
2186
+ Resources: {
2187
+ HandlerRole: {
2188
+ Type: "AWS::IAM::Role",
2189
+ Properties: {
2190
+ RoleName: "orders-role",
2191
+ AssumeRolePolicyDocument: {
2192
+ Version: "2012-10-17",
2193
+ Statement: [
2194
+ {
2195
+ Effect: "Allow",
2196
+ Principal: { Service: "lambda.amazonaws.com" },
2197
+ Action: "sts:AssumeRole",
2198
+ },
2199
+ ],
2200
+ },
2201
+ },
2202
+ },
2203
+ Handler: {
2204
+ Type: "AWS::Lambda::Function",
2205
+ Properties: {
2206
+ FunctionName: "orders",
2207
+ Role: { "Fn::GetAtt": ["HandlerRole", "Arn"] },
2208
+ Handler: "index.handler",
2209
+ Runtime: "nodejs20.x",
2210
+ Code: {
2211
+ ZipFile:
2212
+ "exports.handler = async () => ({ statusCode: 200, body: 'orders' });",
2213
+ },
2214
+ },
2215
+ },
2216
+ HandlerPermission: {
2217
+ Type: "AWS::Lambda::Permission",
2218
+ Properties: {
2219
+ Action: "lambda:InvokeFunction",
2220
+ FunctionName: { "Fn::GetAtt": ["Handler", "Arn"] },
2221
+ Principal: "apigateway.amazonaws.com",
2222
+ SourceArn: {
2223
+ "Fn::Join": [
2224
+ "",
2225
+ [
2226
+ "arn:aws:execute-api:",
2227
+ { Ref: "AWS::Region" },
2228
+ ":",
2229
+ { Ref: "AWS::AccountId" },
2230
+ ":",
2231
+ { Ref: "Api" },
2232
+ "/*/*",
2233
+ ],
2234
+ ],
2235
+ },
2236
+ },
2237
+ },
2238
+ Api: {
2239
+ Type: "AWS::ApiGatewayV2::Api",
2240
+ Properties: { Name: "orders", ProtocolType: "HTTP" },
2241
+ },
2242
+ Stage: {
2243
+ Type: "AWS::ApiGatewayV2::Stage",
2244
+ Properties: {
2245
+ ApiId: { Ref: "Api" },
2246
+ StageName: "$default",
2247
+ AutoDeploy: true,
2248
+ },
2249
+ },
2250
+ Integration: {
2251
+ Type: "AWS::ApiGatewayV2::Integration",
2252
+ Properties: {
2253
+ ApiId: { Ref: "Api" },
2254
+ IntegrationType: "AWS_PROXY",
2255
+ IntegrationUri: { "Fn::GetAtt": ["Handler", "Arn"] },
2256
+ PayloadFormatVersion: "2.0",
2257
+ },
2258
+ },
2259
+ Route: {
2260
+ Type: "AWS::ApiGatewayV2::Route",
2261
+ Properties: {
2262
+ ApiId: { Ref: "Api" },
2263
+ RouteKey: "GET /orders",
2264
+ AuthorizationType: "NONE",
2265
+ Target: {
2266
+ "Fn::Join": ["", ["integrations/", { Ref: "Integration" }]],
2267
+ },
2268
+ },
2269
+ },
2270
+ },
2271
+ Outputs: {
2272
+ ApiEndpoint: { Value: { "Fn::GetAtt": ["Api", "ApiEndpoint"] } },
2273
+ },
2274
+ },
2275
+ });
2276
+
2277
+ await stack.waitForDeployComplete();
2278
+
2279
+ // https://<api-id>.execute-api.us-east-1.amazonaws.com
2280
+ const apiEndpoint = stack.output("ApiEndpoint");
2281
+
2282
+ const srv = await serveSimAws({ simAws });
2283
+
2284
+ const response = await fetch(srv.localUrl(`${apiEndpoint}/orders`));
2285
+
2286
+ console.log(response.status);
2287
+ // 200
2288
+
2289
+ console.log(await response.text());
2290
+ // "orders"
2291
+
2292
+ await srv.close();
2293
+ ```
2294
+
2295
+ Every property outside the simulated set is left out of what is created and recorded in
2296
+ [`stack.ignoredProperties`](https://yulinsim.dev/services/cloudformation/#properties-a-resource-was-created-without),
2297
+ naming the Resource type, the logical id and the ones this can act on instead. The API, authorizer,
2298
+ integration, route or stage is created either way. The stack deploys, and the record says which of its
2299
+ parts behaves differently to the template. The simulated properties are:
2300
+
2301
+ - `Api`: `Name`, `ProtocolType`, `Description`, `DisableExecuteApiEndpoint`, `Body`,
2302
+ `FailOnWarnings`
2303
+ - `Authorizer`: `ApiId`, `Name`, `AuthorizerType`, `IdentitySource`, `JwtConfiguration`,
2304
+ `AuthorizerUri`, `AuthorizerPayloadFormatVersion`, `EnableSimpleResponses`,
2305
+ `AuthorizerResultTtlInSeconds`
2306
+ - `Integration`: `ApiId`, `IntegrationType`, `IntegrationUri`, `PayloadFormatVersion`, `Description`
2307
+ - `Route`: `ApiId`, `RouteKey`, `Target`, `AuthorizationType`, `AuthorizerId`, `AuthorizationScopes`
2308
+ - `Stage`: `ApiId`, `StageName`, `AutoDeploy`, `StageVariables`, `Description`,
2309
+ `DefaultRouteSettings`, `RouteSettings`
2310
+
2311
+ CDK's `HttpIamAuthorizer` deploys too. It emits no `AWS::ApiGatewayV2::Authorizer` and no
2312
+ `AuthorizerId`, only `AuthorizationType: "AWS_IAM"` on the `Route`, and the deployed route then
2313
+ requires IAM authorization when it is served.
2314
+
2315
+ CDK's `HttpJwtAuthorizer` and `HttpUserPoolAuthorizer` both deploy. `HttpUserPoolAuthorizer` builds
2316
+ its issuer from `Fn::GetAtt <UserPool>.ProviderURL`, which resolves to the same string the pool's
2317
+ tokens name as their issuer. A CDK-declared authorizer and a CDK-deployed pool agree, with no
2318
+ configuration to write. Pass the app client explicitly through `userPoolClients`, because otherwise the
2319
+ authorizer adds a client of its own with CDK's defaults, and those emit the OAuth properties
2320
+ [simulated Cognito refuses](https://yulinsim.dev/services/cognito/#limitations).
2321
+
2322
+ An `Authorizer` with `AuthorizerType: "REQUEST"` deploys as a Lambda authorizer, and a `Route` with
2323
+ `AuthorizationType: "CUSTOM"` and a `Ref` to it is decided by that authorizer's function.
2324
+ `AuthorizerUri` is read the same way an integration's URI is. The bare function ARN and the
2325
+ `arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations` form both
2326
+ work. The `AWS::Lambda::Permission` alongside it needs a `SourceArn` of
2327
+ `arn:aws:execute-api:<region>:<account>:<api-id>/authorizers/<authorizer-id>`. That is what CDK
2328
+ writes, and what the authorizer is invoked under. A function used both as an integration and as an
2329
+ authorizer needs two permissions, as it does on AWS.
2330
+
2331
+ CDK's `HttpLambdaAuthorizer` deploys when it is given `responseTypes: [HttpLambdaResponseType.SIMPLE]`.
2332
+ Its default is `IAM`, which sets `AuthorizerPayloadFormatVersion` to `1.0`, and that authorizer event
2333
+ is [not simulated](#limitations).
2334
+
2335
+ ```typescript
2336
+ const authorizer = new HttpLambdaAuthorizer("SessionAuthorizer", authorizerFn, {
2337
+ responseTypes: [HttpLambdaResponseType.SIMPLE],
2338
+ identitySource: ["$request.header.cookie"],
2339
+ });
2340
+
2341
+ httpApi.addRoutes({
2342
+ path: "/account",
2343
+ methods: [HttpMethod.GET],
2344
+ integration: new HttpLambdaIntegration("Account", accountFn),
2345
+ authorizer,
2346
+ });
2347
+ ```
2348
+
2349
+ A `Route` with `AuthorizationType: "JWT"` or `"CUSTOM"` whose `AuthorizerId` resolves to something
2350
+ other than an authorizer of that API, or to one of the other kind, fails the stack, naming both. That
2351
+ covers a `Ref` to a Resource this simulation skipped. A skipped Resource resolves to its own logical
2352
+ ID, and no authorizer has that id.
2353
+
2354
+ `Api.Body` carries an OpenAPI document as an inline JSON object. CloudFormation resolves `Ref` and
2355
+ `Fn::GetAtt` inside it as it does anywhere else, and an operation's integration URI can be an
2356
+ `Fn::GetAtt` on a function the same stack deploys. The document goes through the same `ImportApi`
2357
+ translator an SDK caller reaches, so the two produce the same API.
2358
+
2359
+ ```yaml
2360
+ Api:
2361
+ Type: AWS::ApiGatewayV2::Api
2362
+ Properties:
2363
+ Body:
2364
+ openapi: "3.0.1"
2365
+ info: { title: orders, version: "1.0" }
2366
+ paths:
2367
+ /orders/{orderId}:
2368
+ get:
2369
+ x-amazon-apigateway-integration:
2370
+ type: aws_proxy
2371
+ httpMethod: POST
2372
+ uri: !GetAtt Handler.Arn
2373
+ payloadFormatVersion: "2.0"
2374
+ ```
2375
+
2376
+ `Name` and `ProtocolType` are both optional alongside a `Body`, as AWS documents. A `ProtocolType`
2377
+ that is present has to be `HTTP`, and a `Name` that is present names the API in place of the
2378
+ document's `info.title`. `Description` and `DisableExecuteApiEndpoint` are recorded rather than
2379
+ applied alongside a `Body`, because `ImportApi` takes neither, and no command here changes an API
2380
+ after it is created. The API is created from the document without them, and the stack deploys.
2381
+
2382
+ A template combining an `Api` with a `Body` and a separate `Route`, `Integration` or `Authorizer`
2383
+ Resource for that same API fails the stack, naming both logical IDs. The document already declares
2384
+ the API's parts, and which of the two AWS would keep is unestablished.
2385
+
2386
+ An `Api` carrying a `Policy` property is refused with its own message, in place of the generic one.
2387
+ AWS has no such property on this Resource type, because an HTTP API has no resource policy. A template
2388
+ carrying one was written for a REST API.
2389
+
2390
+ `AWS::ApiGatewayV2::Deployment`, `VpcLink` and the WebSocket-only `Model`, `RouteResponse` and
2391
+ `IntegrationResponse` create no resource. A template carrying one has that resource skipped.
2392
+
2393
+ ### A custom domain from a template
2394
+
2395
+ `AWS::ApiGatewayV2::DomainName` creates the domain and `AWS::ApiGatewayV2::ApiMapping` points a base
2396
+ path of it at an API and a stage, the pair CDK's `apigatewayv2.DomainName` synthesizes. The mapping
2397
+ needs its stage to exist, and CDK gives it an explicit `DependsOn` for that, because the `Stage`
2398
+ property carries the stage's name rather than a `Ref` CloudFormation could take an order from. A
2399
+ hand-written template wanting the same order writes that `DependsOn` itself.
2400
+
2401
+ `Fn::GetAtt: ["Domain", "RegionalDomainName"]` answers with the `d-<id>.execute-api.<region>.amazonaws.com`
2402
+ endpoint API Gateway issued the domain (see [The two names a domain has](#the-two-names-a-domain-has)).
2403
+ A CloudFront Origin or a Route53 alias record built on it reaches the API behind the domain. That is
2404
+ the stack shape a cache policy keying on `host` needs, since the generated endpoint refuses a
2405
+ forwarded viewer hostname (see [Which hostnames an API answers on](#which-hostnames-an-api-answers-on)).
2406
+
2407
+ `RegionalHostedZoneId` answers with one fixed value for every Region, the way a load balancer's
2408
+ `CanonicalHostedZoneID` does. Nothing resolves through it, and an alias record reaches the domain by
2409
+ the endpoint name it points at.
2410
+
2411
+ ## Authorization
2412
+
2413
+ Every command is authorized by simulated IAM. API Gateway is unusual in what it asks for. The action
2414
+ is the HTTP method of the underlying REST call, not a name matching the SDK operation, and the
2415
+ resource is the request path, where other services name an ARN. Creating a route on API
2416
+ `a1b2c3d4e5` asks whether the caller may `apigateway:POST` on
2417
+ `arn:aws:apigateway:<region>::/apis/a1b2c3d4e5/routes`. Those ARNs carry no Account id, as API Gateway
2418
+ control-plane ARNs leave the Account segment empty.
2419
+
2420
+ A policy written the way policies for other services are written matches no request here, as it
2421
+ matches none on real AWS.
2422
+
2423
+ ## SDK interception
2424
+
2425
+ An `ApiGatewayV2Client` can be intercepted, so application code that builds its own client reaches
2426
+ the simulation without being given one. See the
2427
+ [SDK interception docs](https://yulinsim.dev/sdk/ "Simulated AWS SDK interception docs").
2428
+
2429
+ ## Available functionality
2430
+
2431
+ - `CreateApi`, `GetApi`, `GetApis` and `DeleteApi`, with the API id, the generated endpoint, and the
2432
+ Account and Region scoping a real API has
2433
+ - `CreateIntegration`, `GetIntegrations` and `DeleteIntegration` for an `AWS_PROXY` integration
2434
+ naming a Lambda function, with an integration a route still targets refused, never deleted
2435
+ - `CreateRoute`, `GetRoutes` and `DeleteRoute`, with route keys parsed and validated at creation,
2436
+ requests matched to a route by method, literal segment, `{name}` parameter, `{proxy+}` parameter
2437
+ and `$default`, and a deleted route no longer matching anything
2438
+ - Path parameters captured by the matched route, reaching the handler as `event.pathParameters`
2439
+ - `CreateAuthorizer`, `GetAuthorizers` and `DeleteAuthorizer` for a JWT authorizer, and routes
2440
+ protected by one with `AuthorizationType: "JWT"` and `AuthorizationScopes`
2441
+ - Real RS256 verification of a token against the keys its issuer publishes, with the claims checked
2442
+ against the simulation's clock, and the accepted claims reaching the handler as
2443
+ `event.requestContext.authorizer.jwt`
2444
+ - Routes protected with `AuthorizationType: "AWS_IAM"`, evaluating `execute-api:Invoke` against the
2445
+ `execute-api` ARN of the route being called, for a caller resolved from a SigV4 signature or an
2446
+ `x-sim-aws-caller` header, and reaching the handler as `event.requestContext.authorizer.iam`
2447
+ - `CreateAuthorizer`, `GetAuthorizers` and `DeleteAuthorizer` for a Lambda `REQUEST` authorizer, and
2448
+ routes protected by one with `AuthorizationType: "CUSTOM"`, invoking the authorizer's function with
2449
+ the payload format 2.0 authorizer event, reading a simple response or an IAM policy response, and
2450
+ passing the returned context to the handler as `event.requestContext.authorizer.lambda`
2451
+ - `AuthorizerResultTtlInSeconds`, holding a decision against the identity source values it was made
2452
+ for and expiring it against the simulation's clock, with `$context.routeKey` as an identity source
2453
+ to hold one per route
2454
+ - `CreateStage`, `GetStages` and `DeleteStage` for the `$default` stage and for named stages served
2455
+ under their own path segment, including stage variables
2456
+ - `DefaultRouteSettings` and `RouteSettings` throttling, with a token bucket per route refilling
2457
+ against the simulated clock and a 429 for the requests past it
2458
+ - Serving the generated endpoint through `serveSimAws`, invoking the integrated function with a
2459
+ payload format 2.0 event and turning its result back into an HTTP response
2460
+ - The invoke permission of an integration's function and of an authorizer's, each evaluated against
2461
+ that function's resource policy with its own `AWS:SourceArn`
2462
+ - An integration URI or `AuthorizerUri` ending in a published version number or an alias name, read
2463
+ at each request so a route follows its alias, with the invoke permission decided against the
2464
+ qualified resource
2465
+ - `CreateDomainName`, `GetDomainName`, `GetDomainNames` and `DeleteDomainName`, with the domain
2466
+ answering on its own hostname through `serveSimAws` and the name unique across every simulated
2467
+ Account and Region
2468
+ - `CreateApiMapping`, `GetApiMapping`, `GetApiMappings` and `DeleteApiMapping`, serving an API at the
2469
+ root of a domain or under a base path, with the longest matching base path winning and the
2470
+ mappings of a deleted API going with it
2471
+ - A 403 with `x-amzn-errortype: ForbiddenException` for a request carrying a `Host` the API neither
2472
+ generated nor has mapped
2473
+ - `DisableExecuteApiEndpoint`, refusing requests to the generated endpoint
2474
+ - `ImportApi` for an OpenAPI 3.0 document, creating one route and one integration per operation and
2475
+ one JWT or Lambda `REQUEST` authorizer per security scheme an operation names
2476
+ - Deployment of `AWS::ApiGatewayV2::Api`, `Authorizer`, `Integration`, `Route`, `Stage`, `DomainName`
2477
+ and `ApiMapping` from a CloudFormation template, including one synthesized by CDK from an `HttpApi`,
2478
+ and an `Api` declared as an OpenAPI document through `Body`
2479
+ - Authorization of every command by simulated IAM, against the HTTP method and resource path real
2480
+ API Gateway uses
2481
+ - SDK interception of an `ApiGatewayV2Client`
2482
+
2483
+ ## Limitations
2484
+
2485
+ Current documented limitations:
2486
+
2487
+ - HTTP APIs only. `ProtocolType: "WEBSOCKET"` is refused.
2488
+ - Two of the route selection rules, and the place of the method comparison in the order, are observed
2489
+ rather than published by AWS. See [Which route serves a request](#which-route-serves-a-request).
2490
+ - A route path naming the same parameter twice, such as `GET /pets/{id}/toys/{id}`, is refused. This
2491
+ is stricter than AWS is known to be. It was refused because only one of the two captures could
2492
+ reach the handler, and not because real API Gateway was seen to refuse it.
2493
+ - Deployments are outside the simulation, so `CreateStage` requires `AutoDeploy: true`. A stage
2494
+ without it serves whichever Deployment it was given, which on real AWS is nothing until one is
2495
+ created.
2496
+ - `AccessLogSettings` on a stage is refused, as is any other option `CreateStage` takes and this one
2497
+ lacks. `RouteSettings` and `DefaultRouteSettings` are taken, and only their throttling members are
2498
+ read. `DetailedMetricsEnabled`, `LoggingLevel` and `DataTraceEnabled` are refused by name.
2499
+ - `AWS_PROXY` is the only integration type, and its URI must name a Lambda function ARN, written
2500
+ either as that ARN or as the
2501
+ `arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations` form.
2502
+ Both reach the same function, and `GetIntegrations` answers with the URI as it was written, as AWS
2503
+ does. A version or alias qualifier on the end is kept and resolved at each request (see
2504
+ [Routing to a published version or an alias](#routing-to-a-published-version-or-an-alias)). HTTP
2505
+ proxy integrations and AWS service integrations are outside the simulation.
2506
+ - Payload format 1.0 is refused. A handler written for 1.0 reads event fields absent from a 2.0
2507
+ event, so treating one as the other would pass here and fail on AWS.
2508
+ - `AuthorizationScopes` on an `AWS_IAM` or `CUSTOM` route is refused. This is stricter than AWS, which
2509
+ documents route scopes as meaningful only for `JWT` and ignores them here. Accepting one would let a
2510
+ test assert on a scope restriction no code applies.
2511
+ - The method and path segments of the ARN an `AWS_IAM` route is authorized against are inferred
2512
+ rather than documented, and the two callers of that ARN builder fill them differently. This one
2513
+ names the request's own method and path, while the integration's invoke permission names the route
2514
+ key template. AWS documents one format for both. See
2515
+ [Protecting a route with IAM](#protecting-a-route-with-iam).
2516
+ - A `*` in a policy resource is uniformly greedy here, so it crosses `/` boundaries. AWS distinguishes
2517
+ `*` from `*/*` in some ARN path positions. That makes the simulator more permissive than AWS for a
2518
+ policy relying on the distinction.
2519
+ - Only `execute-api:Invoke` is evaluated. The action string a policy names goes unconstrained, so a
2520
+ policy can be written with `execute-api:ManageConnections` or `execute-api:InvalidateCache` and
2521
+ nothing will ever ask about it.
2522
+ - `accessKey` in the `iam` block is empty, `callerId` and `userId` carry the caller ARN rather than
2523
+ the `AIDA`/`AROA` unique id real AWS puts there, and `cognitoIdentity` and `principalOrgId` are
2524
+ always null. None of those is available at the simulator's request boundary.
2525
+ - A request signed for another service is refused by the serving boundary before route matching, even
2526
+ on a `NONE` route, where real API Gateway would ignore the signature. That boundary answers
2527
+ `{"Message":"Forbidden"}` with a capital `M`, unlike the API's own refusals.
2528
+ - An unsigned request to an `AWS_IAM` route is a 403 rather than the 403 with an
2529
+ `x-amzn-ErrorType` of `IncompleteSignatureException` real API Gateway was observed to send. The
2530
+ simulator resolves such a request to an anonymous caller and refuses it by ordinary IAM evaluation.
2531
+ - HTTP API resource policies are outside the simulation, because AWS has none. A caller from another
2532
+ Account is therefore always refused, since a cross-Account request needs an Allow from the resource
2533
+ side. Assume a Role in the API's Account instead. That is the route through on AWS as well.
2534
+ - `AuthorizerPayloadFormatVersion: "2.0"` is required on a `REQUEST` authorizer, and omitting it is
2535
+ refused too. AWS defaults it to `1.0`, which builds a different event and answers a policy
2536
+ against a method ARN, and none of that is built here. CDK defaults `HttpLambdaAuthorizer` to
2537
+ `responseTypes: [HttpLambdaResponseType.IAM]`, which sets the format to `1.0`, so a default
2538
+ `new HttpLambdaAuthorizer(...)` fails the stack. Pass
2539
+ `responseTypes: [HttpLambdaResponseType.SIMPLE]` to get an authorizer that deploys.
2540
+ - A `REQUEST` authorizer's event carries no `body` and no `isBase64Encoded`. AWS's published example
2541
+ of that event carries neither, so an authorizer cannot read the request body.
2542
+ - An authorizer function that throws is a 500 rather than a 401. Real Lambda turns a thrown error
2543
+ into a payload carrying `errorMessage`, and simulated Lambda rejects with the error itself, so
2544
+ returning `{ "errorMessage": "Unauthorized" }` is how an authorizer asks for a 401 here.
2545
+ - `AuthorizerCredentialsArn` is refused on `CreateAuthorizer`, as is `authorizerCredentials` in an
2546
+ imported document. On an `AWS::ApiGatewayV2::Authorizer` it is recorded instead, and the authorizer
2547
+ is created without it. It names a Role API Gateway assumes to invoke the authorizer, and the
2548
+ function's own resource policy is the whole decision here.
2549
+ - `AuthorizerResultTtlInSeconds` is accepted between 0 and 3600, the range AWS accepts, and is refused
2550
+ on an authorizer with no `IdentitySource`, since the held decision would have no key.
2551
+ - A held decision is the whole answer. A policy response is re-evaluated per route only on a cache
2552
+ miss. That is what AWS's own warning about caching describes, and `$context.routeKey` as an identity
2553
+ source is the documented way to separate routes.
2554
+ - An authorizer that could not answer at all, by throwing or by replying in neither format, is never
2555
+ held. There is no answer to hold, and the next request asks the function again.
2556
+ - A `REQUEST` authorizer requires at least one `IdentitySource`. An authorizer with none is invoked
2557
+ for every request on AWS, including one carrying nothing, and that is outside the simulation.
2558
+ - A `REQUEST` authorizer's identity source is `$request.header.<name>`,
2559
+ `$request.querystring.<name>` or `$context.routeKey`. The rest of `$context` and all of
2560
+ `$stageVariables`, which a `REQUEST` authorizer may also name on AWS, are refused, with nowhere to
2561
+ read them from. A JWT authorizer takes one identity source naming something the client sent, so
2562
+ `$context.routeKey` is refused for it, and a second source is refused outright.
2563
+ - An identity source with an empty name after its prefix is refused, and so is one whose header name is
2564
+ invalid as an HTTP field name. Each would find no value on any request, and an invalid header name
2565
+ would fail at request time, far from the command that configured it.
2566
+ - `JwtConfiguration.Audience` is required. AWS documents no behaviour for an authorizer with an empty
2567
+ audience list. This may be stricter than AWS, in the direction that cannot quietly admit an app
2568
+ client.
2569
+ - A token with no `exp` claim is refused. Real Cognito always sets one, and admitting a token with no
2570
+ expiry is the divergence worth failing on.
2571
+ - `token_use` goes unchecked, as it does on real API Gateway. An ID token passes an authorizer that
2572
+ configures only an audience. See
2573
+ [Route scopes, and access tokens versus ID tokens](#route-scopes-and-access-tokens-versus-id-tokens).
2574
+ - `aws.cognito.signin.user.admin` is the only scope any simulated Cognito flow issues, and the only
2575
+ satisfiable route scope. Resource servers, custom scopes and the client credentials grant are
2576
+ outside the simulation.
2577
+ - A token invalidated by `GlobalSignOut` still passes. Real API Gateway knows nothing about the pool's
2578
+ issued tokens, so consulting them here would refuse a token AWS would accept.
2579
+ - The pool's JWKS is read in process rather than fetched. A pool's published OpenID configuration
2580
+ names the localhost origin it is served from while its tokens name the real AWS URL. A discovery
2581
+ client would reject its own issuer's tokens.
2582
+ - One signing key per issuer, no key rotation and no JWKS caching. Real Cognito publishes two keys
2583
+ and rotates between them, so code assuming a single entry passes here and is still wrong on AWS.
2584
+ - The 403 body for an unmet route scope, the string rendering of claim values, and `scopes` being
2585
+ `null` rather than `[]` are all what the real endpoint was observed to send. AWS publishes none of
2586
+ them. Only the one `error_description` AWS documents is ever sent, and every other refusal names the
2587
+ scheme and nothing else. AWS publishes no layout for the `www-authenticate` parameters around that
2588
+ description either, so they are comma-separated as RFC 6750 writes them.
2589
+ - Deleting an authorizer a route still points at leaves that route refusing every request. What real
2590
+ API Gateway does with such a route is unestablished. It stays closed here.
2591
+ - The method and path segments of the source ARN are inferred rather than documented. See
2592
+ [Granting the API permission to invoke the function](#granting-the-api-permission-to-invoke-the-function).
2593
+ - An integration `CredentialsArn`, the IAM Role alternative to a resource policy grant, is refused
2594
+ by `CreateIntegration`. A permission on the function is the only way to admit the invocation.
2595
+ - No `Update*` commands. A route, integration or stage is changed by deleting it and creating it
2596
+ again. `DeleteApi` deletes everything under the API, as it does on AWS.
2597
+ - A domain name's `DomainNameConfigurations` are recorded and never applied. A simulated request
2598
+ arrives over plain HTTP on localhost, so a certificate and a TLS security policy have nothing to
2599
+ decide, and the record is where a test checks the domain got the certificate its stack meant to
2600
+ give it. `EndpointType: "EDGE"` is refused, since an edge-optimized custom domain is a REST API
2601
+ feature. `MutualTlsAuthentication` and `Tags` are refused by name.
2602
+ - A domain's `RegionalHostedZoneId` is one fixed value for every Region, where AWS publishes a
2603
+ different id per Region. Resolution ignores it.
2604
+ - A domain created without a `DomainNameConfigurations` entry is reported with one holding only the
2605
+ endpoint it was issued. Real API Gateway needs a certificate for a regional domain, so it always
2606
+ has an entry to report that in.
2607
+ - `AWS::ApiGatewayV2::DomainName` records `RoutingMode`, `MutualTlsAuthentication` and `Tags` rather
2608
+ than applying them, and records the `DomainNameConfigurations` members it does not read. Routing
2609
+ rules are a second way to reach an API that nothing here models, and a simulated request carries no
2610
+ client certificate to check.
2611
+ - `requestContext.http.path` drops a mapped base path alongside `rawPath`. AWS documents the
2612
+ `rawPath` half and shows the two fields carrying the same value, and publishes nothing about
2613
+ `http.path` under an API mapping on its own.
2614
+ - `ApiMappingKey` is reported as an empty string for a mapping serving the root of its domain. What
2615
+ real API Gateway reports for that field is unestablished.
2616
+ - An API mapping and the API it names are in one Account and Region, as they are on AWS. A domain
2617
+ name is unique across every simulated Account and Region, and across simulated Cognito's hosted
2618
+ domains too, since a public hostname is unique across the whole of AWS rather than within one
2619
+ service.
2620
+ - Deleting a domain name deletes its mappings without asking, as deleting an API does. Whether real
2621
+ API Gateway refuses either is unestablished.
2622
+ - No paging. `MaxResults` and `NextToken` are refused, never ignored, and every list command answers
2623
+ in full.
2624
+ - `CorsConfiguration` and `Tags` are refused, as is the `RouteKey`/`Target` quick-create shorthand on
2625
+ `CreateApi`. Anything else the real commands accept and this one lacks is refused by name rather
2626
+ than dropped.
2627
+ - `AWS::ApiGatewayV2::Api` records `CorsConfiguration` rather than applying it. CORS request handling
2628
+ is outside the simulation, so a CDK stack using `corsPreflight` deploys and its API answers preflight
2629
+ requests here differently from AWS. The record is where a test checks that.
2630
+ - Only OpenAPI 3.0.x is imported. A `swagger: "2.0"` document and an `openapi: "3.1.0"` one are both
2631
+ refused by version, and only JSON is parsed, not YAML.
2632
+ - `FailOnWarnings` is honoured only in its strict sense. Everything an import cannot apply is refused
2633
+ outright, with no warning, so `true` is accepted and has no further effect and `false` is refused by
2634
+ name. What AWS defaults the property to is unestablished, and no code here relies on a default.
2635
+ - `Basepath` on `ImportApi`, `BasePath` on `AWS::ApiGatewayV2::Api` and `servers` in the document are
2636
+ all refused. A base path changes the path every route matches on, which belongs with custom domain
2637
+ names.
2638
+ - `ReimportApi` is outside the simulation, as every `Update*` command is. Delete the API and import
2639
+ again.
2640
+ - An imported `operationId` is dropped. AWS maps it to the route's
2641
+ `OperationName`, and no command here takes one.
2642
+ - A `trace` operation, a path item `$ref`, `x-amazon-apigateway-any-method` and a document-level
2643
+ `security` are each refused by name. None of the four is established for HTTP APIs by the research
2644
+ behind this, so each is refused, and never turned into a route the API may lack on AWS.
2645
+ - An operation carrying more than one security requirement is refused, as is a requirement naming
2646
+ more than one scheme. A route has one authorizer.
2647
+ - Anything other than an `oauth2` scheme with an explicit `jwtConfiguration.issuer` is refused. That
2648
+ includes `openIdConnect`, where AWS reads the issuer out of the discovery document at
2649
+ `openIdConnectUrl`. Nothing here is fetched over HTTP, and taking the URL as the issuer would
2650
+ mismatch every token's `iss` and answer a silent 401.
2651
+ - `http_proxy` integrations, `integrationMethod`, `integrationSubtype`, `requestParameters`,
2652
+ `credentials`, `tlsConfig`, `responseTransferMode`, `connectionId` and `connectionType` in an
2653
+ imported integration are all refused by name, as their `AWS::ApiGatewayV2::Integration`
2654
+ counterparts are.
2655
+ - `x-amazon-apigateway-cors` is refused, alongside the `CorsConfiguration` refusal above.
2656
+ - A referenced `x-amazon-apigateway-integrations` definition becomes one shared integration, so two
2657
+ operations naming it produce one entry in `GetIntegrations`. Whether AWS shares one or creates one
2658
+ per use is unestablished. This is what a reusable definition reads as.
2659
+ - A `Name` on an `AWS::ApiGatewayV2::Api` with a `Body` names the API rather than the document's
2660
+ `info.title`. Which of the two AWS takes when both are present is unestablished, and it affects only
2661
+ the name `GetApi` reports.
2662
+ - A terminal `{proxy+}` in an imported path reaches `CreateRoute` unchanged. Greedy segments are
2663
+ established for route keys, and not for OpenAPI path templating.
2664
+ - `AWS::ApiGatewayV2::Api` records `BodyS3Location` rather than reading it. The API is created with no
2665
+ routes at all. Reading a document out of a simulated S3 bucket adds a fetch path and nothing about
2666
+ OpenAPI.
2667
+ - `AWS::ApiGatewayV2::Api` refuses `Policy` with a message of its own saying an HTTP API has no
2668
+ resource policy. The real Resource type has no such property. A template carrying one was written
2669
+ for a REST API, not against a gap here.
2670
+ - A stack update replaces a changed resource of these types rather than updating it in place, as it
2671
+ does for any other type. See the [CloudFormation limitations](https://yulinsim.dev/services/cloudformation/#limitations).
2672
+ - Access logging, usage plans and API keys are outside the simulation. Stage and route
2673
+ throttling is simulated (see [Throttling a stage and a route](#throttling-a-stage-and-a-route)).
2674
+ The account-level rate and burst limits are not. A stage that names no limit throttles nothing,
2675
+ and a settings entry naming one limit alone leaves that route unthrottled.
2676
+ - The response an API Gateway endpoint returns itself uses a lower-case `message` field, as a real
2677
+ HTTP API does. A Lambda Function URL uses `Message` for the same thing, so the two cannot be
2678
+ swapped.