diff --git a/.projen/deps.json b/.projen/deps.json index 0e00bc4f..b536b419 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -65,6 +65,10 @@ "version": "2.20.0", "type": "build" }, + { + "name": "axios", + "type": "build" + }, { "name": "cdk-assets", "version": "2.20.0", @@ -191,6 +195,10 @@ "name": "@functionless/nodejs-closure-serializer", "type": "runtime" }, + { + "name": "@types/aws-lambda", + "type": "runtime" + }, { "name": "fs-extra", "type": "runtime" diff --git a/.projenrc.js b/.projenrc.js index fc96d5d7..eda46ee7 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -75,13 +75,19 @@ const project = new CustomTypescriptProject({ bin: { functionless: "./bin/functionless.js", }, - deps: ["fs-extra", "minimatch", "@functionless/nodejs-closure-serializer"], + deps: [ + "@types/aws-lambda", + "fs-extra", + "minimatch", + "@functionless/nodejs-closure-serializer", + ], devDeps: [ `@aws-cdk/aws-appsync-alpha@${MIN_CDK_VERSION}-alpha.0`, "@types/fs-extra", "@types/minimatch", "@types/uuid", "amplify-appsync-simulator", + "axios", "graphql-request", "prettier", "ts-node", diff --git a/.vscode/launch.json b/.vscode/launch.json index c45d77d7..e652515d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,7 +19,7 @@ "request": "launch", "runtimeExecutable": "node", "runtimeArgs": ["--nolazy"], - "args": ["./lib/app.js"], + "args": ["./lib/api-test.js"], "outFiles": [ "${workspaceRoot}/lib/**/*.js", "${workspaceRoot}/test-app/lib/**/*.js", diff --git a/package.json b/package.json index 0ab61e06..2e6acef1 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "amplify-appsync-simulator": "^2.3.13", "aws-cdk": "2.20.0", "aws-cdk-lib": "2.20.0", + "axios": "^0.27.2", "cdk-assets": "2.20.0", "constructs": "10.0.0", "esbuild": "0.14.42", @@ -75,6 +76,7 @@ }, "dependencies": { "@functionless/nodejs-closure-serializer": "^0.0.2", + "@types/aws-lambda": "^8.10.98", "fs-extra": "^10.1.0", "minimatch": "^5.1.0" }, diff --git a/scripts/compile-test-app.js b/scripts/compile-test-app.js index 2360fc87..eeaf273c 100644 --- a/scripts/compile-test-app.js +++ b/scripts/compile-test-app.js @@ -3,4 +3,7 @@ const { tsc } = require("../lib/tsc"); (async function () { await tsc(path.join(__dirname, "..", "test-app")); -})().catch((err) => process.exit(1)); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..93a29635 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,545 @@ +import { aws_apigateway } from "aws-cdk-lib"; +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + APIGatewayEventRequestContext, +} from "aws-lambda"; +import { Construct } from "constructs"; +import { FunctionDecl, isFunctionDecl } from "./declaration"; +import { isErr } from "./error"; +import { CallExpr, Expr } from "./expression"; +import { Function } from "./function"; +import { IntegrationImpl } from "./integration"; +import { isReturnStmt, Stmt } from "./statement"; +import { AnyFunction } from "./util"; +import { VTL } from "./vtl"; + +/** + * HTTP Methods that API Gateway supports. + */ +export type HttpMethod = + | "ANY" + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "HEAD" + | "OPTIONS"; + +export interface MethodProps { + httpMethod: HttpMethod; + resource: aws_apigateway.IResource; +} + +type ParameterMap = Record; + +/** + * Request to an API Gateway method. Parameters can be passed in via + * the path, query string or headers, and the body is a JSON object. + * None of these are required. + */ +export interface ApiRequest< + PathParams extends ParameterMap | undefined = undefined, + Body extends object | undefined = undefined, + QueryParams extends ParameterMap | undefined = undefined, + HeaderParams extends ParameterMap | undefined = undefined +> { + /** + * Parameters in the path. + */ + pathParameters?: PathParams; + /** + * Body of the request. + */ + body?: Body; + /** + * Parameters in the query string. + */ + queryStringParameters?: QueryParams; + /** + * Parameters in the headers. + */ + headers?: HeaderParams; +} + +export abstract class BaseApiIntegration { + /** + * Identify subclasses as API integrations to the Functionless plugin + */ + public static readonly FunctionlessType = "ApiIntegration"; + protected readonly functionlessKind = BaseApiIntegration.FunctionlessType; + + abstract readonly method: aws_apigateway.Method; +} + +/** + * A Mock integration lets you return pre-configured responses by status code. + * No backend service is invoked. + * + * To use you provide a `request` function that returns a status code from the + * request and a `responses` object that maps a status code to a function + * returning the pre-configured response for that status code. Functionless will + * convert these functions to VTL mapping templates and configure the necessary + * method responses. + * + * Only `application/json` is supported. + * + * TODO: provide example usage after api is stabilized + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html + */ +export class MockApiIntegration< + StatusCode extends number, + MethodResponses extends { [C in StatusCode]: any } +> extends BaseApiIntegration { + private readonly request: FunctionDecl; + private readonly responses: { [K in keyof MethodResponses]: FunctionDecl }; + readonly method; + + public constructor( + props: MethodProps, + /** + * Map API request to a status code. This code will be used by API Gateway + * to select the response to return. + */ + request: ( + $input: APIGatewayInput, + $context: APIGatewayContext + ) => { statusCode: StatusCode }, + /** + * Map of status codes to response to return. + */ + responses: { + [C in StatusCode]: ( + code: C, + $context: APIGatewayContext + ) => MethodResponses[C]; + } + ) { + super(); + this.request = validateFunctionDecl(request); + this.responses = Object.fromEntries( + Object.entries(responses).map(([k, v]) => [k, validateFunctionDecl(v)]) + ) as { [K in keyof MethodResponses]: FunctionDecl }; + + const requestTemplate = new APIGatewayVTL("request"); + requestTemplate.eval(this.request.body); + + const integrationResponses: aws_apigateway.IntegrationResponse[] = + Object.entries(this.responses).map(([statusCode, fn]) => { + const responseTemplate = new APIGatewayVTL("response"); + responseTemplate.eval((fn as FunctionDecl).body); + return { + statusCode, + responseTemplates: { + "application/json": responseTemplate.toVTL(), + }, + selectionPattern: `^${statusCode}$`, + }; + }); + + const integration = new aws_apigateway.MockIntegration({ + requestTemplates: { + "application/json": requestTemplate.toVTL(), + }, + integrationResponses, + }); + + const methodResponses = Object.keys(this.responses).map((statusCode) => ({ + statusCode, + })); + + // TODO: support requestParameters, authorizers, models and validators + this.method = props.resource.addMethod(props.httpMethod, integration, { + methodResponses, + }); + } +} + +/** + * An AWS API Gateway integration lets you integrate an API with an AWS service + * supported by Functionless. The request is transformed via VTL and sent to the + * service via API call, and the response is transformed via VTL and returned in + * the response. + * + * Only `application/json` is supported. + * + * TODO: provide example usage after api is stabilized + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html + */ +export class AwsApiIntegration< + MethodResponse, + IntegrationResponse +> extends BaseApiIntegration { + private readonly request: FunctionDecl; + private readonly response: FunctionDecl; + private readonly errors: { [statusCode: number]: FunctionDecl }; + readonly method; + public constructor( + readonly props: MethodProps, + /** + * Function that maps an API request to an integration request and calls an + * integration. This will be compiled to a VTL request mapping template and + * an API GW integration. + * + * At present the function body must be a single statement calling an integration + * with an object literal argument. E.g + * + * ```ts + * (req) => fn({ id: req.body.id }); + * ``` + * + * The supported syntax will be expanded in the future. + */ + request: ( + $input: APIGatewayInput, + $context: APIGatewayContext + ) => IntegrationResponse, + /** + * Function that maps an integration response to a 200 method response. This + * is the happy path and is modeled explicitly so that the return type of the + * integration can be inferred. This will be compiled to a VTL template. + * + * At present the function body must be a single statement returning an object + * literal. The supported syntax will be expanded in the future. + */ + response: ( + response: IntegrationResponse, + $context: APIGatewayContext + ) => MethodResponse, + /** + * Map of status codes to a function defining the response to return. This is used + * to configure the failure path method responses, for e.g. when an integration fails. + */ + errors?: { [statusCode: number]: ($context: APIGatewayContext) => any } + ) { + super(); + this.request = validateFunctionDecl(request); + this.response = validateFunctionDecl(response); + this.errors = Object.fromEntries( + Object.entries(errors ?? {}).map(([k, v]) => [k, validateFunctionDecl(v)]) + ); + + const responseTemplate = new APIGatewayVTL( + "response", + "#set($inputRoot = $input.path('$'))" + ); + responseTemplate.eval(this.response.body); + const requestTemplate = new APIGatewayVTL( + "request", + "#set($inputRoot = $input.path('$'))" + ); + requestTemplate.eval(this.request.body); + + const integration = requestTemplate.integration; + + const errorResponses: aws_apigateway.IntegrationResponse[] = Object.entries( + this.errors + ).map(([statusCode, fn]) => { + const errorTemplate = new APIGatewayVTL("response"); + errorTemplate.eval(fn.body, "response"); + return { + statusCode: statusCode, + selectionPattern: `^${statusCode}$`, + responseTemplates: { + "application/json": errorTemplate.toVTL(), + }, + }; + }); + + const integrationResponses: aws_apigateway.IntegrationResponse[] = [ + { + statusCode: "200", + responseTemplates: { + "application/json": responseTemplate.toVTL(), + }, + }, + ...errorResponses, + ]; + + // TODO: resource is not the right scope, prevents adding 2 methods to the resource + // because of the IAM roles created + // should `this` be a Method? + const apiGwIntegration = integration!.apiGWVtl.createIntegration( + props.resource, + requestTemplate.toVTL(), + integrationResponses + ); + + const methodResponses = [ + { statusCode: "200" }, + ...Object.keys(this.errors).map((statusCode) => ({ + statusCode, + })), + ]; + + this.method = props.resource.addMethod(props.httpMethod, apiGwIntegration, { + methodResponses, + }); + } +} + +export class APIGatewayVTL extends VTL { + public integration: IntegrationImpl | undefined; + constructor( + readonly location: "request" | "response", + ...statements: string[] + ) { + super(...statements); + } + + protected integrate( + target: IntegrationImpl, + call: CallExpr + ): string { + if (this.location === "response") { + throw new Error( + `Cannot call an integration from within a API Gateway Response Template` + ); + } + if (target.apiGWVtl) { + // ew, mutation + // TODO: refactor to pure functions + this.integration = target; + return target.apiGWVtl.renderRequest(call, this); + } else { + throw new Error( + `Resource type ${target.kind} does not support API Gateway Integrations` + ); + } + } + + public eval(node?: Expr, returnVar?: string): string; + public eval(node: Stmt, returnVar?: string): void; + public eval(node?: Expr | Stmt, returnVar?: string): string | void { + if (isReturnStmt(node)) { + return this.add(this.json(this.eval(node.expr))); + } + return super.eval(node as any, returnVar); + } + + public json(reference: string): string { + return this.jsonStage(reference, 0); + } + + private jsonStage(varName: string, level: number): string { + if (level === 3) { + return "#stop"; + } + + const itemVarName = this.newLocalVarName(); + return `#if(${varName}.class.name === 'java.lang.String') +"${varName}" +#elseif(${varName}.class.name === 'java.lang.Integer') +${varName} +#elseif(${varName}.class.name === 'java.lang.Double') +${varName} +#elseif(${varName}.class.name === 'java.lang.Boolean') +${varName} +#elseif(${varName}.class.name === 'java.lang.LinkedHashMap') +{ +#foreach(${itemVarName} in ${varName}.keySet()) +"${itemVarName}": ${this.jsonStage( + itemVarName, + level + 1 + )}#if($foreach.hasNext),#end +#end +} +#elseif(${varName}.class.name === 'java.util.ArrayList') +[ +#foreach(${itemVarName} in ${varName}) +${this.jsonStage(itemVarName, level + 1)}#if($foreach.hasNext),#end +#end +]`.replace(/\n/g, `${new Array(level * 2).join(" ")}\n`); + } +} +function validateFunctionDecl(a: any): FunctionDecl { + if (isFunctionDecl(a)) { + return a; + } else if (isErr(a)) { + throw a.error; + } else { + throw Error("Unknown compiler error."); + } +} + +/** + * Hooks used to create API Gateway integrations. + */ +export interface ApiGatewayVtlIntegration { + /** + * Render the Request Payload as a VTL string. + */ + renderRequest: (call: CallExpr, context: APIGatewayVTL) => string; + + /** + * Construct an API GW integration. + */ + createIntegration: ( + scope: Construct, + requestTemplate: string, + responses: aws_apigateway.IntegrationResponse[] + ) => aws_apigateway.Integration; +} + +export interface LambdaProxyApiIntegrationProps + extends Omit< + aws_apigateway.LambdaIntegrationOptions, + | "requestParameters" + | "requestTemplates" + | "integrationResponses" + | "passthroughBehavior" + | "proxy" + > { + function: Function; + httpMethod: HttpMethod; + resource: aws_apigateway.IResource; +} + +export class LambdaProxyApiMethod extends BaseApiIntegration { + readonly function; + readonly method; + + constructor(private readonly props: LambdaProxyApiIntegrationProps) { + super(); + this.function = props.function; + + this.method = props.resource.addMethod( + props.httpMethod, + new aws_apigateway.LambdaIntegration(this.function.resource, { + ...this.props, + proxy: true, + }) + ); + } +} + +/** + * The `$input` VTL variable containing all of the request data available in API Gateway's VTL engine. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#input-variable-reference + */ +export interface APIGatewayInput { + /** + * The raw request payload as a string. + */ + readonly body: string; + /** + * This function evaluates a JSONPath expression and returns the results as a JSON string. + * + * For example, `$input.json('$.pets')` returns a JSON string representing the pets structure. + * + * @param jsonPath JSONPath expression to select data from the body. + * @see http://goessner.net/articles/JsonPath/ + * @see https://github.com/jayway/JsonPath + */ + json(jsonPath: string): any; + + /** + * Returns a map of all the request parameters. We recommend that you use + * `$util.escapeJavaScript` to sanitize the result to avoid a potential + * injection attack. For full control of request sanitization, use a proxy + * integration without a template and handle request sanitization in your + * integration. + */ + params(): Record; + + /** + * Returns the value of a method request parameter from the path, query string, + * or header value (searched in that order), given a parameter name string x. + * We recommend that you use $util.escapeJavaScript to sanitize the parameter + * to avoid a potential injection attack. For full control of parameter + * sanitization, use a proxy integration without a template and handle request + * sanitization in your integration. + * + * @param name name of the path. + */ + params(name: string): string | number | undefined; + + path(jsonPath: string): any; +} + +/** + * Type of the `$context` variable available within Velocity Templates in API Gateway. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference + */ +export interface APIGatewayContext extends APIGatewayEventRequestContext { + /** + * The AWS endpoint's request ID. + */ + readonly awsEndpointRequestId: string; + /** + * API Gateway error information. + */ + readonly error: APIGatewayError; + /** + * The HTTP method used. + */ + readonly httpMethod: HttpMethod; + /** + * The response received from AWS WAF: WAF_ALLOW or WAF_BLOCK. Will not be set if the stage is not associated with a web ACL. For more information, see Using AWS WAF to protect your APIs. + */ + readonly wafResponseCode?: "WAF_ALLOW" | "WAF_BLOCK"; + /** + * The complete ARN of the web ACL that is used to decide whether to allow or block the request. Will not be set if the stage is not associated with a web ACL. For more information, see Using AWS WAF to protect your APIs. + */ + readonly webaclArn?: string; + /** + * Request properties that can be overridden. + */ + readonly requestOverride: APIGatewayRequestOverride; + /** + * Response properties that can be overridden. + */ + readonly responseOverride: APIGatewayResponseOverride; +} + +export interface APIGatewayError { + /** + * A string containing an API Gateway error message. This variable can only be used for simple variable substitution in a GatewayResponse body-mapping template, which is not processed by the Velocity Template Language engine, and in access logging. For more information, see Monitoring WebSocket API execution with CloudWatch metrics and Setting up gateway responses to customize error responses. + */ + readonly message: string; + /** + * The quoted value of $context.error.message, namely "$context.error.message". + */ + readonly messageString: string; + /** + * A type of GatewayResponse. This variable can only be used for simple variable substitution in a GatewayResponse body-mapping template, which is not processed by the Velocity Template Language engine, and in access logging. For more information, see Monitoring WebSocket API execution with CloudWatch metrics and Setting up gateway responses to customize error responses. + */ + readonly responseType: string; + /** + * A string containing a detailed validation error message. + */ + readonly validationErrorString: string; +} + +export interface APIGatewayRequestOverride { + /** + * The request header override. If this parameter is defined, it contains the headers to be used instead of the HTTP Headers that are defined in the Integration Request pane. For more information, see Use a mapping template to override an API's request and response parameters and status codes. + */ + readonly header: Record; + /** + * The request path override. If this parameter is defined, it contains the request path to be used instead of the URL Path Parameters that are defined in the Integration Request pane. For more information, see Use a mapping template to override an API's request and response parameters and status codes. + */ + readonly path: Record; + /** + * The request query string override. If this parameter is defined, it contains the request query strings to be used instead of the URL Query String Parameters that are defined in the Integration Request pane. For more information, see Use a mapping template to override an API's request and response parameters and status codes. + */ + readonly querystring: Record; +} + +export interface APIGatewayResponseOverride { + /** + * The response header override. If this parameter is defined, it contains the header to be returned instead of the Response header that is defined as the Default mapping in the Integration Response pane. For more information, see Use a mapping template to override an API's request and response parameters and status codes. + */ + readonly header: Record; + + /** + * The response status code override. If this parameter is defined, it contains the status code to be returned instead of the Method response status that is defined as the Default mapping in the Integration Response pane. For more information, see Use a mapping template to override an API's request and response parameters and status codes. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-override-request-response-parameters.html + */ + status: number; +} diff --git a/src/appsync.ts b/src/appsync.ts index 0a5fca9a..acc4558c 100644 --- a/src/appsync.ts +++ b/src/appsync.ts @@ -10,7 +10,7 @@ import { isErr } from "./error"; import { CallExpr, Expr } from "./expression"; import { findDeepIntegration, IntegrationImpl } from "./integration"; import { Literal } from "./literal"; -import { singletonConstruct } from "./util"; +import { AnyFunction, singletonConstruct } from "./util"; import { VTL } from "./vtl"; /** @@ -74,6 +74,25 @@ export class SynthesizedAppsyncResolver extends appsync.Resolver { } } +export class AppsyncVTL extends VTL { + public static readonly CircuitBreaker = `#if($context.stash.return__flag) + #return($context.stash.return__val) +#end`; + + protected integrate( + target: IntegrationImpl, + call: CallExpr + ): string { + if (target.appSyncVtl) { + return target.appSyncVtl.request(call, this); + } else { + throw new Error( + `Integration ${target.kind} does not support Appsync Resolvers` + ); + } + } +} + /** * An AWS AppSync Resolver Function derived from TypeScript syntax. * @@ -305,7 +324,9 @@ export class AppsyncResolver< ) { const templates: string[] = []; let template = - resolverCount === 0 ? new VTL() : new VTL(VTL.CircuitBreaker); + resolverCount === 0 + ? new AppsyncVTL() + : new AppsyncVTL(AppsyncVTL.CircuitBreaker); const functions = decl.body.statements .map((stmt, i) => { const isLastExpr = i + 1 === decl.body.statements.length; @@ -425,7 +446,7 @@ export class AppsyncResolver< const requestMappingTemplateString = template.toVTL(); templates.push(requestMappingTemplateString); templates.push(responseMappingTemplate); - template = new VTL(VTL.CircuitBreaker); + template = new AppsyncVTL(AppsyncVTL.CircuitBreaker); const name = getUniqueName( api, appsyncSafeName(integration.kind) diff --git a/src/checker.ts b/src/checker.ts index 2820eb81..f6ebbbfe 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -1,5 +1,6 @@ import * as ts from "typescript"; import * as tsserver from "typescript/lib/tsserverlibrary"; +import { BaseApiIntegration } from "./api"; import { AppsyncResolver } from "./appsync"; import { EventBus, Rule } from "./event-bridge"; import { EventTransform } from "./event-bridge/transform"; @@ -55,6 +56,14 @@ export type FunctionInterface = ts.NewExpression & { ]; }; +export type ApiIntegrationsStaticMethodInterface = ts.CallExpression & { + arguments: [ts.ObjectLiteralExpression]; +}; + +export type ApiIntegrationInterface = ts.NewExpression & { + arguments: [ts.ObjectLiteralExpression]; +}; + export type FunctionlessChecker = ReturnType; export function makeFunctionlessChecker( @@ -72,6 +81,7 @@ export function makeFunctionlessChecker( isReflectFunction, isStepFunction, isNewFunctionlessFunction, + isApiIntegration, isCDKConstruct, getFunctionlessTypeKind, }; @@ -207,6 +217,16 @@ export function makeFunctionlessChecker( ); } + function isApiIntegration(node: ts.Node): node is ApiIntegrationInterface { + return ( + ts.isNewExpression(node) && + isFunctionlessClassOfKind( + node.expression, + BaseApiIntegration.FunctionlessType + ) + ); + } + /** * Heuristically evaluate the fqn of a symbol to be in a module and of a type name. * diff --git a/src/compile.ts b/src/compile.ts index 139d0429..5fcd8342 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -4,6 +4,7 @@ import type { PluginConfig, TransformerExtras } from "ts-patch"; import ts from "typescript"; import { assertDefined } from "./assert"; import { + ApiIntegrationInterface, EventBusMapInterface, RuleInterface, EventTransformInterface, @@ -114,6 +115,8 @@ export function compile( return visitEventTransform(node); } else if (checker.isNewFunctionlessFunction(node)) { return visitFunction(node, ctx); + } else if (checker.isApiIntegration(node)) { + return visitApiIntegration(node); } return node; }; @@ -494,7 +497,7 @@ export function compile( function toFunction( type: "FunctionDecl" | "FunctionExpr", - impl: TsFunctionParameter, + impl: ts.Expression, dropArgs?: number ): ts.Expression { if ( @@ -537,6 +540,41 @@ export function compile( ]); } + function visitApiIntegration(node: ApiIntegrationInterface): ts.Node { + const [props, request, response, errors] = node.arguments; + + return ts.factory.updateNewExpression( + node, + node.expression, + node.typeArguments, + [ + props, + toFunction("FunctionDecl", request), + ts.isObjectLiteralExpression(response) + ? visitApiErrors(response) + : toFunction("FunctionDecl", response), + ...(errors && ts.isObjectLiteralExpression(errors) + ? [visitApiErrors(errors)] + : []), + ] + ); + } + + function visitApiErrors(errors: ts.ObjectLiteralExpression) { + return ts.factory.updateObjectLiteralExpression( + errors, + errors.properties.map((prop) => + ts.isPropertyAssignment(prop) + ? ts.factory.updatePropertyAssignment( + prop, + prop.name, + toFunction("FunctionDecl", prop.initializer) + ) + : prop + ) + ); + } + function toExpr( node: ts.Node | undefined, scope: ts.Node diff --git a/src/event-bridge/event-pattern/synth.ts b/src/event-bridge/event-pattern/synth.ts index 844b5703..76d78fdc 100644 --- a/src/event-bridge/event-pattern/synth.ts +++ b/src/event-bridge/event-pattern/synth.ts @@ -21,7 +21,7 @@ import { CallExpr, ElementAccessExpr, Expr, - isBooleanLiteral, + isBooleanLiteralExpr, isUndefinedLiteralExpr, PropAccessExpr, UnaryExpr, @@ -37,31 +37,31 @@ import { ReferencePath, } from "../utils"; import { + createSingleNumericRange, + intersectNumericAggregation, + intersectNumericAggregationWithRange, intersectNumericRange, + negateNumericRange, reduceNumericAggregate, unionNumericRange, - negateNumericRange, - intersectNumericAggregation, - intersectNumericAggregationWithRange, - createSingleNumericRange, } from "./numeric"; import { - PatternDocument, - Pattern, - isPatternDocument, - isNumericAggregationPattern, - isNumericRangePattern, - isPresentPattern, isAggregatePattern, isAnythingButPattern, - isExactMatchPattern, - isPrefixMatchPattern, - isEmptyPattern, - patternDocumentToEventPattern, isAnythingButPrefixPattern, + isEmptyPattern, + isExactMatchPattern, isNeverPattern, - NumericRangePattern, + isNumericAggregationPattern, + isNumericRangePattern, + isPatternDocument, + isPrefixMatchPattern, + isPresentPattern, NeverPattern, + NumericRangePattern, + Pattern, + PatternDocument, + patternDocumentToEventPattern, } from "./pattern"; const OPERATIONS = { STARTS_WITH: "startsWith", INCLUDES: "includes" }; @@ -139,7 +139,7 @@ export const synthesizePatternDocument = ( return evalUnaryExpression(expr); } else if (isCallExpr(expr)) { return evalCall(expr); - } else if (isBooleanLiteral(expr)) { + } else if (isBooleanLiteralExpr(expr)) { return { doc: {} }; } else { throw new Error(`${expr.kind} is unsupported`); diff --git a/src/event-bridge/utils.ts b/src/event-bridge/utils.ts index 31bc0cfb..f2820548 100644 --- a/src/event-bridge/utils.ts +++ b/src/event-bridge/utils.ts @@ -25,7 +25,7 @@ import { TemplateExpr, UnaryExpr, } from "../expression"; -import { isReturn, isVariableStmt, Stmt, VariableStmt } from "../statement"; +import { isReturnStmt, isVariableStmt, Stmt, VariableStmt } from "../statement"; import { Constant, evalToConstant } from "../util"; /** @@ -306,7 +306,7 @@ export const flattenReturnEvent = (stmts: Stmt[]) => { const ret = stmts[stmts.length - 1]; - if (!ret || !isReturn(ret)) { + if (!ret || !isReturnStmt(ret)) { throw Error("No return statement found in event bridge target function."); } diff --git a/src/expression.ts b/src/expression.ts index 75be6dc8..d360aa4d 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -47,7 +47,7 @@ export function isExpr(a: any): a is Expr { (isArgument(a) || isArrayLiteralExpr(a) || isBinaryExpr(a) || - isBooleanLiteral(a) || + isBooleanLiteralExpr(a) || isCallExpr(a) || isConditionExpr(a) || isComputedPropertyNameExpr(a) || @@ -359,7 +359,7 @@ export class UndefinedLiteralExpr extends BaseExpr<"UndefinedLiteralExpr"> { } } -export const isBooleanLiteral = typeGuard("BooleanLiteralExpr"); +export const isBooleanLiteralExpr = typeGuard("BooleanLiteralExpr"); export class BooleanLiteralExpr extends BaseExpr<"BooleanLiteralExpr"> { constructor(readonly value: boolean) { diff --git a/src/function.ts b/src/function.ts index 4396d261..66e432cb 100644 --- a/src/function.ts +++ b/src/function.ts @@ -4,6 +4,7 @@ import * as appsync from "@aws-cdk/aws-appsync-alpha"; import { serializeFunction } from "@functionless/nodejs-closure-serializer"; import { AssetHashType, + aws_apigateway, aws_dynamodb, aws_events_targets, aws_lambda, @@ -18,20 +19,21 @@ import type { Context } from "aws-lambda"; // eslint-disable-next-line import/no-extraneous-dependencies import AWS from "aws-sdk"; import { Construct } from "constructs"; +import { ApiGatewayVtlIntegration } from "./api"; import type { AppSyncVtlIntegration } from "./appsync"; import { ASL } from "./asl"; import { - NativeFunctionDecl, - isNativeFunctionDecl, IntegrationInvocation, + isNativeFunctionDecl, + NativeFunctionDecl, } from "./declaration"; import { Err, isErr } from "./error"; import { EventBusTargetIntegration } from "./event-bridge"; import { makeEventBusIntegration } from "./event-bridge/event-bus"; import { CallExpr, Expr, isVariableReference } from "./expression"; import { - IntegrationImpl, Integration, + IntegrationImpl, INTEGRATION_TYPE_KEYS, } from "./integration"; import { AnyFunction, anyOf } from "./util"; @@ -77,6 +79,7 @@ abstract class FunctionBase implements IFunction { public static readonly FunctionlessType = "Function"; readonly appSyncVtl: AppSyncVtlIntegration; + readonly apiGWVtl: ApiGatewayVtlIntegration; // @ts-ignore - this makes `F` easily available at compile time readonly __functionBrand: ConditionalFunction; @@ -138,6 +141,27 @@ abstract class FunctionBase implements IFunction { return context.json(request); }, }; + + this.apiGWVtl = { + renderRequest: (call, context) => { + const payloadArg = call.getArgument("payload"); + const payload = payloadArg?.expr + ? context.eval(payloadArg.expr) + : "$null"; + return context.json(payload); + }, + + createIntegration: (_scope, requestTemplate, integrationResponses) => { + return new aws_apigateway.LambdaIntegration(this.resource, { + proxy: false, + passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + "application/json": requestTemplate, + }, + integrationResponses, + }); + }, + }; } public asl(call: CallExpr, context: ASL) { @@ -223,8 +247,9 @@ export class Function extends FunctionBase { * To correctly resolve these for CDK synthesis, either use `asyncSynth()` or use `cdk synth` in the CDK cli. * https://twitter.com/samgoodwin89/status/1516887131108438016?s=20&t=7GRGOQ1Bp0h_cPsJgFk3Ww */ - public static readonly promises = ((global as any)[PromisesSymbol] = - (global as any)[PromisesSymbol] ?? []); + public static readonly promises: Promise[] = ((global as any)[ + PromisesSymbol + ] = (global as any)[PromisesSymbol] ?? []); /** * Wrap a {@link aws_lambda.Function} with Functionless. @@ -310,6 +335,7 @@ export class Function extends FunctionBase { integrations = func.integrations; } else { + debugger; throw Error( "Expected lambda to be passed a compiled function closure or a aws_lambda.IFunction" ); diff --git a/src/index.ts b/src/index.ts index 467c15a8..91745cb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,14 @@ -export * from "./aws"; +export * from "./api"; export * from "./appsync"; export * from "./async-synth"; +export * from "./aws"; export * from "./declaration"; export * from "./error"; export * from "./error-code"; export * from "./event-bridge"; export * from "./expression"; -export { Integration } from "./integration"; export * from "./function"; +export { Integration } from "./integration"; export * from "./reflect"; export * from "./statement"; export * from "./step-function"; diff --git a/src/integration.ts b/src/integration.ts index 106ee967..8fc4b968 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -1,3 +1,4 @@ +import { ApiGatewayVtlIntegration } from "./api"; import { AppSyncVtlIntegration } from "./appsync"; import { ASL, State } from "./asl"; import { EventBus, EventBusTargetIntegration } from "./event-bridge"; @@ -18,6 +19,7 @@ export const isIntegration = >( */ const INTEGRATION_TYPES: { [P in keyof IntegrationMethods]: P } = { appSyncVtl: "appSyncVtl", + apiGWVtl: "apiGWVtl", asl: "asl", native: "native", eventBus: "eventBus", @@ -40,6 +42,11 @@ export interface IntegrationMethods< * @private */ appSyncVtl: AppSyncVtlIntegration; + /** + * Integrate with API Gateway VTL applications. + * @private + */ + apiGWVtl: ApiGatewayVtlIntegration; /** * Integrate with ASL applications like StepFunctions. * @@ -174,6 +181,14 @@ export class IntegrationImpl ); } + public get apiGWVtl(): ApiGatewayVtlIntegration { + return this.assertIntegrationDefined( + // TODO: differentiate Velocity Template? + "Velocity Template", + this.integration.apiGWVtl + ); + } + // TODO: Update to use an interface https://github.com/functionless/functionless/issues/197 public asl(call: CallExpr, context: ASL): Omit { return this.assertIntegrationDefined( diff --git a/src/statement.ts b/src/statement.ts index 6588becb..ad52035b 100644 --- a/src/statement.ts +++ b/src/statement.ts @@ -33,7 +33,7 @@ export function isStmt(a: any): a is Stmt { isForInStmt(a) || isForOfStmt(a) || isIfStmt(a) || - isReturn(a) || + isReturnStmt(a) || isThrowStmt(a) || isTryStmt(a) || isVariableStmt(a)) @@ -149,7 +149,7 @@ export class BlockStmt extends BaseStmt<"BlockStmt", BlockStmtParent> { } } -export const isReturn = typeGuard("ReturnStmt"); +export const isReturnStmt = typeGuard("ReturnStmt"); export class ReturnStmt extends BaseStmt<"ReturnStmt"> { constructor(readonly expr: Expr) { diff --git a/src/step-function.ts b/src/step-function.ts index 19825d08..30f35583 100644 --- a/src/step-function.ts +++ b/src/step-function.ts @@ -2,6 +2,7 @@ import * as appsync from "@aws-cdk/aws-appsync-alpha"; import { Arn, ArnFormat, + aws_apigateway, aws_cloudwatch, aws_events_targets, aws_iam, @@ -13,6 +14,7 @@ import { // eslint-disable-next-line import/no-extraneous-dependencies import { StepFunctions } from "aws-sdk"; import { Construct } from "constructs"; +import { ApiGatewayVtlIntegration } from "./api"; import { AppSyncVtlIntegration } from "./appsync"; import { ASL, @@ -398,6 +400,7 @@ abstract class BaseStepFunction< readonly resource: aws_stepfunctions.CfnStateMachine; readonly appSyncVtl: AppSyncVtlIntegration; + readonly apiGWVtl: ApiGatewayVtlIntegration; // @ts-ignore readonly __functionBrand: (arg: CallIn) => CallOut; @@ -514,6 +517,64 @@ abstract class BaseStepFunction< }`; }, }); + + // Integration object for api gateway vtl + this.apiGWVtl = { + renderRequest: (call, context) => { + const { name, input, traceHeader } = retrieveMachineArgs(call); + if (input === undefined) { + debugger; + throw new Error(`missing input`); + } + const inputVar = context.var(input); + context.qr(`$${inputVar}.stateMachineArn = "${this.stateMachineArn}"`); + if (name) { + context.qr(`$${inputVar}.name = "${name}"`); + } + if (traceHeader) { + context.qr(`$${inputVar}.traceHeader = "${traceHeader}"`); + } + return context.json(inputVar); + }, + + createIntegration: (scope, requestTemplate, integrationResponses) => { + const credentialsRole = new aws_iam.Role( + scope, + "ApiGatewayIntegrationRole", + { + assumedBy: new aws_iam.ServicePrincipal("apigateway.amazonaws.com"), + } + ); + + this.grantRead(credentialsRole); + if ( + this.getStepFunctionType() === + aws_stepfunctions.StateMachineType.EXPRESS + ) { + this.grantStartSyncExecution(credentialsRole); + } else { + this.grantStartExecution(credentialsRole); + } + + return new aws_apigateway.AwsIntegration({ + service: "states", + action: + this.getStepFunctionType() === + aws_stepfunctions.StateMachineType.EXPRESS + ? "StartSyncExecution" + : "StartExecution", + integrationHttpMethod: "POST", + options: { + credentialsRole, + passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + "application/json": requestTemplate, + }, + integrationResponses, + }, + }); + }, + }; } appSyncIntegration( diff --git a/src/table.ts b/src/table.ts index 56ad7a01..eee1a7d4 100644 --- a/src/table.ts +++ b/src/table.ts @@ -1,5 +1,5 @@ import * as appsync from "@aws-cdk/aws-appsync-alpha"; -import { aws_dynamodb } from "aws-cdk-lib"; +import { aws_apigateway, aws_dynamodb, aws_iam } from "aws-cdk-lib"; import { JsonFormat } from "typesafe-dynamodb"; import { NativeBinaryAttribute, @@ -9,7 +9,6 @@ import { ExpressionAttributeNames, ExpressionAttributeValues, } from "typesafe-dynamodb/lib/expression-attributes"; - // @ts-ignore - imported for typedoc import { TableKey } from "typesafe-dynamodb/lib/key"; import { Narrow } from "typesafe-dynamodb/lib/narrow"; @@ -312,6 +311,43 @@ export class Table< }, ...integration.appSyncVtl, }, + apiGWVtl: { + renderRequest: (call, context) => { + const input = call.getArgument("input"); + if (input === undefined) { + throw new Error(`missing input`); + } + const inputVar = context.var(input); + context.qr(`$${inputVar}.tableName = "${this.resource.tableName}"`); + return context.json(inputVar); + }, + + createIntegration: (api, template, integrationResponses) => { + const credentialsRole = new aws_iam.Role( + api, + "ApiGatewayIntegrationRole", + { + assumedBy: new aws_iam.ServicePrincipal( + "apigateway.amazonaws.com" + ), + } + ); + + return new aws_apigateway.AwsIntegration({ + service: "dynamodb", + action: methodName, + integrationHttpMethod: "POST", + options: { + credentialsRole, + passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + "application/json": template, + }, + integrationResponses, + }, + }); + }, + }, unhandledContext(kind, contextKind) { throw new Error( `${kind} is only allowed within a '${VTL.ContextName}' context, but was called within a '${contextKind}' context.` diff --git a/src/tsc.ts b/src/tsc.ts index 6a91e38b..318edd38 100644 --- a/src/tsc.ts +++ b/src/tsc.ts @@ -31,7 +31,7 @@ export async function tsc( projectRoot: string = process.cwd(), props?: TscProps ) { - const tsConfigPath = path.join(projectRoot, "tsconfig.json"); + const tsConfigPath = path.join(projectRoot, "tsconfig.dev.json"); let tsConfig: { include: string[]; exclude?: string[]; diff --git a/src/util.ts b/src/util.ts index 2c85dd2f..59561a52 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,7 +4,7 @@ import { Expr, isArrayLiteralExpr, isBinaryExpr, - isBooleanLiteral, + isBooleanLiteralExpr, isComputedPropertyNameExpr, isIdentifier, isNullLiteralExpr, @@ -162,7 +162,7 @@ export const evalToConstant = (expr: Expr): Constant | undefined => { if ( isStringLiteralExpr(expr) || isNumberLiteralExpr(expr) || - isBooleanLiteral(expr) || + isBooleanLiteralExpr(expr) || isNullLiteralExpr(expr) || isUndefinedLiteralExpr(expr) ) { diff --git a/src/vtl.ts b/src/vtl.ts index f07136d8..b17d5207 100644 --- a/src/vtl.ts +++ b/src/vtl.ts @@ -1,9 +1,59 @@ import { assertNever, assertNodeKind } from "./assert"; -import { CallExpr, Expr, FunctionExpr } from "./expression"; -import { findIntegration } from "./integration"; +import { + isFunctionDecl, + isNativeFunctionDecl, + isParameterDecl, +} from "./declaration"; +import { isErr } from "./error"; +import { + CallExpr, + Expr, + FunctionExpr, + isArgument, + isArrayLiteralExpr, + isBinaryExpr, + isBooleanLiteralExpr, + isCallExpr, + isComputedPropertyNameExpr, + isConditionExpr, + isElementAccessExpr, + isFunctionExpr, + isIdentifier, + isNewExpr, + isNullLiteralExpr, + isNumberLiteralExpr, + isObjectLiteralExpr, + isPropAccessExpr, + isPropAssignExpr, + isReferenceExpr, + isSpreadAssignExpr, + isSpreadElementExpr, + isStringLiteralExpr, + isTemplateExpr, + isTypeOfExpr, + isUnaryExpr, + isUndefinedLiteralExpr, +} from "./expression"; +import { findIntegration, IntegrationImpl } from "./integration"; import { FunctionlessNode } from "./node"; -import { Stmt } from "./statement"; -import { isInTopLevelScope } from "./util"; +import { + isBlockStmt, + isBreakStmt, + isCatchClause, + isContinueStmt, + isDoStmt, + isExprStmt, + isForInStmt, + isForOfStmt, + isIfStmt, + isReturnStmt, + isThrowStmt, + isTryStmt, + isVariableStmt, + isWhileStmt, + Stmt, +} from "./statement"; +import { AnyFunction, isInTopLevelScope } from "./util"; // https://velocity.apache.org/engine/devel/user-guide.html#conditionals // https://cwiki.apache.org/confluence/display/VELOCITY/CheckingForNull @@ -13,13 +63,10 @@ export function isVTL(a: any): a is VTL { return (a as VTL | undefined)?.kind === VTL.ContextName; } -export class VTL { +export abstract class VTL { static readonly ContextName = "Velocity Template"; readonly kind = VTL.ContextName; - public static readonly CircuitBreaker = `#if($context.stash.return__flag) - #return($context.stash.return__val) -#end`; private readonly statements: string[] = []; @@ -37,7 +84,7 @@ export class VTL { this.statements.push(...statements); } - private newLocalVarName() { + protected newLocalVarName() { return `$v${(this.varIt += 1)}`; } @@ -150,6 +197,16 @@ export class VTL { this.add(this.eval(call)); } + /** + * Configure the integration between this VTL template and a target service. + * @param target the target service to integrate with. + * @param call the CallExpr representing the integration logic + */ + protected abstract integrate( + target: IntegrationImpl | undefined, + call: CallExpr + ): string; + /** * Evaluate an {@link Expr} or {@link Stmt} by emitting statements to this VTL template and * return a variable reference to the evaluated value. @@ -163,339 +220,330 @@ export class VTL { if (!node) { return "$null"; } - switch (node.kind) { - case "ArrayLiteralExpr": { - if ( - node.items.find((item) => item.kind === "SpreadElementExpr") === - undefined - ) { - return `[${node.items.map((item) => this.eval(item)).join(", ")}]`; - } else { - // contains a spread, e.g. [...i], so we will store in a variable - const list = this.var("[]"); - for (const item of node.items) { - if (item.kind === "SpreadElementExpr") { - this.qr(`${list}.addAll(${this.eval(item.expr)})`); - } else { - // we use addAll because `list.push(item)` is pared as `list.push(...[item])` - // - i.e. the compiler passes us an ArrayLiteralExpr even if there is one arg - this.qr(`${list}.add(${this.eval(item)})`); - } + if (isArrayLiteralExpr(node)) { + if ( + node.items.find((item) => item.kind === "SpreadElementExpr") === + undefined + ) { + return `[${node.items.map((item) => this.eval(item)).join(", ")}]`; + } else { + // contains a spread, e.g. [...i], so we will store in a variable + const list = this.var("[]"); + for (const item of node.items) { + if (item.kind === "SpreadElementExpr") { + this.qr(`${list}.addAll(${this.eval(item.expr)})`); + } else { + // we use addAll because `list.push(item)` is pared as `list.push(...[item])` + // - i.e. the compiler passes us an ArrayLiteralExpr even if there is one arg + this.qr(`${list}.add(${this.eval(item)})`); } - return list; } + return list; } - case "BinaryExpr": - // VTL fails to evaluate binary expressions inside an object put e.g. $obj.put('x', 1 + 1) - // a workaround is to use a temp variable. - return this.var( - `${this.eval(node.left)} ${node.op} ${this.eval(node.right)}` - ); - case "BlockStmt": - for (const stmt of node.statements) { - this.eval(stmt); - } - return undefined; - case "BooleanLiteralExpr": - return `${node.value}`; - case "BreakStmt": - return this.add("#break"); - case "CallExpr": { - const serviceCall = findIntegration(node); - if (serviceCall) { - return serviceCall.appSyncVtl.request(node, this); - } else if ( - // If the parent is a propAccessExpr - node.expr.kind === "PropAccessExpr" && - (node.expr.name === "map" || - node.expr.name === "forEach" || - node.expr.name === "reduce") - ) { - if (node.expr.name === "map" || node.expr.name == "forEach") { - // list.map(item => ..) - // list.map((item, idx) => ..) - // list.forEach(item => ..) - // list.forEach((item, idx) => ..) - const newList = - node.expr.name === "map" ? this.var("[]") : undefined; - - const [value, index, array] = getMapForEachArgs(node); - - // Try to flatten any maps before this operation - // returns the first variable to be used in the foreach of this operation (may be the `value`) - const list = this.flattenListMapOperations( - node.expr.expr, - value, - (firstVariable, list) => { - this.add(`#foreach(${firstVariable} in ${list})`); - }, - // If array is present, do not flatten the map, this option immediatly evaluates the next expression - !!array - ); - - // Render the body - const tmp = this.renderMapOrForEachBody( - node, - list, - // the return location will be generated - undefined, - index, - array - ); - - // Add the final value to the array - if (node.expr.name === "map") { - this.qr(`${newList}.add(${tmp})`); - } - - this.add("#end"); - return newList ?? "$null"; - } else if (node.expr.name === "reduce") { - // list.reduce((result: string[], next) => [...result, next], []); - // list.reduce((result, next) => [...result, next]); - - const fn = assertNodeKind( - node.getArgument("callbackfn")?.expr, - "FunctionExpr" - ); - const initialValue = node.getArgument("initialValue")?.expr; - - // (previousValue: string[], currentValue: string, currentIndex: number, array: string[]) - const previousValue = fn.parameters[0]?.name - ? `$${fn.parameters[0].name}` - : this.newLocalVarName(); - const currentValue = fn.parameters[1]?.name - ? `$${fn.parameters[1].name}` - : this.newLocalVarName(); - const currentIndex = fn.parameters[2]?.name - ? `$${fn.parameters[2].name}` - : undefined; - const array = fn.parameters[3]?.name - ? `$${fn.parameters[3].name}` - : undefined; - - // create a new local variable name to hold the initial/previous value - // this is becaue previousValue may not be unique and isn't contained within the loop - const previousTmp = this.newLocalVarName(); - - const list = this.flattenListMapOperations( - node.expr.expr, - currentValue, - (firstVariable, list) => { - if (initialValue !== undefined) { - this.set(previousTmp, initialValue); - } else { - this.add(`#if(${list}.isEmpty())`); - this.add( - "$util.error('Reduce of empty array with no initial value')" - ); - this.add("#end"); - } - - this.add(`#foreach(${firstVariable} in ${list})`); - }, - // If array is present, do not flatten maps before the reduce, this option immediatly evaluates the next expression - !!array - ); - - if (currentIndex) { - this.add(`#set(${currentIndex} = $foreach.index)`); - } - if (array) { - this.add(`#set(${array} = ${list})`); - } + } else if (isBinaryExpr(node)) { + // VTL fails to evaluate binary expressions inside an object put e.g. $obj.put('x', 1 + 1) + // a workaround is to use a temp variable. + return this.var( + `${this.eval(node.left)} ${node.op} ${this.eval(node.right)}` + ); + } else if (isBlockStmt(node)) { + for (const stmt of node.statements) { + this.eval(stmt); + } + return undefined; + } else if (isBooleanLiteralExpr(node)) { + return `${node.value}`; + } else if (isBreakStmt(node)) { + return this.add("#break"); + } else if (isCallExpr(node)) { + const serviceCall = findIntegration(node); + if (serviceCall) { + return this.integrate(serviceCall, node); + } else if ( + // If the parent is a propAccessExpr + node.expr.kind === "PropAccessExpr" && + (node.expr.name === "map" || + node.expr.name === "forEach" || + node.expr.name === "reduce") + ) { + if (node.expr.name === "map" || node.expr.name == "forEach") { + // list.map(item => ..) + // list.map((item, idx) => ..) + // list.forEach(item => ..) + // list.forEach((item, idx) => ..) + const newList = node.expr.name === "map" ? this.var("[]") : undefined; + + const [value, index, array] = getMapForEachArgs(node); + + // Try to flatten any maps before this operation + // returns the first variable to be used in the foreach of this operation (may be the `value`) + const list = this.flattenListMapOperations( + node.expr.expr, + value, + (firstVariable, list) => { + this.add(`#foreach(${firstVariable} in ${list})`); + }, + // If array is present, do not flatten the map, this option immediately evaluates the next expression + !!array + ); + + // Render the body + const tmp = this.renderMapOrForEachBody( + node, + list, + // the return location will be generated + undefined, + index, + array + ); + + // Add the final value to the array + if (node.expr.name === "map") { + this.qr(`${newList}.add(${tmp})`); + } - const body = () => { - // set previousValue variable name to avoid remapping - this.set(previousValue, previousTmp); - const tmp = this.newLocalVarName(); - for (const stmt of fn.body.statements) { - this.eval(stmt, tmp); + this.add("#end"); + return newList ?? "$null"; + } else if (node.expr.name === "reduce") { + // list.reduce((result: string[], next) => [...result, next], []); + // list.reduce((result, next) => [...result, next]); + + const fn = assertNodeKind( + node.getArgument("callbackfn")?.expr, + "FunctionExpr" + ); + const initialValue = node.getArgument("initialValue")?.expr; + + // (previousValue: string[], currentValue: string, currentIndex: number, array: string[]) + const previousValue = fn.parameters[0]?.name + ? `$${fn.parameters[0].name}` + : this.newLocalVarName(); + const currentValue = fn.parameters[1]?.name + ? `$${fn.parameters[1].name}` + : this.newLocalVarName(); + const currentIndex = fn.parameters[2]?.name + ? `$${fn.parameters[2].name}` + : undefined; + const array = fn.parameters[3]?.name + ? `$${fn.parameters[3].name}` + : undefined; + + // create a new local variable name to hold the initial/previous value + // this is because previousValue may not be unique and isn't contained within the loop + const previousTmp = this.newLocalVarName(); + + const list = this.flattenListMapOperations( + node.expr.expr, + currentValue, + (firstVariable, list) => { + if (initialValue !== undefined) { + this.set(previousTmp, initialValue); + } else { + this.add(`#if(${list}.isEmpty())`); + this.add( + "$util.error('Reduce of empty array with no initial value')" + ); + this.add("#end"); } - // set the previous temp to be used later - this.set(previousTmp, `${tmp}`); - - this.add("#end"); - }; - - if (initialValue === undefined) { - this.add("#if($foreach.index == 0)"); - this.set(previousTmp, currentValue); - this.add("#else"); - body(); - this.add("#end"); - } else { - body(); + + this.add(`#foreach(${firstVariable} in ${list})`); + }, + // If array is present, do not flatten maps before the reduce, this option immediately evaluates the next expression + !!array + ); + + if (currentIndex) { + this.add(`#set(${currentIndex} = $foreach.index)`); + } + if (array) { + this.add(`#set(${array} = ${list})`); + } + + const body = () => { + // set previousValue variable name to avoid remapping + this.set(previousValue, previousTmp); + const tmp = this.newLocalVarName(); + for (const stmt of fn.body.statements) { + this.eval(stmt, tmp); } + // set the previous temp to be used later + this.set(previousTmp, `${tmp}`); + + this.add("#end"); + }; - return previousTmp; + if (initialValue === undefined) { + this.add("#if($foreach.index == 0)"); + this.set(previousTmp, currentValue); + this.add("#else"); + body(); + this.add("#end"); + } else { + body(); } - // this is an array map, forEach, reduce call + + return previousTmp; } - return `${this.eval(node.expr)}(${Object.values(node.args) - .map((arg) => this.eval(arg)) - .join(", ")})`; + // this is an array map, forEach, reduce call } - case "ConditionExpr": { - const val = this.newLocalVarName(); - this.add(`#if(${this.eval(node.when)})`); - this.set(val, node.then); + return `${this.eval(node.expr)}(${Object.values(node.args) + .map((arg) => this.eval(arg)) + .join(", ")})`; + } else if (isConditionExpr(node)) { + const val = this.newLocalVarName(); + this.add(`#if(${this.eval(node.when)})`); + this.set(val, node.then); + this.add("#else"); + this.set(val, node._else); + this.add("#end"); + return val; + } else if (isIfStmt(node)) { + this.add(`#if(${this.eval(node.when)})`); + this.eval(node.then); + if (node._else) { this.add("#else"); - this.set(val, node._else); - this.add("#end"); - return val; + this.eval(node._else); } - case "IfStmt": { - this.add(`#if(${this.eval(node.when)})`); - this.eval(node.then); - if (node._else) { - this.add("#else"); - this.eval(node._else); - } - this.add("#end"); - return undefined; + this.add("#end"); + return undefined; + } else if (isExprStmt(node)) { + return this.qr(this.eval(node.expr)); + } else if (isForOfStmt(node)) { + } else if (isForInStmt(node)) { + this.add( + `#foreach($${node.variableDecl.name} in ${this.eval(node.expr)}${ + node.kind === "ForInStmt" ? ".keySet()" : "" + })` + ); + this.eval(node.body); + this.add("#end"); + return undefined; + } else if (isFunctionDecl(node)) { + } else if (isNativeFunctionDecl(node)) { + throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); + } else if (isFunctionExpr(node)) { + return this.eval(node.body); + } else if (isIdentifier(node)) { + const ref = node.lookup(); + if (ref?.kind === "VariableStmt" && isInTopLevelScope(ref)) { + return `$context.stash.${node.name}`; + } else if ( + ref?.kind === "ParameterDecl" && + ref.parent?.kind === "FunctionDecl" + ) { + // regardless of the name of the first argument in the root FunctionDecl, it is always the intrinsic Appsync `$context`. + return "$context"; } - case "ExprStmt": - return this.qr(this.eval(node.expr)); - case "ForOfStmt": - case "ForInStmt": - this.add( - `#foreach($${node.variableDecl.name} in ${this.eval(node.expr)}${ - node.kind === "ForInStmt" ? ".keySet()" : "" - })` - ); - this.eval(node.body); - this.add("#end"); - return undefined; - case "FunctionDecl": - case "NativeFunctionDecl": - throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); - case "FunctionExpr": - return this.eval(node.body); - case "Identifier": { - const ref = node.lookup(); - if (ref?.kind === "VariableStmt" && isInTopLevelScope(ref)) { - return `$context.stash.${node.name}`; - } else if ( - ref?.kind === "ParameterDecl" && - ref.parent?.kind === "FunctionDecl" - ) { - // regardless of the name of the first argument in the root FunctionDecl, it is always the intrinsic Appsync `$context`. - return "$context"; - } - if (node.name.startsWith("$")) { - return node.name; + if (node.name.startsWith("$")) { + return node.name; + } else { + return `$${node.name}`; + } + } else if (isNewExpr(node)) { + throw new Error("NewExpr is not supported by Velocity Templates"); + } else if (isPropAccessExpr(node)) { + let name = node.name; + if (name === "push" && node.parent?.kind === "CallExpr") { + // this is a push to an array, rename to 'addAll' + // addAll because the var-args are converted to an ArrayLiteralExpr + name = "addAll"; + } + return `${this.eval(node.expr)}.${name}`; + } else if (isElementAccessExpr(node)) { + return `${this.eval(node.expr)}[${this.eval(node.element)}]`; + } else if (isNullLiteralExpr(node)) { + } else if (isUndefinedLiteralExpr(node)) { + return "$null"; + } else if (isNumberLiteralExpr(node)) { + return node.value.toString(10); + } else if (isObjectLiteralExpr(node)) { + const obj = this.var("{}"); + for (const prop of node.properties) { + if (prop.kind === "PropAssignExpr") { + const name = + prop.name.kind === "Identifier" + ? this.str(prop.name.name) + : this.eval(prop.name); + this.put(obj, name, prop.expr); + } else if (prop.kind === "SpreadAssignExpr") { + this.putAll(obj, prop.expr); } else { - return `$${node.name}`; + assertNever(prop); } } - case "NewExpr": - throw new Error("NewExpr is not supported by Velocity Templates"); - case "PropAccessExpr": { - let name = node.name; - if (name === "push" && node.parent?.kind === "CallExpr") { - // this is a push to an array, rename to 'addAll' - // addAll because the var-args are converted to an ArrayLiteralExpr - name = "addAll"; - } - return `${this.eval(node.expr)}.${name}`; + return obj; + } else if (isComputedPropertyNameExpr(node)) { + return this.eval(node.expr); + } else if (isParameterDecl(node)) { + } else if (isPropAssignExpr(node)) { + } else if (isReferenceExpr(node)) { + throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); + } else if (isReturnStmt(node)) { + if (returnVar) { + this.set(returnVar, node.expr ?? "$null"); + } else { + this.set("$context.stash.return__val", node.expr ?? "$null"); + this.add("#set($context.stash.return__flag = true)"); + this.add("#return($context.stash.return__val)"); } - case "ElementAccessExpr": - return `${this.eval(node.expr)}[${this.eval(node.element)}]`; - case "NullLiteralExpr": - case "UndefinedLiteralExpr": - return "$null"; - case "NumberLiteralExpr": - return node.value.toString(10); - case "ObjectLiteralExpr": { - const obj = this.var("{}"); - for (const prop of node.properties) { - if (prop.kind === "PropAssignExpr") { - const name = - prop.name.kind === "Identifier" - ? this.str(prop.name.name) - : this.eval(prop.name); - this.put(obj, name, prop.expr); - } else if (prop.kind === "SpreadAssignExpr") { - this.putAll(obj, prop.expr); + return undefined; + } else if (isSpreadAssignExpr(node)) { + } else if (isSpreadElementExpr(node)) { + throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); + // handled as part of ObjectLiteral + } else if (isStringLiteralExpr(node)) { + return this.str(node.value); + } else if (isTemplateExpr(node)) { + return `"${node.exprs + .map((expr) => { + if (expr.kind === "StringLiteralExpr") { + return expr.value; + } + const text = this.eval(expr, returnVar); + if (text.startsWith("$")) { + return `\${${text.slice(1)}}`; } else { - assertNever(prop); + const varName = this.var(text); + return `\${${varName.slice(1)}}`; } - } - return obj; + }) + .join("")}"`; + } else if (isUnaryExpr(node)) { + // VTL fails to evaluate unary expressions inside an object put e.g. $obj.put('x', -$v1) + // a workaround is to use a temp variable. + // it also doesn't handle like - signs alone (e.g. - $v1) so we have to put a 0 in front + // no such problem with ! signs though + if (node.op === "-") { + return this.var(`0 - ${this.eval(node.expr)}`); + } else { + return this.var(`${node.op}${this.eval(node.expr)}`); } - case "ComputedPropertyNameExpr": - return this.eval(node.expr); - case "ParameterDecl": - case "PropAssignExpr": - case "ReferenceExpr": - throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); - case "ReturnStmt": - if (returnVar) { - this.set(returnVar, node.expr ?? "$null"); - } else { - this.set("$context.stash.return__val", node.expr ?? "$null"); - this.add("#set($context.stash.return__flag = true)"); - this.add("#return($context.stash.return__val)"); - } - return undefined; - case "SpreadAssignExpr": - case "SpreadElementExpr": - throw new Error(`cannot evaluate Expr kind: '${node.kind}'`); - // handled as part of ObjectLiteral - case "StringLiteralExpr": - return this.str(node.value); - case "TemplateExpr": - return `"${node.exprs - .map((expr) => { - if (expr.kind === "StringLiteralExpr") { - return expr.value; - } - const text = this.eval(expr, returnVar); - if (text.startsWith("$")) { - return `\${${text.slice(1)}}`; - } else { - const varName = this.var(text); - return `\${${varName.slice(1)}}`; - } - }) - .join("")}"`; - case "UnaryExpr": - // VTL fails to evaluate unary expressions inside an object put e.g. $obj.put('x', -$v1) - // a workaround is to use a temp variable. - // it also doesn't handle like - signs alone (e.g. - $v1) so we have to put a 0 in front - // no such problem with ! signs though - if (node.op === "-") { - return this.var(`0 - ${this.eval(node.expr)}`); - } else { - return this.var(`${node.op}${this.eval(node.expr)}`); - } - case "VariableStmt": - const varName = isInTopLevelScope(node) - ? `$context.stash.${node.name}` - : `$${node.name}`; - - if (node.expr) { - return this.set(varName, node.expr); - } else { - return varName; - } - case "ThrowStmt": - return `#throw(${this.eval(node.expr)})`; - case "TryStmt": - case "CatchClause": - case "ContinueStmt": - case "DoStmt": - case "TypeOfExpr": - case "WhileStmt": - throw new Error(`${node.kind} is not yet supported in VTL`); - case "Err": - throw node.error; - case "Argument": - return this.eval(node.expr); + } else if (isVariableStmt(node)) { + const varName = isInTopLevelScope(node) + ? `$context.stash.${node.name}` + : `$${node.name}`; + + if (node.expr) { + return this.set(varName, node.expr); + } else { + return varName; + } + } else if (isThrowStmt(node)) { + return `#throw(${this.eval(node.expr)})`; + } else if (isTryStmt(node)) { + } else if (isCatchClause(node)) { + } else if (isContinueStmt(node)) { + } else if (isDoStmt(node)) { + } else if (isTypeOfExpr(node)) { + } else if (isWhileStmt(node)) { + throw new Error(`${node.kind} is not yet supported in VTL`); + } else if (isErr(node)) { + throw node.error; + } else if (isArgument(node)) { + return this.eval(node.expr); + } else { + return assertNever(node); } - - return assertNever(node); } /** @@ -541,7 +589,7 @@ export class VTL { } /** - * Recursively flattens map operations until a non-map or a map with `array` paremeter is found. + * Recursively flattens map operations until a non-map or a map with `array` parameter is found. * Evaluates the expression after the last map. * * @param before a method which executes once the diff --git a/test-app/cdk.json b/test-app/cdk.json index 87517a62..6e614347 100644 --- a/test-app/cdk.json +++ b/test-app/cdk.json @@ -1,3 +1,3 @@ { - "app": "ts-node ./src/message-board.ts" + "app": "ts-node ./src/api-test.ts" } diff --git a/test-app/src/api-test.ts b/test-app/src/api-test.ts new file mode 100644 index 00000000..de2bb24e --- /dev/null +++ b/test-app/src/api-test.ts @@ -0,0 +1,158 @@ +import { + App, + aws_apigateway, + aws_dynamodb, + aws_logs, + Stack, +} from "aws-cdk-lib"; +import { + AwsApiIntegration, + MockApiIntegration, + ExpressStepFunction, + Function, + Table, + APIGatewayInput, +} from "functionless"; + +export const app = new App(); + +const stack = new Stack(app, "api-test-app-stack"); + +const restApi = new aws_apigateway.RestApi(stack, "api", { + restApiName: "api-test-app-api", +}); + +const fn = new Function( + stack, + "fn", + async (event: { inNum: number; inStr: string; inBool: boolean }) => { + return { + fnNum: event.inNum, + fnStr: event.inStr, + nested: { + again: { + num: event.inNum, + }, + }, + }; + } +); + +const fnResource = restApi.root.addResource("fn").addResource("{num}"); + +new AwsApiIntegration( + { + httpMethod: "POST", + resource: fnResource, + }, + ($input: APIGatewayInput) => + fn({ + inNum: $input.params("num") as number, + inStr: $input.params("str") as string, + inBool: $input.json("$.body"), + }), + (resp) => ({ + resultNum: resp.fnNum, + resultStr: resp.fnStr, + nested: resp.nested.again.num, + }), + { + 400: () => ({ msg: "400" }), + } +); + +const sfn = new ExpressStepFunction( + stack, + "express-sfn", + { + logs: { + destination: new aws_logs.LogGroup(stack, "express-sfn-logs"), + includeExecutionData: true, + }, + }, + (input: { num: number; str: string }) => ({ + sfnNum: input.num, + sfnStr: input.str, + }) +); + +const mockResource = restApi.root.addResource("mock").addResource("{num}"); +new MockApiIntegration( + { + httpMethod: "POST", + resource: mockResource, + }, + ($input) => ({ + statusCode: $input.params("num") as number, + }), + { + 200: () => ({ + body: { + num: 12345, + }, + }), + 500: () => ({ + msg: "error", + }), + } +); + +interface Item { + id: string; + name: string; +} +const table = new Table( + new aws_dynamodb.Table(stack, "table", { + partitionKey: { + name: "id", + type: aws_dynamodb.AttributeType.NUMBER, + }, + }) +); + +const dynamoResource = restApi.root.addResource("dynamo").addResource("{num}"); +new AwsApiIntegration( + { + httpMethod: "GET", + resource: dynamoResource, + }, + ($input: APIGatewayInput) => + table.getItem({ + key: { + id: { + S: `${$input.params("id")}`, + }, + }, + }), + (resp) => ({ foo: resp.name }), + { + 400: () => ({ msg: "400" }), + } +); + +const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); + +new AwsApiIntegration( + { + httpMethod: "GET", + resource: sfnResource, + }, + ($input: APIGatewayInput) => + sfn({ + input: { + num: $input.params("num") as number, + str: $input.params("str") as string, + }, + }), + (resp, $context) => { + if (resp.status === "SUCCEEDED") { + return { + resultNum: resp.output.sfnNum, + resultStr: resp.output.sfnStr, + }; + } else { + $context.responseOverride.status = 500; + return resp.error; + } + } +); diff --git a/test-app/yarn.lock b/test-app/yarn.lock index e4e74738..dcdce030 100644 --- a/test-app/yarn.lock +++ b/test-app/yarn.lock @@ -1614,6 +1614,11 @@ resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.93.tgz#3e2c80894122477040aabf29b7320556f5702a76" integrity sha512-Vsyi9ogDAY3REZDjYnXMRJJa62SDvxHXxJI5nGDQdZW058dDE+av/anynN2rLKbCKXDRNw3D/sQmqxVflZFi4A== +"@types/aws-lambda@^8.10.98": + version "8.10.98" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.98.tgz#2936975c19529011b8b5c08850d42157711c690d" + integrity sha512-dJ/R9qamtI2nNpxhNwPBTwsfYwbcCWsYBJxhpgGyMLCD0HxKpORcMpPpSrFP/FIceNEYfnS3R5EfjSZJmX2oJg== + "@types/js-yaml@^4.0.0": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" @@ -2695,6 +2700,7 @@ function.prototype.name@^1.1.5: version "0.0.0" dependencies: "@functionless/nodejs-closure-serializer" "^0.0.2" + "@types/aws-lambda" "^8.10.98" fs-extra "^10.1.0" minimatch "^5.1.0" diff --git a/test/api.localstack.test.ts b/test/api.localstack.test.ts new file mode 100644 index 00000000..c995def9 --- /dev/null +++ b/test/api.localstack.test.ts @@ -0,0 +1,127 @@ +import { aws_apigateway } from "aws-cdk-lib"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import axios from "axios"; +import { + AwsApiIntegration, + Function, + LambdaProxyApiMethod, + MockApiIntegration, +} from "../src"; +import { localstackTestSuite } from "./localstack"; + +localstackTestSuite("apiGatewayStack", (test, stack) => { + test.skip( + "mock integration", + () => { + const api = new aws_apigateway.RestApi(stack, "MockAPI"); + const code = api.root.addResource("{code}"); + new MockApiIntegration({ + httpMethod: "GET", + resource: code, + request: (req: { + pathParameters: { + code: number; + }; + }) => ({ + statusCode: req.pathParameters.code, + }), + responses: { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + }, + }); + + return { + outputs: { + endpoint: api.url, + }, + }; + }, + async (context) => { + const response = await axios.get(`${context.endpoint}200`); + expect(response.data).toEqual({ + response: "OK", + }); + } + ); + + test.skip( + "lambda function integration", + () => { + const api = new aws_apigateway.RestApi(stack, "LambdaAPI"); + const func = new Function(stack, "Func", async (_input: any) => { + return { key: "hello" }; + }); + + new AwsApiIntegration({ + httpMethod: "GET", + resource: api.root, + request: (req: { + pathParameters: { + code: number; + }; + }) => + func({ + input: req.pathParameters.code, + }), + response: (result) => ({ + result: result.key, + }), + }); + + return { + outputs: { + endpoint: api.url, + }, + }; + }, + async (context) => { + const response = await axios.get(context.endpoint); + expect(response.data).toEqual({ result: "hello" }); + } + ); + + test( + "lambda proxy method", + () => { + const api = new aws_apigateway.RestApi(stack, "LambdaAPI"); + const func = new Function( + stack, + "Func", + async (request) => { + return { + statusCode: 200, + body: JSON.stringify({ + hello: "world", + path: request.path, + }), + }; + } + ); + + new LambdaProxyApiMethod({ + httpMethod: "GET", + resource: api.root, + function: func, + }); + + return { + outputs: { + endpoint: api.url, + }, + }; + }, + async (context) => { + const response = await axios.get(context.endpoint); + expect(response.status).toEqual(200); + expect(response.data).toMatchObject({ + hello: "world", + path: "/", + }); + } + ); +}); diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 00000000..ece8e9f9 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,217 @@ +import "jest"; +import { aws_apigateway, IResolvable, Stack } from "aws-cdk-lib"; +import { + AwsApiIntegration, + MockApiIntegration, + Function, + BaseApiIntegration, + ExpressStepFunction, +} from "../src"; + +let stack: Stack; +let func: Function; +beforeEach(() => { + stack = new Stack(); + func = new Function(stack, "F", (p) => { + return p; + }); +}); + +test("mock integration with object literal", () => { + const api = new aws_apigateway.RestApi(stack, "API"); + + const method = getCfnMethod( + new MockApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + ($input) => ({ + statusCode: $input.params("code") as number, + }), + { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + } + ) + ); + + expect(method.httpMethod).toEqual("GET"); + expect(method.integration.requestTemplates).toEqual({ + "application/json": `#set($inputRoot = $input.path('$')) +{"statusCode":$input.params().path.code}`, + }); + expect(method.integration.integrationResponses).toEqual([ + { + statusCode: "200", + selectionPattern: "^200$", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"response":"OK"}`, + }, + }, + { + statusCode: "500", + selectionPattern: "^500$", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"response":"BAD"}`, + }, + }, + ]); +}); + +test.skip("mock integration with object literal and literal type in pathParameters", () => { + const api = new aws_apigateway.RestApi(stack, "API"); + + const method = getCfnMethod( + new MockApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + (req) => ({ + statusCode: req.params("code") as number, + }), + { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + } + ) + ); + + expect(method.httpMethod).toEqual("GET"); + expect(method.integration.requestTemplates).toEqual({ + "application/json": `#set($inputRoot = $input.path('$')) +{"statusCode":$input.params().path.code}`, + }); + expect(method.integration.integrationResponses).toEqual([ + { + statusCode: "200", + selectionPattern: "^200$", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"response":"OK"}`, + }, + }, + { + statusCode: "500", + selectionPattern: "^500$", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"response":"BAD"}`, + }, + }, + ]); +}); + +test("AWS integration with Function", () => { + const api = new aws_apigateway.RestApi(stack, "API"); + + const method = getCfnMethod( + new AwsApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + ($input) => func($input.json("$")), + (result) => ({ + result, + }) + ) + ); + + expect(method.httpMethod).toEqual("GET"); + expect(method.integration.requestTemplates).toEqual({ + "application/json": `#set($inputRoot = $input.path('$')) +"$inputRoot"`, + }); + expect(method.integration.integrationResponses).toEqual([ + { + statusCode: "200", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"result":"$inputRoot"}`, + }, + }, + ]); +}); + +test("AWS integration with Express Step Function", () => { + const api = new aws_apigateway.RestApi(stack, "API"); + const sfn = new ExpressStepFunction(stack, "SFN", () => { + return "done"; + }); + + const method = getCfnMethod( + new AwsApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + ($input) => + sfn({ + input: { + num: $input.params("num") as number, + str: $input.params("str") as string, + }, + }), + (response, $context) => { + if (response.status === "SUCCEEDED") { + return response.output; + } else { + $context.responseOverride.status = 500; + return response.error; + } + } + ) + ); + + expect(method.httpMethod).toEqual("GET"); + expect(method.integration.requestTemplates).toEqual({ + "application/json": `#set($inputRoot = $input.path('$')) +{"input":{"num":"$input.pathParameters}}`, + }); + expect(method.integration.integrationResponses).toEqual([ + { + statusCode: "200", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$')) +{"result":"$inputRoot"}`, + }, + }, + ]); +}); + +type CfnIntegration = Exclude< + aws_apigateway.CfnMethod["integration"], + IResolvable | undefined +> & { + integrationResponses: IntegrationResponseProperty[]; +}; + +interface IntegrationResponseProperty { + readonly contentHandling?: string; + readonly responseParameters?: { + [key: string]: string; + }; + readonly responseTemplates?: { + [key: string]: string; + }; + readonly selectionPattern?: string; + readonly statusCode: string; +} + +function getCfnMethod(method: BaseApiIntegration): aws_apigateway.CfnMethod & { + integration: CfnIntegration; +} { + return method.method.node.findChild("Resource") as any; +} diff --git a/test/localstack.ts b/test/localstack.ts index 477400a8..b69f5300 100644 --- a/test/localstack.ts +++ b/test/localstack.ts @@ -6,6 +6,7 @@ import { CloudFormationDeployments } from "aws-cdk/lib/api/cloudformation-deploy import { CloudFormation } from "aws-sdk"; import { Construct } from "constructs"; import { asyncSynth } from "../src/async-synth"; +import { Function } from "../src/function"; export const clientConfig = { endpoint: "http://localhost:4566", @@ -44,10 +45,11 @@ export const deployStack = async (app: App, stack: Stack) => { sdkProvider, }); + const stackArtifact = cloudAssembly.getStackArtifact( + stack.artifactId + ) as unknown as cxapi.CloudFormationStackArtifact; await cfn.deployStack({ - stack: cloudAssembly.getStackArtifact( - stack.artifactId - ) as unknown as cxapi.CloudFormationStackArtifact, + stack: stackArtifact, force: true, }); }; @@ -125,6 +127,8 @@ export const localstackTestSuite = ( return {}; }); + await Promise.all(Function.promises); + await deployStack(app, stack); stackOutputs = ( diff --git a/yarn.lock b/yarn.lock index fa03f80f..8e32e25b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1504,6 +1504,11 @@ resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.97.tgz#9b2f2adfa63a215173a9da37604e4f65dd56cb98" integrity sha512-BZk3qO4R2KN8Ts3eR6CW1n8LI46UOgv1KoDZjo8J9vOQvDeX/rsrv1H0BpEAMcSqZ1mLwTEyAMtlua5tlSn0kw== +"@types/aws-lambda@^8.10.98": + version "8.10.98" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.98.tgz#2936975c19529011b8b5c08850d42157711c690d" + integrity sha512-dJ/R9qamtI2nNpxhNwPBTwsfYwbcCWsYBJxhpgGyMLCD0HxKpORcMpPpSrFP/FIceNEYfnS3R5EfjSZJmX2oJg== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" @@ -2107,6 +2112,14 @@ aws-sdk@^2.1079.0, aws-sdk@^2.1093.0, aws-sdk@^2.1113.0: uuid "8.0.0" xml2js "0.4.19" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + babel-jest@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.5.1.tgz#a1bf8d61928edfefd21da27eb86a695bfd691444" @@ -3843,6 +3856,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== +follow-redirects@^1.14.9: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + form-data@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" @@ -3852,6 +3870,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"