UNPKG

@aws-amplify/amplify-category-api

Version:
509 lines (465 loc) 16.5 kB
/* eslint-disable jsdoc/require-param-description */ /* eslint-disable no-use-before-define */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable no-underscore-dangle */ import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { JSONUtilities } from '@aws-amplify/amplify-cli-core'; import _ from 'lodash'; import { v4 as uuid } from 'uuid'; import { ADMIN_QUERIES_NAME } from '../../../category-constants'; import { AmplifyApigwResourceTemplate, ApigwInputs, ApigwPathPolicy, Path, PermissionSetting } from './types'; const CFN_TEMPLATE_FORMAT_VERSION = '2010-09-09'; const ROOT_CFN_DESCRIPTION = 'API Gateway Resource for AWS Amplify CLI'; export class AmplifyApigwResourceStack extends cdk.Stack implements AmplifyApigwResourceTemplate { restApi: apigw.CfnRestApi; deploymentResource: apigw.CfnDeployment; paths: Record<string, any>; policies: { [pathName: string]: ApigwPathPolicy }; private _scope: Construct; private _props: ApigwInputs; private _cfnParameterMap: Map<string, cdk.CfnParameter> = new Map(); private _cfnParameterValues: Record<string, any>; private _seenLogicalIds: Set<string>; constructor(scope: Construct, id: string, props: ApigwInputs) { super(scope, id, undefined); this._scope = scope; this._props = props; this.paths = {}; this._seenLogicalIds = new Set(); this._cfnParameterValues = {}; this.policies = {}; this.templateOptions.templateFormatVersion = CFN_TEMPLATE_FORMAT_VERSION; this.templateOptions.description = ROOT_CFN_DESCRIPTION; } addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void { this.validateLogicalId(logicalId); new cdk.CfnOutput(this, logicalId, props); } addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void { this.validateLogicalId(logicalId); new cdk.CfnMapping(this, logicalId, props); } addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void { this.validateLogicalId(logicalId); new cdk.CfnCondition(this, logicalId, props); } addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void { this.validateLogicalId(logicalId); new cdk.CfnResource(this, logicalId, props); } addCfnLambdaPermissionResource(props: lambda.CfnPermissionProps, logicalId: string): void { this.validateLogicalId(logicalId); new lambda.CfnPermission(this, logicalId, props); } // eslint-disable-next-line jsdoc/require-description /** * @param props * @param logicalId * @param value optional value which will be stored in parameters.json */ addCfnParameter(props: cdk.CfnParameterProps, logicalId: string, value?: string | Record<string, any>): void { this.validateLogicalId(logicalId); this._cfnParameterMap.set(logicalId, new cdk.CfnParameter(this, logicalId, props)); if (value !== undefined) { this._cfnParameterValues[logicalId] = value; } } getCfnParameterValues() { return this._cfnParameterValues; } private validateLogicalId(logicalId: string): void { if (this._seenLogicalIds.has(logicalId)) { throw new Error(`logical id "${logicalId}" already exists`); } this._seenLogicalIds.add(logicalId); } // eslint-disable-next-line class-methods-use-this private _craftPolicyDocument(apiResourceName: string, pathName: string, supportedOperations: string[]) { const policyPathName = pathName.replace(/{[a-zA-Z0-9-]+}/g, '*'); const paths = [policyPathName, appendToUrlPath(policyPathName, '*')]; const resources = paths.flatMap((path) => supportedOperations.map((op) => cdk.Fn.join('', [ 'arn:aws:execute-api:', cdk.Fn.ref('AWS::Region'), ':', cdk.Fn.ref('AWS::AccountId'), ':', cdk.Fn.ref(apiResourceName), '/', cdk.Fn.conditionIf('ShouldNotCreateEnvResources', 'Prod', cdk.Fn.ref('env')).toString(), op, path, ]), ), ); return new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ actions: ['execute-api:Invoke'], effect: iam.Effect.ALLOW, resources, }), ], }); } addIamPolicyResourceForUserPoolGroup( apiResourceName: string, authRoleLogicalId: string, groupName: string, pathName: string, supportedOperations: string[], ): void { const alphanumericPathName = pathName.replace(/[^-a-z0-9]/g, ''); const policyName = [apiResourceName, alphanumericPathName, groupName, 'group', 'policy'].join('-'); const iamPolicy = new iam.CfnPolicy(this, `${groupName}Group${alphanumericPathName}Policy`, { policyDocument: this._craftPolicyDocument(apiResourceName, pathName, supportedOperations), policyName, roles: [cdk.Fn.join('-', [cdk.Fn.ref(authRoleLogicalId), `${groupName}GroupRole`])], }); _.set(this.policies, [pathName, 'groups', groupName], iamPolicy); } renderCloudFormationTemplate = (): string => JSONUtilities.stringify(this._toCloudFormation()); generateAdminQueriesStack = (resourceName: string, authResourceName: string) => { this._constructCfnPaths(resourceName); this.restApi = new apigw.CfnRestApi(this, resourceName, { description: '', name: resourceName, body: { swagger: '2.0', info: { version: '2018-05-24T17:52:00Z', title: resourceName, }, host: cdk.Fn.join('', ['apigateway.', cdk.Fn.ref('AWS::Region'), '.amazonaws.com']), basePath: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', '/Prod', cdk.Fn.join('', ['/', cdk.Fn.ref('env')])), schemes: ['https'], paths: this.paths, securityDefinitions: { Cognito: { type: 'apiKey', name: 'Authorization', in: 'header', 'x-amazon-apigateway-authtype': 'cognito_user_pools', 'x-amazon-apigateway-authorizer': { providerARNs: [ cdk.Fn.join('', [ 'arn:aws:cognito-idp:', cdk.Fn.ref('AWS::Region'), ':', cdk.Fn.ref('AWS::AccountId'), ':userpool/', cdk.Fn.ref(`auth${authResourceName}UserPoolId`), ]), ], type: 'cognito_user_pools', }, }, }, definitions: { Empty: { type: 'object', title: 'Empty Schema', }, }, 'x-amazon-apigateway-request-validators': { 'Validate query string parameters and headers': { validateRequestParameters: true, validateRequestBody: false, }, }, }, }); this._setDeploymentResource(resourceName); }; generateStackResources = (resourceName: string) => { this._constructCfnPaths(resourceName); this.restApi = new apigw.CfnRestApi(this, resourceName, { description: '', failOnWarnings: true, name: resourceName, body: { swagger: '2.0', info: { version: '2018-05-24T17:52:00Z', title: resourceName, }, host: cdk.Fn.join('', ['apigateway.', cdk.Fn.ref('AWS::Region'), '.amazonaws.com']), basePath: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', '/Prod', cdk.Fn.join('', ['/', cdk.Fn.ref('env')])), schemes: ['https'], paths: this.paths, securityDefinitions: { sigv4: { type: 'apiKey', name: 'Authorization', in: 'header', 'x-amazon-apigateway-authtype': 'awsSigv4', }, }, definitions: { RequestSchema: { type: 'object', required: ['request'], properties: { request: { type: 'string', }, }, title: 'Request Schema', }, ResponseSchema: { type: 'object', required: ['response'], properties: { response: { type: 'string', }, }, title: 'Response Schema', }, }, }, }); // Append a random id to the logical id of the gateway response // This is required to resolve issue with dropping gateway responses on updating api resource const [responseRandomId] = uuid().split('-'); const default4xx = new apigw.CfnGatewayResponse(this, `${resourceName}Default4XXResponse${responseRandomId}`, { responseType: 'DEFAULT_4XX', restApiId: cdk.Fn.ref(resourceName), responseParameters: defaultCorsGatewayResponseParams, }); const default5xx = new apigw.CfnGatewayResponse(this, `${resourceName}Default5XXResponse${responseRandomId}`, { responseType: 'DEFAULT_5XX', restApiId: cdk.Fn.ref(resourceName), responseParameters: defaultCorsGatewayResponseParams, }); this._setDeploymentResource(resourceName, [default4xx, default5xx]); }; private _constructCfnPaths(resourceName: string) { const addedFunctionPermissions = new Set(); for (const [pathName, path] of Object.entries(this._props.paths)) { let lambdaPermissionLogicalId: string; if (resourceName === ADMIN_QUERIES_NAME) { this.paths['/{proxy+}'] = getAdminQueriesPathObject(path.lambdaFunction); lambdaPermissionLogicalId = `${ADMIN_QUERIES_NAME}APIGWPolicyForLambda`; } else { this.paths[pathName] = createPathObject(path); this.paths[appendToUrlPath(pathName, '{proxy+}')] = createPathObject(path); lambdaPermissionLogicalId = `function${path.lambdaFunction}Permission${resourceName}`; } if (!addedFunctionPermissions.has(path.lambdaFunction)) { addedFunctionPermissions.add(path.lambdaFunction); this.addCfnLambdaPermissionResource( { functionName: cdk.Fn.ref(`function${path.lambdaFunction}Name`), action: 'lambda:InvokeFunction', principal: 'apigateway.amazonaws.com', sourceArn: cdk.Fn.join('', [ 'arn:aws:execute-api:', cdk.Fn.ref('AWS::Region'), ':', cdk.Fn.ref('AWS::AccountId'), ':', cdk.Fn.ref(resourceName), '/*/*/*', ]), }, lambdaPermissionLogicalId, ); } } } private _setDeploymentResource = (apiName: string, dependencies: cdk.CfnResource[] = []) => { const [shortId] = uuid().split('-'); this.deploymentResource = new apigw.CfnDeployment(this, `DeploymentAPIGW${apiName}${shortId}`, { description: 'The Development stage deployment of your API.', stageName: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', 'Prod', cdk.Fn.ref('env')).toString(), restApiId: cdk.Fn.ref(apiName), }); dependencies.forEach((dep) => this.deploymentResource.addDependency(dep)); }; } const appendToUrlPath = (path: string, postfix: string) => path.charAt(path.length - 1) === '/' ? `${path}${postfix}` : `${path}/${postfix}`; const getAdminQueriesPathObject = (lambdaFunctionName: string) => ({ options: { consumes: ['application/json'], produces: ['application/json'], responses: { 200: { description: '200 response', schema: { $ref: '#/definitions/Empty', }, headers: { 'Access-Control-Allow-Origin': { type: 'string', }, 'Access-Control-Allow-Methods': { type: 'string', }, 'Access-Control-Allow-Headers': { type: 'string', }, }, }, }, 'x-amazon-apigateway-integration': { responses: { default: { statusCode: '200', responseParameters: { 'method.response.header.Access-Control-Allow-Methods': "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", 'method.response.header.Access-Control-Allow-Origin': "'*'", }, }, }, passthroughBehavior: 'when_no_match', requestTemplates: { 'application/json': '{"statusCode": 200}', }, type: 'mock', }, }, 'x-amazon-apigateway-any-method': { produces: ['application/json'], parameters: [ { name: 'proxy', in: 'path', required: true, type: 'string', }, { name: 'Authorization', in: 'header', required: false, type: 'string', }, ], responses: {}, security: [ { Cognito: ['aws.cognito.signin.user.admin'], }, ], 'x-amazon-apigateway-request-validator': 'Validate query string parameters and headers', 'x-amazon-apigateway-integration': { uri: cdk.Fn.join('', [ 'arn:aws:apigateway:', cdk.Fn.ref('AWS::Region'), ':lambda:path/2015-03-31/functions/', cdk.Fn.ref(`function${lambdaFunctionName}Arn`), '/invocations', ]), passthroughBehavior: 'when_no_match', httpMethod: 'POST', cacheNamespace: 'n40eb9', cacheKeyParameters: ['method.request.path.proxy'], contentHandling: 'CONVERT_TO_TEXT', type: 'aws_proxy', }, }, }); const createPathObject = (path: Path) => { const defaultPathObject: Record<string, any> = { options: { consumes: ['application/json'], produces: ['application/json'], responses: { 200: response200, }, 'x-amazon-apigateway-integration': { responses: { default: defaultCorsResponseObject, }, requestTemplates: { 'application/json': '{"statusCode": 200}', }, passthroughBehavior: 'when_no_match', type: 'mock', }, }, 'x-amazon-apigateway-any-method': { consumes: ['application/json'], produces: ['application/json'], parameters: [ { in: 'body', name: 'RequestSchema', required: false, schema: { $ref: '#/definitions/RequestSchema', }, }, ], responses: { 200: { description: '200 response', schema: { $ref: '#/definitions/ResponseSchema', }, }, }, 'x-amazon-apigateway-integration': { responses: { default: { statusCode: '200', }, }, uri: cdk.Fn.join('', [ 'arn:aws:apigateway:', cdk.Fn.ref('AWS::Region'), ':lambda:path/2015-03-31/functions/', cdk.Fn.ref(`function${path.lambdaFunction}Arn`), '/invocations', ]), passthroughBehavior: 'when_no_match', httpMethod: 'POST', type: 'aws_proxy', }, }, }; if (path.permissions.setting !== PermissionSetting.OPEN) { defaultPathObject['x-amazon-apigateway-any-method'].security = [ { sigv4: [], }, ]; } return defaultPathObject; }; const defaultCorsResponseObject = { statusCode: '200', responseParameters: { 'method.response.header.Access-Control-Allow-Methods': "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", 'method.response.header.Access-Control-Allow-Origin': "'*'", }, }; const defaultCorsGatewayResponseParams = { 'gatewayresponse.header.Access-Control-Allow-Origin': "'*'", 'gatewayresponse.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", 'gatewayresponse.header.Access-Control-Allow-Methods': "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", 'gatewayresponse.header.Access-Control-Expose-Headers': "'Date,X-Amzn-ErrorType'", }; const response200 = { description: '200 response', headers: { 'Access-Control-Allow-Origin': { type: 'string', }, 'Access-Control-Allow-Methods': { type: 'string', }, 'Access-Control-Allow-Headers': { type: 'string', }, }, };