From dff17ade6eba0fb7e2242d6110947794ab75302e Mon Sep 17 00:00:00 2001 From: Brendan McKee Date: Tue, 24 May 2022 18:22:14 -0700 Subject: [PATCH 01/12] feat(api-gateway): introduce API Gateway integrations --- src/api.ts | 395 +++++++++++++++++++++++++++++++++++++++ src/checker.ts | 20 ++ src/compile.ts | 71 +++++++ src/function.ts | 29 ++- src/index.ts | 5 +- src/integration.ts | 15 ++ src/step-function.ts | 55 ++++++ test-app/cdk.json | 2 +- test-app/src/api-test.ts | 106 +++++++++++ 9 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 src/api.ts create mode 100644 test-app/src/api-test.ts diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..9578aa8d --- /dev/null +++ b/src/api.ts @@ -0,0 +1,395 @@ +import { aws_apigateway } from "aws-cdk-lib"; +import { isPropAccessExpr } from "."; +import { FunctionDecl, isFunctionDecl } from "./declaration"; +import { isErr } from "./error"; +import { isIdentifier, PropAccessExpr } from "./expression"; +import { Function } from "./function"; +import { FunctionlessNode } from "./node"; +import { ExpressStepFunction } from "./step-function"; + +/** + * HTTP Methods that API Gateway supports. + */ +export type HttpMethod = + | "HEAD" + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "HEAD" + | "OPTIONS"; + +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 ApiRequestProps< + PathParams extends ParameterMap | undefined, + Body extends object, + QueryParams extends ParameterMap | undefined, + HeaderParams extends ParameterMap | 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; +} + +type RequestTransformerFunction< + Request extends ApiRequestProps, + IntegrationRequest +> = (req: Request) => IntegrationRequest; + +type ResponseTransformerFunction = ( + resp: IntegrationResponse +) => MethodResponse; + +// TODO: support other types +type IntegrationTarget = + | Function + | ExpressStepFunction; + +interface BaseApiIntegration { + /** + * Add this integration as a Method to an API Gateway resource. + * + * TODO: this mirrors the AppsyncResolver.addResolver method, but it + * is on the chopping block: https://github.com/functionless/functionless/issues/137 + * The 2 classes are conceptually similar so we should keep the DX in sync. + */ + addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void; +} + +/** + * Static constructors for the supported API Gateway integrations. + * These are the preferred entrypoints as they offer superior type + * inference. + */ +export class ApiIntegrations { + /** + * Create a {@link MockApiIntegration}. + */ + public static mock< + Request extends ApiRequestProps, + StatusCode extends number, + MethodResponses extends { [C in StatusCode]: any } + >( + props: MockApiIntegrationProps + ): MockApiIntegration { + return new MockApiIntegration(props); + } + + /** + * Create a {@link AwsApiIntegration}. + */ + public static aws< + Request extends ApiRequestProps, + IntegrationRequest, + IntegrationResponse, + MethodResponse + >( + props: AwsApiIntegrationProps< + Request, + IntegrationRequest, + IntegrationResponse, + MethodResponse + > + ): AwsApiIntegration { + return new AwsApiIntegration(props); + } +} + +export interface MockApiIntegrationProps< + Request extends ApiRequestProps, + StatusCode extends number, + MethodResponses extends { [C in StatusCode]: any } +> { + /** + * Map API request to a status code. This code will be used by API Gateway + * to select the response to return. + */ + request: RequestTransformerFunction; + /** + * Map of status codes to response to return. + */ + responses: { [C in StatusCode]: (code: C) => MethodResponses[C] }; +} + +/** + * A Mock integration lets you return preconfigured 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 preconfigured 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< + Props extends MockApiIntegrationProps +> implements BaseApiIntegration +{ + /** + * This static property identifies this class as a MockApiIntegration to the Functionless plugin. + */ + public static readonly FunctionlessType = "MockApiIntegration"; + + private readonly request: FunctionDecl; + private readonly responses: { [K in keyof Props["responses"]]: FunctionDecl }; + + public constructor(props: Props) { + this.request = validateFunctionDecl(props.request); + this.responses = Object.fromEntries( + Object.entries(props.responses).map(([k, v]) => [ + k, + validateFunctionDecl(v), + ]) + ) as { [K in keyof Props["responses"]]: FunctionDecl }; + } + + addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void { + const requestTemplate = toVTL(this.request, "request"); + + const responseEntries: [string, FunctionDecl][] = Object.entries( + this.responses + ); + const integrationResponses: aws_apigateway.IntegrationResponse[] = + responseEntries.map(([statusCode, fn]) => ({ + statusCode, + responseTemplates: { + "application/json": toVTL(fn, "response"), + }, + selectionPattern: `^${statusCode}$`, + })); + + const integration = new aws_apigateway.MockIntegration({ + requestTemplates: { + "application/json": requestTemplate, + }, + integrationResponses, + }); + + const methodResponses = Object.keys(this.responses).map((statusCode) => ({ + statusCode, + })); + + // TODO: support requestParameters, authorizers, models and validators + resource.addMethod(httpMethod, integration, { + methodResponses, + }); + } +} + +export interface AwsApiIntegrationProps< + Request extends ApiRequestProps, + IntegrationRequest, + IntegrationResponse, + MethodResponse +> { + /** + * Map API request to an integration request. + */ + request: RequestTransformerFunction; + /** + * Integration target backing this API. The result of `request` will be sent. + */ + integration: IntegrationTarget; + /** + * Map integration response to a method response. + * TODO: we need to handle multiple responses + */ + response: ResponseTransformerFunction; +} + +/** + * 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. + * + * TODO: we need to support multiple 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 AwsApiIntegration< + Props extends AwsApiIntegrationProps +> implements BaseApiIntegration +{ + /** + * This static property identifies this class as an AwsApiIntegration to the Functionless plugin. + */ + public static readonly FunctionlessType = "AwsApiIntegration"; + + private readonly request: FunctionDecl; + private readonly response: FunctionDecl; + private readonly integration: Props["integration"]; + + constructor(props: Props) { + this.request = validateFunctionDecl(props.request); + this.response = validateFunctionDecl(props.response); + this.integration = props.integration; + } + + addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void { + const requestTemplate = toVTL(this.request, "request"); + const responseTemplate = toVTL(this.response, "response"); + + const apiGWIntegration = this.integration.apiGWVtl.integration( + requestTemplate, + responseTemplate + ); + + // TODO: support requestParameters, authorizers, models and validators + resource.addMethod(httpMethod, apiGWIntegration, { + methodResponses: [ + { + statusCode: "200", + }, + ], + }); + } +} + +/** + * Simple VTL interpreter for a FunctionDecl. The interpreter in vtl.ts + * is based on the AppSync VTL engine which has much more flexibility than + * the API Gateway VTL engine. In particular it has a toJson utility function + * which means the VTL can just create an object in memory and then toJson it + * the end. Here we need to manually output the JSON which is how VTL is + * typically meant to be used. + * + * For now, only Literals and references to the template input are supported. + * It is definitely possible to support more, but we will start with just + * small core and support more syntax carefully over time. + * + * @param node Function to interpret. + * @param template Whether we are creating a request or response mapping template. + */ +function toVTL(node: FunctionDecl, template: "request" | "response") { + const statements = node.body.statements.map((stmt) => inner(stmt)).join("\n"); + + if (template === "request") { + return `#set($inputRoot = $input.path('$'))${statements}`; + } else { + return statements; + } + + function inner(node: FunctionlessNode): string { + switch (node.kind) { + case "ArrayLiteralExpr": + return `[${node.children.map(inner).join(",")}]`; + + case "BooleanLiteralExpr": + return node.value.toString(); + + case "NumberLiteralExpr": + return node.value.toString(); + + case "ObjectLiteralExpr": + return `{${node.properties.map(inner).join(",")}}`; + + case "PropAccessExpr": + if (descendedFromFunctionParameter(node)) { + let param; + if (template === "request") { + switch (node.expr.name) { + case "body": + param = `$inputRoot.${node.name}`; + break; + case "pathParameters": + param = `$input.params().path.${node.name}`; + break; + case "queryStringParameters": + param = `$input.params().querystring.${node.name}`; + break; + case "headers": + param = `$input.params().header.${node.name}`; + break; + default: + throw new Error("Unknown parameter type."); + } + if (node.type === "string") { + param = `"${param}"`; + } + } else { + param = `$inputRoot.${node.name}`; + if (node.type === "string") { + return `"${param}"`; + } + } + return param; + } + return `${inner(node.expr)}.${node.name};`; + + case "PropAssignExpr": + return `${inner(node.name)}: ${inner(node.expr)}`; + + case "ReturnStmt": + return inner(node.expr); + + case "StringLiteralExpr": + return `"${node.value}"`; + } + throw new Error(`Unsupported node type: ${node.kind}`); + } +} + +function validateFunctionDecl(a: any): FunctionDecl { + if (isFunctionDecl(a)) { + return a; + } else if (isErr(a)) { + throw a.error; + } else { + throw Error("Unknown compiler error."); + } +} + +const isFunctionParameter = (node: FunctionlessNode) => { + if (!isIdentifier(node)) return false; + const ref = node.lookup(); + return ref?.kind === "ParameterDecl" && ref.parent?.kind === "FunctionDecl"; +}; + +const descendedFromFunctionParameter = ( + node: PropAccessExpr +): node is PropAccessExpr & { expr: PropAccessExpr } => { + if (isFunctionParameter(node.expr)) return true; + return ( + isPropAccessExpr(node.expr) && descendedFromFunctionParameter(node.expr) + ); +}; + +/** + * Hooks used to create API Gateway integrations. + */ +export interface ApiGatewayVtlIntegration { + integration: ( + requestTemplate: string, + responseTemplate: string + ) => aws_apigateway.Integration; +} diff --git a/src/checker.ts b/src/checker.ts index d11f1218..6db55dd6 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -55,6 +55,10 @@ export type FunctionInterface = ts.NewExpression & { ]; }; +export type ApiIntegrationsStaticMethodInterface = ts.CallExpression & { + arguments: [ts.ObjectLiteralExpression]; +}; + export type FunctionlessChecker = ReturnType; export function makeFunctionlessChecker( @@ -72,6 +76,7 @@ export function makeFunctionlessChecker( isReflectFunction, isStepFunction, isNewFunctionlessFunction, + isApiIntegrationsStaticMethod, isCDKConstruct, getFunctionlessTypeKind, }; @@ -211,6 +216,21 @@ export function makeFunctionlessChecker( ); } + function isApiIntegrationsStaticMethod( + node: ts.Node + ): node is ApiIntegrationsStaticMethodInterface { + const x = + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + (node.expression.name.text === "mock" || + node.expression.name.text == "aws") && + ts.isIdentifier(node.expression.expression) && + // TODO: is this enough? should we grab the type and make sure it + // has FunctionlessKind? + node.expression.expression.text === "ApiIntegrations"; + return x; + } + /** * 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 71c4fed5..a74341ca 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 { + ApiIntegrationsStaticMethodInterface, EventBusMapInterface, EventBusRuleInterface, EventBusTransformInterface, @@ -114,6 +115,8 @@ export function compile( return visitEventTransform(node); } else if (checker.isNewFunctionlessFunction(node)) { return visitFunction(node, ctx); + } else if (checker.isApiIntegrationsStaticMethod(node)) { + return visitApiIntegrationsStaticMethod(node); } return node; }; @@ -525,6 +528,74 @@ export function compile( ]); } + function visitApiIntegrationsStaticMethod( + node: ApiIntegrationsStaticMethodInterface + ): ts.CallExpression { + const [props] = node.arguments; + + const updatedProps = ts.factory.updateObjectLiteralExpression( + props, + props.properties.map((prop) => { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + if (prop.name.text === "responses") { + return visitApiIntegrationResponsesProp(prop); + } else if ( + prop.name.text === "request" || + prop.name.text === "response" + ) { + return visitApiIntegrationMapperProp(prop); + } + } + return prop; + }) + ); + + return ts.factory.updateCallExpression( + node, + node.expression, + node.typeArguments, + [updatedProps] + ); + } + + function visitApiIntegrationMapperProp( + prop: ts.PropertyAssignment + ): ts.PropertyAssignment { + const { initializer } = prop; + if ( + !ts.isFunctionExpression(initializer) && + !ts.isArrowFunction(initializer) + ) { + return prop; + } + + return ts.factory.updatePropertyAssignment( + prop, + prop.name, + errorBoundary(() => toFunction("FunctionDecl", initializer)) + ); + } + + function visitApiIntegrationResponsesProp( + prop: ts.PropertyAssignment + ): ts.PropertyAssignment { + const { initializer } = prop; + if (!ts.isObjectLiteralExpression(initializer)) { + return prop; + } + + return ts.factory.updatePropertyAssignment( + prop, + prop.name, + ts.factory.updateObjectLiteralExpression( + initializer, + initializer.properties.map((p) => + ts.isPropertyAssignment(p) ? visitApiIntegrationMapperProp(p) : p + ) + ) + ); + } + function toExpr( node: ts.Node | undefined, scope: ts.Node diff --git a/src/function.ts b/src/function.ts index aca32dee..5fd16868 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_lambda, CfnResource, @@ -17,18 +18,19 @@ 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 { CallExpr, Expr, isVariableReference } from "./expression"; import { - IntegrationImpl, Integration, + IntegrationImpl, INTEGRATION_TYPE_KEYS, } from "./integration"; import { AnyFunction, anyOf } from "./util"; @@ -63,6 +65,7 @@ abstract class FunctionBase public static readonly FunctionlessType = "Function"; readonly appSyncVtl: AppSyncVtlIntegration; + readonly apiGWVtl: ApiGatewayVtlIntegration; // @ts-ignore - this makes `F` easily available at compile time readonly __functionBrand: ConditionalFunction; @@ -124,6 +127,26 @@ abstract class FunctionBase return context.json(request); }, }; + + this.apiGWVtl = { + integration: (requestTemplate, responseTemplate) => { + return new aws_apigateway.LambdaIntegration(this.resource, { + proxy: false, + passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + "application/json": requestTemplate, + }, + integrationResponses: [ + { + statusCode: "200", + responseTemplates: { + "application/json": `#set($inputRoot = $input.path('$'))\n${responseTemplate}`, + }, + }, + ], + }); + }, + }; } public asl(call: CallExpr, context: ASL) { 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 476df6b0..fb37087d 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 { CallExpr } from "./expression"; @@ -13,6 +14,7 @@ import { VTL } from "./vtl"; */ const INTEGRATION_TYPES: { [P in keyof IntegrationMethods]: P } = { appSyncVtl: "appSyncVtl", + apiGWVtl: "apiGWVtl", asl: "asl", native: "native", }; @@ -28,6 +30,11 @@ interface IntegrationMethods { * @private */ appSyncVtl: AppSyncVtlIntegration; + /** + * Integrate with API Gateway VTL applications. + * @private + */ + apiGWVtl: ApiGatewayVtlIntegration; /** * Integrate with ASL applications like StepFunctions. * @private @@ -137,6 +144,14 @@ export class IntegrationImpl return this.unhandledContext("Velocity Template"); } + public get apiGWVtl(): ApiGatewayVtlIntegration { + if (this.integration.apiGWVtl) { + return this.integration.apiGWVtl; + } + // TODO: differentiate Velocity Template? + return this.unhandledContext("Velocity Template"); + } + public asl(call: CallExpr, context: ASL): Omit { if (this.integration.asl) { return this.integration.asl(call, context); diff --git a/src/step-function.ts b/src/step-function.ts index 68576f70..d7bf8798 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_iam, aws_stepfunctions, @@ -12,6 +13,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, @@ -388,6 +390,7 @@ abstract class BaseStepFunction< readonly resource: aws_stepfunctions.CfnStateMachine; readonly appSyncVtl: AppSyncVtlIntegration; + readonly apiGWVtl: ApiGatewayVtlIntegration; // @ts-ignore readonly __functionBrand: (arg: P) => CallOut; @@ -504,6 +507,58 @@ abstract class BaseStepFunction< }`; }, }); + + // Integration object for api gateway vtl + this.apiGWVtl = { + integration: (requestTemplate, responseTemplate) => { + const credentialsRole = new aws_iam.Role( + this, + "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); + } + + const escapedInput = requestTemplate.replace(/\"/g, '\\"'); + 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": `{ + "input": "${escapedInput}", + "stateMachineArn": "${this.stateMachineArn}" + }`, + }, + integrationResponses: [ + { + statusCode: "200", + responseTemplates: { + "application/json": `#set($inputRoot = $util.parseJson($input.path('$.output')))\n${responseTemplate}`, + }, + }, + ], + }, + }); + }, + }; } appSyncIntegration( diff --git a/test-app/cdk.json b/test-app/cdk.json index 87517a62..7815f97c 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/app.ts" } diff --git a/test-app/src/api-test.ts b/test-app/src/api-test.ts new file mode 100644 index 00000000..7a7b3467 --- /dev/null +++ b/test-app/src/api-test.ts @@ -0,0 +1,106 @@ +import { App, aws_apigateway, aws_logs, Stack } from "aws-cdk-lib"; +import { ApiIntegrations, ExpressStepFunction, Function } 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< + { inNum: number; inStr: string; inBool: boolean }, + { fnNum: number; fnStr: string; fnBool: boolean } +>(stack, "fn", async (event) => ({ + fnNum: event.inNum, + fnStr: event.inStr, + fnBool: event.inBool, +})); + +interface FnRequest { + pathParameters: { + num: number; + }; + queryStringParameters: { + str: string; + }; + body: { + bool: boolean; + }; +} + +const fnResource = restApi.root.addResource("fn").addResource("{num}"); +const fnIntegration = ApiIntegrations.aws({ + request: (req: FnRequest) => ({ + inNum: req.pathParameters.num, + inStr: req.queryStringParameters.str, + inBool: req.body.bool, + }), + integration: fn, + response: (resp) => ({ + outNum: resp.fnNum, + outStr: resp.fnStr, + outBool: resp.fnBool, + }), +}); +fnIntegration.addMethod("POST", fnResource); + +const sfn = new ExpressStepFunction( + stack, + "express-sfn", + { + logs: { + destination: new aws_logs.LogGroup(stack, "express-sfn-logs"), + includeExecutionData: true, + }, + }, + (req: { num: number; str: string }) => ({ + sfnNum: req.num, + sfnStr: req.str, + }) +); + +interface SfnRequest { + pathParameters: { + num: number; + }; + queryStringParameters: { + str: string; + }; +} + +const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); +const sfnIntegration = ApiIntegrations.aws({ + request: (req: SfnRequest) => ({ + num: req.pathParameters.num, + str: req.queryStringParameters.str, + }), + integration: sfn, + response: (resp) => ({ resultNum: resp.sfnNum, resultStr: resp.sfnStr }), +}); +sfnIntegration.addMethod("GET", sfnResource); + +interface MockRequest { + pathParameters: { + num: 200 | 500; + }; +} + +const mockResource = restApi.root.addResource("mock").addResource("{num}"); +const mock = ApiIntegrations.mock({ + request: (req: MockRequest) => ({ + statusCode: req.pathParameters.num, + }), + responses: { + 200: () => ({ + body: { + num: 12345, + }, + }), + 500: () => ({ + msg: "error", + }), + }, +}); +mock.addMethod("GET", mockResource); From 768c4e5734de44ede1e047c0501680667070826c Mon Sep 17 00:00:00 2001 From: Brendan McKee Date: Tue, 31 May 2022 10:26:03 -0700 Subject: [PATCH 02/12] fix(api-gateway): address simple PR feedback --- src/api.ts | 145 +++++++++++++----------- src/compile.ts | 9 +- src/event-bridge/event-pattern/synth.ts | 34 +++--- src/event-bridge/utils.ts | 4 +- src/expression.ts | 4 +- src/statement.ts | 4 +- src/util.ts | 4 +- 7 files changed, 110 insertions(+), 94 deletions(-) diff --git a/src/api.ts b/src/api.ts index 9578aa8d..e136753b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,17 +1,27 @@ import { aws_apigateway } from "aws-cdk-lib"; -import { isPropAccessExpr } from "."; import { FunctionDecl, isFunctionDecl } from "./declaration"; import { isErr } from "./error"; -import { isIdentifier, PropAccessExpr } from "./expression"; +import { + isArrayLiteralExpr, + isBooleanLiteralExpr, + isIdentifier, + isNumberLiteralExpr, + isObjectLiteralExpr, + isPropAccessExpr, + isPropAssignExpr, + isStringLiteralExpr, + PropAccessExpr, +} from "./expression"; import { Function } from "./function"; import { FunctionlessNode } from "./node"; +import { isReturnStmt } from "./statement"; import { ExpressStepFunction } from "./step-function"; /** * HTTP Methods that API Gateway supports. */ export type HttpMethod = - | "HEAD" + | "ANY" | "GET" | "POST" | "PUT" @@ -26,11 +36,11 @@ type ParameterMap = Record; * the path, query string or headers, and the body is a JSON object. * None of these are required. */ -export interface ApiRequestProps< - PathParams extends ParameterMap | undefined, - Body extends object, - QueryParams extends ParameterMap | undefined, - HeaderParams extends ParameterMap | undefined +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. @@ -51,7 +61,7 @@ export interface ApiRequestProps< } type RequestTransformerFunction< - Request extends ApiRequestProps, + Request extends ApiRequest, IntegrationRequest > = (req: Request) => IntegrationRequest; @@ -85,7 +95,7 @@ export class ApiIntegrations { * Create a {@link MockApiIntegration}. */ public static mock< - Request extends ApiRequestProps, + Request extends ApiRequest, StatusCode extends number, MethodResponses extends { [C in StatusCode]: any } >( @@ -98,7 +108,7 @@ export class ApiIntegrations { * Create a {@link AwsApiIntegration}. */ public static aws< - Request extends ApiRequestProps, + Request extends ApiRequest, IntegrationRequest, IntegrationResponse, MethodResponse @@ -115,7 +125,7 @@ export class ApiIntegrations { } export interface MockApiIntegrationProps< - Request extends ApiRequestProps, + Request extends ApiRequest, StatusCode extends number, MethodResponses extends { [C in StatusCode]: any } > { @@ -168,7 +178,10 @@ export class MockApiIntegration< ) as { [K in keyof Props["responses"]]: FunctionDecl }; } - addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void { + public addMethod( + httpMethod: HttpMethod, + resource: aws_apigateway.Resource + ): void { const requestTemplate = toVTL(this.request, "request"); const responseEntries: [string, FunctionDecl][] = Object.entries( @@ -202,7 +215,7 @@ export class MockApiIntegration< } export interface AwsApiIntegrationProps< - Request extends ApiRequestProps, + Request extends ApiRequest, IntegrationRequest, IntegrationResponse, MethodResponse @@ -255,7 +268,10 @@ export class AwsApiIntegration< this.integration = props.integration; } - addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void { + public addMethod( + httpMethod: HttpMethod, + resource: aws_apigateway.Resource + ): void { const requestTemplate = toVTL(this.request, "request"); const responseTemplate = toVTL(this.response, "response"); @@ -300,65 +316,60 @@ function toVTL(node: FunctionDecl, template: "request" | "response") { } function inner(node: FunctionlessNode): string { - switch (node.kind) { - case "ArrayLiteralExpr": - return `[${node.children.map(inner).join(",")}]`; - - case "BooleanLiteralExpr": - return node.value.toString(); - - case "NumberLiteralExpr": - return node.value.toString(); - - case "ObjectLiteralExpr": - return `{${node.properties.map(inner).join(",")}}`; - - case "PropAccessExpr": - if (descendedFromFunctionParameter(node)) { - let param; - if (template === "request") { - switch (node.expr.name) { - case "body": - param = `$inputRoot.${node.name}`; - break; - case "pathParameters": - param = `$input.params().path.${node.name}`; - break; - case "queryStringParameters": - param = `$input.params().querystring.${node.name}`; - break; - case "headers": - param = `$input.params().header.${node.name}`; - break; - default: - throw new Error("Unknown parameter type."); - } - if (node.type === "string") { - param = `"${param}"`; - } - } else { - param = `$inputRoot.${node.name}`; - if (node.type === "string") { - return `"${param}"`; - } + if (isBooleanLiteralExpr(node) || isNumberLiteralExpr(node)) { + return node.value.toString(); + } else if (isStringLiteralExpr(node)) { + return wrapStr(node.value); + } else if (isArrayLiteralExpr(node)) { + return `[${node.children.map(inner).join(",")}]`; + } else if (isObjectLiteralExpr(node)) { + return `{${node.properties.map(inner).join(",")}}`; + } else if (isPropAccessExpr(node)) { + if (descendedFromFunctionParameter(node)) { + let param; + if (template === "request") { + switch (node.expr.name) { + case "body": + param = `$inputRoot.${node.name}`; + break; + case "pathParameters": + param = `$input.params().path.${node.name}`; + break; + case "queryStringParameters": + param = `$input.params().querystring.${node.name}`; + break; + case "headers": + param = `$input.params().header.${node.name}`; + break; + default: + throw new Error("Unknown parameter type."); + } + if (node.type === "string") { + return wrapStr(param); + } + } else { + param = `$inputRoot.${node.name}`; + if (node.type === "string") { + return wrapStr(param); } - return param; } - return `${inner(node.expr)}.${node.name};`; - - case "PropAssignExpr": - return `${inner(node.name)}: ${inner(node.expr)}`; - - case "ReturnStmt": - return inner(node.expr); - - case "StringLiteralExpr": - return `"${node.value}"`; + return param; + } + return `${inner(node.expr)}.${node.name};`; + } else if (isPropAssignExpr(node)) { + return `${inner(node.name)}: ${inner(node.expr)}`; + } else if (isReturnStmt(node)) { + return inner(node.expr); } + throw new Error(`Unsupported node type: ${node.kind}`); } } +function wrapStr(str: string): string { + return `"${str}"`; +} + function validateFunctionDecl(a: any): FunctionDecl { if (isFunctionDecl(a)) { return a; diff --git a/src/compile.ts b/src/compile.ts index a74341ca..58e1f75a 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -536,7 +536,10 @@ export function compile( const updatedProps = ts.factory.updateObjectLiteralExpression( props, props.properties.map((prop) => { - if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + if ( + ts.isPropertyAssignment(prop) && + (ts.isStringLiteral(prop.name) || ts.isIdentifier(prop.name)) + ) { if (prop.name.text === "responses") { return visitApiIntegrationResponsesProp(prop); } else if ( @@ -566,7 +569,9 @@ export function compile( !ts.isFunctionExpression(initializer) && !ts.isArrowFunction(initializer) ) { - return prop; + throw new Error( + `Expected mapping property of an ApiIntegration to be a function. Found ${initializer.getText()}.` + ); } return ts.factory.updatePropertyAssignment( 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/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/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) ) { From d00d31b411e8d5b602ebbb6e5271150eb090eb17 Mon Sep 17 00:00:00 2001 From: Brendan McKee Date: Tue, 31 May 2022 11:18:27 -0700 Subject: [PATCH 03/12] feat(api-gateway): support instantiating ApiIntegrations directly --- src/api.ts | 31 +++++++++++++++---------------- src/checker.ts | 16 ++++++++++++++++ src/compile.ts | 34 ++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/api.ts b/src/api.ts index e136753b..e4b621f0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -74,7 +74,13 @@ type IntegrationTarget = | Function | ExpressStepFunction; -interface BaseApiIntegration { +export abstract class BaseApiIntegration { + /** + * Identify subclasses as API integrations to the Functionless plugin + */ + public static readonly FunctionlessType = "ApiIntegration"; + protected readonly functionlessKind = BaseApiIntegration.FunctionlessType; + /** * Add this integration as a Method to an API Gateway resource. * @@ -82,7 +88,10 @@ interface BaseApiIntegration { * is on the chopping block: https://github.com/functionless/functionless/issues/137 * The 2 classes are conceptually similar so we should keep the DX in sync. */ - addMethod(httpMethod: HttpMethod, resource: aws_apigateway.Resource): void; + public abstract addMethod( + httpMethod: HttpMethod, + resource: aws_apigateway.Resource + ): void; } /** @@ -158,17 +167,12 @@ export interface MockApiIntegrationProps< */ export class MockApiIntegration< Props extends MockApiIntegrationProps -> implements BaseApiIntegration -{ - /** - * This static property identifies this class as a MockApiIntegration to the Functionless plugin. - */ - public static readonly FunctionlessType = "MockApiIntegration"; - +> extends BaseApiIntegration { private readonly request: FunctionDecl; private readonly responses: { [K in keyof Props["responses"]]: FunctionDecl }; public constructor(props: Props) { + super(); this.request = validateFunctionDecl(props.request); this.responses = Object.fromEntries( Object.entries(props.responses).map(([k, v]) => [ @@ -251,18 +255,13 @@ export interface AwsApiIntegrationProps< */ export class AwsApiIntegration< Props extends AwsApiIntegrationProps -> implements BaseApiIntegration -{ - /** - * This static property identifies this class as an AwsApiIntegration to the Functionless plugin. - */ - public static readonly FunctionlessType = "AwsApiIntegration"; - +> extends BaseApiIntegration { private readonly request: FunctionDecl; private readonly response: FunctionDecl; private readonly integration: Props["integration"]; constructor(props: Props) { + super(); this.request = validateFunctionDecl(props.request); this.response = validateFunctionDecl(props.response); this.integration = props.integration; diff --git a/src/checker.ts b/src/checker.ts index 6db55dd6..7ebeb4d7 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, EventBusRule } from "./event-bridge"; import { EventBusTransform } from "./event-bridge/transform"; @@ -59,6 +60,10 @@ export type ApiIntegrationsStaticMethodInterface = ts.CallExpression & { arguments: [ts.ObjectLiteralExpression]; }; +export type ApiIntegrationInterface = ts.NewExpression & { + arguments: [ts.ObjectLiteralExpression]; +}; + export type FunctionlessChecker = ReturnType; export function makeFunctionlessChecker( @@ -77,6 +82,7 @@ export function makeFunctionlessChecker( isStepFunction, isNewFunctionlessFunction, isApiIntegrationsStaticMethod, + isApiIntegration, isCDKConstruct, getFunctionlessTypeKind, }; @@ -231,6 +237,16 @@ export function makeFunctionlessChecker( return x; } + 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 58e1f75a..6e5b5269 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, ApiIntegrationsStaticMethodInterface, EventBusMapInterface, EventBusRuleInterface, @@ -117,6 +118,8 @@ export function compile( return visitFunction(node, ctx); } else if (checker.isApiIntegrationsStaticMethod(node)) { return visitApiIntegrationsStaticMethod(node); + } else if (checker.isApiIntegration(node)) { + return visitApiIntegration(node); } return node; }; @@ -533,7 +536,29 @@ export function compile( ): ts.CallExpression { const [props] = node.arguments; - const updatedProps = ts.factory.updateObjectLiteralExpression( + return ts.factory.updateCallExpression( + node, + node.expression, + node.typeArguments, + [visitApiIntegrationProps(props)] + ); + } + + function visitApiIntegration(node: ApiIntegrationInterface): ts.Node { + const [props] = node.arguments; + + return ts.factory.updateNewExpression( + node, + node.expression, + node.typeArguments, + [visitApiIntegrationProps(props)] + ); + } + + function visitApiIntegrationProps( + props: ts.ObjectLiteralExpression + ): ts.ObjectLiteralExpression { + return ts.factory.updateObjectLiteralExpression( props, props.properties.map((prop) => { if ( @@ -552,13 +577,6 @@ export function compile( return prop; }) ); - - return ts.factory.updateCallExpression( - node, - node.expression, - node.typeArguments, - [updatedProps] - ); } function visitApiIntegrationMapperProp( From ce74a9462792b6ae97dc04d672e63db2840d2867 Mon Sep 17 00:00:00 2001 From: Brendan McKee Date: Thu, 2 Jun 2022 00:55:18 -0700 Subject: [PATCH 04/12] feat: refactor to a simpler AwsApiIntegration interface using integrations as functions --- src/api.ts | 352 ++++++++++++++++++++++++++++----------- src/compile.ts | 5 +- src/function.ts | 13 +- src/step-function.ts | 28 ++-- src/table.ts | 37 +++- test-app/src/api-test.ts | 152 ++++++++++++----- 6 files changed, 418 insertions(+), 169 deletions(-) diff --git a/src/api.ts b/src/api.ts index e4b621f0..91844af3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,21 +1,26 @@ import { aws_apigateway } from "aws-cdk-lib"; +import { Construct } from "constructs"; import { FunctionDecl, isFunctionDecl } from "./declaration"; import { isErr } from "./error"; import { + Identifier, + isArgument, isArrayLiteralExpr, isBooleanLiteralExpr, + isCallExpr, isIdentifier, + isNullLiteralExpr, isNumberLiteralExpr, isObjectLiteralExpr, isPropAccessExpr, - isPropAssignExpr, isStringLiteralExpr, + isTemplateExpr, + ObjectLiteralExpr, PropAccessExpr, } from "./expression"; -import { Function } from "./function"; +import { findIntegration, IntegrationImpl } from "./integration"; import { FunctionlessNode } from "./node"; import { isReturnStmt } from "./statement"; -import { ExpressStepFunction } from "./step-function"; /** * HTTP Methods that API Gateway supports. @@ -69,11 +74,6 @@ type ResponseTransformerFunction = ( resp: IntegrationResponse ) => MethodResponse; -// TODO: support other types -type IntegrationTarget = - | Function - | ExpressStepFunction; - export abstract class BaseApiIntegration { /** * Identify subclasses as API integrations to the Functionless plugin @@ -118,17 +118,11 @@ export class ApiIntegrations { */ public static aws< Request extends ApiRequest, - IntegrationRequest, IntegrationResponse, MethodResponse >( - props: AwsApiIntegrationProps< - Request, - IntegrationRequest, - IntegrationResponse, - MethodResponse - > - ): AwsApiIntegration { + props: AwsApiIntegrationProps + ) { return new AwsApiIntegration(props); } } @@ -186,19 +180,19 @@ export class MockApiIntegration< httpMethod: HttpMethod, resource: aws_apigateway.Resource ): void { - const requestTemplate = toVTL(this.request, "request"); + const [requestTemplate] = toVTL(this.request, "request"); - const responseEntries: [string, FunctionDecl][] = Object.entries( - this.responses - ); const integrationResponses: aws_apigateway.IntegrationResponse[] = - responseEntries.map(([statusCode, fn]) => ({ - statusCode, - responseTemplates: { - "application/json": toVTL(fn, "response"), - }, - selectionPattern: `^${statusCode}$`, - })); + Object.entries(this.responses).map(([statusCode, fn]) => { + const [template] = toVTL(fn, "response"); + return { + statusCode, + responseTemplates: { + "application/json": template, + }, + selectionPattern: `^${statusCode}$`, + }; + }); const integration = new aws_apigateway.MockIntegration({ requestTemplates: { @@ -220,23 +214,38 @@ export class MockApiIntegration< export interface AwsApiIntegrationProps< Request extends ApiRequest, - IntegrationRequest, IntegrationResponse, MethodResponse > { /** - * Map API request to an integration request. + * 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: RequestTransformerFunction; + request: RequestTransformerFunction; /** - * Integration target backing this API. The result of `request` will be sent. + * 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. */ - integration: IntegrationTarget; + response: ResponseTransformerFunction; /** - * Map integration response to a method response. - * TODO: we need to handle multiple responses + * 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. */ - response: ResponseTransformerFunction; + errors: { [statusCode: number]: () => any }; } /** @@ -245,8 +254,6 @@ export interface AwsApiIntegrationProps< * service via API call, and the response is transformed via VTL and returned in * the response. * - * TODO: we need to support multiple responses - * * Only `application/json` is supported. * * TODO: provide example usage after api is stabilized @@ -254,38 +261,69 @@ export interface AwsApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class AwsApiIntegration< - Props extends AwsApiIntegrationProps + Props extends AwsApiIntegrationProps > extends BaseApiIntegration { private readonly request: FunctionDecl; private readonly response: FunctionDecl; - private readonly integration: Props["integration"]; + private readonly errors: { [statusCode: number]: FunctionDecl }; - constructor(props: Props) { + public constructor(props: Props) { super(); this.request = validateFunctionDecl(props.request); this.response = validateFunctionDecl(props.response); - this.integration = props.integration; + this.errors = Object.fromEntries( + Object.entries(props.errors).map(([k, v]) => [k, validateFunctionDecl(v)]) + ); } public addMethod( httpMethod: HttpMethod, resource: aws_apigateway.Resource ): void { - const requestTemplate = toVTL(this.request, "request"); - const responseTemplate = toVTL(this.response, "response"); + const [requestTemplate, integration] = toVTL(this.request, "request"); + const [responseTemplate] = toVTL(this.response, "response"); + + const errorResponses: aws_apigateway.IntegrationResponse[] = Object.entries( + this.errors + ).map(([statusCode, fn]) => { + const [template] = toVTL(fn, "response"); + return { + statusCode: statusCode, + selectionPattern: `^${statusCode}$`, + responseTemplates: { + "application/json": template, + }, + }; + }); - const apiGWIntegration = this.integration.apiGWVtl.integration( + const integrationResponses: aws_apigateway.IntegrationResponse[] = [ + { + statusCode: "200", + responseTemplates: { + "application/json": responseTemplate, + }, + }, + ...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.makeIntegration( + resource, requestTemplate, - responseTemplate + integrationResponses ); - // TODO: support requestParameters, authorizers, models and validators - resource.addMethod(httpMethod, apiGWIntegration, { - methodResponses: [ - { - statusCode: "200", - }, - ], + const methodResponses = [ + { statusCode: "200" }, + ...Object.keys(this.errors).map((statusCode) => ({ + statusCode, + })), + ]; + + resource.addMethod(httpMethod, apiGwIntegration, { + methodResponses, }); } } @@ -305,68 +343,164 @@ export class AwsApiIntegration< * @param node Function to interpret. * @param template Whether we are creating a request or response mapping template. */ -function toVTL(node: FunctionDecl, template: "request" | "response") { - const statements = node.body.statements.map((stmt) => inner(stmt)).join("\n"); +export function toVTL( + node: FunctionDecl, + location: "request" | "response" +): [string, IntegrationImpl | undefined] { + // TODO: polish these error messages and put them into error-codes.ts + if (node.body.statements.length !== 1) { + throw new Error("Expected function body to be a single return statement"); + } + + const stmt = node.body.statements[0]; + + if (!isReturnStmt(stmt)) { + throw new Error("Expected function body to be a single return statement"); + } - if (template === "request") { - return `#set($inputRoot = $input.path('$'))${statements}`; + if (location === "request") { + const call = stmt.expr; + if (!isCallExpr(call)) { + throw new Error( + "Expected request function body to return an integration call" + ); + } + + // TODO: validate args. also should it always be an object? + const argObj = inner(call.args[0].expr! as ObjectLiteralExpr); + const serviceCall = findIntegration(call); + + if (!serviceCall) { + throw new Error( + "Expected request function body to return an integration call" + ); + } + + const prepared = serviceCall.apiGWVtl.prepareRequest(argObj); + const template = `#set($inputRoot = $input.path('$'))\n${stringify( + prepared + )}`; + return [template, serviceCall]; } else { - return statements; + const obj = stmt.expr; + if (!isObjectLiteralExpr(obj)) { + throw new Error( + "Expected response function body to return an object literal" + ); + } + const template = `#set($inputRoot = $input.path('$'))\n${stringify( + inner(obj) + )}`; + return [template, undefined]; } - function inner(node: FunctionlessNode): string { - if (isBooleanLiteralExpr(node) || isNumberLiteralExpr(node)) { - return node.value.toString(); - } else if (isStringLiteralExpr(node)) { - return wrapStr(node.value); + function inner(node: FunctionlessNode): any { + if ( + isBooleanLiteralExpr(node) || + isNumberLiteralExpr(node) || + isStringLiteralExpr(node) || + isNullLiteralExpr(node) + ) { + return node.value; } else if (isArrayLiteralExpr(node)) { - return `[${node.children.map(inner).join(",")}]`; + return node.children.map(inner); } else if (isObjectLiteralExpr(node)) { - return `{${node.properties.map(inner).join(",")}}`; + return Object.fromEntries( + node.properties.map((prop) => { + switch (prop.kind) { + case "PropAssignExpr": + return [inner(prop.name), inner(prop.expr)]; + case "SpreadAssignExpr": + throw new Error("TODO: support SpreadAssignExpr"); + } + }) + ); + } else if (isArgument(node)) { + return inner(node); } else if (isPropAccessExpr(node)) { - if (descendedFromFunctionParameter(node)) { - let param; - if (template === "request") { - switch (node.expr.name) { + // ignore the function param name, we'll replace it with the VTL + // mapping template inputs + const path = pathFromFunctionParameter(node)?.slice(1); + + if (path) { + if (location === "response") { + const ref: Ref = { + __refType: node.type! as any, + value: `$inputRoot.${path.join(".")}`, + }; + return ref; + } else { + const [paramLocation, ...rest] = path; + + let prefix; + switch (paramLocation) { case "body": - param = `$inputRoot.${node.name}`; + prefix = "$inputRoot"; break; case "pathParameters": - param = `$input.params().path.${node.name}`; + prefix = "$input.params().path"; break; case "queryStringParameters": - param = `$input.params().querystring.${node.name}`; + prefix = "$input.params().querystring"; break; case "headers": - param = `$input.params().header.${node.name}`; + prefix = "$input.params().header"; break; default: throw new Error("Unknown parameter type."); } - if (node.type === "string") { - return wrapStr(param); - } - } else { - param = `$inputRoot.${node.name}`; - if (node.type === "string") { - return wrapStr(param); - } + + const param = `${prefix}.${rest.join(".")}`; + + const ref: Ref = { __refType: node.type! as any, value: param }; + return ref; } - return param; } return `${inner(node.expr)}.${node.name};`; - } else if (isPropAssignExpr(node)) { - return `${inner(node.name)}: ${inner(node.expr)}`; - } else if (isReturnStmt(node)) { - return inner(node.expr); + } else if (isTemplateExpr(node)) { + // TODO: not right, compare to vtl.ts + return inner(node.exprs[0]); } throw new Error(`Unsupported node type: ${node.kind}`); } } -function wrapStr(str: string): string { - return `"${str}"`; +// These represent variable references and carry the type information. +// stringify will serialize them to the appropriate VTL +// e.g. if `request.pathParameters.id` is a number, we want to serialize +// it as `$input.params().path.id`, not `"$input.params().path.id"` which +// is what JSON.stringify would do +type Ref = + | { __refType: "string"; value: string } + | { __refType: "number"; value: string } + | { __refType: "boolean"; value: string }; + +const isRef = (x: any): x is Ref => x.__refType !== undefined; + +function stringify(obj: any): string { + if (isRef(obj)) { + switch (obj.__refType) { + case "string": + return `"${obj.value}"`; + case "number": + return obj.value; + case "boolean": + return obj.value; + } + } + if (typeof obj === "string") { + return `"${obj}"`; + } else if (typeof obj === "number" || typeof obj === "boolean") { + return obj.toString(); + } else if (Array.isArray(obj)) { + return `[${obj.map(stringify).join(",")}]`; + } else if (typeof obj === "object") { + const props = Object.entries(obj).map(([k, v]) => `"${k}":${stringify(v)}`); + return `{${props.join(",")}}`; + } + + throw new Error(`Unsupported type: ${typeof obj}`); } function validateFunctionDecl(a: any): FunctionDecl { @@ -379,27 +513,47 @@ function validateFunctionDecl(a: any): FunctionDecl { } } -const isFunctionParameter = (node: FunctionlessNode) => { +function isFunctionParameter(node: FunctionlessNode): node is Identifier { if (!isIdentifier(node)) return false; const ref = node.lookup(); return ref?.kind === "ParameterDecl" && ref.parent?.kind === "FunctionDecl"; -}; +} -const descendedFromFunctionParameter = ( - node: PropAccessExpr -): node is PropAccessExpr & { expr: PropAccessExpr } => { - if (isFunctionParameter(node.expr)) return true; - return ( - isPropAccessExpr(node.expr) && descendedFromFunctionParameter(node.expr) - ); -}; +/** + * path from a function parameter to this node, if one exists. + * e.g. `request.pathParameters.id` => ["request", "pathParameters", "id"] + */ +function pathFromFunctionParameter(node: PropAccessExpr): string[] | undefined { + if (isFunctionParameter(node.expr)) { + return [node.expr.name, node.name]; + } else if (isPropAccessExpr(node.expr)) { + const path = pathFromFunctionParameter(node.expr); + if (path) { + return [...path, node.name]; + } else { + return undefined; + } + } else { + return undefined; + } +} /** * Hooks used to create API Gateway integrations. */ export interface ApiGatewayVtlIntegration { - integration: ( + /** + * Prepare the request object for the integration. This can be used to inject + * properties into the object before serializing to VTL. + */ + prepareRequest: (obj: object) => object; + + /** + * Construct an API GW integration. + */ + makeIntegration: ( + scope: Construct, requestTemplate: string, - responseTemplate: string + responses: aws_apigateway.IntegrationResponse[] ) => aws_apigateway.Integration; } diff --git a/src/compile.ts b/src/compile.ts index 6e5b5269..5e93e9de 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -565,7 +565,10 @@ export function compile( ts.isPropertyAssignment(prop) && (ts.isStringLiteral(prop.name) || ts.isIdentifier(prop.name)) ) { - if (prop.name.text === "responses") { + if ( + prop.name.text === "responses" || + prop.name.text === "errors" + ) { return visitApiIntegrationResponsesProp(prop); } else if ( prop.name.text === "request" || diff --git a/src/function.ts b/src/function.ts index 5fd16868..d204ff20 100644 --- a/src/function.ts +++ b/src/function.ts @@ -129,21 +129,16 @@ abstract class FunctionBase }; this.apiGWVtl = { - integration: (requestTemplate, responseTemplate) => { + prepareRequest: (obj) => obj, + + makeIntegration: (_scope, requestTemplate, integrationResponses) => { return new aws_apigateway.LambdaIntegration(this.resource, { proxy: false, passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, requestTemplates: { "application/json": requestTemplate, }, - integrationResponses: [ - { - statusCode: "200", - responseTemplates: { - "application/json": `#set($inputRoot = $input.path('$'))\n${responseTemplate}`, - }, - }, - ], + integrationResponses, }); }, }; diff --git a/src/step-function.ts b/src/step-function.ts index d7bf8798..d6135752 100644 --- a/src/step-function.ts +++ b/src/step-function.ts @@ -510,9 +510,18 @@ abstract class BaseStepFunction< // Integration object for api gateway vtl this.apiGWVtl = { - integration: (requestTemplate, responseTemplate) => { + prepareRequest: (obj) => { + // TODO: this is currently broken. StepFunction interface requires a + // top level `input` key to be passed in but it shouldn't + return { + ...obj, + stateMachineArn: this.stateMachineArn, + }; + }, + + makeIntegration: (scope, requestTemplate, integrationResponses) => { const credentialsRole = new aws_iam.Role( - this, + scope, "ApiGatewayIntegrationRole", { assumedBy: new aws_iam.ServicePrincipal("apigateway.amazonaws.com"), @@ -529,7 +538,6 @@ abstract class BaseStepFunction< this.grantStartExecution(credentialsRole); } - const escapedInput = requestTemplate.replace(/\"/g, '\\"'); return new aws_apigateway.AwsIntegration({ service: "states", action: @@ -542,19 +550,9 @@ abstract class BaseStepFunction< credentialsRole, passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, requestTemplates: { - "application/json": `{ - "input": "${escapedInput}", - "stateMachineArn": "${this.stateMachineArn}" - }`, + "application/json": requestTemplate, }, - integrationResponses: [ - { - statusCode: "200", - responseTemplates: { - "application/json": `#set($inputRoot = $util.parseJson($input.path('$.output')))\n${responseTemplate}`, - }, - }, - ], + integrationResponses, }, }); }, diff --git a/src/table.ts b/src/table.ts index 8b98ce4e..bd945d0d 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"; @@ -309,6 +308,40 @@ export class Table< }, ...integration.appSyncVtl, }, + apiGWVtl: { + prepareRequest: (obj) => { + return { + ...obj, + tableName: this.resource.node.addr, + }; + }, + + makeIntegration: (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/test-app/src/api-test.ts b/test-app/src/api-test.ts index 7a7b3467..2e3b481d 100644 --- a/test-app/src/api-test.ts +++ b/test-app/src/api-test.ts @@ -1,5 +1,17 @@ -import { App, aws_apigateway, aws_logs, Stack } from "aws-cdk-lib"; -import { ApiIntegrations, ExpressStepFunction, Function } from "functionless"; +import { + App, + aws_apigateway, + aws_dynamodb, + aws_logs, + Stack, +} from "aws-cdk-lib"; +import { + ApiIntegrations, + ExpressStepFunction, + Function, + SyncExecutionSuccessResult, + Table, +} from "functionless"; export const app = new App(); @@ -9,15 +21,6 @@ const restApi = new aws_apigateway.RestApi(stack, "api", { restApiName: "api-test-app-api", }); -const fn = new Function< - { inNum: number; inStr: string; inBool: boolean }, - { fnNum: number; fnStr: string; fnBool: boolean } ->(stack, "fn", async (event) => ({ - fnNum: event.inNum, - fnStr: event.inStr, - fnBool: event.inBool, -})); - interface FnRequest { pathParameters: { num: number; @@ -30,19 +33,37 @@ interface FnRequest { }; } +const fn = new Function( + stack, + "fn", + async (event: { inNum: number; inStr: string; inBool: boolean }) => ({ + fnNum: event.inNum, + fnStr: event.inStr, + fnBool: event.inBool, + nested: { + again: { + num: 123, + }, + }, + }) +); + const fnResource = restApi.root.addResource("fn").addResource("{num}"); const fnIntegration = ApiIntegrations.aws({ - request: (req: FnRequest) => ({ - inNum: req.pathParameters.num, - inStr: req.queryStringParameters.str, - inBool: req.body.bool, - }), - integration: fn, + request: (req: FnRequest) => + fn({ + inNum: req.pathParameters.num, + inStr: req.queryStringParameters.str, + inBool: req.body.bool, + }), response: (resp) => ({ - outNum: resp.fnNum, - outStr: resp.fnStr, - outBool: resp.fnBool, + resultNum: resp.fnNum, + resultStr: resp.fnStr, + nested: resp.nested.again.num, }), + errors: { + 400: () => ({ msg: "400" }), + }, }); fnIntegration.addMethod("POST", fnResource); @@ -55,32 +76,12 @@ const sfn = new ExpressStepFunction( includeExecutionData: true, }, }, - (req: { num: number; str: string }) => ({ - sfnNum: req.num, - sfnStr: req.str, + (input: { num: number; str: string }) => ({ + sfnNum: input.num, + sfnStr: input.str, }) ); -interface SfnRequest { - pathParameters: { - num: number; - }; - queryStringParameters: { - str: string; - }; -} - -const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); -const sfnIntegration = ApiIntegrations.aws({ - request: (req: SfnRequest) => ({ - num: req.pathParameters.num, - str: req.queryStringParameters.str, - }), - integration: sfn, - response: (resp) => ({ resultNum: resp.sfnNum, resultStr: resp.sfnStr }), -}); -sfnIntegration.addMethod("GET", sfnResource); - interface MockRequest { pathParameters: { num: 200 | 500; @@ -104,3 +105,68 @@ const mock = ApiIntegrations.mock({ }, }); mock.addMethod("GET", mockResource); + +interface Item { + id: number; + name: string; +} +const table = new Table( + new aws_dynamodb.Table(stack, "table", { + partitionKey: { + name: "id", + type: aws_dynamodb.AttributeType.NUMBER, + }, + }) +); + +interface DynamoRequest { + pathParameters: { + id: number; + }; +} + +const dynamoResource = restApi.root.addResource("dynamo").addResource("{num}"); +const dynamoIntegration = ApiIntegrations.aws({ + request: (req: DynamoRequest) => + table.getItem({ + key: { + id: { + N: `${req.pathParameters.id}`, + }, + }, + }), + // @ts-ignore TODO: resp is never for some reason + response: (resp) => ({ foo: resp.item.foo }), + errors: { + 400: () => ({ msg: "400" }), + }, +}); +dynamoIntegration.addMethod("GET", dynamoResource); + +interface SfnRequest { + pathParameters: { + num: number; + }; + queryStringParameters: { + str: string; + }; +} + +const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); +const sfnIntegration = ApiIntegrations.aws({ + // @ts-ignore TODO: output is only on success, need to support if stmt + request: (req: SfnRequest) => + sfn({ + input: { + num: req.pathParameters.num, + str: req.queryStringParameters.str, + }, + }), + response: (resp: SyncExecutionSuccessResult) => ({ + resultNum: resp.output.sfnNum, + resultStr: resp.output.sfnStr, + }), + // TODO: make errors optional? + errors: {}, +}); +sfnIntegration.addMethod("GET", sfnResource); From 2b38e6f0d8414aaad0c22aeac52b75a52f069fe0 Mon Sep 17 00:00:00 2001 From: Brendan McKee Date: Thu, 2 Jun 2022 18:10:25 -0700 Subject: [PATCH 05/12] fix: address PR feedback --- src/api.ts | 18 ++++++++++++------ src/checker.ts | 13 +++++++------ src/compile.ts | 39 +++++++++++++++++++-------------------- src/function.ts | 2 +- src/step-function.ts | 2 +- src/table.ts | 2 +- 6 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/api.ts b/src/api.ts index 91844af3..8de622d1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,6 @@ import { aws_apigateway } from "aws-cdk-lib"; import { Construct } from "constructs"; +import { isParameterDecl } from "."; import { FunctionDecl, isFunctionDecl } from "./declaration"; import { isErr } from "./error"; import { @@ -100,6 +101,11 @@ export abstract class BaseApiIntegration { * inference. */ export class ApiIntegrations { + /** + * Identify subclasses as API integrations to the Functionless plugin + */ + public static readonly FunctionlessType = "ApiIntegrations"; + /** * Create a {@link MockApiIntegration}. */ @@ -309,7 +315,7 @@ export class AwsApiIntegration< // 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.makeIntegration( + const apiGwIntegration = integration!.apiGWVtl.createIntegration( resource, requestTemplate, integrationResponses @@ -415,12 +421,12 @@ export function toVTL( } }) ); - } else if (isArgument(node)) { - return inner(node); + } else if (isArgument(node) && node.expr) { + return inner(node.expr); } else if (isPropAccessExpr(node)) { // ignore the function param name, we'll replace it with the VTL // mapping template inputs - const path = pathFromFunctionParameter(node)?.slice(1); + const [_, ...path] = pathFromFunctionParameter(node) ?? []; if (path) { if (location === "response") { @@ -516,7 +522,7 @@ function validateFunctionDecl(a: any): FunctionDecl { function isFunctionParameter(node: FunctionlessNode): node is Identifier { if (!isIdentifier(node)) return false; const ref = node.lookup(); - return ref?.kind === "ParameterDecl" && ref.parent?.kind === "FunctionDecl"; + return isParameterDecl(ref) && isFunctionDecl(ref.parent); } /** @@ -551,7 +557,7 @@ export interface ApiGatewayVtlIntegration { /** * Construct an API GW integration. */ - makeIntegration: ( + createIntegration: ( scope: Construct, requestTemplate: string, responses: aws_apigateway.IntegrationResponse[] diff --git a/src/checker.ts b/src/checker.ts index 7ebeb4d7..fb290fa2 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -1,6 +1,6 @@ import * as ts from "typescript"; import * as tsserver from "typescript/lib/tsserverlibrary"; -import { BaseApiIntegration } from "./api"; +import { ApiIntegrations, BaseApiIntegration } from "./api"; import { AppsyncResolver } from "./appsync"; import { EventBus, EventBusRule } from "./event-bridge"; import { EventBusTransform } from "./event-bridge/transform"; @@ -225,16 +225,17 @@ export function makeFunctionlessChecker( function isApiIntegrationsStaticMethod( node: ts.Node ): node is ApiIntegrationsStaticMethodInterface { - const x = + return ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && (node.expression.name.text === "mock" || node.expression.name.text == "aws") && ts.isIdentifier(node.expression.expression) && - // TODO: is this enough? should we grab the type and make sure it - // has FunctionlessKind? - node.expression.expression.text === "ApiIntegrations"; - return x; + isFunctionlessClassOfKind( + node.expression.expression, + ApiIntegrations.FunctionlessType + ) + ); } function isApiIntegration(node: ts.Node): node is ApiIntegrationInterface { diff --git a/src/compile.ts b/src/compile.ts index 5e93e9de..64f7ddf2 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -582,26 +582,6 @@ export function compile( ); } - function visitApiIntegrationMapperProp( - prop: ts.PropertyAssignment - ): ts.PropertyAssignment { - const { initializer } = prop; - if ( - !ts.isFunctionExpression(initializer) && - !ts.isArrowFunction(initializer) - ) { - throw new Error( - `Expected mapping property of an ApiIntegration to be a function. Found ${initializer.getText()}.` - ); - } - - return ts.factory.updatePropertyAssignment( - prop, - prop.name, - errorBoundary(() => toFunction("FunctionDecl", initializer)) - ); - } - function visitApiIntegrationResponsesProp( prop: ts.PropertyAssignment ): ts.PropertyAssignment { @@ -622,6 +602,25 @@ export function compile( ); } + function visitApiIntegrationMapperProp( + prop: ts.PropertyAssignment + ): ts.PropertyAssignment { + const { initializer } = prop; + const toFunc = errorBoundary(() => { + if ( + !ts.isFunctionExpression(initializer) && + !ts.isArrowFunction(initializer) + ) { + throw new Error( + `Expected mapping property of an ApiIntegration to be a function. Found ${initializer.getText()}.` + ); + } + return toFunction("FunctionDecl", initializer); + }); + + return ts.factory.updatePropertyAssignment(prop, prop.name, toFunc); + } + function toExpr( node: ts.Node | undefined, scope: ts.Node diff --git a/src/function.ts b/src/function.ts index d204ff20..ca49ebe7 100644 --- a/src/function.ts +++ b/src/function.ts @@ -131,7 +131,7 @@ abstract class FunctionBase this.apiGWVtl = { prepareRequest: (obj) => obj, - makeIntegration: (_scope, requestTemplate, integrationResponses) => { + createIntegration: (_scope, requestTemplate, integrationResponses) => { return new aws_apigateway.LambdaIntegration(this.resource, { proxy: false, passthroughBehavior: aws_apigateway.PassthroughBehavior.NEVER, diff --git a/src/step-function.ts b/src/step-function.ts index d6135752..af7cecec 100644 --- a/src/step-function.ts +++ b/src/step-function.ts @@ -519,7 +519,7 @@ abstract class BaseStepFunction< }; }, - makeIntegration: (scope, requestTemplate, integrationResponses) => { + createIntegration: (scope, requestTemplate, integrationResponses) => { const credentialsRole = new aws_iam.Role( scope, "ApiGatewayIntegrationRole", diff --git a/src/table.ts b/src/table.ts index bd945d0d..3270eb7f 100644 --- a/src/table.ts +++ b/src/table.ts @@ -316,7 +316,7 @@ export class Table< }; }, - makeIntegration: (api, template, integrationResponses) => { + createIntegration: (api, template, integrationResponses) => { const credentialsRole = new aws_iam.Role( api, "ApiGatewayIntegrationRole", From 8448a4167b8374058672d1e08d327cb649c238c2 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 5 Jun 2022 14:21:12 -0700 Subject: [PATCH 06/12] fix: fix type inference for API GW integrations --- src/api.ts | 79 ++++++++++------------------------------ src/checker.ts | 4 +- test-app/src/api-test.ts | 17 +++++---- 3 files changed, 32 insertions(+), 68 deletions(-) diff --git a/src/api.ts b/src/api.ts index 8de622d1..729ea7c3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -66,15 +66,6 @@ export interface ApiRequest< headers?: HeaderParams; } -type RequestTransformerFunction< - Request extends ApiRequest, - IntegrationRequest -> = (req: Request) => IntegrationRequest; - -type ResponseTransformerFunction = ( - resp: IntegrationResponse -) => MethodResponse; - export abstract class BaseApiIntegration { /** * Identify subclasses as API integrations to the Functionless plugin @@ -95,44 +86,6 @@ export abstract class BaseApiIntegration { ): void; } -/** - * Static constructors for the supported API Gateway integrations. - * These are the preferred entrypoints as they offer superior type - * inference. - */ -export class ApiIntegrations { - /** - * Identify subclasses as API integrations to the Functionless plugin - */ - public static readonly FunctionlessType = "ApiIntegrations"; - - /** - * Create a {@link MockApiIntegration}. - */ - public static mock< - Request extends ApiRequest, - StatusCode extends number, - MethodResponses extends { [C in StatusCode]: any } - >( - props: MockApiIntegrationProps - ): MockApiIntegration { - return new MockApiIntegration(props); - } - - /** - * Create a {@link AwsApiIntegration}. - */ - public static aws< - Request extends ApiRequest, - IntegrationResponse, - MethodResponse - >( - props: AwsApiIntegrationProps - ) { - return new AwsApiIntegration(props); - } -} - export interface MockApiIntegrationProps< Request extends ApiRequest, StatusCode extends number, @@ -142,7 +95,7 @@ export interface MockApiIntegrationProps< * Map API request to a status code. This code will be used by API Gateway * to select the response to return. */ - request: RequestTransformerFunction; + request: (request: Request) => { statusCode: StatusCode }; /** * Map of status codes to response to return. */ @@ -150,12 +103,12 @@ export interface MockApiIntegrationProps< } /** - * A Mock integration lets you return preconfigured responses by status code. + * 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 preconfigured response for that status code. Functionless will + * returning the pre-configured response for that status code. Functionless will * convert these functions to VTL mapping templates and configure the necessary * method responses. * @@ -166,12 +119,16 @@ export interface MockApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class MockApiIntegration< - Props extends MockApiIntegrationProps + Request extends ApiRequest, + StatusCode extends number, + MethodResponses extends { [C in StatusCode]: any } > extends BaseApiIntegration { private readonly request: FunctionDecl; - private readonly responses: { [K in keyof Props["responses"]]: FunctionDecl }; + private readonly responses: { [K in keyof MethodResponses]: FunctionDecl }; - public constructor(props: Props) { + public constructor( + props: MockApiIntegrationProps + ) { super(); this.request = validateFunctionDecl(props.request); this.responses = Object.fromEntries( @@ -179,7 +136,7 @@ export class MockApiIntegration< k, validateFunctionDecl(v), ]) - ) as { [K in keyof Props["responses"]]: FunctionDecl }; + ) as { [K in keyof MethodResponses]: FunctionDecl }; } public addMethod( @@ -190,7 +147,7 @@ export class MockApiIntegration< const integrationResponses: aws_apigateway.IntegrationResponse[] = Object.entries(this.responses).map(([statusCode, fn]) => { - const [template] = toVTL(fn, "response"); + const [template] = toVTL(fn as FunctionDecl, "response"); return { statusCode, responseTemplates: { @@ -237,7 +194,7 @@ export interface AwsApiIntegrationProps< * * The supported syntax will be expanded in the future. */ - request: RequestTransformerFunction; + request: (req: Request) => 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 @@ -246,7 +203,7 @@ export interface AwsApiIntegrationProps< * At present the function body must be a single statement returning an object * literal. The supported syntax will be expanded in the future. */ - response: ResponseTransformerFunction; + response: (response: IntegrationResponse) => 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. @@ -267,13 +224,17 @@ export interface AwsApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class AwsApiIntegration< - Props extends AwsApiIntegrationProps + Request, + IntegrationResponse, + MethodResponse > extends BaseApiIntegration { private readonly request: FunctionDecl; private readonly response: FunctionDecl; private readonly errors: { [statusCode: number]: FunctionDecl }; - public constructor(props: Props) { + public constructor( + props: AwsApiIntegrationProps + ) { super(); this.request = validateFunctionDecl(props.request); this.response = validateFunctionDecl(props.response); diff --git a/src/checker.ts b/src/checker.ts index 7c7a7187..b4463e3a 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -1,6 +1,6 @@ import * as ts from "typescript"; import * as tsserver from "typescript/lib/tsserverlibrary"; -import { ApiIntegrations, BaseApiIntegration } from "./api"; +import { BaseApiIntegration } from "./api"; import { AppsyncResolver } from "./appsync"; import { EventBus, Rule } from "./event-bridge"; import { EventTransform } from "./event-bridge/transform"; @@ -229,7 +229,7 @@ export function makeFunctionlessChecker( ts.isIdentifier(node.expression.expression) && isFunctionlessClassOfKind( node.expression.expression, - ApiIntegrations.FunctionlessType + BaseApiIntegration.FunctionlessType ) ); } diff --git a/test-app/src/api-test.ts b/test-app/src/api-test.ts index 2e3b481d..1fae6be3 100644 --- a/test-app/src/api-test.ts +++ b/test-app/src/api-test.ts @@ -6,7 +6,8 @@ import { Stack, } from "aws-cdk-lib"; import { - ApiIntegrations, + AwsApiIntegration, + MockApiIntegration, ExpressStepFunction, Function, SyncExecutionSuccessResult, @@ -49,7 +50,8 @@ const fn = new Function( ); const fnResource = restApi.root.addResource("fn").addResource("{num}"); -const fnIntegration = ApiIntegrations.aws({ + +const fnIntegration = new AwsApiIntegration({ request: (req: FnRequest) => fn({ inNum: req.pathParameters.num, @@ -89,7 +91,7 @@ interface MockRequest { } const mockResource = restApi.root.addResource("mock").addResource("{num}"); -const mock = ApiIntegrations.mock({ +const mock = new MockApiIntegration({ request: (req: MockRequest) => ({ statusCode: req.pathParameters.num, }), @@ -107,7 +109,7 @@ const mock = ApiIntegrations.mock({ mock.addMethod("GET", mockResource); interface Item { - id: number; + id: string; name: string; } const table = new Table( @@ -126,12 +128,12 @@ interface DynamoRequest { } const dynamoResource = restApi.root.addResource("dynamo").addResource("{num}"); -const dynamoIntegration = ApiIntegrations.aws({ +const dynamoIntegration = new AwsApiIntegration({ request: (req: DynamoRequest) => table.getItem({ key: { id: { - N: `${req.pathParameters.id}`, + S: `${req.pathParameters.id}`, }, }, }), @@ -153,7 +155,7 @@ interface SfnRequest { } const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); -const sfnIntegration = ApiIntegrations.aws({ +const sfnIntegration = new AwsApiIntegration({ // @ts-ignore TODO: output is only on success, need to support if stmt request: (req: SfnRequest) => sfn({ @@ -162,6 +164,7 @@ const sfnIntegration = ApiIntegrations.aws({ str: req.queryStringParameters.str, }, }), + // TODO: we should not need to narrow this explicitly response: (resp: SyncExecutionSuccessResult) => ({ resultNum: resp.output.sfnNum, resultStr: resp.output.sfnStr, From 7cf271b1d0fea04fc109a45737a2d3787e4fced8 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 5 Jun 2022 14:46:31 -0700 Subject: [PATCH 07/12] fix: make errors optional --- src/api.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 729ea7c3..5d4da2b4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -208,7 +208,7 @@ export interface AwsApiIntegrationProps< * 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]: () => any }; + errors?: { [statusCode: number]: () => any }; } /** @@ -239,7 +239,10 @@ export class AwsApiIntegration< this.request = validateFunctionDecl(props.request); this.response = validateFunctionDecl(props.response); this.errors = Object.fromEntries( - Object.entries(props.errors).map(([k, v]) => [k, validateFunctionDecl(v)]) + Object.entries(props.errors ?? {}).map(([k, v]) => [ + k, + validateFunctionDecl(v), + ]) ); } From 749c474fdf68c9c8b738cb2d640459c3f617d622 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 5 Jun 2022 15:49:59 -0700 Subject: [PATCH 08/12] chore: set up unit tests for AWS and Mock integrations --- src/api.ts | 49 +++++++++++++++----- test/api.test.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 test/api.test.ts diff --git a/src/api.ts b/src/api.ts index 5d4da2b4..359cf8fc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,6 +9,7 @@ import { isArrayLiteralExpr, isBooleanLiteralExpr, isCallExpr, + isComputedPropertyNameExpr, isIdentifier, isNullLiteralExpr, isNumberLiteralExpr, @@ -21,7 +22,7 @@ import { } from "./expression"; import { findIntegration, IntegrationImpl } from "./integration"; import { FunctionlessNode } from "./node"; -import { isReturnStmt } from "./statement"; +import { isReturnStmt, isVariableStmt, ReturnStmt } from "./statement"; /** * HTTP Methods that API Gateway supports. @@ -83,7 +84,7 @@ export abstract class BaseApiIntegration { public abstract addMethod( httpMethod: HttpMethod, resource: aws_apigateway.Resource - ): void; + ): aws_apigateway.Method; } export interface MockApiIntegrationProps< @@ -141,8 +142,8 @@ export class MockApiIntegration< public addMethod( httpMethod: HttpMethod, - resource: aws_apigateway.Resource - ): void { + resource: aws_apigateway.IResource + ): aws_apigateway.Method { const [requestTemplate] = toVTL(this.request, "request"); const integrationResponses: aws_apigateway.IntegrationResponse[] = @@ -169,7 +170,7 @@ export class MockApiIntegration< })); // TODO: support requestParameters, authorizers, models and validators - resource.addMethod(httpMethod, integration, { + return resource.addMethod(httpMethod, integration, { methodResponses, }); } @@ -246,10 +247,7 @@ export class AwsApiIntegration< ); } - public addMethod( - httpMethod: HttpMethod, - resource: aws_apigateway.Resource - ): void { + public addMethod(httpMethod: HttpMethod, resource: aws_apigateway.IResource) { const [requestTemplate, integration] = toVTL(this.request, "request"); const [responseTemplate] = toVTL(this.response, "response"); @@ -292,7 +290,7 @@ export class AwsApiIntegration< })), ]; - resource.addMethod(httpMethod, apiGwIntegration, { + return resource.addMethod(httpMethod, apiGwIntegration, { methodResponses, }); } @@ -330,6 +328,11 @@ export function toVTL( if (location === "request") { const call = stmt.expr; + + if (isObjectLiteralExpr(call)) { + return literal(stmt); + } + if (!isCallExpr(call)) { throw new Error( "Expected request function body to return an integration call" @@ -352,6 +355,12 @@ export function toVTL( )}`; return [template, serviceCall]; } else { + return literal(stmt); + } + + function literal( + stmt: ReturnStmt + ): [string, IntegrationImpl | undefined] { const obj = stmt.expr; if (!isObjectLiteralExpr(obj)) { throw new Error( @@ -365,7 +374,16 @@ export function toVTL( } function inner(node: FunctionlessNode): any { - if ( + if (isIdentifier(node)) { + const ref = node.lookup(); + if (isParameterDecl(ref)) { + return `$inputRoot`; + } else if (isVariableStmt(ref)) { + return `$${ref.name}`; + } else { + throw new Error(`Cannot find name: ${node.name}`); + } + } else if ( isBooleanLiteralExpr(node) || isNumberLiteralExpr(node) || isStringLiteralExpr(node) || @@ -379,7 +397,14 @@ export function toVTL( node.properties.map((prop) => { switch (prop.kind) { case "PropAssignExpr": - return [inner(prop.name), inner(prop.expr)]; + return [ + isComputedPropertyNameExpr(prop.name) + ? inner(prop.name) + : isStringLiteralExpr(prop.name) + ? prop.name.value + : prop.name.name, + inner(prop.expr), + ]; case "SpreadAssignExpr": throw new Error("TODO: support SpreadAssignExpr"); } diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 00000000..18230772 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,117 @@ +import "jest"; +import { aws_apigateway, IResolvable, Stack } from "aws-cdk-lib"; +import { AwsApiIntegration, MockApiIntegration, Function } from "../src"; + +let stack: Stack; +let func: Function; +beforeEach(() => { + stack = new Stack(); + func = new Function(stack, "F", (p) => p); +}); + +test("mock integration with object literal", () => { + const api = new aws_apigateway.RestApi(stack, "API"); + + const method = getMethodTemplates( + new MockApiIntegration({ + request: (req: { + pathParameters: { + code: number; + }; + }) => ({ + statusCode: req.pathParameters.code, + }), + responses: { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + }, + }).addMethod("GET", api.root) + ); + + 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 = getMethodTemplates( + new AwsApiIntegration({ + request: (req: { + pathParameters: { + code: number; + }; + }) => func(req), + response: (result) => ({ + result, + }), + }).addMethod("GET", api.root) + ); + + 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"}`, + }, + }, + ]); +}); + +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 getMethodTemplates( + method: aws_apigateway.Method +): aws_apigateway.CfnMethod & { + integration: CfnIntegration; +} { + return method.node.findChild("Resource") as any; +} From a1c366c99f29dd38dd48ffbc163508bf23ffa0d0 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 5 Jun 2022 17:00:31 -0700 Subject: [PATCH 09/12] chore: localstack API GW test and fix api-test --- .projen/deps.json | 4 +++ .projenrc.js | 1 + package.json | 1 + src/api.ts | 1 + src/checker.ts | 17 ---------- src/compile.ts | 16 --------- src/function.ts | 1 + test-app/src/api-test.ts | 2 +- test/api.localstack.test.ts | 43 ++++++++++++++++++++++++ test/api.test.ts | 67 +++++++++++++++++++++++++++++++++---- yarn.lock | 22 ++++++++++++ 11 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 test/api.localstack.test.ts diff --git a/.projen/deps.json b/.projen/deps.json index 0e00bc4f..f9a13e8e 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", diff --git a/.projenrc.js b/.projenrc.js index fc96d5d7..f8e97c47 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -82,6 +82,7 @@ const project = new CustomTypescriptProject({ "@types/minimatch", "@types/uuid", "amplify-appsync-simulator", + "axios", "graphql-request", "prettier", "ts-node", diff --git a/package.json b/package.json index 0ab61e06..17c547c3 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", diff --git a/src/api.ts b/src/api.ts index 359cf8fc..f1723115 100644 --- a/src/api.ts +++ b/src/api.ts @@ -504,6 +504,7 @@ function validateFunctionDecl(a: any): FunctionDecl { } else if (isErr(a)) { throw a.error; } else { + debugger; throw Error("Unknown compiler error."); } } diff --git a/src/checker.ts b/src/checker.ts index b4463e3a..f6ebbbfe 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -81,7 +81,6 @@ export function makeFunctionlessChecker( isReflectFunction, isStepFunction, isNewFunctionlessFunction, - isApiIntegrationsStaticMethod, isApiIntegration, isCDKConstruct, getFunctionlessTypeKind, @@ -218,22 +217,6 @@ export function makeFunctionlessChecker( ); } - function isApiIntegrationsStaticMethod( - node: ts.Node - ): node is ApiIntegrationsStaticMethodInterface { - return ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - (node.expression.name.text === "mock" || - node.expression.name.text == "aws") && - ts.isIdentifier(node.expression.expression) && - isFunctionlessClassOfKind( - node.expression.expression, - BaseApiIntegration.FunctionlessType - ) - ); - } - function isApiIntegration(node: ts.Node): node is ApiIntegrationInterface { return ( ts.isNewExpression(node) && diff --git a/src/compile.ts b/src/compile.ts index 31d037e1..c9d5a381 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -5,7 +5,6 @@ import ts from "typescript"; import { assertDefined } from "./assert"; import { ApiIntegrationInterface, - ApiIntegrationsStaticMethodInterface, EventBusMapInterface, RuleInterface, EventTransformInterface, @@ -116,8 +115,6 @@ export function compile( return visitEventTransform(node); } else if (checker.isNewFunctionlessFunction(node)) { return visitFunction(node, ctx); - } else if (checker.isApiIntegrationsStaticMethod(node)) { - return visitApiIntegrationsStaticMethod(node); } else if (checker.isApiIntegration(node)) { return visitApiIntegration(node); } @@ -543,19 +540,6 @@ export function compile( ]); } - function visitApiIntegrationsStaticMethod( - node: ApiIntegrationsStaticMethodInterface - ): ts.CallExpression { - const [props] = node.arguments; - - return ts.factory.updateCallExpression( - node, - node.expression, - node.typeArguments, - [visitApiIntegrationProps(props)] - ); - } - function visitApiIntegration(node: ApiIntegrationInterface): ts.Node { const [props] = node.arguments; diff --git a/src/function.ts b/src/function.ts index 6c77b755..1b9ae84c 100644 --- a/src/function.ts +++ b/src/function.ts @@ -328,6 +328,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/test-app/src/api-test.ts b/test-app/src/api-test.ts index 1fae6be3..0e8adab1 100644 --- a/test-app/src/api-test.ts +++ b/test-app/src/api-test.ts @@ -86,7 +86,7 @@ const sfn = new ExpressStepFunction( interface MockRequest { pathParameters: { - num: 200 | 500; + num: number; }; } diff --git a/test/api.localstack.test.ts b/test/api.localstack.test.ts new file mode 100644 index 00000000..567b1719 --- /dev/null +++ b/test/api.localstack.test.ts @@ -0,0 +1,43 @@ +import { aws_apigateway } from "aws-cdk-lib"; +import axios from "axios"; +import { MockApiIntegration } from "../src"; +import { localstackTestSuite } from "./localstack"; + +localstackTestSuite("apiGatewayStack", (test, stack) => { + test( + "mock integration", + () => { + const api = new aws_apigateway.RestApi(stack, "MockAPI"); + const code = api.root.addResource("{code}"); + new MockApiIntegration({ + request: (req: { + pathParameters: { + code: number; + }; + }) => ({ + statusCode: req.pathParameters.code, + }), + responses: { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + }, + }).addMethod("GET", code); + + return { + outputs: { + endpoint: api.url, + }, + }; + }, + async (context) => { + const response = await axios.get(`${context.endpoint}200`); + expect(response.data).toEqual({ + response: "OK", + }); + } + ); +}); diff --git a/test/api.test.ts b/test/api.test.ts index 18230772..4977f92b 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -6,19 +6,74 @@ let stack: Stack; let func: Function; beforeEach(() => { stack = new Stack(); - func = new Function(stack, "F", (p) => p); + func = new Function(stack, "F", (p) => { + return p; + }); }); test("mock integration with object literal", () => { const api = new aws_apigateway.RestApi(stack, "API"); + interface MockRequest { + pathParameters: { + code: number; + }; + } + const method = getMethodTemplates( new MockApiIntegration({ - request: (req: { - pathParameters: { - code: number; - }; - }) => ({ + request: (req: MockRequest) => ({ + statusCode: req.pathParameters.code, + }), + responses: { + 200: () => ({ + response: "OK", + }), + 500: () => ({ + response: "BAD", + }), + }, + }).addMethod("GET", api.root) + ); + + 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"); + + interface MockRequest { + pathParameters: { + // TODO: this breaks the interpreter which expects a string | number + code: 200 | 500; + }; + } + + const method = getMethodTemplates( + new MockApiIntegration({ + request: (req: MockRequest) => ({ statusCode: req.pathParameters.code, }), responses: { diff --git a/yarn.lock b/yarn.lock index fa03f80f..47b20cf5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2107,6 +2107,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 +3851,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 +3865,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" From 6fba585593a4126d8123c5d36630323d76f82cf4 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 5 Jun 2022 17:16:49 -0700 Subject: [PATCH 10/12] fix: skip localstack API GW test --- test/api.localstack.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api.localstack.test.ts b/test/api.localstack.test.ts index 567b1719..21755635 100644 --- a/test/api.localstack.test.ts +++ b/test/api.localstack.test.ts @@ -4,7 +4,7 @@ import { MockApiIntegration } from "../src"; import { localstackTestSuite } from "./localstack"; localstackTestSuite("apiGatewayStack", (test, stack) => { - test( + test.skip( "mock integration", () => { const api = new aws_apigateway.RestApi(stack, "MockAPI"); From 81b29f63a256313a3807b8a570842ca46c64c6f9 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 6 Jun 2022 00:47:07 -0700 Subject: [PATCH 11/12] feat: lambda proxy integration --- .projen/deps.json | 4 ++ .projenrc.js | 7 +++- package.json | 1 + scripts/compile-test-app.js | 5 ++- src/api.ts | 42 +++++++++++++++++-- src/function.ts | 5 ++- src/tsc.ts | 2 +- test-app/yarn.lock | 6 +++ test/api.localstack.test.ts | 80 ++++++++++++++++++++++++++++++++++++- test/localstack.ts | 10 +++-- yarn.lock | 5 +++ 11 files changed, 154 insertions(+), 13 deletions(-) diff --git a/.projen/deps.json b/.projen/deps.json index f9a13e8e..b536b419 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -195,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 f8e97c47..eda46ee7 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -75,7 +75,12 @@ 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", diff --git a/package.json b/package.json index 17c547c3..2e6acef1 100644 --- a/package.json +++ b/package.json @@ -76,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 index f1723115..4156dbc4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ import { aws_apigateway } from "aws-cdk-lib"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import { Construct } from "constructs"; -import { isParameterDecl } from "."; -import { FunctionDecl, isFunctionDecl } from "./declaration"; +import { FunctionDecl, isFunctionDecl, isParameterDecl } from "./declaration"; import { isErr } from "./error"; import { Identifier, @@ -20,6 +20,7 @@ import { ObjectLiteralExpr, PropAccessExpr, } from "./expression"; +import { Function } from "./function"; import { findIntegration, IntegrationImpl } from "./integration"; import { FunctionlessNode } from "./node"; import { isReturnStmt, isVariableStmt, ReturnStmt } from "./statement"; @@ -83,7 +84,7 @@ export abstract class BaseApiIntegration { */ public abstract addMethod( httpMethod: HttpMethod, - resource: aws_apigateway.Resource + resource: aws_apigateway.IResource ): aws_apigateway.Method; } @@ -225,7 +226,7 @@ export interface AwsApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class AwsApiIntegration< - Request, + Request extends ApiRequest, IntegrationResponse, MethodResponse > extends BaseApiIntegration { @@ -553,3 +554,36 @@ export interface ApiGatewayVtlIntegration { responses: aws_apigateway.IntegrationResponse[] ) => aws_apigateway.Integration; } + +export interface LambdaProxyApiIntegrationProps + extends Omit< + aws_apigateway.LambdaIntegrationOptions, + | "requestParameters" + | "requestTemplates" + | "integrationResponses" + | "passthroughBehavior" + | "proxy" + > { + function: Function; +} + +export class LambdaProxyApiIntegration extends BaseApiIntegration { + readonly function; + constructor(private readonly props: LambdaProxyApiIntegrationProps) { + super(); + this.function = props.function; + } + + public addMethod( + httpMethod: HttpMethod, + resource: aws_apigateway.IResource + ): aws_apigateway.Method { + return resource.addMethod( + httpMethod, + new aws_apigateway.LambdaIntegration(this.function.resource, { + ...this.props, + proxy: true, + }) + ); + } +} diff --git a/src/function.ts b/src/function.ts index 1b9ae84c..1f318e44 100644 --- a/src/function.ts +++ b/src/function.ts @@ -241,8 +241,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. 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/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 index 21755635..9d508e1b 100644 --- a/test/api.localstack.test.ts +++ b/test/api.localstack.test.ts @@ -1,6 +1,12 @@ import { aws_apigateway } from "aws-cdk-lib"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import axios from "axios"; -import { MockApiIntegration } from "../src"; +import { + AwsApiIntegration, + Function, + LambdaProxyApiIntegration, + MockApiIntegration, +} from "../src"; import { localstackTestSuite } from "./localstack"; localstackTestSuite("apiGatewayStack", (test, stack) => { @@ -40,4 +46,76 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { }); } ); + + 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({ + request: (req: { + pathParameters: { + code: number; + }; + }) => + func({ + input: req.pathParameters.code, + }), + response: (result) => ({ + result: result.key, + }), + }).addMethod("GET", api.root); + + return { + outputs: { + endpoint: api.url, + }, + }; + }, + async (context) => { + const response = await axios.get(context.endpoint); + expect(response.data).toEqual({ result: "hello" }); + } + ); + + test( + "lambda proxy integration", + () => { + 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 LambdaProxyApiIntegration({ + function: func, + }).addMethod("GET", api.root); + + 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/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 47b20cf5..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" From a0120a14369dad7b255406adba4e731b24c03f2b Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 6 Jun 2022 23:10:44 -0700 Subject: [PATCH 12/12] chore: update API GW to share VTL implementation from Appsync --- .vscode/launch.json | 2 +- src/api.ts | 644 ++++++++++++++++----------------- src/appsync.ts | 27 +- src/compile.ts | 86 ++--- src/function.ts | 8 +- src/step-function.ts | 22 +- src/table.ts | 13 +- src/vtl.ts | 692 +++++++++++++++++++----------------- test-app/cdk.json | 2 +- test-app/src/api-test.ts | 149 ++++---- test/api.localstack.test.ts | 18 +- test/api.test.ts | 129 ++++--- 12 files changed, 913 insertions(+), 879 deletions(-) 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/src/api.ts b/src/api.ts index 4156dbc4..93a29635 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,29 +1,18 @@ import { aws_apigateway } from "aws-cdk-lib"; -import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + APIGatewayEventRequestContext, +} from "aws-lambda"; import { Construct } from "constructs"; -import { FunctionDecl, isFunctionDecl, isParameterDecl } from "./declaration"; +import { FunctionDecl, isFunctionDecl } from "./declaration"; import { isErr } from "./error"; -import { - Identifier, - isArgument, - isArrayLiteralExpr, - isBooleanLiteralExpr, - isCallExpr, - isComputedPropertyNameExpr, - isIdentifier, - isNullLiteralExpr, - isNumberLiteralExpr, - isObjectLiteralExpr, - isPropAccessExpr, - isStringLiteralExpr, - isTemplateExpr, - ObjectLiteralExpr, - PropAccessExpr, -} from "./expression"; +import { CallExpr, Expr } from "./expression"; import { Function } from "./function"; -import { findIntegration, IntegrationImpl } from "./integration"; -import { FunctionlessNode } from "./node"; -import { isReturnStmt, isVariableStmt, ReturnStmt } from "./statement"; +import { IntegrationImpl } from "./integration"; +import { isReturnStmt, Stmt } from "./statement"; +import { AnyFunction } from "./util"; +import { VTL } from "./vtl"; /** * HTTP Methods that API Gateway supports. @@ -37,6 +26,11 @@ export type HttpMethod = | "HEAD" | "OPTIONS"; +export interface MethodProps { + httpMethod: HttpMethod; + resource: aws_apigateway.IResource; +} + type ParameterMap = Record; /** @@ -75,33 +69,7 @@ export abstract class BaseApiIntegration { public static readonly FunctionlessType = "ApiIntegration"; protected readonly functionlessKind = BaseApiIntegration.FunctionlessType; - /** - * Add this integration as a Method to an API Gateway resource. - * - * TODO: this mirrors the AppsyncResolver.addResolver method, but it - * is on the chopping block: https://github.com/functionless/functionless/issues/137 - * The 2 classes are conceptually similar so we should keep the DX in sync. - */ - public abstract addMethod( - httpMethod: HttpMethod, - resource: aws_apigateway.IResource - ): aws_apigateway.Method; -} - -export interface MockApiIntegrationProps< - Request extends ApiRequest, - StatusCode extends number, - MethodResponses extends { [C in StatusCode]: any } -> { - /** - * Map API request to a status code. This code will be used by API Gateway - * to select the response to return. - */ - request: (request: Request) => { statusCode: StatusCode }; - /** - * Map of status codes to response to return. - */ - responses: { [C in StatusCode]: (code: C) => MethodResponses[C] }; + abstract readonly method: aws_apigateway.Method; } /** @@ -121,39 +89,50 @@ export interface MockApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class MockApiIntegration< - Request extends ApiRequest, 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: MockApiIntegrationProps + 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(props.request); + this.request = validateFunctionDecl(request); this.responses = Object.fromEntries( - Object.entries(props.responses).map(([k, v]) => [ - k, - validateFunctionDecl(v), - ]) + Object.entries(responses).map(([k, v]) => [k, validateFunctionDecl(v)]) ) as { [K in keyof MethodResponses]: FunctionDecl }; - } - public addMethod( - httpMethod: HttpMethod, - resource: aws_apigateway.IResource - ): aws_apigateway.Method { - const [requestTemplate] = toVTL(this.request, "request"); + const requestTemplate = new APIGatewayVTL("request"); + requestTemplate.eval(this.request.body); const integrationResponses: aws_apigateway.IntegrationResponse[] = Object.entries(this.responses).map(([statusCode, fn]) => { - const [template] = toVTL(fn as FunctionDecl, "response"); + const responseTemplate = new APIGatewayVTL("response"); + responseTemplate.eval((fn as FunctionDecl).body); return { statusCode, responseTemplates: { - "application/json": template, + "application/json": responseTemplate.toVTL(), }, selectionPattern: `^${statusCode}$`, }; @@ -161,7 +140,7 @@ export class MockApiIntegration< const integration = new aws_apigateway.MockIntegration({ requestTemplates: { - "application/json": requestTemplate, + "application/json": requestTemplate.toVTL(), }, integrationResponses, }); @@ -171,48 +150,12 @@ export class MockApiIntegration< })); // TODO: support requestParameters, authorizers, models and validators - return resource.addMethod(httpMethod, integration, { + this.method = props.resource.addMethod(props.httpMethod, integration, { methodResponses, }); } } -export interface AwsApiIntegrationProps< - Request extends ApiRequest, - IntegrationResponse, - MethodResponse -> { - /** - * 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: (req: Request) => 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) => 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]: () => any }; -} - /** * 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 @@ -226,41 +169,81 @@ export interface AwsApiIntegrationProps< * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-integration-types.html */ export class AwsApiIntegration< - Request extends ApiRequest, - IntegrationResponse, - MethodResponse + MethodResponse, + IntegrationResponse > extends BaseApiIntegration { private readonly request: FunctionDecl; private readonly response: FunctionDecl; private readonly errors: { [statusCode: number]: FunctionDecl }; - + readonly method; public constructor( - props: AwsApiIntegrationProps + 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(props.request); - this.response = validateFunctionDecl(props.response); + this.request = validateFunctionDecl(request); + this.response = validateFunctionDecl(response); this.errors = Object.fromEntries( - Object.entries(props.errors ?? {}).map(([k, v]) => [ - k, - validateFunctionDecl(v), - ]) + Object.entries(errors ?? {}).map(([k, v]) => [k, validateFunctionDecl(v)]) ); - } - public addMethod(httpMethod: HttpMethod, resource: aws_apigateway.IResource) { - const [requestTemplate, integration] = toVTL(this.request, "request"); - const [responseTemplate] = toVTL(this.response, "response"); + 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 [template] = toVTL(fn, "response"); + const errorTemplate = new APIGatewayVTL("response"); + errorTemplate.eval(fn.body, "response"); return { statusCode: statusCode, selectionPattern: `^${statusCode}$`, responseTemplates: { - "application/json": template, + "application/json": errorTemplate.toVTL(), }, }; }); @@ -269,7 +252,7 @@ export class AwsApiIntegration< { statusCode: "200", responseTemplates: { - "application/json": responseTemplate, + "application/json": responseTemplate.toVTL(), }, }, ...errorResponses, @@ -279,8 +262,8 @@ export class AwsApiIntegration< // because of the IAM roles created // should `this` be a Method? const apiGwIntegration = integration!.apiGWVtl.createIntegration( - resource, - requestTemplate, + props.resource, + requestTemplate.toVTL(), integrationResponses ); @@ -291,259 +274,104 @@ export class AwsApiIntegration< })), ]; - return resource.addMethod(httpMethod, apiGwIntegration, { + this.method = props.resource.addMethod(props.httpMethod, apiGwIntegration, { methodResponses, }); } } -/** - * Simple VTL interpreter for a FunctionDecl. The interpreter in vtl.ts - * is based on the AppSync VTL engine which has much more flexibility than - * the API Gateway VTL engine. In particular it has a toJson utility function - * which means the VTL can just create an object in memory and then toJson it - * the end. Here we need to manually output the JSON which is how VTL is - * typically meant to be used. - * - * For now, only Literals and references to the template input are supported. - * It is definitely possible to support more, but we will start with just - * small core and support more syntax carefully over time. - * - * @param node Function to interpret. - * @param template Whether we are creating a request or response mapping template. - */ -export function toVTL( - node: FunctionDecl, - location: "request" | "response" -): [string, IntegrationImpl | undefined] { - // TODO: polish these error messages and put them into error-codes.ts - if (node.body.statements.length !== 1) { - throw new Error("Expected function body to be a single return statement"); - } - - const stmt = node.body.statements[0]; - - if (!isReturnStmt(stmt)) { - throw new Error("Expected function body to be a single return statement"); +export class APIGatewayVTL extends VTL { + public integration: IntegrationImpl | undefined; + constructor( + readonly location: "request" | "response", + ...statements: string[] + ) { + super(...statements); } - if (location === "request") { - const call = stmt.expr; - - if (isObjectLiteralExpr(call)) { - return literal(stmt); - } - - if (!isCallExpr(call)) { + protected integrate( + target: IntegrationImpl, + call: CallExpr + ): string { + if (this.location === "response") { throw new Error( - "Expected request function body to return an integration call" + `Cannot call an integration from within a API Gateway Response Template` ); } - - // TODO: validate args. also should it always be an object? - const argObj = inner(call.args[0].expr! as ObjectLiteralExpr); - const serviceCall = findIntegration(call); - - if (!serviceCall) { + if (target.apiGWVtl) { + // ew, mutation + // TODO: refactor to pure functions + this.integration = target; + return target.apiGWVtl.renderRequest(call, this); + } else { throw new Error( - "Expected request function body to return an integration call" + `Resource type ${target.kind} does not support API Gateway Integrations` ); } - - const prepared = serviceCall.apiGWVtl.prepareRequest(argObj); - const template = `#set($inputRoot = $input.path('$'))\n${stringify( - prepared - )}`; - return [template, serviceCall]; - } else { - return literal(stmt); } - function literal( - stmt: ReturnStmt - ): [string, IntegrationImpl | undefined] { - const obj = stmt.expr; - if (!isObjectLiteralExpr(obj)) { - throw new Error( - "Expected response function body to return an object literal" - ); + 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))); } - const template = `#set($inputRoot = $input.path('$'))\n${stringify( - inner(obj) - )}`; - return [template, undefined]; + return super.eval(node as any, returnVar); } - function inner(node: FunctionlessNode): any { - if (isIdentifier(node)) { - const ref = node.lookup(); - if (isParameterDecl(ref)) { - return `$inputRoot`; - } else if (isVariableStmt(ref)) { - return `$${ref.name}`; - } else { - throw new Error(`Cannot find name: ${node.name}`); - } - } else if ( - isBooleanLiteralExpr(node) || - isNumberLiteralExpr(node) || - isStringLiteralExpr(node) || - isNullLiteralExpr(node) - ) { - return node.value; - } else if (isArrayLiteralExpr(node)) { - return node.children.map(inner); - } else if (isObjectLiteralExpr(node)) { - return Object.fromEntries( - node.properties.map((prop) => { - switch (prop.kind) { - case "PropAssignExpr": - return [ - isComputedPropertyNameExpr(prop.name) - ? inner(prop.name) - : isStringLiteralExpr(prop.name) - ? prop.name.value - : prop.name.name, - inner(prop.expr), - ]; - case "SpreadAssignExpr": - throw new Error("TODO: support SpreadAssignExpr"); - } - }) - ); - } else if (isArgument(node) && node.expr) { - return inner(node.expr); - } else if (isPropAccessExpr(node)) { - // ignore the function param name, we'll replace it with the VTL - // mapping template inputs - const [_, ...path] = pathFromFunctionParameter(node) ?? []; - - if (path) { - if (location === "response") { - const ref: Ref = { - __refType: node.type! as any, - value: `$inputRoot.${path.join(".")}`, - }; - return ref; - } else { - const [paramLocation, ...rest] = path; - - let prefix; - switch (paramLocation) { - case "body": - prefix = "$inputRoot"; - break; - case "pathParameters": - prefix = "$input.params().path"; - break; - case "queryStringParameters": - prefix = "$input.params().querystring"; - break; - case "headers": - prefix = "$input.params().header"; - break; - default: - throw new Error("Unknown parameter type."); - } - - const param = `${prefix}.${rest.join(".")}`; - - const ref: Ref = { __refType: node.type! as any, value: param }; - return ref; - } - } - return `${inner(node.expr)}.${node.name};`; - } else if (isTemplateExpr(node)) { - // TODO: not right, compare to vtl.ts - return inner(node.exprs[0]); - } - - throw new Error(`Unsupported node type: ${node.kind}`); + public json(reference: string): string { + return this.jsonStage(reference, 0); } -} -// These represent variable references and carry the type information. -// stringify will serialize them to the appropriate VTL -// e.g. if `request.pathParameters.id` is a number, we want to serialize -// it as `$input.params().path.id`, not `"$input.params().path.id"` which -// is what JSON.stringify would do -type Ref = - | { __refType: "string"; value: string } - | { __refType: "number"; value: string } - | { __refType: "boolean"; value: string }; - -const isRef = (x: any): x is Ref => x.__refType !== undefined; - -function stringify(obj: any): string { - if (isRef(obj)) { - switch (obj.__refType) { - case "string": - return `"${obj.value}"`; - case "number": - return obj.value; - case "boolean": - return obj.value; + private jsonStage(varName: string, level: number): string { + if (level === 3) { + return "#stop"; } - } - if (typeof obj === "string") { - return `"${obj}"`; - } else if (typeof obj === "number" || typeof obj === "boolean") { - return obj.toString(); - } else if (Array.isArray(obj)) { - return `[${obj.map(stringify).join(",")}]`; - } else if (typeof obj === "object") { - const props = Object.entries(obj).map(([k, v]) => `"${k}":${stringify(v)}`); - return `{${props.join(",")}}`; - } - throw new Error(`Unsupported type: ${typeof obj}`); + 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 { - debugger; throw Error("Unknown compiler error."); } } -function isFunctionParameter(node: FunctionlessNode): node is Identifier { - if (!isIdentifier(node)) return false; - const ref = node.lookup(); - return isParameterDecl(ref) && isFunctionDecl(ref.parent); -} - -/** - * path from a function parameter to this node, if one exists. - * e.g. `request.pathParameters.id` => ["request", "pathParameters", "id"] - */ -function pathFromFunctionParameter(node: PropAccessExpr): string[] | undefined { - if (isFunctionParameter(node.expr)) { - return [node.expr.name, node.name]; - } else if (isPropAccessExpr(node.expr)) { - const path = pathFromFunctionParameter(node.expr); - if (path) { - return [...path, node.name]; - } else { - return undefined; - } - } else { - return undefined; - } -} - /** * Hooks used to create API Gateway integrations. */ export interface ApiGatewayVtlIntegration { /** - * Prepare the request object for the integration. This can be used to inject - * properties into the object before serializing to VTL. + * Render the Request Payload as a VTL string. */ - prepareRequest: (obj: object) => object; + renderRequest: (call: CallExpr, context: APIGatewayVTL) => string; /** * Construct an API GW integration. @@ -565,21 +393,20 @@ export interface LambdaProxyApiIntegrationProps | "proxy" > { function: Function; + httpMethod: HttpMethod; + resource: aws_apigateway.IResource; } -export class LambdaProxyApiIntegration extends BaseApiIntegration { +export class LambdaProxyApiMethod extends BaseApiIntegration { readonly function; + readonly method; + constructor(private readonly props: LambdaProxyApiIntegrationProps) { super(); this.function = props.function; - } - public addMethod( - httpMethod: HttpMethod, - resource: aws_apigateway.IResource - ): aws_apigateway.Method { - return resource.addMethod( - httpMethod, + this.method = props.resource.addMethod( + props.httpMethod, new aws_apigateway.LambdaIntegration(this.function.resource, { ...this.props, proxy: true, @@ -587,3 +414,132 @@ export class LambdaProxyApiIntegration extends BaseApiIntegration { ); } } + +/** + * 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/compile.ts b/src/compile.ts index c9d5a381..5fcd8342 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -497,7 +497,7 @@ export function compile( function toFunction( type: "FunctionDecl" | "FunctionExpr", - impl: TsFunctionParameter, + impl: ts.Expression, dropArgs?: number ): ts.Expression { if ( @@ -541,82 +541,40 @@ export function compile( } function visitApiIntegration(node: ApiIntegrationInterface): ts.Node { - const [props] = node.arguments; + const [props, request, response, errors] = node.arguments; return ts.factory.updateNewExpression( node, node.expression, node.typeArguments, - [visitApiIntegrationProps(props)] + [ + props, + toFunction("FunctionDecl", request), + ts.isObjectLiteralExpression(response) + ? visitApiErrors(response) + : toFunction("FunctionDecl", response), + ...(errors && ts.isObjectLiteralExpression(errors) + ? [visitApiErrors(errors)] + : []), + ] ); } - function visitApiIntegrationProps( - props: ts.ObjectLiteralExpression - ): ts.ObjectLiteralExpression { + function visitApiErrors(errors: ts.ObjectLiteralExpression) { return ts.factory.updateObjectLiteralExpression( - props, - props.properties.map((prop) => { - if ( - ts.isPropertyAssignment(prop) && - (ts.isStringLiteral(prop.name) || ts.isIdentifier(prop.name)) - ) { - if ( - prop.name.text === "responses" || - prop.name.text === "errors" - ) { - return visitApiIntegrationResponsesProp(prop); - } else if ( - prop.name.text === "request" || - prop.name.text === "response" - ) { - return visitApiIntegrationMapperProp(prop); - } - } - return prop; - }) - ); - } - - function visitApiIntegrationResponsesProp( - prop: ts.PropertyAssignment - ): ts.PropertyAssignment { - const { initializer } = prop; - if (!ts.isObjectLiteralExpression(initializer)) { - return prop; - } - - return ts.factory.updatePropertyAssignment( - prop, - prop.name, - ts.factory.updateObjectLiteralExpression( - initializer, - initializer.properties.map((p) => - ts.isPropertyAssignment(p) ? visitApiIntegrationMapperProp(p) : p - ) + errors, + errors.properties.map((prop) => + ts.isPropertyAssignment(prop) + ? ts.factory.updatePropertyAssignment( + prop, + prop.name, + toFunction("FunctionDecl", prop.initializer) + ) + : prop ) ); } - function visitApiIntegrationMapperProp( - prop: ts.PropertyAssignment - ): ts.PropertyAssignment { - const { initializer } = prop; - const toFunc = errorBoundary(() => { - if ( - !ts.isFunctionExpression(initializer) && - !ts.isArrowFunction(initializer) - ) { - throw new Error( - `Expected mapping property of an ApiIntegration to be a function. Found ${initializer.getText()}.` - ); - } - return toFunction("FunctionDecl", initializer); - }); - - return ts.factory.updatePropertyAssignment(prop, prop.name, toFunc); - } - function toExpr( node: ts.Node | undefined, scope: ts.Node diff --git a/src/function.ts b/src/function.ts index 1f318e44..66e432cb 100644 --- a/src/function.ts +++ b/src/function.ts @@ -143,7 +143,13 @@ abstract class FunctionBase implements IFunction { }; this.apiGWVtl = { - prepareRequest: (obj) => obj, + 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, { diff --git a/src/step-function.ts b/src/step-function.ts index 0f6b770e..30f35583 100644 --- a/src/step-function.ts +++ b/src/step-function.ts @@ -520,13 +520,21 @@ abstract class BaseStepFunction< // Integration object for api gateway vtl this.apiGWVtl = { - prepareRequest: (obj) => { - // TODO: this is currently broken. StepFunction interface requires a - // top level `input` key to be passed in but it shouldn't - return { - ...obj, - stateMachineArn: this.stateMachineArn, - }; + 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) => { diff --git a/src/table.ts b/src/table.ts index 3f3d7758..eee1a7d4 100644 --- a/src/table.ts +++ b/src/table.ts @@ -312,11 +312,14 @@ export class Table< ...integration.appSyncVtl, }, apiGWVtl: { - prepareRequest: (obj) => { - return { - ...obj, - tableName: this.resource.node.addr, - }; + 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) => { 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 7815f97c..6e614347 100644 --- a/test-app/cdk.json +++ b/test-app/cdk.json @@ -1,3 +1,3 @@ { - "app": "ts-node ./src/app.ts" + "app": "ts-node ./src/api-test.ts" } diff --git a/test-app/src/api-test.ts b/test-app/src/api-test.ts index 0e8adab1..de2bb24e 100644 --- a/test-app/src/api-test.ts +++ b/test-app/src/api-test.ts @@ -10,8 +10,8 @@ import { MockApiIntegration, ExpressStepFunction, Function, - SyncExecutionSuccessResult, Table, + APIGatewayInput, } from "functionless"; export const app = new App(); @@ -22,52 +22,44 @@ const restApi = new aws_apigateway.RestApi(stack, "api", { restApiName: "api-test-app-api", }); -interface FnRequest { - pathParameters: { - num: number; - }; - queryStringParameters: { - str: string; - }; - body: { - bool: boolean; - }; -} - const fn = new Function( stack, "fn", - async (event: { inNum: number; inStr: string; inBool: boolean }) => ({ - fnNum: event.inNum, - fnStr: event.inStr, - fnBool: event.inBool, - nested: { - again: { - num: 123, + 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}"); -const fnIntegration = new AwsApiIntegration({ - request: (req: FnRequest) => +new AwsApiIntegration( + { + httpMethod: "POST", + resource: fnResource, + }, + ($input: APIGatewayInput) => fn({ - inNum: req.pathParameters.num, - inStr: req.queryStringParameters.str, - inBool: req.body.bool, + inNum: $input.params("num") as number, + inStr: $input.params("str") as string, + inBool: $input.json("$.body"), }), - response: (resp) => ({ + (resp) => ({ resultNum: resp.fnNum, resultStr: resp.fnStr, nested: resp.nested.again.num, }), - errors: { + { 400: () => ({ msg: "400" }), - }, -}); -fnIntegration.addMethod("POST", fnResource); + } +); const sfn = new ExpressStepFunction( stack, @@ -84,18 +76,16 @@ const sfn = new ExpressStepFunction( }) ); -interface MockRequest { - pathParameters: { - num: number; - }; -} - const mockResource = restApi.root.addResource("mock").addResource("{num}"); -const mock = new MockApiIntegration({ - request: (req: MockRequest) => ({ - statusCode: req.pathParameters.num, +new MockApiIntegration( + { + httpMethod: "POST", + resource: mockResource, + }, + ($input) => ({ + statusCode: $input.params("num") as number, }), - responses: { + { 200: () => ({ body: { num: 12345, @@ -104,9 +94,8 @@ const mock = new MockApiIntegration({ 500: () => ({ msg: "error", }), - }, -}); -mock.addMethod("GET", mockResource); + } +); interface Item { id: string; @@ -121,55 +110,49 @@ const table = new Table( }) ); -interface DynamoRequest { - pathParameters: { - id: number; - }; -} - const dynamoResource = restApi.root.addResource("dynamo").addResource("{num}"); -const dynamoIntegration = new AwsApiIntegration({ - request: (req: DynamoRequest) => +new AwsApiIntegration( + { + httpMethod: "GET", + resource: dynamoResource, + }, + ($input: APIGatewayInput) => table.getItem({ key: { id: { - S: `${req.pathParameters.id}`, + S: `${$input.params("id")}`, }, }, }), - // @ts-ignore TODO: resp is never for some reason - response: (resp) => ({ foo: resp.item.foo }), - errors: { + (resp) => ({ foo: resp.name }), + { 400: () => ({ msg: "400" }), - }, -}); -dynamoIntegration.addMethod("GET", dynamoResource); - -interface SfnRequest { - pathParameters: { - num: number; - }; - queryStringParameters: { - str: string; - }; -} + } +); const sfnResource = restApi.root.addResource("sfn").addResource("{num}"); -const sfnIntegration = new AwsApiIntegration({ - // @ts-ignore TODO: output is only on success, need to support if stmt - request: (req: SfnRequest) => + +new AwsApiIntegration( + { + httpMethod: "GET", + resource: sfnResource, + }, + ($input: APIGatewayInput) => sfn({ input: { - num: req.pathParameters.num, - str: req.queryStringParameters.str, + num: $input.params("num") as number, + str: $input.params("str") as string, }, }), - // TODO: we should not need to narrow this explicitly - response: (resp: SyncExecutionSuccessResult) => ({ - resultNum: resp.output.sfnNum, - resultStr: resp.output.sfnStr, - }), - // TODO: make errors optional? - errors: {}, -}); -sfnIntegration.addMethod("GET", sfnResource); + (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/api.localstack.test.ts b/test/api.localstack.test.ts index 9d508e1b..c995def9 100644 --- a/test/api.localstack.test.ts +++ b/test/api.localstack.test.ts @@ -4,7 +4,7 @@ import axios from "axios"; import { AwsApiIntegration, Function, - LambdaProxyApiIntegration, + LambdaProxyApiMethod, MockApiIntegration, } from "../src"; import { localstackTestSuite } from "./localstack"; @@ -16,6 +16,8 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { 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; @@ -31,7 +33,7 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { response: "BAD", }), }, - }).addMethod("GET", code); + }); return { outputs: { @@ -56,6 +58,8 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { }); new AwsApiIntegration({ + httpMethod: "GET", + resource: api.root, request: (req: { pathParameters: { code: number; @@ -67,7 +71,7 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { response: (result) => ({ result: result.key, }), - }).addMethod("GET", api.root); + }); return { outputs: { @@ -82,7 +86,7 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { ); test( - "lambda proxy integration", + "lambda proxy method", () => { const api = new aws_apigateway.RestApi(stack, "LambdaAPI"); const func = new Function( @@ -99,9 +103,11 @@ localstackTestSuite("apiGatewayStack", (test, stack) => { } ); - new LambdaProxyApiIntegration({ + new LambdaProxyApiMethod({ + httpMethod: "GET", + resource: api.root, function: func, - }).addMethod("GET", api.root); + }); return { outputs: { diff --git a/test/api.test.ts b/test/api.test.ts index 4977f92b..ece8e9f9 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,6 +1,12 @@ import "jest"; import { aws_apigateway, IResolvable, Stack } from "aws-cdk-lib"; -import { AwsApiIntegration, MockApiIntegration, Function } from "../src"; +import { + AwsApiIntegration, + MockApiIntegration, + Function, + BaseApiIntegration, + ExpressStepFunction, +} from "../src"; let stack: Stack; let func: Function; @@ -14,26 +20,24 @@ beforeEach(() => { test("mock integration with object literal", () => { const api = new aws_apigateway.RestApi(stack, "API"); - interface MockRequest { - pathParameters: { - code: number; - }; - } - - const method = getMethodTemplates( - new MockApiIntegration({ - request: (req: MockRequest) => ({ - statusCode: req.pathParameters.code, + const method = getCfnMethod( + new MockApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + ($input) => ({ + statusCode: $input.params("code") as number, }), - responses: { + { 200: () => ({ response: "OK", }), 500: () => ({ response: "BAD", }), - }, - }).addMethod("GET", api.root) + } + ) ); expect(method.httpMethod).toEqual("GET"); @@ -64,27 +68,24 @@ test("mock integration with object literal", () => { test.skip("mock integration with object literal and literal type in pathParameters", () => { const api = new aws_apigateway.RestApi(stack, "API"); - interface MockRequest { - pathParameters: { - // TODO: this breaks the interpreter which expects a string | number - code: 200 | 500; - }; - } - - const method = getMethodTemplates( - new MockApiIntegration({ - request: (req: MockRequest) => ({ - statusCode: req.pathParameters.code, + const method = getCfnMethod( + new MockApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + (req) => ({ + statusCode: req.params("code") as number, }), - responses: { + { 200: () => ({ response: "OK", }), 500: () => ({ response: "BAD", }), - }, - }).addMethod("GET", api.root) + } + ) ); expect(method.httpMethod).toEqual("GET"); @@ -115,17 +116,17 @@ test.skip("mock integration with object literal and literal type in pathParamete test("AWS integration with Function", () => { const api = new aws_apigateway.RestApi(stack, "API"); - const method = getMethodTemplates( - new AwsApiIntegration({ - request: (req: { - pathParameters: { - code: number; - }; - }) => func(req), - response: (result) => ({ + const method = getCfnMethod( + new AwsApiIntegration( + { + httpMethod: "GET", + resource: api.root, + }, + ($input) => func($input.json("$")), + (result) => ({ result, - }), - }).addMethod("GET", api.root) + }) + ) ); expect(method.httpMethod).toEqual("GET"); @@ -144,6 +145,52 @@ test("AWS integration with Function", () => { ]); }); +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 @@ -163,10 +210,8 @@ interface IntegrationResponseProperty { readonly statusCode: string; } -function getMethodTemplates( - method: aws_apigateway.Method -): aws_apigateway.CfnMethod & { +function getCfnMethod(method: BaseApiIntegration): aws_apigateway.CfnMethod & { integration: CfnIntegration; } { - return method.node.findChild("Resource") as any; + return method.method.node.findChild("Resource") as any; }