diff --git a/packages/@aws-cdk/aws-cloudfront/README.md b/packages/@aws-cdk/aws-cloudfront/README.md index debc3b09d734f..3b35de52533da 100644 --- a/packages/@aws-cdk/aws-cloudfront/README.md +++ b/packages/@aws-cdk/aws-cloudfront/README.md @@ -386,6 +386,28 @@ new cloudfront.Distribution(this, 'distro', { }); ``` +### CloudFront Function + +You can also deploy CloudFront functions and add them to a CloudFront distribution. + +```ts +const cfFunction = new cloudfront.Function(stack, 'Function', { + code: cloudfront.FunctionCode.fromInline('function handler(event) { return event.request }'), +}); + +new cloudfront.Distribution(stack, 'distro', { + defaultBehavior: { + origin: new origins.S3Origin(s3Bucket), + functionAssociations: [{ + function: cfFunction, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }], + }, +}); +``` + +It will auto-generate the name of the function and deploy it to the `live` stage. + ### Logging You can configure CloudFront to create log files that contain detailed information about every user request that CloudFront receives. diff --git a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts index 3482d53e9624c..fb1bca2c0b278 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts @@ -5,6 +5,7 @@ import { IResource, Lazy, Resource, Stack, Token, Duration, Names } from '@aws-c import { Construct } from 'constructs'; import { ICachePolicy } from './cache-policy'; import { CfnDistribution } from './cloudfront.generated'; +import { FunctionAssociation } from './function'; import { GeoRestriction } from './geo-restriction'; import { IKeyGroup } from './key-group'; import { IOrigin, OriginBindConfig, OriginBindOptions } from './origin'; @@ -445,7 +446,7 @@ export class Distribution extends Resource implements IDistribution { } private renderViewerCertificate(certificate: acm.ICertificate, - minimumProtocolVersion: SecurityPolicyProtocol = SecurityPolicyProtocol.TLS_V1_2_2019) : CfnDistribution.ViewerCertificateProperty { + minimumProtocolVersion: SecurityPolicyProtocol = SecurityPolicyProtocol.TLS_V1_2_2019): CfnDistribution.ViewerCertificateProperty { return { acmCertificateArn: certificate.certificateArn, sslSupportMethod: SSLMethod.SNI, @@ -706,6 +707,13 @@ export interface AddBehaviorOptions { */ readonly viewerProtocolPolicy?: ViewerProtocolPolicy; + /** + * The CloudFront functions to invoke before serving the contents. + * + * @default - no functions will be invoked + */ + readonly functionAssociations?: FunctionAssociation[]; + /** * The Lambda@Edge functions to invoke before serving the contents. * diff --git a/packages/@aws-cdk/aws-cloudfront/lib/function.ts b/packages/@aws-cdk/aws-cloudfront/lib/function.ts new file mode 100644 index 0000000000000..8f8f396ca6afa --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/lib/function.ts @@ -0,0 +1,180 @@ +import { IResource, Names, Resource, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnFunction } from './cloudfront.generated'; + +/** + * Represents the function's source code + */ +export abstract class FunctionCode { + + /** + * Inline code for function + * @returns `InlineCode` with inline code. + * @param code The actual function code + */ + public static fromInline(code: string): FunctionCode { + return new InlineCode(code); + } + + /** + * renders the function code + */ + public abstract render(): string; +} + +/** + * Represents the function's source code as inline code + */ +class InlineCode extends FunctionCode { + + constructor(private code: string) { + super(); + } + + public render(): string { + return this.code; + } +} + +/** + * Represents a CloudFront Function + */ +export interface IFunction extends IResource { + /** + * The name of the function. + * @attribute + */ + readonly functionName: string; + + /** + * The ARN of the function. + * @attribute + */ + readonly functionArn: string; +} + +/** + * Attributes of an existing CloudFront Function to import it + */ +export interface FunctionAttributes { + /** + * The name of the function. + */ + readonly functionName: string; + + /** + * The ARN of the function. + */ + readonly functionArn: string; +} + +/** + * Properties for creating a CloudFront Function + */ +export interface FunctionProps { + /** + * A name to identify the function. + * @default - generated from the `id` + */ + readonly functionName?: string; + + /** + * A comment to describe the function. + * @default - same as `functionName` + */ + readonly comment?: string; + + /** + * The source code of the function. + */ + readonly code: FunctionCode; +} + +/** + * A CloudFront Function + * + * @resource AWS::CloudFront::Function + */ +export class Function extends Resource implements IFunction { + + /** Imports a function by its name and ARN */ + public static fromFunctionAttributes(scope: Construct, id: string, attrs: FunctionAttributes): IFunction { + return new class extends Resource implements IFunction { + public readonly functionName = attrs.functionName; + public readonly functionArn = attrs.functionArn; + }(scope, id); + } + + /** + * the name of the CloudFront function + * @attribute + */ + public readonly functionName: string; + /** + * the ARN of the CloudFront function + * @attribute + */ + public readonly functionArn: string; + /** + * the deployment stage of the CloudFront function + * @attribute + */ + public readonly functionStage: string; + + constructor(scope: Construct, id: string, props: FunctionProps) { + super(scope, id); + + this.functionName = props.functionName ?? this.generateName(); + + const resource = new CfnFunction(this, 'Resource', { + autoPublish: true, + functionCode: props.code.render(), + functionConfig: { + comment: props.comment ?? this.functionName, + runtime: 'cloudfront-js-1.0', + }, + name: this.functionName, + }); + + this.functionArn = resource.attrFunctionArn; + this.functionStage = resource.attrStage; + } + + private generateName(): string { + const name = Stack.of(this).region + Names.uniqueId(this); + if (name.length > 64) { + return name.substring(0, 32) + name.substring(name.length - 32); + } + return name; + } +} + +/** + * The type of events that a CloudFront function can be invoked in response to. + */ +export enum FunctionEventType { + + /** + * The viewer-request specifies the incoming request + */ + VIEWER_REQUEST = 'viewer-request', + + /** + * The viewer-response specifies the outgoing response + */ + VIEWER_RESPONSE = 'viewer-response', +} + +/** + * Represents a CloudFront function and event type when using CF Functions. + * The type of the {@link AddBehaviorOptions.functionAssociations} property. + */ +export interface FunctionAssociation { + /** + * The CloudFront function that will be invoked. + */ + readonly function: IFunction; + + /** The type of event which should invoke the function. */ + readonly eventType: FunctionEventType; +} diff --git a/packages/@aws-cdk/aws-cloudfront/lib/index.ts b/packages/@aws-cdk/aws-cloudfront/lib/index.ts index 7de2aa62b4412..74b5b4644919c 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/index.ts @@ -1,5 +1,6 @@ export * from './cache-policy'; export * from './distribution'; +export * from './function'; export * from './geo-restriction'; export * from './key-group'; export * from './origin'; diff --git a/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts b/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts index d804dd8465750..4e6f71589bb7e 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts @@ -50,6 +50,10 @@ export class CacheBehavior { originRequestPolicyId: this.props.originRequestPolicy?.originRequestPolicyId, smoothStreaming: this.props.smoothStreaming, viewerProtocolPolicy: this.props.viewerProtocolPolicy ?? ViewerProtocolPolicy.ALLOW_ALL, + functionAssociations: this.props.functionAssociations?.map(association => ({ + functionArn: association.function.functionArn, + eventType: association.eventType.toString(), + })), lambdaFunctionAssociations: this.props.edgeLambdas?.map(edgeLambda => ({ lambdaFunctionArn: edgeLambda.functionVersion.edgeArn, eventType: edgeLambda.eventType.toString(), diff --git a/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts index 17aafe5e4f6fd..db8db2b2bdeb4 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/web-distribution.ts @@ -6,6 +6,7 @@ import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnDistribution } from './cloudfront.generated'; import { HttpVersion, IDistribution, LambdaEdgeEventType, OriginProtocolPolicy, PriceClass, ViewerProtocolPolicy, SSLMethod, SecurityPolicyProtocol } from './distribution'; +import { FunctionAssociation } from './function'; import { GeoRestriction } from './geo-restriction'; import { IKeyGroup } from './key-group'; import { IOriginAccessIdentity } from './origin-access-identity'; @@ -422,6 +423,13 @@ export interface Behavior { */ readonly lambdaFunctionAssociations?: LambdaFunctionAssociation[]; + /** + * The CloudFront functions to invoke before serving the contents. + * + * @default - no functions will be invoked + */ + readonly functionAssociations?: FunctionAssociation[]; + } export interface LambdaFunctionAssociation { @@ -771,9 +779,9 @@ export class CloudFrontWebDistribution extends cdk.Resource implements IDistribu // Comments have an undocumented limit of 128 characters const trimmedComment = - props.comment && props.comment.length > 128 - ? `${props.comment.substr(0, 128 - 3)}...` - : props.comment; + props.comment && props.comment.length > 128 + ? `${props.comment.substr(0, 128 - 3)}...` + : props.comment; let distributionConfig: CfnDistribution.DistributionConfigProperty = { comment: trimmedComment, @@ -957,6 +965,14 @@ export class CloudFrontWebDistribution extends cdk.Resource implements IDistribu if (!input.isDefaultBehavior) { toReturn = Object.assign(toReturn, { pathPattern: input.pathPattern }); } + if (input.functionAssociations) { + toReturn = Object.assign(toReturn, { + functionAssociations: input.functionAssociations.map(association => ({ + functionArn: association.function.functionArn, + eventType: association.eventType.toString(), + })), + }); + } if (input.lambdaFunctionAssociations) { const includeBodyEventTypes = [LambdaEdgeEventType.ORIGIN_REQUEST, LambdaEdgeEventType.VIEWER_REQUEST]; if (input.lambdaFunctionAssociations.some(fna => fna.includeBody && !includeBodyEventTypes.includes(fna.eventType))) { @@ -1069,23 +1085,23 @@ export class CloudFrontWebDistribution extends cdk.Resource implements IDistribu : originConfig.customOriginSource!.domainName, originPath: originConfig.originPath ?? originConfig.customOriginSource?.originPath ?? originConfig.s3OriginSource?.originPath, originCustomHeaders: - originHeaders.length > 0 ? originHeaders : undefined, + originHeaders.length > 0 ? originHeaders : undefined, s3OriginConfig, customOriginConfig: originConfig.customOriginSource ? { httpPort: originConfig.customOriginSource.httpPort || 80, httpsPort: originConfig.customOriginSource.httpsPort || 443, originKeepaliveTimeout: - (originConfig.customOriginSource.originKeepaliveTimeout && - originConfig.customOriginSource.originKeepaliveTimeout.toSeconds()) || - 5, + (originConfig.customOriginSource.originKeepaliveTimeout && + originConfig.customOriginSource.originKeepaliveTimeout.toSeconds()) || + 5, originReadTimeout: - (originConfig.customOriginSource.originReadTimeout && - originConfig.customOriginSource.originReadTimeout.toSeconds()) || - 30, + (originConfig.customOriginSource.originReadTimeout && + originConfig.customOriginSource.originReadTimeout.toSeconds()) || + 30, originProtocolPolicy: - originConfig.customOriginSource.originProtocolPolicy || - OriginProtocolPolicy.HTTPS_ONLY, + originConfig.customOriginSource.originProtocolPolicy || + OriginProtocolPolicy.HTTPS_ONLY, originSslProtocols: originConfig.customOriginSource .allowedOriginSSLVersions || [OriginSslPolicy.TLS_V1_2], } diff --git a/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts b/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts index f6294ad1b6d08..52bd0a81c2c8a 100644 --- a/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts +++ b/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts @@ -4,7 +4,7 @@ import * as acm from '@aws-cdk/aws-certificatemanager'; import * as lambda from '@aws-cdk/aws-lambda'; import * as s3 from '@aws-cdk/aws-s3'; import { App, Duration, Stack } from '@aws-cdk/core'; -import { CfnDistribution, Distribution, GeoRestriction, HttpVersion, IOrigin, LambdaEdgeEventType, PriceClass, SecurityPolicyProtocol } from '../lib'; +import { CfnDistribution, Distribution, Function, FunctionCode, FunctionEventType, GeoRestriction, HttpVersion, IOrigin, LambdaEdgeEventType, PriceClass, SecurityPolicyProtocol } from '../lib'; import { defaultOrigin, defaultOriginGroup } from './test-origin'; let app: App; @@ -730,6 +730,44 @@ describe('with Lambda@Edge functions', () => { }); }); +describe('with CloudFront functions', () => { + + test('can add a CloudFront function to the default behavior', () => { + new Distribution(stack, 'MyDist', { + defaultBehavior: { + origin: defaultOrigin(), + functionAssociations: [ + { + eventType: FunctionEventType.VIEWER_REQUEST, + function: new Function(stack, 'TestFunction', { + code: FunctionCode.fromInline('foo'), + }), + }, + ], + }, + }); + + expect(stack).toHaveResourceLike('AWS::CloudFront::Distribution', { + DistributionConfig: { + DefaultCacheBehavior: { + FunctionAssociations: [ + { + EventType: 'viewer-request', + FunctionARN: { + 'Fn::GetAtt': [ + 'TestFunction22AD90FC', + 'FunctionARN', + ], + }, + }, + ], + }, + }, + }); + }); + +}); + test('price class is included if provided', () => { const origin = defaultOrigin(); new Distribution(stack, 'Dist', { diff --git a/packages/@aws-cdk/aws-cloudfront/test/function.test.ts b/packages/@aws-cdk/aws-cloudfront/test/function.test.ts new file mode 100644 index 0000000000000..22b29ba48c857 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/function.test.ts @@ -0,0 +1,109 @@ +import '@aws-cdk/assert-internal/jest'; +import { expect as expectStack } from '@aws-cdk/assert-internal'; +import { App, Stack } from '@aws-cdk/core'; +import { Function, FunctionCode } from '../lib'; + +describe('CloudFront Function', () => { + + test('minimal example', () => { + const app = new App(); + const stack = new Stack(app, 'Stack', { + env: { account: '123456789012', region: 'testregion' }, + }); + new Function(stack, 'CF2', { + code: FunctionCode.fromInline('code'), + }); + + expectStack(stack).toMatch({ + Resources: { + CF2D7241DD7: { + Type: 'AWS::CloudFront::Function', + Properties: { + Name: 'testregionStackCF2CE3F783F', + AutoPublish: true, + FunctionCode: 'code', + FunctionConfig: { + Comment: 'testregionStackCF2CE3F783F', + Runtime: 'cloudfront-js-1.0', + }, + }, + }, + }, + }); + }); + + test('minimal example in environment agnostic stack', () => { + const app = new App(); + const stack = new Stack(app, 'Stack'); + new Function(stack, 'CF2', { + code: FunctionCode.fromInline('code'), + }); + + expectStack(stack).toMatch({ + Resources: { + CF2D7241DD7: { + Type: 'AWS::CloudFront::Function', + Properties: { + Name: { + 'Fn::Join': [ + '', + [ + { + Ref: 'AWS::Region', + }, + 'StackCF2CE3F783F', + ], + ], + }, + AutoPublish: true, + FunctionCode: 'code', + FunctionConfig: { + Comment: { + 'Fn::Join': [ + '', + [ + { + Ref: 'AWS::Region', + }, + 'StackCF2CE3F783F', + ], + ], + }, + Runtime: 'cloudfront-js-1.0', + }, + }, + }, + }, + }); + }); + + test('maximum example', () => { + const app = new App(); + const stack = new Stack(app, 'Stack', { + env: { account: '123456789012', region: 'testregion' }, + }); + new Function(stack, 'CF2', { + code: FunctionCode.fromInline('code'), + comment: 'My super comment', + functionName: 'FunctionName', + }); + + expectStack(stack).toMatch({ + Resources: { + CF2D7241DD7: { + Type: 'AWS::CloudFront::Function', + Properties: { + Name: 'FunctionName', + AutoPublish: true, + FunctionCode: 'code', + FunctionConfig: { + Comment: 'My super comment', + Runtime: 'cloudfront-js-1.0', + }, + }, + }, + }, + }); + }); + +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.expected.json b/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.expected.json new file mode 100644 index 0000000000000..cc3517ea5897b --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.expected.json @@ -0,0 +1,70 @@ +{ + "Resources": { + "Function76856677": { + "Type": "AWS::CloudFront::Function", + "Properties": { + "Name": "eu-west-1integdistributionfunctionFunctionDCD62A02", + "AutoPublish": true, + "FunctionCode": "function handler(event) { return event.request }", + "FunctionConfig": { + "Comment": "eu-west-1integdistributionfunctionFunctionDCD62A02", + "Runtime": "cloudfront-js-1.0" + } + } + }, + "DistB3B78991": { + "Type": "AWS::CloudFront::Distribution", + "Properties": { + "DistributionConfig": { + "DefaultCacheBehavior": { + "CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", + "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "Function76856677", + "FunctionARN" + ] + } + } + ], + "TargetOriginId": "integdistributionfunctionDistOrigin1D1E9DF17", + "ViewerProtocolPolicy": "allow-all" + }, + "Enabled": true, + "HttpVersion": "http2", + "IPV6Enabled": true, + "Origins": [ + { + "CustomOriginConfig": { + "OriginProtocolPolicy": "https-only" + }, + "DomainName": "www.example.com", + "Id": "integdistributionfunctionDistOrigin1D1E9DF17" + } + ] + } + } + } + }, + "Outputs": { + "FunctionArn": { + "Value": { + "Fn::GetAtt": [ + "Function76856677", + "FunctionARN" + ] + } + }, + "FunctionStage": { + "Value": { + "Fn::GetAtt": [ + "Function76856677", + "Stage" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.ts b/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.ts new file mode 100644 index 0000000000000..df48a6799cdf3 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/integ.distribution-function.ts @@ -0,0 +1,26 @@ +import * as cdk from '@aws-cdk/core'; +import * as cloudfront from '../lib'; +import { TestOrigin } from './test-origin'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'integ-distribution-function', { env: { region: 'eu-west-1' } }); + +const cfFunction = new cloudfront.Function(stack, 'Function', { + code: cloudfront.FunctionCode.fromInline('function handler(event) { return event.request }'), +}); + +new cloudfront.Distribution(stack, 'Dist', { + defaultBehavior: { + origin: new TestOrigin('www.example.com'), + cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, + functionAssociations: [{ + function: cfFunction, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }], + }, +}); + +new cdk.CfnOutput(stack, 'FunctionArn', { value: cfFunction.functionArn }); +new cdk.CfnOutput(stack, 'FunctionStage', { value: cfFunction.functionStage }); + +app.synth(); diff --git a/packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts b/packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts index 6e10b54defc77..517aa18b3364c 100644 --- a/packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts +++ b/packages/@aws-cdk/aws-cloudfront/test/web-distribution.test.ts @@ -7,6 +7,9 @@ import { nodeunitShim, Test } from 'nodeunit-shim'; import { CfnDistribution, CloudFrontWebDistribution, + Function, + FunctionCode, + FunctionEventType, GeoRestriction, KeyGroup, LambdaEdgeEventType, @@ -597,6 +600,52 @@ added the ellipsis so a user would know there was more to ...`, test.done(); }, + 'distribution with CloudFront function-association'(test: Test) { + const stack = new cdk.Stack(); + const sourceBucket = new s3.Bucket(stack, 'Bucket'); + + new CloudFrontWebDistribution(stack, 'AnAmazingWebsiteProbably', { + originConfigs: [ + { + s3OriginSource: { + s3BucketSource: sourceBucket, + }, + behaviors: [ + { + isDefaultBehavior: true, + functionAssociations: [{ + eventType: FunctionEventType.VIEWER_REQUEST, + function: new Function(stack, 'TestFunction', { + code: FunctionCode.fromInline('foo'), + }), + }], + }, + ], + }, + ], + }); + + expect(stack).to(haveResourceLike('AWS::CloudFront::Distribution', { + 'DistributionConfig': { + 'DefaultCacheBehavior': { + 'FunctionAssociations': [ + { + 'EventType': 'viewer-request', + 'FunctionARN': { + 'Fn::GetAtt': [ + 'TestFunction22AD90FC', + 'FunctionARN', + ], + }, + }, + ], + }, + }, + })); + + test.done(); + }, + 'distribution with resolvable lambda-association'(test: Test) { const stack = new cdk.Stack(); const sourceBucket = new s3.Bucket(stack, 'Bucket');