AWS CDK 如何根据 OpenApi 规范创建由 Lambda 支持的 API 网关?

AWS CDK how to create an API Gateway backed by Lambda from OpenApi spec?

我想使用 AWS CDK 定义 API 网关和 APIG 将代理到的 lambda。

OpenAPI 规范支持对 Swagger 规范的 x-amazon-apigateway-integration 自定义扩展(详细 here),为此需要调用 URL lambda。如果 lambda 定义在与 API 相同的堆栈中,我看不到如何在 OpenAPI 规范中提供它。我能想到的最好的方法是定义一个包含 lambda 的堆栈,然后从中获取输出并 运行 sed 在 OpenAPI 中进行查找和替换spec 以插入 uri,然后使用此修改后的 OpenAPI spec.

创建第二个堆栈

示例:

  /items:
    post:
      x-amazon-apigateway-integration:
        uri: "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:123456789012:function:MyStack-SingletonLambda4677ac3018fa48679f6-B1OYQ50UIVWJ/invocations"
        passthroughBehavior: "when_no_match"
        httpMethod: "POST"
        type: "aws_proxy"

Q1。这似乎是一个先有鸡还是先有蛋的问题,只有上面的方法吗?

我尝试使用 defaultIntegration property of the SpecRestApi CDK 结构。文档指出:

An integration to use as a default for all methods created within this API unless an integration is specified.

这似乎应该能够使用 CDK 规范中定义的 lambda 定义默认集成,因此所有方法都使用此集成,而无需提前知道 lambda 的 uri。

因此我尝试了这个:

SingletonFunction myLambda = ...

SpecRestApi openapiRestApi = SpecRestApi.Builder.create(this, "MyApi")
                        .restApiName("MyApi")
                        .apiDefinition(ApiDefinition.fromAsset("openapi.yaml"))
                        .defaultIntegration(LambdaIntegration.Builder.create(myLambda)
                                    .proxy(false)
                                    .build())
                        .deploy(true)
                        .build();

openapi.yaml 中定义的 OpenAPI 规范不包含 x-amazon-apigateway-integration 节;它只有一个在标准 OpenApi 3 规范中定义的 GET 方法。

但是,当我尝试部署它时,出现错误:

No integration defined for method (Service: AmazonApiGateway; Status Code: 400; Error Code: BadRequestException; Request ID: 56113150-1460-4ed2-93b9-a12618864582)

这似乎是一个错误,所以我提交了一个 here

Q2。如何使用 CDK 定义 API 网关和 Lambda,并通过 OpenAPI 规范将两者连接在一起?

this CDK issue. In the meantime, I was guided by the comment on that issue here 似乎跟踪了我的目标,并提出了解决方法。

我使用 https://github.com/spullara/mustache.java 解析我的 OpenAPI 规范文件并替换其中引用 API 网关调用 ARN 的模板值(它本身引用 Lambda ARN) .

Map<String, Object> variables = new HashMap<>();
variables.put("restapi-lambda", String.format("arn:aws:apigateway:%s:lambda:path/2015-03-31/functions/%s/invocations", props.getEnv().getRegion(), myLambda.getFunctionArn()));

Writer writer = new StringWriter();
MustacheFactory mf = new DefaultMustacheFactory();

Object openapiSpecAsObject;
try (Reader reader = new FileReader(new File("myapi.yaml"))) {
    Mustache mustache = mf.compile(reader, "OAS");
    mustache.execute(writer, scopes);
    writer.flush();

    ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
    openapiSpecAsObject = yamlMapper.readValue(writer.toString(), Object.class);

}

SpecRestApi openapiRestApi = SpecRestApi.Builder.create(this, "MyRestApi")
                                                .restApiName("MyRestApi")
                                                .apiDefinition(ApiDefinition.fromInline(openapiSpecAsObject))
                                                .deploy(true)
                                                .build();

请注意,props 是引用 Stack 道具的变量,myLambda 是对 SingletonFunction.

的引用

我的 OpenAPI 规范如下所示(header 和模型部分已删除):

paths:
  /items:
    get:
      summary: List all items.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ItemList'
      x-amazon-apigateway-integration:
        uri: "{{restapi-lambda}}"
        passthroughBehavior: "when_no_match"
        httpMethod: "POST"
        type: "aws_proxy"

另请注意,当我授予 API 网关权限以像这样调用 lambda 时:

myLambda.grantInvoke(ServicePrincipal.Builder.create("apigateway.amazonaws.com")
                                              .build());

我仍然收到 500 错误,并且在日志中我可以看到一条 "Invalid permissions on Lambda function" 错误消息。如果我向 Lambda 添加权限,如下所示:

myLambda.addPermission("PermitAPIGInvocation", Permission.builder()
                                  .action("lambda:InvokeFunction")
                                  .principal(ServicePrincipal.Builder.create("apigateway.amazonaws.com")
                                     .build())
                                  .sourceArn(openapiRestApi.arnForExecuteApi())
                                  .build());

那么我目前需要在权限生效之前重新部署 API。我仍在研究如何避免这种情况。

现有解决方法。方法如下:

您的 OpenAPI 文件必须如下所示:

openapi: "3.0.1"
info:
  title: "The Super API"
  description: "API to do super things"
  version: "2019-09-09T12:56:55Z"

servers:
- url: ""
  variables:
    basePath:
      default:
        Fn::Sub: ${ApiStage}

paths:
  /path/subpath:
    get:
      parameters:
      - name: "Password"
        in: "header"
        schema:
          type: "string"
      responses:
        200:
          description: "200 response"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserConfigResponseModel"
      security:
      - sigv4: []
      x-amazon-apigateway-integration:
        uri: 
          Fn::Sub: "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MySuperLambda.Arn}/invocations"
        responses:
          default:
            statusCode: "200"
        requestTemplates:
          application/json: "{blablabla}"
        passthroughBehavior: "when_no_match"
        httpMethod: "POST"
        type: "aws"

