Simulated API Gateway REST APIs
Yulin includes a simulated API Gateway v1 service, reachable as simAws.apiGateway(). It covers the
REST API resource tree, the methods declared on it, a Lambda proxy integration behind each method,
and the deployments and stages that publish them. REST-API-specific types are imported from the
@kensio/yulin/apigateway subpath.
This is the v1 service. HTTP APIs are v2, on a separate SDK client, and they are documented under
API Gateway HTTP APIs. The two hold separate state. A REST API created here
stays out of simAws.apiGatewayV2().
A handler behind a REST API can be tested against a real HTTP request, with no hand-built event to keep in step.
Creating a REST API
Section titled “Creating a REST API”CreateRestApiCommand creates a REST API together with the root resource every path hangs off.
/** * Creating a simulated API Gateway REST API. * * The root resource is created with the API, and `rootResourceId` is what the * first `CreateResource` names as its parent. */
import { CreateRestApiCommand } from "@aws-sdk/client-api-gateway";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const apiGateway = simAws .account("555555555555") .region("eu-west-2") .apiGateway();
const created = await apiGateway.createRestApi( new CreateRestApiCommand({ name: "orders", description: "The orders API" }),);
console.log(created.name);// "orders"
console.log(typeof created.id);// "string"
console.log(typeof created.rootResourceId);// "string"A REST API name identifies nothing. Two APIs in one account and region may share a name, and the id is what tells them apart. Hold the id the create returns.
Building the path tree
Section titled “Building the path tree”A REST API path is a chain of resources, each holding one segment. CreateResourceCommand adds a
segment under a parent and reports the full path its place in the tree gives it.
/** * Building /orders/{orderId} out of two resources. * * Each resource holds one segment and names its parent, and API Gateway * computes the full path from where the resource sits. */
import { CreateResourceCommand, CreateRestApiCommand, GetResourcesCommand,} from "@aws-sdk/client-api-gateway";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const apiGateway = simAws.apiGateway();
const api = await apiGateway.createRestApi( new CreateRestApiCommand({ name: "orders" }),);
const orders = await apiGateway.createResource( new CreateResourceCommand({ restApiId: api.id, parentId: api.rootResourceId, pathPart: "orders", }),);
await apiGateway.createResource( new CreateResourceCommand({ restApiId: api.id, parentId: orders.id, pathPart: "{orderId}", }),);
const listed = await apiGateway.getResources( new GetResourcesCommand({ restApiId: api.id }),);
console.log(listed.items.map((resource) => resource.path));// [ "/", "/orders", "/orders/{orderId}" ]A segment is a literal such as orders, a path parameter such as {orderId}, or a greedy path
parameter such as {proxy+}. A greedy segment matches the rest of the request path. A resource
holding one therefore takes no children, and adding under it is refused.
Deleting a resource deletes everything under it, the way real API Gateway does. The root resource stays, because every REST API has one.
Methods and their integrations
Section titled “Methods and their integrations”A method is declared on a resource with PutMethodCommand, and what it does with a request goes
behind it with PutIntegrationCommand. Both address the same resource id and HTTP method, since a
REST API method has no id of its own.
/** * Declaring an ANY method on a greedy resource and putting a Lambda proxy * integration behind it, which is the shape a CDK LambdaRestApi produces. */
import { CreateResourceCommand, CreateRestApiCommand, GetMethodCommand, PutIntegrationCommand, PutMethodCommand,} from "@aws-sdk/client-api-gateway";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const apiGateway = simAws.apiGateway();
const api = await apiGateway.createRestApi( new CreateRestApiCommand({ name: "orders" }),);const proxy = await apiGateway.createResource( new CreateResourceCommand({ restApiId: api.id, parentId: api.rootResourceId, pathPart: "{proxy+}", }),);
await apiGateway.putMethod( new PutMethodCommand({ restApiId: api.id, resourceId: proxy.id, httpMethod: "ANY", authorizationType: "NONE", }),);
await apiGateway.putIntegration( new PutIntegrationCommand({ restApiId: api.id, resourceId: proxy.id, httpMethod: "ANY", type: "AWS_PROXY", // API Gateway always calls a Lambda integration with POST, whatever // method the client used. integrationHttpMethod: "POST", uri: "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/" + "arn:aws:lambda:eu-west-2:111111111111:function:orders/invocations", }),);
const method = await apiGateway.getMethod( new GetMethodCommand({ restApiId: api.id, resourceId: proxy.id, httpMethod: "ANY", }),);
console.log(method.methodIntegration?.uri);// "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/// arn:aws:lambda:eu-west-2:111111111111:function:orders/invocations",// echoed back as one line, the way it was configuredThe integration URI is written either as the bare function ARN, which CDK emits, or wrapped in the API Gateway invoke path above, which CloudFormation templates and OpenAPI documents emit. Both reach the same function, and the string is echoed back as it was configured, the way real API Gateway does. A version or alias qualifier on the end of the ARN is kept. An integration built on an alias therefore follows that alias.
Deleting a method deletes its integration, because a REST API integration is part of the method.
Deployments and stages
Section titled “Deployments and stages”A REST API has an invocation URL once a stage exists, and every stage is the first path segment of
that URL. An HTTP API can serve a $default stage at the root, and a REST API always carries the
segment.
/** * Publishing an API to a stage, and building the URL a request to it goes to. * * `CreateDeployment` with a `stageName` is the one-call form. Without it the * deployment is created and a `CreateStage` points at it separately. Deploying * again to a stage that is already there points that stage at the new * deployment, which is what every release after the first does. */
import { CreateDeploymentCommand, CreateRestApiCommand, GetStageCommand,} from "@aws-sdk/client-api-gateway";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const apiGateway = simAws .account("555555555555") .region("eu-west-2") .apiGateway();
const api = await apiGateway.createRestApi( new CreateRestApiCommand({ name: "orders" }),);
const deployment = await apiGateway.createDeployment( new CreateDeploymentCommand({ restApiId: api.id, stageName: "prod", variables: { catalogue: "v2" }, }),);
const stage = await apiGateway.getStage( new GetStageCommand({ restApiId: api.id, stageName: "prod" }),);
console.log(stage.deploymentId === deployment.id);// true
const restApi = apiGateway.findRestApi(api.id);console.log(restApi?.invokeUrl("prod"));// "https://<api-id>.execute-api.eu-west-2.amazonaws.com/prod"invokeUrl is a simulator accessor. Real API Gateway reports no endpoint for a REST API and leaves
callers to build the URL themselves. CDK’s RestApi.urlForPath builds the same one.
Real API Gateway freezes the resources and methods into a deployment, and an edit made afterwards reaches no client until another deployment is created. Here a stage serves the API’s current resources. A test that edits a method sees the change straight away, with no redeployment in between. That is the one place this departs from AWS.
Serving a request
Section titled “Serving a request”A request to the stage’s invoke URL walks the resource tree to a method and invokes that method’s integration, and the handler’s response becomes the HTTP response.
/** * Serving a request through a REST API to its Lambda proxy integration. * * The handler reads the payload format 1.0 event a REST API sends, which is * the older of the two formats and the only one a REST API uses. */
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/orders/{orderId}"], handler: (event) => ({ statusCode: 200, headers: { "content-type": "text/plain" }, body: `order ${event.pathParameters?.["orderId"] ?? "none"}`, }), }, simAws,);
const srv = await serveSimAws({ simAws });
const response = await fetch( srv.localUrl(`${restApi.invokeUrl("prod")}/orders/6`),);
console.log(response.status);// 200
console.log(await response.text());// "order 6"
await srv.close();simRestApiLambdaProxyFactory builds the function, the resources, the method, the integration, the
invoke permission and the deployment in one call. A test about serving wants all of them and is
about none of them. A test about the commands themselves sends them one at a time.
The event a handler receives
Section titled “The event a handler receives”A REST API sends payload format 1.0. It carries both a single-value and a multi-value map for the
headers and the query string, and it sends null for an empty map where format 2.0 omits the field:
| Field | Empty case |
|---|---|
queryStringParameters, multiValueQueryStringParameters |
null |
pathParameters, stageVariables |
null |
body |
null |
resource is the template the path matched, such as /orders/{orderId}, and path is the path the
client asked for, stage segment and all. A handler behind a {proxy+} reads resource to tell which
template caught its request.
Each format’s handler reads the other format’s event wrongly. One function behind both an HTTP API and a REST API therefore has to pick a side.
The response a handler returns
Section titled “The response a handler returns”A REST API proxy integration takes one shape. A result carrying a numeric statusCode becomes the
response, and multiValueHeaders sends a header more than once. Anything else is a 502 with
Internal server error. That is what real API Gateway answers when it cannot read the integration
response. Payload format 2.0 is the lenient one, wrapping an unrecognised value in a 200, and a
handler relying on that behaves differently here for the same reason it does on AWS.
Answers when the request matches nothing
Section titled “Answers when the request matches nothing”| Case | Answer |
|---|---|
| A stage the API does not serve | 403 Forbidden |
| A path or method the stage has no entry for | 403 Missing Authentication Token |
| The generated endpoint switched off | 403 Forbidden |
| No integration, no function, or no permission | 502 Internal server error |
| The handler threw | 502 Internal server error |
Missing Authentication Token is the wording real API Gateway is well known for. It answers a path
that matched nothing just as much as one that needed credentials.
The invoke permission
Section titled “The invoke permission”A method’s integration runs once the function’s resource policy allows
apigateway.amazonaws.com to invoke it, exactly as on AWS. The method the request matched is
supplied as AWS:SourceArn, in the form
arn:aws:execute-api:{region}:{account}:{apiId}/{stage}/{METHOD}/{resourcePath}. A permission
granted for one method therefore leaves the others closed. CDK wildcards the stage, method and path segments,
which admits every method of the API.
Throttling a stage and a method
Section titled “Throttling a stage and a method”A stage holds a token bucket for every method it serves. A method setting is addressed by
{resourcePath}/{httpMethod}, which is how API Gateway addresses one, and the entry keyed with a
resource path of /* and a method of * is the stage default. throttlingRateLimit is requests per
second, and throttlingBurstLimit is how many requests a method will take at once.
A request that finds an empty bucket is answered 429 with {"message":"Too Many Requests"}. The
method’s authorizer and its integration are both skipped.
The buckets refill against the simulated clock. Freeze it, spend a method’s burst, assert on the 429, then move a second on and watch the method serve again.
/** * Throttling a REST API stage and one of its methods. */
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/password-reset", "/profile"], httpMethod: "POST", methodSettings: { "/*/*": { throttlingRateLimit: 10, throttlingBurstLimit: 5 }, "/password-reset/POST": { throttlingRateLimit: 1, throttlingBurstLimit: 2, }, }, handler: () => ({ statusCode: 200, body: "ok" }), }, simAws,);
const srv = await serveSimAws({ simAws });
// Stop simulated time. A bucket now refills only when this example moves it.simAws.clock().freeze();
const passwordReset = async (): Promise<Response> => await fetch(srv.localUrl(`${restApi.invokeUrl("prod")}/password-reset`), { method: "POST", });
const first = await passwordReset();const second = await passwordReset();const third = await passwordReset();
console.log(first.status, second.status, third.status);console.log(await third.text());
// Another method, drawing on the stage default and a bucket of its own.const profile = await fetch( srv.localUrl(`${restApi.invokeUrl("prod")}/profile`), { method: "POST" },);console.log(profile.status);
// One second at a rate limit of one is one token back.await simAws.clock().advanceBy({ seconds: 1 });const afterASecond = await passwordReset();console.log(afterASecond.status);
await srv.close();The burst of two is served, the third password reset is refused, and the profile method is untouched by any of it:
200 200 429{"message":"Too Many Requests"}200200A template writes the same thing as a list, with each entry naming the method it applies to:
ProdStage: Type: AWS::ApiGateway::Stage Properties: RestApiId: !Ref Api DeploymentId: !Ref Deployment StageName: prod MethodSettings: - ResourcePath: "/*" HttpMethod: "*" ThrottlingRateLimit: 10 ThrottlingBurstLimit: 5 - ResourcePath: "/password-reset" HttpMethod: POST ThrottlingRateLimit: 1 ThrottlingBurstLimit: 2Every client of a method draws on the same bucket. Two callers sending one request each spend two
tokens between them. A WAFv2 RateBasedStatement counts each client on its own (see
Rate limiting), and a stack often carries both.
A method is throttled here only where the settings reaching it name both limits. Naming one alone leaves the other at the account limit on real AWS. Account limits are outside this simulation, and a method configured that way is served unthrottled.
The httpMethod half of a key is the one the method was declared with. A resource declaring ANY
is named ANY, whatever method the client sent.
Real CreateStage carries no method settings. AWS sets them with UpdateStage patch operations,
which are outside this simulation, or from an AWS::ApiGateway::Stage. The methodSettings input
above is this simulator’s own, so that a test can throttle a stage without a template, and the SDK’s
CreateStageCommand declares no such member. GetStage reports the settings the way AWS reports
them.
Authorizing a method
Section titled “Authorizing a method”A method is open unless it names an authorizer. CreateAuthorizerCommand creates one, and
PutMethodCommand binds it to a method with authorizationType: "CUSTOM" and the authorizerId the
API allocated.
A TOKEN authorizer reads one header and sends its value to a Lambda function of its own. That
function answers an IAM policy document, evaluated for execute-api:Invoke against the ARN of the
request being made. Whatever context it returns reaches the handler.
A REQUEST authorizer sends the whole request to its function (see
A REQUEST authorizer), so it can identify a caller by several headers
together or by the query string. It answers the same policy document.
/** * Gating a REST API method with a TOKEN Lambda authorizer. * * The authorizer reads the Authorization header, and the policy it answers is * evaluated against the ARN of the request being made. */
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/orders"], authorizerHandler: (event) => ({ principalId: "user-6", context: { tenantId: "acme" }, policyDocument: { Version: "2012-10-17", Statement: [ { Action: "execute-api:Invoke", Effect: event.authorizationToken === "Bearer valid" ? "Allow" : "Deny", Resource: event.methodArn, }, ], }, }), handler: (event) => ({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(event.requestContext.authorizer), }), }, simAws,);
const srv = await serveSimAws({ simAws });const url = srv.localUrl(`${restApi.invokeUrl("prod")}/orders`);
const admitted = await fetch(url, { headers: { authorization: "Bearer valid" },});
console.log(admitted.status);// 200
console.log(await admitted.text());// '{"tenantId":"acme","principalId":"user-6"}'
const refused = await fetch(url, { headers: { authorization: "Bearer stale" },});
console.log(refused.status);// 403
const anonymous = await fetch(url);
console.log(anonymous.status);// 401
await srv.close();simRestApiLambdaProxyFactory builds the authorizer’s function, the authorizer, its invoke
permission and the methods bound to it when it is given an authorizerHandler.
The event the authorizer receives
Section titled “The event the authorizer receives”A TOKEN authorizer sees three fields and no more.
| Field | What it carries |
|---|---|
type |
The literal TOKEN |
authorizationToken |
The value the request carried at the identity source |
methodArn |
arn:aws:execute-api:{region}:{account}:{apiId}/{stage}/{METHOD}/{path} |
The methodArn names the path the client asked for rather than the resource template it matched. A
request to /orders/6 behind an /orders/{orderId} resource is named as GET/orders/6.
A TOKEN authorizer’s identity source is one header, written as
method.request.header.Authorization. An expression naming anywhere else is refused by
CreateAuthorizer, because an authorizer that looks where the request never carries anything
refuses everyone. That refusal reads like a signing problem when the configuration is what went
wrong.
A REQUEST authorizer
Section titled “A REQUEST authorizer”type: "REQUEST" sends the request itself to the function. The identitySource is one
comma-separated string naming as many places as identify a caller, in the
method.request.header.<name> and method.request.querystring.<name> forms. An HTTP API takes a
list here, and a REST API takes the string.
/** * Gating a REST API method with a REQUEST Lambda authorizer. * * The authorizer reads a header and a query string parameter together, which * is what a TOKEN authorizer cannot do. */
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/orders"], httpMethod: "GET", authorizerIdentitySource: "method.request.header.X-Tenant,method.request.querystring.plan", requestAuthorizerHandler: (event) => ({ principalId: event.headers["x-tenant"], context: { plan: event.queryStringParameters["plan"] }, policyDocument: { Version: "2012-10-17", Statement: [ { Action: "execute-api:Invoke", Effect: event.queryStringParameters["plan"] === "gold" ? "Allow" : "Deny", Resource: event.methodArn, }, ], }, }), handler: (event) => ({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(event.requestContext.authorizer), }), }, simAws,);
const srv = await serveSimAws({ simAws });const url = srv.localUrl(`${restApi.invokeUrl("prod")}/orders`).href;const headers = { "x-tenant": "acme" };
const admitted = await fetch(`${url}?plan=gold`, { headers });
console.log(admitted.status);// 200
console.log(await admitted.text());// '{"plan":"gold","principalId":"acme"}'
const refused = await fetch(`${url}?plan=free`, { headers });
console.log(refused.status);// 403
const anonymous = await fetch(`${url}?plan=gold`);
console.log(anonymous.status);// 401
await srv.close();The function is invoked only once the request carries something at every identity source. The
request with no X-Tenant header above got its 401 without the function running.
An authorizer created with no identitySource is refused. Real AWS invokes that authorizer for
every request including one carrying nothing, and CDK’s RequestAuthorizer requires at least one
source.
The event a REQUEST authorizer receives
Section titled “The event a REQUEST authorizer receives”The event is the payload format 1.0 request event with type and methodArn added and the body
left out.
| Field | What it carries |
|---|---|
type |
The literal REQUEST |
methodArn |
The ARN of the request, the same one a TOKEN authorizer gets |
resource, path, httpMethod |
The resource template, the path asked for and the method |
headers, multiValueHeaders |
The request headers, in both forms payload format 1.0 sends |
queryStringParameters, multiValueQueryStringParameters |
The query string, in both forms |
pathParameters, stageVariables |
What the resource path captured, and the stage’s variables |
requestContext |
The same block a handler gets, without the authorizer member |
The maps are empty objects where the request supplied nothing. An integration event sends null
there, and AWS’s own example of the authorizer event sends {}, so a function reading
event.queryStringParameters.plan finds nothing rather than throwing.
Answering with a policy
Section titled “Answering with a policy”A REST API authorizer always answers a policy. An HTTP API authorizer may answer a boolean instead. A function written for one is read wrongly by the other.
{ "principalId": "user-6", "context": { "tenantId": "acme" }, "policyDocument": { "Version": "2012-10-17", "Statement": [ { "Action": "execute-api:Invoke", "Effect": "Allow", "Resource": "arn:aws:execute-api:eu-west-2:111111111111:a1b2c3d4e5/prod/GET/orders" } ] }}The document goes to simulated IAM and is evaluated for execute-api:Invoke on the methodArn. A
policy naming one method leaves the others unauthorized, and an authorizer wanting to open the whole
API wildcards the resource the way any IAM policy does.
principalId is a name the authorizer chose for the caller. It identifies that caller in the
authorizer’s own logs, and IAM never sees it.
The context the handler receives
Section titled “The context the handler receives”context reaches the handler under requestContext.authorizer, flattened alongside principalId.
Payload format 2.0 keeps the context in a block of its own, so a handler moved between a REST API and
an HTTP API reads a different shape.
An open method has no caller to describe, and leaves requestContext.authorizer out of the event
altogether.
What a refused request gets back
Section titled “What a refused request gets back”| Case | Answer |
|---|---|
| No value at the identity source | 401 Unauthorized |
The authorizer returned { "errorMessage": "Unauthorized" } |
401 Unauthorized |
| A Deny statement matched the method | 403 User is not authorized to access this resource with an explicit deny |
| The policy allowed nothing covering the method | 403 User is not authorized to access this resource |
| The authorizer failed, or answered a shape AWS cannot read | 500 Internal server error |
A request carrying nothing at the identity source is refused before the function is invoked. An authorizer counting its own invocations never sees one.
The 401 body member is message and the 403 one is Message. Real API Gateway is inconsistent about
the two and this follows it.
A function that throws is an authorizer failure, and gets the 500. Real Lambda turns a thrown error
into a payload carrying errorMessage, while simulated Lambda rejects with the error itself.
Returning the value is the way to ask for a 401.
The authorizer’s invoke permission
Section titled “The authorizer’s invoke permission”The authorizer’s function needs a grant of its own, under an ARN naming the authorizer:
arn:aws:execute-api:{region}:{account}:{apiId}/authorizers/{authorizerId}That ARN names no stage. A function used both as an integration and as an authorizer needs two
permissions, as it does on AWS. CDK’s TokenAuthorizer and RequestAuthorizer both write this
one.
Caching the authorizer’s decision
Section titled “Caching the authorizer’s decision”authorizerResultTtlInSeconds holds a decision for that many seconds. A second request presenting
the same identity within that period reaches the handler without the function running again. AWS
accepts a whole number of seconds up to 3600, and 0 switches the holding off.
An authorizer that says nothing about the member gets 0 here and gets 300 on real API Gateway. Write the member out to have a test and a deployment agree on it. CDK writes it either way, at five minutes by default (see CDK).
A TOKEN authorizer is keyed on the token it was handed. A REQUEST authorizer is keyed on the
values its identity sources found, in the order they were configured. Both are held per method,
because what is held is the admission or refusal the authorizer’s policy produced for one method
ARN, and that answer covers no other method. (An HTTP API keys on the identity source values alone,
and one decision there covers every route using the authorizer.)
A refusal is held the way an admission is. AWS holds whatever answer the authorizer gave. A failed authorizer holds no answer, and its function is asked again on the next request.
Expiry follows simulated time. simAws.clock().advanceBy(...) drops a decision that was being
reused a moment before.
/** * Caching a simulated REST API Lambda authorizer's decision. * * The authorizer counts its own invocations and reports the count in its * context, so the handler shows which decision served each request. */
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();const counter = { invocations: 0 };
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/orders"], authorizerResultTtlSeconds: 300, authorizerHandler: (event) => { counter.invocations += 1;
return { principalId: "user-6", context: { ...counter }, policyDocument: { Version: "2012-10-17", Statement: [ { Action: "execute-api:Invoke", Effect: "Allow", Resource: event.methodArn, }, ], }, }; }, handler: (event) => ({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(event.requestContext.authorizer), }), }, simAws,);
const srv = await serveSimAws({ simAws });const url = srv.localUrl(`${restApi.invokeUrl("prod")}/orders`);const call = async (): Promise<unknown> => { const response = await fetch(url, { headers: { authorization: "Bearer session-6" }, });
return await response.json();};
console.log(await call());// { invocations: 1, principalId: 'user-6' }
console.log(await call());// { invocations: 1, principalId: 'user-6' }, held rather than asked again
await simAws.clock().advanceBy({ minutes: 6 });
console.log(await call());// { invocations: 2, principalId: 'user-6' }
await srv.close();A COGNITO_USER_POOLS authorizer verifies each token as it arrives here, and CreateAuthorizer
refuses authorizerResultTtlInSeconds on one. Real API Gateway holds a Cognito authorizer’s
decision too, and a token that expires inside the period is still accepted there.
Protecting a method with IAM
Section titled “Protecting a method with IAM”A method declared authorizationType: "AWS_IAM" reaches its integration only when the caller is
allowed execute-api:Invoke on the ARN of the method being called. IAM decides. The method takes no
authorizer, and naming one is refused by PutMethod.
The caller comes from the request, through either a SigV4 signature or an x-sim-aws-caller header
naming a principal directly. A request offering neither is anonymous, owns no policies, and is
refused. See callers of HTTP requests in the IAM docs for how that
resolution works and how to sign a served request.
The ARN a request is authorized against is:
arn:aws:execute-api:<region>:<account>:<apiId>/<stage>/<METHOD>/<path>- The Account and Region are the API’s own, not the caller’s.
- The stage is the one that served the request.
- The method is the one the client sent, upper case. A
GETreaching a resource declaringANYgivesGET. - The path is the request path with the stage segment and the leading slash taken off, so
/prod/orders/42served from stageprodgivesorders/42. It is the path the client asked for, because a policy names it by hand. A request to a method declared on/orders/{orderId}is authorized underGET/orders/42, with no braces in it. A request to the API root gives an ARN ending/GET/.
An identity policy may wildcard any part of that. <apiId>/*, <apiId>/prod/* and
<apiId>/*/GET/orders/* all allow a GET of /orders/42 on stage prod.
/** * Protecting a simulated REST API method with IAM. * * The caller the request was attributed to has to be allowed * execute-api:Invoke on the method it is calling. */
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const restApi = await simRestApiLambdaProxyFactory.make( { iamAuthorization: true, resourcePaths: ["/orders/{orderId}"], handler: (event) => ({ statusCode: 200, headers: { "content-type": "text/plain" }, body: `orders for ${event.requestContext.identity.userArn ?? "nobody"}`, }), }, simAws,);
// A Role of the API's own Account, allowed to call the orders methods of this// API on the stage it is deployed to.await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "Reporter", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { AWS: "arn:aws:iam::888888888888:root" }, Action: "sts:AssumeRole", }, ], }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "Reporter", PolicyName: "InvokeOrders", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Action: "execute-api:Invoke", Resource: `arn:aws:execute-api:us-east-1:888888888888:${restApi.apiId}/prod/GET/orders/*`, }, ], }), }),);
const srv = await serveSimAws({ simAws });const url = srv.localUrl(`${restApi.invokeUrl("prod")}/orders/42`);
const anonymous = await fetch(url);
console.log(anonymous.status);// 403
const reporter = await fetch(url, { headers: { "x-sim-aws-caller": "arn:aws:iam::888888888888:role/Reporter" },});
console.log(await reporter.text());// "orders for arn:aws:iam::888888888888:role/Reporter"
await srv.close();A caller IAM does not allow gets 403 with User is not authorized to access this resource. An
explicit Deny gets that same body, where a Lambda authorizer’s Deny gets one of its own.
Only the caller’s identity policies are read. A REST API also has a resource policy on real AWS, and
Policy on an AWS::ApiGateway::RestApi is recorded against the Resource with the API deployed
without it. A caller from another Account is therefore always refused, because a cross-Account
request needs an Allow from each side. The way through, here as on AWS, is to assume a Role in the
API’s Account.
The identity the handler receives
Section titled “The identity the handler receives”An admitted caller reaches the handler under requestContext.identity.
| Field | What it carries |
|---|---|
accountId |
The Account the caller’s ARN names |
caller |
The caller’s ARN |
user |
The caller’s ARN |
userArn |
The caller’s ARN |
Real API Gateway puts the unique id of the principal in caller and user, such as AIDA... for a
User. A request carries no such id into the simulation, so the ARN identifying the caller goes in
every field that can be filled from it.
A method of any other authorization type leaves those four null, and so does an AWS_IAM method
called by a principal with no ARN behind it. accessKey, apiKey, apiKeyId, principalOrgId and
the two Cognito identity pool fields are null throughout. sourceIp and userAgent describe the
request itself and are filled for every method.
Authorizing a method with a user pool
Section titled “Authorizing a method with a user pool”A COGNITO_USER_POOLS authorizer verifies the token itself against the keys the user pools it names
publish. Nothing is invoked, so there is no function to write and no policy to answer.
CreateAuthorizerCommand takes the pools as providerARNs, and PutMethodCommand binds the
authorizer to a method with authorizationType: "COGNITO_USER_POOLS".
/** * Gating a REST API method with a Cognito user pool authorizer. * * The authorizer verifies the token against the keys the pool publishes, and * the token's own claims reach the handler under `requestContext.authorizer`. */
import { AdminCreateUserCommand, AdminInitiateAuthCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand,} from "@aws-sdk/client-cognito-identity-provider";
import { SimAws } from "@kensio/yulin";import { simRestApiLambdaProxyFactory } from "@kensio/yulin/apigateway";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();const cognito = simAws.cognitoIdentityProvider();
const pool = await cognito.createUserPool( new CreateUserPoolCommand({ PoolName: "myapp-users" }),);const UserPoolId = pool.UserPool!.Id!;
const appClient = await cognito.createUserPoolClient( new CreateUserPoolClientCommand({ UserPoolId, ClientName: "web", ExplicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH"], }),);const ClientId = appClient.UserPoolClient!.ClientId!;
await cognito.adminCreateUser( new AdminCreateUserCommand({ UserPoolId, Username: "ada" }),);await cognito.adminSetUserPassword( new AdminSetUserPasswordCommand({ UserPoolId, Username: "ada", Password: "Correct-horse-1", Permanent: true, }),);
const restApi = await simRestApiLambdaProxyFactory.make( { resourcePaths: ["/orders"], cognitoUserPoolArns: [pool.UserPool!.Arn!], handler: (event) => ({ statusCode: 200, headers: { "content-type": "text/plain" }, body: `orders for ${ event.requestContext.authorizer?.claims?.["cognito:username"] ?? "nobody" }`, }), }, simAws,);
const signedIn = await cognito.adminInitiateAuth( new AdminInitiateAuthCommand({ UserPoolId, ClientId, AuthFlow: "ADMIN_USER_PASSWORD_AUTH", AuthParameters: { USERNAME: "ada", PASSWORD: "Correct-horse-1" }, }),);const idToken = signedIn.AuthenticationResult!.IdToken!;
const srv = await serveSimAws({ simAws });const url = srv.localUrl(`${restApi.invokeUrl("prod")}/orders`);
const anonymous = await fetch(url);
console.log(anonymous.status);// 401
const authorized = await fetch(url, { headers: { authorization: idToken } });
console.log(await authorized.text());// "orders for ada"
// Advancing the simulation's clock past the token's expiry closes the method// to the same token, with nothing reissued.await simAws.clock().advanceBy({ hours: 2 });
const expired = await fetch(url, { headers: { authorization: idToken } });
console.log(expired.status);// 401
await srv.close();simRestApiLambdaProxyFactory builds the authorizer and the methods bound to it when it is given
cognitoUserPoolArns.
A providerARN is read for the pool id it names, and the pool is looked up across every simulated
account. The token is accepted when any one of the named pools signed it, its iss names that same
pool, and its time claims hold against the simulation’s clock. Advancing the clock past a token’s
exp therefore closes a method that was open to it.
The claims the handler receives
Section titled “The claims the handler receives”The token’s own claims reach the handler under requestContext.authorizer.claims, and every value
arrives as a string. A list claim such as cognito:groups is rendered the way Go prints a slice, so
two groups arrive as [Admins Readers].
{ "claims": { "sub": "5c4a5f6c-6c31-4a2e-9a55-2c4dcb8f2f4f", "cognito:username": "ada", "cognito:groups": "[Admins Readers]", "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_aBcDeFgHi", "token_use": "id" }}An HTTP API puts the same claims under requestContext.authorizer.jwt.claims, with the scopes
beside them, so a handler moved between the two reads a different shape.
Scopes
Section titled “Scopes”authorizationScopes on the method is met by any one of the scopes the token’s scope claim
carries. The scopes are the method’s own, so one authorizer covers methods asking for different
ones.
A method asking for no scope takes an id token and an access token alike, because token_use is not
checked, which is what real API Gateway does. A method asking for a scope takes only an access
token, since an id token carries no scope claim at all.
What a refused request gets back
Section titled “What a refused request gets back”| Case | Answer |
|---|---|
| No value at the identity source | 401 Unauthorized |
| A value that is not a readable JWT | 401 Unauthorized |
| A token no named pool signed, or one signed by another key | 401 Unauthorized |
A token that has expired, or has no exp at all |
401 Unauthorized |
| A verified token claiming none of the method’s scopes | 403 User is not authorized to access this resource |
Every refusal up to and including the claim checks is the same 401, so a client learns that its token was not accepted and nothing about which check it failed. An unmet scope is the one 403: the token was accepted, and it does not allow this method.
The token is taken with or without the Bearer scheme in front of it. The identity source is one
header, as it is for a TOKEN authorizer.
Protecting a stage with a web ACL
Section titled “Protecting a stage with a web ACL”A simulated WAFv2 web ACL can go in front of a stage, and every request that stage serves is then
put through its rules. AssociateWebACL names the stage by its ARN, which SimRestApi.stageArn
builds.
The web ACL sees the request before the method is matched and before any authorizer runs, ahead of IAM, a Lambda authorizer and a Cognito authorizer alike. A blocked request gets 403 with WAF’s body, and the integration is never invoked. See Protecting an API Gateway REST API stage in the WAFv2 docs.
Intercepting an SDK client
Section titled “Intercepting an SDK client”SimSdk routes @aws-sdk/client-api-gateway commands to the simulation. Code under test builds its
own client and reaches simulated API Gateway through it.
/** * Reaching simulated API Gateway through a real APIGatewayClient. */
import { APIGatewayClient, CreateRestApiCommand, GetRestApisCommand,} from "@aws-sdk/client-api-gateway";
import { SimAws } from "@kensio/yulin";import { SimSdk } from "@kensio/yulin/sdk";
const simAws = new SimAws();using simSdk = new SimSdk({ simAws });
const client = new APIGatewayClient({ region: "eu-west-2" });simSdk.intercept(client);
await client.send(new CreateRestApiCommand({ name: "orders" }));
const listed = await client.send(new GetRestApisCommand({}));console.log(listed.items?.map((restApi) => restApi.name));// [ "orders" ]The client’s region decides which simulated account and region scope the API lands in, the same way it does for every other intercepted service.
Importing an OpenAPI definition
Section titled “Importing an OpenAPI definition”ImportRestApiCommand takes a serialised OpenAPI 3.0 document and creates the API, the resources of
its path tree, one method per operation and the integration behind each method. Every segment of a
path becomes a resource, and paths sharing a prefix share the nodes that spell it, so
/pets/{petId} is a {petId} resource under a pets one under the root.
An import creates no stage. CreateDeploymentCommand or an AWS::ApiGateway::Stage is still
declared separately, and an imported API with no stage answers 403.
/** * Creating a simulated REST API from an OpenAPI 3 definition. */
import { CreateDeploymentCommand, GetResourcesCommand, ImportRestApiCommand,} from "@aws-sdk/client-api-gateway";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";import { serveSimAws, type SimPayload1Event } from "@kensio/yulin/serve";
const simAws = new SimAws();
const { FunctionArn } = await simAws.lambda().createFunction( new CreateFunctionCommand({ FunctionName: "pets", Role: "arn:aws:iam::111111111111:role/PetsRole", Code: { ZipFile: makeLambdaZipFileInput((event: SimPayload1Event) => ({ statusCode: 200, headers: { "content-type": "text/plain" }, body: `pet ${event.pathParameters?.["petId"] ?? "none"}`, })), }, }),);
const openApi = { openapi: "3.0.1", info: { title: "pets", version: "1.0" }, paths: { "/pets/{petId}": { get: { // Ignored, as on AWS, since no request validator names this schema. responses: { "200": { description: "200 response" } }, "x-amazon-apigateway-integration": { type: "aws_proxy", httpMethod: "POST", uri: `arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/` + `${FunctionArn}/invocations`, }, }, }, },};
const apiGateway = simAws.apiGateway();const definition = new TextEncoder().encode(JSON.stringify(openApi));
const { id: restApiId } = await apiGateway.importRestApi( new ImportRestApiCommand({ body: definition }),);
const resources = await apiGateway.getResources( new GetResourcesCommand({ restApiId }),);
console.log(resources.items.map((resource) => resource.path));// [ "/", "/pets", "/pets/{petId}" ]
// An import creates no stage. The API answers 403 until one is deployed.await apiGateway.createDeployment( new CreateDeploymentCommand({ restApiId, stageName: "prod" }),);
await simAws.lambda().addPermission( new AddPermissionCommand({ FunctionName: "pets", StatementId: "api-gateway-invoke", Action: "lambda:InvokeFunction", Principal: "apigateway.amazonaws.com", SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${restApiId}/*/*/*`, }),);
const srv = await serveSimAws({ simAws });
const response = await fetch( srv.localUrl( `https://${restApiId}.execute-api.us-east-1.amazonaws.com/prod/pets/42`, ),);
console.log(await response.text());// "pet 42"
await srv.close();The API is named by info.title. uri is read as either the long
arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations form above
or as the bare function ARN. An operation carrying no security becomes an open method, and one
naming a security scheme is gated by the authorizer that scheme declares. See
Security schemes.
The catch-all operation key
Section titled “The catch-all operation key”x-amazon-apigateway-any-method declares an ANY method on the path. That method serves every verb
the resource has no method of its own for, and OpenAPI has no operation key of its own for it:
{ "/pets": { "x-amazon-apigateway-any-method": { "x-amazon-apigateway-integration": { "type": "aws_proxy", "uri": "..." } } }}A {proxy+} segment becomes a greedy resource that matches the rest of the request path. One path
of /{proxy+} carrying that extension is the whole of what CDK’s LambdaRestApi builds.
Security schemes
Section titled “Security schemes”A components.securitySchemes member carrying x-amazon-apigateway-authtype becomes an authorizer,
and security on an operation puts that authorizer in front of the method. The scheme key names it.
One authorizer is created per scheme, shared by every operation naming it.
/** * Importing a REST API whose method is gated by a security scheme. */
import { GetAuthorizersCommand, GetMethodCommand, GetResourcesCommand, ImportRestApiCommand,} from "@aws-sdk/client-api-gateway";import { CreateFunctionCommand } from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import type { SimRestApiTokenAuthorizerEvent } from "@kensio/yulin/apigateway";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws();
const { FunctionArn: petsArn } = await simAws.lambda().createFunction( new CreateFunctionCommand({ FunctionName: "pets", Role: "arn:aws:iam::111111111111:role/PetsRole", Code: { ZipFile: makeLambdaZipFileInput(() => ({ statusCode: 200, body: "pets", })), }, }),);
const { FunctionArn: authorizerArn } = await simAws.lambda().createFunction( new CreateFunctionCommand({ FunctionName: "pet-authorizer", Role: "arn:aws:iam::111111111111:role/PetsRole", Code: { ZipFile: makeLambdaZipFileInput( (event: SimRestApiTokenAuthorizerEvent) => ({ principalId: "pet-owner", policyDocument: { Version: "2012-10-17", Statement: [ { Action: "execute-api:Invoke", Effect: event.authorizationToken === "Bearer valid" ? "Allow" : "Deny", Resource: event.methodArn, }, ], }, }), ), }, }),);
const openApi = { openapi: "3.0.1", info: { title: "pets", version: "1.0" }, paths: { "/pets": { get: { security: [{ "pet-authorizer": [] }], "x-amazon-apigateway-integration": { type: "aws_proxy", httpMethod: "POST", uri: petsArn, }, }, }, }, components: { securitySchemes: { "pet-authorizer": { type: "apiKey", name: "Authorization", in: "header", "x-amazon-apigateway-authtype": "custom", "x-amazon-apigateway-authorizer": { type: "token", authorizerUri: authorizerArn, authorizerResultTtlInSeconds: 300, }, }, }, },};
const apiGateway = simAws.apiGateway();
const definition = new TextEncoder().encode(JSON.stringify(openApi));
const { id: restApiId } = await apiGateway.importRestApi( new ImportRestApiCommand({ body: definition }),);
const authorizers = await apiGateway.getAuthorizers( new GetAuthorizersCommand({ restApiId }),);
console.log(authorizers.items.map((one) => [one.name, one.type]));// [ [ "pet-authorizer", "TOKEN" ] ]
const resources = await apiGateway.getResources( new GetResourcesCommand({ restApiId }),);const pets = resources.items.find((resource) => resource.path === "/pets");
const method = await apiGateway.getMethod( new GetMethodCommand({ restApiId, resourceId: pets?.id, httpMethod: "GET" }),);
console.log(method.authorizationType);// "CUSTOM"Three x-amazon-apigateway-authtype values are read, and each writes a different kind of gate on
the method.
x-amazon-apigateway-authtype |
The authorizer type under it |
Method AuthorizationType |
|---|---|---|
custom |
token or request |
CUSTOM |
cognito_user_pools |
cognito_user_pools |
COGNITO_USER_POOLS |
awsSigv4 |
(the scheme carries none) | AWS_IAM |
A cognito_user_pools authorizer carries the providerARNs of the pools it accepts tokens from,
and the scopes a requirement asks for (security: [{ "pet-authorizer": ["pets.read"] }]) become the
method’s authorizationScopes. PutMethod refuses scopes on every other kind of method, since a
token is what they are checked against.
An awsSigv4 scheme names a header and leaves the deciding to IAM. x-amazon-apigateway-auth on an
operation asks for the same gate, and is what an API Gateway console export writes:
{ "x-amazon-apigateway-auth": { "type": "AWS_IAM" } }A token or cognito_user_pools authorizer reads the one header the scheme’s own name and in
name, which for the scheme above is method.request.header.Authorization. That is where AWS reads
it from, and an identitySource written inside either authorizer is refused. A request authorizer
names its own identitySource, with as many comma-separated expressions as identify its callers.
authorizerResultTtlInSeconds on a Lambda authorizer is how long its decisions are held for, the
same as it is on CreateAuthorizer. See
Caching the authorizer’s decision.
A scheme whose x-amazon-apigateway-authtype and whose authorizer type disagree is refused,
naming the pointer of the member they disagree about, such as
#/components/securitySchemes/pet-authorizer/x-amazon-apigateway-authorizer/type. These are refused
where a document writes them too:
- An
apiKeyscheme carrying nox-amazon-apigateway-authtype. That is an API key, and API keys and usage plans are outside this simulation. - An
http,oauth2oropenIdConnectscheme. A REST API method is gated by the three authtypes above and by nothing else. authorizerCredentialsandidentityValidationExpressionon an authorizer.CreateAuthorizerrefuses both of them as well.- A
securityrequirement at the root of the document. Write the requirement on each operation. - An operation naming two requirements, or one requirement naming two schemes. One authorizer decides a method.
Members that are ignored
Section titled “Members that are ignored”AWS sorts what an import finds into three categories, and the third is valid OpenAPI a REST API
leaves unsupported without a request validator. AWS ignores it silently, and so does this:
requestBody, the content schemas under responses, components.schemas, and an operation’s
parameters, summary, description and tags. Request validation is refused at the root of the
document, so a request whose body contradicts a declared schema still reaches the handler.
operationId is ignored too, since it only supplies the OperationName a method carries for
documentation.
Everything else the document carries and this simulation cannot apply is refused, naming the JSON
pointer of the member, such as
#/paths/~1pets~1{petId}/get/x-amazon-apigateway-integration/passthroughBehavior.
Replacing a definition
Section titled “Replacing a definition”PutRestApiCommand with mode: "overwrite" replaces an API’s whole definition with the document’s.
The API keeps its id, its endpoint and the stages serving it. Its path tree and its authorizers are
built again from the document, and an authorizer the previous definition left behind goes with them.
await apiGateway.putRestApi( new PutRestApiCommand({ restApiId, mode: "overwrite", body: definition }),);mode: "merge" is refused, and so is a PutRestApi that leaves the mode out, which AWS reads as a
merge. A merge adds the document’s paths to the API’s existing ones, and which of two declarations
of one method it keeps decides what every request to that method reaches.
A refused replacement leaves the API with an empty path tree. The old definition has already been taken out by the time a member deep in the document is refused, and an API serving nothing is clearer than one serving half of each document.
Deploying from CloudFormation and CDK
Section titled “Deploying from CloudFormation and CDK”Simulated CloudFormation deploys
AWS::ApiGateway::RestApi, AWS::ApiGateway::Resource, AWS::ApiGateway::Method,
AWS::ApiGateway::Deployment and AWS::ApiGateway::Stage. A synthesized or hand-written template
produces an API that serves requests.
Ref and Fn::GetAtt return what real CloudFormation returns for each type:
| Resource type | Ref |
Fn::GetAtt |
|---|---|---|
RestApi |
the API id | RestApiId, RootResourceId |
Resource |
the resource id | ResourceId |
Method |
the logical id | none, as AWS documents none |
Deployment |
the deployment id | DeploymentId |
Stage |
the stage name | none, as AWS documents none |
A REST API method has no id of its own. It is addressed by its API, its resource and its HTTP verb,
and a Ref to one falls back on the CloudFormation logical id. CDK reads that value only to publish
Method.methodId.
The API publishes no endpoint attribute either, because real API Gateway reports none. CDK joins the
URL out of a Ref to the API, the region, AWS::URLSuffix and a Ref to the stage. That suffix
resolves to the local sim-aws.localhost hostname, and the stage is the first path segment of what
it builds.
A Resource names its place in the tree through ParentId. The top of the tree reads
Fn::GetAtt: ["<Api>", "RootResourceId"], and everything below it a Ref to the node above.
A Method carries its integration as an Integration block of its own, which is how the REST API
models one. The block becomes the PutIntegration that follows the method’s PutMethod, and its
Uri is read as the bare Lambda function ARN or as the
arn:aws:apigateway:<region>:lambda:path/2015-03-31/functions/<function-arn>/invocations string CDK
builds with Fn::Join.
/** * Deploying a simulated REST API from a CloudFormation template. */
import { SimAws } from "@kensio/yulin";import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const stack = await simAws.cloudFormation().deployTemplate({ stackName: "orders-stack", template: { Resources: { HandlerRole: { Type: "AWS::IAM::Role", Properties: { RoleName: "orders-role", AssumeRolePolicyDocument: { Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { Service: "lambda.amazonaws.com" }, Action: "sts:AssumeRole", }, ], }, }, }, Handler: { Type: "AWS::Lambda::Function", Properties: { FunctionName: "orders", Role: { "Fn::GetAtt": ["HandlerRole", "Arn"] }, Handler: "index.handler", Runtime: "nodejs20.x", Code: { ZipFile: "exports.handler = async (event) => ({ statusCode: 200, body: 'order ' + event.pathParameters.orderId });", }, }, }, HandlerPermission: { Type: "AWS::Lambda::Permission", Properties: { Action: "lambda:InvokeFunction", FunctionName: { "Fn::GetAtt": ["Handler", "Arn"] }, Principal: "apigateway.amazonaws.com", SourceArn: { "Fn::Join": [ "", [ "arn:aws:execute-api:", { Ref: "AWS::Region" }, ":", { Ref: "AWS::AccountId" }, ":", { Ref: "Api" }, "/*/*/*", ], ], }, }, }, Api: { Type: "AWS::ApiGateway::RestApi", Properties: { Name: "orders" }, }, OrdersResource: { Type: "AWS::ApiGateway::Resource", Properties: { RestApiId: { Ref: "Api" }, ParentId: { "Fn::GetAtt": ["Api", "RootResourceId"] }, PathPart: "orders", }, }, OrderResource: { Type: "AWS::ApiGateway::Resource", Properties: { RestApiId: { Ref: "Api" }, ParentId: { Ref: "OrdersResource" }, PathPart: "{orderId}", }, }, GetOrder: { Type: "AWS::ApiGateway::Method", Properties: { RestApiId: { Ref: "Api" }, ResourceId: { Ref: "OrderResource" }, HttpMethod: "GET", AuthorizationType: "NONE", Integration: { Type: "AWS_PROXY", IntegrationHttpMethod: "POST", Uri: { "Fn::Join": [ "", [ "arn:aws:apigateway:", { Ref: "AWS::Region" }, ":lambda:path/2015-03-31/functions/", { "Fn::GetAtt": ["Handler", "Arn"] }, "/invocations", ], ], }, }, }, }, Deployment: { Type: "AWS::ApiGateway::Deployment", Properties: { RestApiId: { Ref: "Api" } }, DependsOn: ["GetOrder"], }, Stage: { Type: "AWS::ApiGateway::Stage", Properties: { RestApiId: { Ref: "Api" }, DeploymentId: { Ref: "Deployment" }, StageName: "prod", }, }, }, Outputs: { ApiUrl: { Value: { "Fn::Join": [ "", [ "https://", { Ref: "Api" }, ".execute-api.", { Ref: "AWS::Region" }, ".", { Ref: "AWS::URLSuffix" }, "/", { Ref: "Stage" }, "/", ], ], }, }, }, },});
await stack.waitForDeployComplete();
// https://<api-id>.execute-api.us-east-1.sim-aws.localhost/prod/const apiUrl = stack.output("ApiUrl");
const srv = await serveSimAws({ simAws });
const response = await fetch(srv.localUrl(`${apiUrl}orders/6`));
console.log(response.status);// 200
console.log(await response.text());// "order 6"
await srv.close();Every property outside the simulated set is left out of what is created and recorded in
stack.ignoredProperties,
naming the Resource type, the logical id and the ones this can act on. The API, resource, method,
deployment or stage is created either way. The stack deploys, and the record says which of its parts
behaves differently to the template. The simulated properties are:
RestApi:Name,Description,DisableExecuteApiEndpoint,Body,FailOnWarningsResource:RestApiId,ParentId,PathPartMethod:RestApiId,ResourceId,HttpMethod,AuthorizationType,AuthorizerId,AuthorizationScopes,ApiKeyRequired,OperationName,Integration- A method’s
Integrationblock:Type,IntegrationHttpMethod,Uri Authorizer:RestApiId,Name,Type,AuthorizerUri,ProviderARNs,IdentitySourceDeployment:RestApiId,Description,StageNameStage:RestApiId,DeploymentId,StageName,Description,Variables,MethodSettings- A stage’s
MethodSettingsentries:ResourcePath,HttpMethod,ThrottlingRateLimit,ThrottlingBurstLimit
A Body on the RestApi is an inline OpenAPI document declaring the API’s resources, methods and
integrations. It goes through ImportRestApi, the same translator an SDK caller importing a
document reaches, and the template then carries no Resource or Method of its own. See
Importing an OpenAPI definition for what the document may hold.
Api: Type: AWS::ApiGateway::RestApi Properties: Body: openapi: 3.0.1 info: { title: pets, version: "1.0" } paths: /pets/{petId}: get: x-amazon-apigateway-integration: type: aws_proxy httpMethod: POST uri: !GetAtt Handler.ArnName names the API where the template carries both, and the document’s info.title names it
otherwise. Description and DisableExecuteApiEndpoint beside a Body are recorded and left off
the API, because ImportRestApi takes neither and AWS applies them in a second step. A
Resource, Method or other entry adding to an API a Body already declared fails the stack
naming both, since the template would then declare the API two ways at once. BodyS3Location and
Mode are recorded like any other unsimulated property.
A Body holding a Swagger 2.0 document is the one document that is recorded instead of refused. The
API deploys with an empty path tree and the record says why. SAM writes Swagger 2.0 for an
AWS::Serverless::Api unless the template asks for OpenApiVersion: 3.0.1, and failing the stack
over the version of a document would take a whole SAM API down with it. Every other document the
import refuses fails the Resource, because an API deployed with an empty tree answers 403 for every
path the document declared.
StageName on a Deployment is the older one-Resource form, where the deployment publishes a stage
of that name by itself and the template carries one Resource fewer.
A template naming the SAM transform reaches the same resource types.
AWS::Serverless::Api becomes a REST API with its deployment and stage, and the Api event of an
AWS::Serverless::Function becomes the path resources, the method and the invoke permission that
put the function behind it. Events naming no RestApiId share one API on a Prod stage.
The SAM section of the CloudFormation docs covers both.
CDK’s RestApi and LambdaRestApi both deploy and serve. LambdaRestApi synthesizes an ANY
method on the root and another on a {proxy+} resource, both in front of one function, with the
AWS::Lambda::Permission each needs. restApi.url and restApi.urlForPath resolve to the local
hostname through AWS::URLSuffix.
TokenAuthorizer and RequestAuthorizer deploy too, with the AWS::Lambda::Permission each
writes for its own function. CDK holds a decision for five minutes by default, and
resultsCacheTtl sets that period. See
Caching the authorizer’s decision.
CognitoUserPoolsAuthorizer deploys as it stands, naming the pools it was given by ARN. A method
takes it with authorizationType: apigateway.AuthorizationType.COGNITO, and a user pool the same
stack declares is reached through the Fn::GetAtt on its ARN that CDK writes.
CDK also writes an AWS::ApiGateway::Account and a CloudWatch role beside a default RestApi.
Neither is simulated. The Account Resource is recorded in
stack.skippedResources and the rest of the
stack deploys.
ApiKey, UsagePlan, UsagePlanKey, RequestValidator, Model, DomainName and
BasePathMapping are recorded there too, each naming the reason. A method under one of them is
refused by PutMethod, because a method that looked gated to the template and answered every
request here is worse than a failed deployment.
Authorization
Section titled “Authorization”Every command is authorized by simulated IAM, and API Gateway asks IAM an unusual question. The
action is the HTTP method of the underlying REST call, apigateway:POST and friends, and the
resource is the request path. CreateResource on API a1b2c3d4e5 asks whether the caller may
apigateway:POST on arn:aws:apigateway:eu-west-2::/restapis/a1b2c3d4e5/resources. Those ARNs
carry no account id, because API Gateway control-plane ARNs leave that segment empty.
A policy naming an action such as apigateway:CreateResource matches nothing here, and it matches
nothing on real AWS.
What is refused
Section titled “What is refused”An input outside what this simulates is refused. Dropping it would let a request look applied here and behave differently deployed. The refusals worth knowing about:
- Authorizer kinds.
CreateAuthorizertakesTOKEN,REQUESTandCOGNITO_USER_POOLS, which is every kind a REST API has. TheJWTauthorizer an HTTP API takes is the v2 service’s and is refused here. AnAWS_IAMmethod is decided by IAM and names no authorizer. See Protecting a method with IAM. - Identity source expressions. A
REQUESTauthorizer readsmethod.request.header.<name>andmethod.request.querystring.<name>. AWS also allowsmethod.request.path,contextandstageVariables, and each is refused byCreateAuthorizer. - Scopes on a method that checks none.
authorizationScopesis refused on a method that is notCOGNITO_USER_POOLS, since a method carrying scopes nothing checks reads as gated by them. - Holding a Cognito authorizer’s decision.
authorizerResultTtlInSecondsis refused on aCOGNITO_USER_POOLSauthorizer. Real API Gateway holds that decision, and a token expiring during the period would still be accepted. A Lambda authorizer takes the member. See Caching the authorizer’s decision. - The default period. A Lambda authorizer written with no
authorizerResultTtlInSecondsholds no decision here, where real API Gateway would hold one for 300 seconds. An authorizer counting its own invocations counts one per request until the member is written out. - Integration types. Only
AWS_PROXYwith a Lambda function URI is simulated.MOCK,HTTP,HTTP_PROXYand the non-proxyAWStype each answer a request from somewhere this cannot reach. - API keys and usage plans.
apiKeyRequired: trueis refused, because a method requiring a key here would answer requests real AWS rejects. A usage plan holds a per-key quota and is its own feature. Stage throttling is simulated, and is a bucket shared by every client. See Throttling a stage and a method. - Method settings outside throttling.
cachingEnabled,metricsEnabled,loggingLevelanddataTraceEnabledare refused byCreateStage. A template carrying one deploys, and the member it named is recorded onstack.ignoredProperties. - Paging. Every list command answers in full, and
limitorpositionis refused. - Endpoint types, request validators, models, mapping templates and WAF. All refused.
- Updates.
UpdateRestApireplaces/nameand/description. Any other patch path is refused. - OpenAPI extensions. An import reads
x-amazon-apigateway-integration,x-amazon-apigateway-any-method,x-amazon-apigateway-authtype,x-amazon-apigateway-authorizerandx-amazon-apigateway-auth. Every otherx-amazon-apigateway-*extension is refused, and so is any integration member beyondtype,httpMethodanduri. Each refusal names the JSON pointer of the member.
Available functionality
Section titled “Available functionality”| Area | Commands |
|---|---|
| REST APIs | CreateRestApi, GetRestApi, GetRestApis, UpdateRestApi, DeleteRestApi |
| OpenAPI | ImportRestApi, PutRestApi |
| Resources | CreateResource, GetResource, GetResources, DeleteResource |
| Authorizers | CreateAuthorizer, GetAuthorizer, GetAuthorizers, DeleteAuthorizer |
| Methods | PutMethod, GetMethod, DeleteMethod |
| Integrations | PutIntegration, GetIntegration |
| Publishing | CreateDeployment, CreateStage, GetStage, GetStages, DeleteStage |
A stage can be protected by a simulated WAFv2 web ACL. See Protecting a stage with a web ACL.
CloudFormation deploys AWS::ApiGateway::RestApi, Resource, Method, Authorizer, Deployment
and Stage, including the template CDK synthesizes from a RestApi or a LambdaRestApi. See
Deploying from CloudFormation and CDK.
Limitations
Section titled “Limitations”- An
AWS::ApiGateway::Deploymentis created and never deleted, because API Gateway deletes one and this simulation has no command for it. A Stack teardown lists it instack.skippedResourceDeletionsand the deployment goes with its API a moment later. - A repeated request header reaches the handler as one joined value in
multiValueHeaders, because that is the form the platform’sHeadershands over. Real API Gateway reports each separately. - A Lambda authorizer’s
contextreaches the handler as the authorizer returned it. AWS accepts a string, a number or a boolean for each value, and how it renders them is not published. - A Cognito authorizer reads a
providerARNfor the pool id it names, so a pool in another account is verified against whenever this simulation holds it.identityValidationExpression, which real API Gateway matches a token against before verifying it, is outside this. - Binary media types negotiated by
Accept, CORS preflight and gateway responses are outside this. A response body is still base64 decoded when the handler saysisBase64Encoded. - The account-level rate and burst limits are outside this. A stage that names no limit throttles nothing, and a method setting naming one limit alone leaves that method unthrottled.
- WebSocket APIs are outside this and outside the v2 service.
- Only OpenAPI 3.0.x is imported.
ImportRestApiandPutRestApirefuse aswagger: "2.0"document and anopenapi: "3.1.0"one by version. ABodycarrying a Swagger 2.0 document is recorded and the API deploys without it. The body is JSON, and YAML is refused with the same message. - A security scheme declares a Lambda authorizer, a Cognito one or IAM authorization. An
apiKeyscheme carrying nox-amazon-apigateway-authtypeis an API key and is refused, and so are thehttp,oauth2andopenIdConnectscheme types. See Security schemes. PutRestApireplaces a definition and never merges one.BodyS3Locationon the Resource, and a document held anywhere but inline, are outside this.
Software Engineering by Kensio Software
This page as plain text: llms.txt
Documenting Yulin v1.20.2
