Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aws-openapigateway-lambda): update construct to allow specifying an inline api definition #1190

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ new OpenApiGatewayToLambda(this, "OpenApiGatewayToLambda", OpenApiGatewayToLambd
|apiDefinitionBucket?|[`s3.IBucket`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.IBucket.html)|S3 Bucket where the OpenAPI spec file is located. When specifying this property, `apiDefinitionKey` must also be specified.|
|apiDefinitionKey?|`string`|S3 Object name of the OpenAPI spec file. When specifying this property, `apiDefinitionBucket` must also be specified.|
|apiDefinitionAsset?|[`aws_s3_assets.Asset`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_assets.Asset.html)|Local file asset of the OpenAPI spec file.|
|apiDefinitionJson?|any|OpenAPI specification represented in a JSON object to be embedded in the CloudFormation template. IMPORTANT - Including the spec in the template introduces a risk of the template growing too big, but there are some use cases that require an embedded spec. Unless your use case explicitly requires an embedded spec you should pass your spec as an S3 asset.|
|apiIntegrations|`ApiIntegration[]`|One or more key-value pairs that contain an id for the api integration and either an existing lambda function or an instance of the LambdaProps. Please see the `Overview of how the OpenAPI file transformation works` section below for more usage details.|
|logGroupProps?|[`logs.LogGroupProps`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_logs.LogGroupProps.html)|User provided props to override the default props for for the CloudWatchLogs LogGroup.|

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,58 +11,18 @@
* and limitations under the License.
*/

import { Aws } from 'aws-cdk-lib';
import * as cdk from "aws-cdk-lib";
import { Construct } from 'constructs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as defaults from '@aws-solutions-constructs/core';
import * as resources from '@aws-solutions-constructs/resources';
import { RestApiBaseProps } from 'aws-cdk-lib/aws-apigateway';
import { Asset } from 'aws-cdk-lib/aws-s3-assets';

/**
* The ApiIntegration interface is used to correlate a user-specified id with either a existing lambda function or set of lambda props.
*
* See the 'Overview of how the OpenAPI file transformation works' section of the README.md for more details on its usage.
*/
export interface ApiIntegration {
/**
* Id of the ApiIntegration, used to correlate this lambda function to the api integration in the open api definition.
*
* Note this is not a CDK Construct ID, and is instead a client defined string used to map the resolved lambda resource with the OpenAPI definition.
*/
readonly id: string;
/**
* The Lambda function to associate with the API method in the OpenAPI file matched by id.
*
* One and only one of existingLambdaObj or lambdaFunctionProps must be specified, any other combination will cause an error.
*/
readonly existingLambdaObj?: lambda.Function;
/**
* Properties for the Lambda function to create and associate with the API method in the OpenAPI file matched by id.
*
* One and only one of existingLambdaObj or lambdaFunctionProps must be specified, any other combination will cause an error.
*/
readonly lambdaFunctionProps?: lambda.FunctionProps;
}

/**
* Helper object to map an ApiIntegration id to its resolved lambda.Function. This type is exposed as a property on the instantiated construct.
*/
export interface ApiLambdaFunction {
/**
* Id of the ApiIntegration, used to correlate this lambda function to the api integration in the open api definition.
*/
readonly id: string;
/**
* The instantiated lambda.Function.
*/
readonly lambdaFunction: lambda.Function;
}
import { ApiIntegration, CheckOpenApiProps, ApiLambdaFunction, ObtainApiDefinition } from './openapi-helper';
// openapi-helper is on its way to core, so these interfaces must be exported here
export { ApiIntegration, ApiLambdaFunction } from './openapi-helper';

export interface OpenApiGatewayToLambdaProps {
/**
Expand All @@ -77,6 +37,13 @@ export interface OpenApiGatewayToLambdaProps {
* Local file asset of the OpenAPI spec file.
*/
readonly apiDefinitionAsset?: Asset;
/**
* OpenAPI specification represented in a JSON object to be embedded in the CloudFormation template.
* IMPORTANT - Including the spec in the template introduces a risk of the template growing too big, but
* there are some use cases that require an embedded spec. Unless your use case explicitly requires an embedded spec
* you should pass your spec as an S3 asset.
*/
readonly apiDefinitionJson?: any;
/**
* One or more key-value pairs that contain an id for the api integration
* and either an existing lambda function or an instance of the LambdaProps.
Expand Down Expand Up @@ -144,10 +111,7 @@ export class OpenApiGatewayToLambda extends Construct {

constructor(scope: Construct, id: string, props: OpenApiGatewayToLambdaProps) {
super(scope, id);
CheckOpenapiProps(props);

const apiDefinitionBucket = props.apiDefinitionBucket ?? props.apiDefinitionAsset?.bucket;
const apiDefinitionKey = props.apiDefinitionKey ?? props.apiDefinitionAsset?.s3ObjectKey;
CheckOpenApiProps(props);

// store a counter to be able to uniquely name lambda functions to avoid naming collisions
let lambdaCounter = 0;
Expand All @@ -169,45 +133,27 @@ export class OpenApiGatewayToLambda extends Construct {
}
});

// Map each id and lambda function pair to the required format for the template writer custom resource
const apiIntegrationUris = this.apiLambdaFunctions.map(apiLambdaFunction => {
// the placeholder string that will be replaced in the OpenAPI Definition
const uriPlaceholderString = apiLambdaFunction.id;
// the endpoint URI of the backing lambda function, as defined in the API Gateway extensions for OpenAPI here:
// https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration.html
const uriResolvedValue = `arn:${Aws.PARTITION}:apigateway:${Aws.REGION}:lambda:path/2015-03-31/functions/${apiLambdaFunction.lambdaFunction.functionArn}/invocations`;

return {
id: uriPlaceholderString,
value: uriResolvedValue
};
});

// This custom resource will overwrite the string placeholders in the openapi definition with the resolved values of the lambda URIs
const apiDefinitionWriter = resources.createTemplateWriterCustomResource(this, 'Api', {
// CheckAlbProps() has confirmed the existence of these values
templateBucket: apiDefinitionBucket!,
templateKey: apiDefinitionKey!,
templateValues: apiIntegrationUris,
timeout: props.internalTransformTimeout ?? cdk.Duration.minutes(1),
memorySize: props.internalTransformMemorySize ?? 1024
const definition = ObtainApiDefinition(this, {
tokenToFunctionMap: this.apiLambdaFunctions,
apiDefinitionBucket: props.apiDefinitionBucket,
apiDefinitionKey: props.apiDefinitionKey,
apiDefinitionAsset: props.apiDefinitionAsset,
apiJsonDefinition: props.apiDefinitionJson,
internalTransformTimeout: props.internalTransformTimeout,
internalTransformMemorySize: props.internalTransformMemorySize
});

const specRestApiResponse = defaults.CreateSpecRestApi(this, {
...props.apiGatewayProps,
apiDefinition: apigateway.ApiDefinition.fromBucket(
apiDefinitionWriter.s3Bucket,
apiDefinitionWriter.s3Key
)
apiDefinition: definition
}, props.logGroupProps);

this.apiGateway = specRestApiResponse.api;
this.apiGatewayCloudWatchRole = specRestApiResponse.role;
this.apiGatewayLogGroup = specRestApiResponse.logGroup;

// Redeploy the API any time the incoming API definition changes (from asset or s3 object)
this.apiGateway.latestDeployment?.addToLogicalId(apiDefinitionKey);

// Redeploy the API any time a decoupled (non-inline) API definition changes (from asset or s3 object)
this.apiGateway.latestDeployment?.addToLogicalId(props.apiDefinitionKey ?? props.apiDefinitionAsset?.s3ObjectKey);
this.apiLambdaFunctions.forEach(apiLambdaFunction => {
// Redeploy the API any time one of the lambda functions changes
this.apiGateway.latestDeployment?.addToLogicalId(apiLambdaFunction.lambdaFunction.functionArn);
Expand All @@ -219,32 +165,3 @@ export class OpenApiGatewayToLambda extends Construct {
});
}
}

function CheckOpenapiProps(props: OpenApiGatewayToLambdaProps) {

let errorMessages = '';
let errorFound = false;

if (props.apiDefinitionAsset && (props.apiDefinitionBucket || props.apiDefinitionKey)) {
errorMessages += 'Either apiDefinitionBucket/apiDefinitionKey or apiDefinitionAsset must be specified, but not both\n';
errorFound = true;
}

const apiDefinitionBucket = props.apiDefinitionBucket ?? props.apiDefinitionAsset?.bucket;
const apiDefinitionKey = props.apiDefinitionKey ?? props.apiDefinitionAsset?.s3ObjectKey;

if (apiDefinitionBucket === undefined || apiDefinitionKey === undefined) {
errorMessages += 'Either apiDefinitionBucket/apiDefinitionKey or apiDefinitionAsset must be specified\n';
errorFound = true;
}

if (props.apiIntegrations === undefined || props.apiIntegrations.length < 1) {
errorMessages += 'At least one ApiIntegration must be specified in the apiIntegrations property\n';
errorFound = true;
}

if (errorFound) {
throw new Error(errorMessages);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
* with the License. A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
* OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/

/*
* The functions found here in the core library are for internal use and can be changed
* or removed outside of a major release. We recommend against calling them directly from client code.
*/

/*
* This file is core openapi functionality and should ideally be in the core library. Since
* that causes a circular reference with the resources library we have left it here for now
* in the interest of getting these updates out faster
*/

import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Asset } from 'aws-cdk-lib/aws-s3-assets';
import { Aws, Duration } from "aws-cdk-lib";
import { Construct } from 'constructs';
import * as resources from '@aws-solutions-constructs/resources';

/**
* The ApiIntegration interface is used to correlate a user-specified id with either a existing lambda function or set of lambda props.
*
* See the 'Overview of how the OpenAPI file transformation works' section of the README.md for more details on its usage.
*/
export interface ApiIntegration {
/**
* Id of the ApiIntegration, used to correlate this lambda function to the api integration in the open api definition.
*
* Note this is not a CDK Construct ID, and is instead a client defined string used to map the resolved lambda resource with the OpenAPI definition.
*/
readonly id: string;
/**
* The Lambda function to associate with the API method in the OpenAPI file matched by id.
*
* One and only one of existingLambdaObj or lambdaFunctionProps must be specified, any other combination will cause an error.
*/
readonly existingLambdaObj?: lambda.Function;
/**
* Properties for the Lambda function to create and associate with the API method in the OpenAPI file matched by id.
*
* One and only one of existingLambdaObj or lambdaFunctionProps must be specified, any other combination will cause an error.
*/
readonly lambdaFunctionProps?: lambda.FunctionProps;
}

/**
* Helper object to map an ApiIntegration id to its resolved lambda.Function. This type is exposed as a property on the instantiated construct.
*/
export interface ApiLambdaFunction {
/**
* Id of the ApiIntegration, used to correlate this lambda function to the api integration in the open api definition.
*/
readonly id: string;
/**
* The instantiated lambda.Function.
*/
readonly lambdaFunction: lambda.Function;
}

export interface OpenApiProps {
readonly apiDefinitionAsset?: Asset,
readonly apiJsonDefinition?: any,
readonly apiDefinitionBucket?: s3.IBucket,
readonly apiDefinitionKey?: string,
readonly apiIntegrations: ApiIntegration[]
}

/**
* @internal This is an internal core function and should not be called directly by Solutions Constructs clients.
*/
export function CheckOpenApiProps(props: OpenApiProps) {

let errorMessages = '';
let errorFound = false;

if ((props.apiDefinitionBucket && !props.apiDefinitionKey) || (!props.apiDefinitionBucket && props.apiDefinitionKey)) {
errorMessages += 'apiDefinitionBucket and apiDefinitionKey must be specified together.\n';
errorFound = true;
}

const definitionCount: number =
(props.apiDefinitionAsset ? 1 : 0) +
(props.apiDefinitionBucket ? 1 : 0) +
(props.apiJsonDefinition ? 1 : 0);

if (definitionCount !== 1) {
errorMessages += 'Exactly one of apiDefinitionAsset, apiInlineDefinition or (apiDefinitionBucket/apiDefinitionKey) must be provided\n';
errorFound = true;
}

if (props.apiIntegrations === undefined || props.apiIntegrations.length < 1) {
errorMessages += 'At least one ApiIntegration must be specified in the apiIntegrations property\n';
errorFound = true;
}

if (errorFound) {
throw new Error(errorMessages);
}

}

export interface ObtainApiDefinitionProps {
readonly tokenToFunctionMap: ApiLambdaFunction[],
readonly apiDefinitionBucket?: s3.IBucket,
readonly apiDefinitionKey?: string,
readonly apiDefinitionAsset?: Asset,
readonly apiJsonDefinition?: any,
readonly internalTransformTimeout?: Duration,
readonly internalTransformMemorySize?: number
}

/**
* @internal This is an internal core function and should not be called directly by Solutions Constructs clients.
*/
export function ObtainApiDefinition(scope: Construct, props: ObtainApiDefinitionProps): apigateway.ApiDefinition {
const apiRawInlineSpec = props.apiJsonDefinition;
const meldedDefinitionBucket = props.apiDefinitionBucket ?? props.apiDefinitionAsset?.bucket;
const meldedDefinitionKey = props.apiDefinitionKey ?? props.apiDefinitionAsset?.s3ObjectKey;

// Map each id and lambda function pair to the required format for the template writer custom resource
const apiIntegrationUris = props.tokenToFunctionMap.map(apiLambdaFunction => {
// the placeholder string that will be replaced in the OpenAPI Definition
const uriPlaceholderString = apiLambdaFunction.id;
// the endpoint URI of the backing lambda function, as defined in the API Gateway extensions for OpenAPI here:
// https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration.html
const uriResolvedValue = `arn:${Aws.PARTITION}:apigateway:${Aws.REGION}:lambda:path/2015-03-31/functions/${apiLambdaFunction.lambdaFunction.functionArn}/invocations`;

return {
id: uriPlaceholderString,
value: uriResolvedValue
};
});

let apiDefinitionWriter: resources.CreateTemplateWriterResponse | undefined;
let newApiDefinition: apigateway.ApiDefinition | undefined;

if (props.apiDefinitionAsset || props.apiDefinitionBucket) {
// This custom resource will overwrite the string placeholders in the openapi definition with the resolved values of the lambda URIs
apiDefinitionWriter = resources.createTemplateWriterCustomResource(scope, 'Api', {
// CheckOpenapiProps() has confirmed the existence of these values
templateBucket: meldedDefinitionBucket!,
templateKey: meldedDefinitionKey!,
templateValues: apiIntegrationUris,
timeout: props.internalTransformTimeout ?? Duration.minutes(1),
memorySize: props.internalTransformMemorySize ?? 1024
});

newApiDefinition = apigateway.ApiDefinition.fromBucket(
apiDefinitionWriter.s3Bucket,
apiDefinitionWriter.s3Key
);
} else if (apiRawInlineSpec) {
const apiInlineSpec = new apigateway.InlineApiDefinition(apiRawInlineSpec);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this isn't needed since apiRawInlineSpec is technically already expected to be a JSON object. Unless you are relying on the initial assignment to trigger validation, which would make sense in this instance. Non-and empty objects would throw.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, code is redundant to the check already done in CheckOpenapiProps. But including it makes the code explicit, these steps aren't just executing because it is "not something else", it is executing because it IS "this". Putting that in code is better than a comment - even at the cost of a couple clock ticks.

newApiDefinition = InlineTemplateWriter(apiInlineSpec.bind(scope), apiIntegrationUris);
} else {
throw new Error("No definition provided (this code should be unreachable)");
}

return newApiDefinition!;
}

function InlineTemplateWriter({ inlineDefinition }: apigateway.ApiDefinitionConfig, templateValues: resources.TemplateValue[]) {
let template = JSON.stringify(inlineDefinition);

// This replicates logic in the template writer custom resource (resources/lib/template-writer-custom-resource/index.ts),
// any logic changes should be made to both locations every time
templateValues.forEach((templateValue) => {
template = template?.replace(new RegExp(templateValue.id, 'g'), templateValue.value);
});

return new apigateway.InlineApiDefinition(JSON.parse(template));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const handler = async (event) => {
switch (event.httpMethod) {
case 'POST':
return {
statusCode: 200,
body: JSON.stringify({"message": "successfully handled POST from photos lambda"})
};
case 'GET':
return {
statusCode: 200,
body: JSON.stringify({"message": "successfully handled GET from photos lambda"})
};
default:
throw new Error(`cannot handle httpMethod: ${event.httpMethod}`);
}
};
Loading