如您所见,此 OpenAPI 模板引用了 ApiStageAWS::RegionMySuperLambda.Arn.

关联的 cdk 文件包含以下内容:

// To pass external string, nothing better than this hacky solution: 
const ApiStage = new CfnParameter(this, 'ApiStage',{type: 'String', default: props.ApiStage})
ApiStage.overrideLogicalId('ApiStage') 

这里使用了 ApiStage 道具。例如,它允许我在 CI 期间使用环境变量将其传递给 cdk 应用程序。

const MySuperLambda = new lambda.Function(this, 'MySuperLambda', {
    functionName: "MySuperLambda",
    description: "Hello world",
    runtime: lambda.Runtime.PYTHON_3_7,
    code: lambda.Code.asset(lambda_asset),
    handler: "MySuperLambda.lambda_handler",
    timeout: cdk.Duration.seconds(30),
    memorySize: 128,
    role: MySuperLambdaRole
  });

  const forceLambdaId = MySuperLambda.node.defaultChild as lambda.CfnFunction
  forceLambdaId.overrideLogicalId('MySuperLambda')

在这里,和以前一样,我强制 CDK 覆盖逻辑 ID,以便我在部署前知道该 ID。否则,cdk 会为逻辑 ID 添加一个后缀。

const asset = new Asset(this, 'SampleAsset', {
    path: './api-gateway-definitions/SuperAPI.yml',
  });

这让我可以将 OpenAPI 文件直接上传到 cdk 存储桶(无需创建新文件,这太棒了)。

const data = Fn.transform('AWS::Include', {'Location': asset.s3ObjectUrl})

这是 Cloudformation 魔法的一部分。这是解释 Fn::Sub 和 Fn::GetAtt 的地方。我无法让它与 !Ref 函数一起使用。

const SuperApiDefinition = apigateway.AssetApiDefinition.fromInline(data)

根据之前读取的文件创建一个 api 定义。

  const sftpApiGateway = new apigateway.SpecRestApi(this, 'superAPI', {
    apiDefinition: SuperApiDefinition,
    deploy: false
  })

最后,创建 SpecRestApi。 运行 和魔法,这是有效的。您可能仍然会遇到 400 错误,这可能是因为您的 OpenAPI 文件格式不正确(并且不要使用 !Ref)。

我会推荐这个吗? 嗯。 这几乎是一种解决方法。如果您想在 CI 中使用带有动态变量的 OpenAPI 格式,这将非常有用。不费吹灰之力,只需切换1个环境变量,即可在dev和prod部署。

然而,这感觉真的很老套,而且似乎不符合 CDK 的理念。这是我目前用于部署的内容,但将来可能会改变。我相信真正的模板解决方案可能更适合这里,但现在,我还没有真正考虑过它。

我想出了一个比这里的其他答案更简单的解决方案,因为它不需要阶段变量或多次部署。

首先,将 x-amazon-apigateway-integrationuri 设置为类似 ${API_LAMBDA_ARN} 的变量,并使用与本例相同的 typehttpMethod

[...]
  "paths": {
    "/pets": {
      "get": {
        "summary": "List all pets",
        "responses": {
          [...]
        },
        "x-amazon-apigateway-integration": {
          "uri": "${API_LAMBDA_ARN}",
          "type": "AWS_PROXY",
          "httpMethod": "POST",
        }
      }
    }
  },
[...]

然后,您可以使用此构造(或等效的 TypeScript 实现)在构建期间替换变量,并基于 OpenAPI 创建 API Gateway Http API文档:

from aws_cdk import (
    core,
    aws_iam as iam,
    aws_lambda as _lambda,
    aws_apigatewayv2 as apigateway
)


class OpenApiLambdaStack(core.Stack):
    def __init__(
        self, scope: core.Construct, construct_id: str, **kwargs
    ) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # function that handles api request(s)
        api_lambda = _lambda.Function([...])

        # read openapi document
        with open("openapi.json", "r") as json_file:
            content = json_file.read()
        # replace the variable by the lambda functions arn
        content = content.replace("${API_LAMBDA_ARN}", api_lambda.function_arn)
        openapi = json.loads(content)

        # create apigateway
        http_api = apigateway.HttpApi(self, "OpenApiLambdaGateway")
        # use escape hatches to import OpenAPI Document
        # see: https://docs.aws.amazon.com/cdk/latest/guide/cfn_layer.html
        http_api_cfn: apigateway.CfnApi = http_api.node.default_child
        http_api_cfn.add_property_override("Body", openapi)
        http_api_cfn.add_property_deletion_override("Name")
        http_api_cfn.add_property_deletion_override("ProtocolType")
        # let it fail on warnings to be sure everything went right
        http_api_cfn.add_property_override("FailOnWarnings", True)

        # construct arn of createad api gateway (to grant permission)
        http_api_arn = (
            f"arn:{self.partition}:execute-api:"
            f"{http_api.env.region}:{http_api.env.account}:"
            f"{http_api.http_api_id}/*/*/*"
        )

        # grant apigateway permission to invoke api lambda function
        api_lambda.add_permission(
            f"Invoke By {http_api.node.id} Permission",
            principal=iam.ServicePrincipal("apigateway.amazonaws.com"),
            action="lambda:InvokeFunction",
            source_arn=http_api_arn,
        )
        
        # output api gateway url
        core.CfnOutput(self, "HttpApiUrl", value=http_api.url)

Python 用户可能还对我发布的 openapigateway 构造感兴趣,该构造使此过程更加直接。它支持 JSON 和 YAML。