From 1198cf476c3f578923c88f02595f76fbf10c21dc Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Fri, 17 Jul 2020 14:40:56 +0100 Subject: [PATCH] feat(cloudfront): Custom origins and more origin properties Adds support to the new Distribution construct for custom HTTP origins and more properties on both S3 and HTTP-based origins. fixes #9106 --- packages/@aws-cdk/aws-cloudfront/README.md | 11 ++ .../@aws-cdk/aws-cloudfront/lib/origin.ts | 133 ++++++++++++++++-- .../aws-cloudfront/test/origin.test.ts | 133 +++++++++++++++++- 3 files changed, 264 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/aws-cloudfront/README.md b/packages/@aws-cdk/aws-cloudfront/README.md index 0ceb2f310733a..82617cb1546a3 100644 --- a/packages/@aws-cdk/aws-cloudfront/README.md +++ b/packages/@aws-cdk/aws-cloudfront/README.md @@ -59,6 +59,17 @@ CloudFront's redirect and error handling will be used. In the latter case, the O underlying bucket. This can be used in conjunction with a bucket that is not public to require that your users access your content using CloudFront URLs and not S3 URLs directly. +#### From an HTTP endpoint + +Origins can also be created from other resources (e.g., load balancers, API gateways), or from any accessible HTTP server, given the domain name. + +```ts +// Creates a distribution for an HTTP server. +new cloudfront.Distribution(this, 'myDist', { +defaultBehavior: { origin: cloudfront.Origin.fromHttpServer({ domainName: 'www.example.com' }) }, +}); +``` + ### Domain Names and Certificates When you create a distribution, CloudFront assigns a domain name for the distribution, for example: `d111111abcdef8.cloudfront.net`; this value can diff --git a/packages/@aws-cdk/aws-cloudfront/lib/origin.ts b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts index f876c71bdf4b2..e1c4ea1c8a29c 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/origin.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts @@ -1,5 +1,5 @@ import { IBucket } from '@aws-cdk/aws-s3'; -import { Construct } from '@aws-cdk/core'; +import { Construct, Duration } from '@aws-cdk/core'; import { CfnDistribution } from './cloudfront.generated'; import { OriginProtocolPolicy } from './distribution'; import { OriginAccessIdentity } from './origin_access_identity'; @@ -15,6 +15,36 @@ export interface OriginProps { * The domain name of the Amazon S3 bucket or HTTP server origin. */ readonly domainName: string; + + /** + * An optional path that CloudFront appends to the origin domain name when CloudFront requests content from the origin. + * Must begin, but not end, with '/' (e.g., '/production/images'). + * + * @default '/' + */ + readonly originPath?: string; + + /** + * The number of seconds that CloudFront waits when trying to establish a connection to the origin. + * Valid values are 1-10 seconds, inclusive. + * + * @default Duration.seconds(10) + */ + readonly connectionTimeout?: Duration; + + /** + * The number of times that CloudFront attempts to connect to the origin; valid values are 1, 2, or 3 attempts. + * + * @default 3 + */ + readonly connectionAttempts?: number; + + /** + * A list of HTTP header names and values that CloudFront adds to requests it sends to the origin. + * + * @default {} + */ + readonly customHeaders?: Record; } /** @@ -54,15 +84,34 @@ export abstract class Origin { } } + /** + * Creates an origin from an HTTP server. + */ + public static fromHttpServer(props: HttpOriginProps): Origin { + return new HttpOrigin(props); + } + /** * The domain name of the origin. */ public readonly domainName: string; - private originId!: string; + private readonly originPath?: string; + private readonly connectionTimeout?: Duration; + private readonly connectionAttempts?: number; + private readonly customHeaders?: Record; + + private originId?: string; + + protected constructor(props: OriginProps) { + validateIntInRangeOrUndefined('connectionTimeout', 1, 10, props.connectionTimeout?.toSeconds()); + validateIntInRangeOrUndefined('connectionAttempts', 1, 3, props.connectionAttempts, false); - constructor(props: OriginProps) { this.domainName = props.domainName; + this.originPath = this.validateOriginPath(props.originPath); + this.connectionTimeout = props.connectionTimeout; + this.connectionAttempts = props.connectionAttempts; + this.customHeaders = props.customHeaders; } /** @@ -100,6 +149,10 @@ export abstract class Origin { return { domainName: this.domainName, id: this.id, + originPath: this.originPath, + connectionAttempts: this.connectionAttempts, + connectionTimeout: this.connectionTimeout?.toSeconds(), + originCustomHeaders: this.renderCustomHeaders(), s3OriginConfig, customOriginConfig, }; @@ -115,6 +168,25 @@ export abstract class Origin { return undefined; } + private renderCustomHeaders(): CfnDistribution.OriginCustomHeaderProperty[] | undefined { + if (!this.customHeaders || Object.entries(this.customHeaders).length === 0) { return undefined; } + return Object.entries(this.customHeaders).map(([headerName, headerValue]) => { + return { headerName, headerValue }; + }); + } + + /** + * If the path is defined, it must start with a '/' and not end with a '/'. + * This method takes in the originPath, and returns it back (if undefined) or adds/removes the '/' as appropriate. + */ + private validateOriginPath(originPath?: string): string | undefined { + if (originPath === undefined) { return undefined; } + let path = originPath; + if (!path.startsWith('/')) { path = '/' + path; } + if (path.endsWith('/')) { path = path.substr(0, path.length - 1); } + return path; + } + } /** @@ -171,6 +243,36 @@ export interface HttpOriginProps extends OriginProps { * @default OriginProtocolPolicy.HTTPS_ONLY */ readonly protocolPolicy?: OriginProtocolPolicy; + + /** + * The HTTP port that CloudFront uses to connect to the origin. + * + * @default 80 + */ + readonly httpPort?: number; + + /** + * The HTTPS port that CloudFront uses to connect to the origin. + * + * @default 443 + */ + readonly httpsPort?: number; + + /** + * Specifies how long, in seconds, CloudFront waits for a response from the origin, also known as the origin response timeout. + * The valid range is from 1 to 60 seconds, inclusive. + * + * @default Duration.seconds(30) + */ + readonly readTimeout?: Duration; + + /** + * Specifies how long, in seconds, CloudFront persists its connection to the origin. + * The valid range is from 1 to 60 seconds, inclusive. + * + * @default Duration.seconds(5) + */ + readonly keepaliveTimeout?: Duration; } /** @@ -180,16 +282,31 @@ export interface HttpOriginProps extends OriginProps { */ export class HttpOrigin extends Origin { - private readonly protocolPolicy?: OriginProtocolPolicy; - - constructor(props: HttpOriginProps) { + constructor(private readonly props: HttpOriginProps) { super(props); - this.protocolPolicy = props.protocolPolicy; + + validateIntInRangeOrUndefined('readTimeout', 1, 60, props.readTimeout?.toSeconds()); + validateIntInRangeOrUndefined('keepaliveTimeout', 1, 60, props.keepaliveTimeout?.toSeconds()); } protected renderCustomOriginConfig(): CfnDistribution.CustomOriginConfigProperty | undefined { return { - originProtocolPolicy: this.protocolPolicy ?? OriginProtocolPolicy.HTTPS_ONLY, + originProtocolPolicy: this.props.protocolPolicy ?? OriginProtocolPolicy.HTTPS_ONLY, + httpPort: this.props.httpPort, + httpsPort: this.props.httpsPort, + originReadTimeout: this.props.readTimeout?.toSeconds(), + originKeepaliveTimeout: this.props.keepaliveTimeout?.toSeconds(), }; } } + +/** + * Throws an error if a value is defined and not an integer or not in a range. + */ +function validateIntInRangeOrUndefined(name: string, min: number, max: number, value?: number, isDuration: boolean = true) { + if (value === undefined) { return; } + if (!Number.isInteger(value) || value < min || value > max) { + const seconds = isDuration ? ' seconds' : ''; + throw new Error(`${name}: Must be an int between ${min} and ${max}${seconds} (inclusive); received ${value}.`); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts b/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts index b02a10e6300db..a3e57515293fc 100644 --- a/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts +++ b/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts @@ -1,7 +1,7 @@ import '@aws-cdk/assert/jest'; import * as s3 from '@aws-cdk/aws-s3'; -import { App, Stack } from '@aws-cdk/core'; -import { Distribution, Origin } from '../lib'; +import { App, Stack, Duration } from '@aws-cdk/core'; +import { CfnDistribution, Distribution, Origin, OriginProps, HttpOrigin, OriginProtocolPolicy } from '../lib'; let app: App; let stack: Stack; @@ -14,8 +14,7 @@ beforeEach(() => { }); describe('fromBucket', () => { - - test('as bucket, renders all properties, including S3Origin config', () => { + test('as bucket, renders all required properties, including S3Origin config', () => { const bucket = new s3.Bucket(stack, 'Bucket'); const origin = Origin.fromBucket(bucket); @@ -52,7 +51,7 @@ describe('fromBucket', () => { }); }); - test('as website buvcket, renders all properties, including custom origin config', () => { + test('as website bucket, renders all required properties, including custom origin config', () => { const bucket = new s3.Bucket(stack, 'Bucket', { websiteIndexDocument: 'index.html', }); @@ -68,6 +67,130 @@ describe('fromBucket', () => { }, }); }); +}); +describe('HttpOrigin', () => { + test('renders a minimal example with required props', () => { + const origin = new HttpOrigin({ domainName: 'www.example.com' }); + origin._bind(stack, { originIndex: 0 }); + + expect(origin._renderOrigin()).toEqual({ + id: 'StackOrigin029E19582', + domainName: 'www.example.com', + customOriginConfig: { + originProtocolPolicy: 'https-only', + }, + }); + }); + + test('renders an example with all available props', () => { + const origin = new HttpOrigin({ + domainName: 'www.example.com', + originPath: '/app', + connectionTimeout: Duration.seconds(5), + connectionAttempts: 2, + customHeaders: { AUTH: 'NONE' }, + protocolPolicy: OriginProtocolPolicy.MATCH_VIEWER, + httpPort: 8080, + httpsPort: 8443, + readTimeout: Duration.seconds(45), + keepaliveTimeout: Duration.seconds(3), + }); + origin._bind(stack, { originIndex: 0 }); + + expect(origin._renderOrigin()).toEqual({ + id: 'StackOrigin029E19582', + domainName: 'www.example.com', + originPath: '/app', + connectionTimeout: 5, + connectionAttempts: 2, + originCustomHeaders: [{ + headerName: 'AUTH', + headerValue: 'NONE', + }], + customOriginConfig: { + originProtocolPolicy: 'match-viewer', + httpPort: 8080, + httpsPort: 8443, + originReadTimeout: 45, + originKeepaliveTimeout: 3, + }, + }); + }); + + test.each([ + Duration.seconds(0), + Duration.seconds(0.5), + Duration.seconds(60.5), + Duration.seconds(61), + Duration.minutes(5), + ])('validates readTimeout is an integer between 1 and 60 seconds', (readTimeout) => { + expect(() => { + new HttpOrigin({ + domainName: 'www.example.com', + readTimeout, + }); + }).toThrow(`readTimeout: Must be an int between 1 and 60 seconds (inclusive); received ${readTimeout.toSeconds()}.`); + }); + + test.each([ + Duration.seconds(0), + Duration.seconds(0.5), + Duration.seconds(60.5), + Duration.seconds(61), + Duration.minutes(5), + ])('validates keepaliveTimeout is an integer between 1 and 60 seconds', (keepaliveTimeout) => { + expect(() => { + new HttpOrigin({ + domainName: 'www.example.com', + keepaliveTimeout, + }); + }).toThrow(`keepaliveTimeout: Must be an int between 1 and 60 seconds (inclusive); received ${keepaliveTimeout.toSeconds()}.`); + }); +});; + +describe('Origin', () => { + test.each([ + Duration.seconds(0), + Duration.seconds(0.5), + Duration.seconds(10.5), + Duration.seconds(11), + Duration.minutes(5), + ])('validates connectionTimeout is an int between 1 and 10 seconds', (connectionTimeout) => { + expect(() => { + new TestOrigin({ + domainName: 'www.example.com', + connectionTimeout, + }); + }).toThrow(`connectionTimeout: Must be an int between 1 and 10 seconds (inclusive); received ${connectionTimeout.toSeconds()}.`); + }); + + test.each([-0.5, 0.5, 1.5, 4]) + ('validates connectionAttempts is an int between 1 and 3', (connectionAttempts) => { + expect(() => { + new TestOrigin({ + domainName: 'www.example.com', + connectionAttempts, + }); + }).toThrow(`connectionAttempts: Must be an int between 1 and 3 (inclusive); received ${connectionAttempts}.`); + }); + + test.each(['api', '/api', '/api/', 'api/']) + ('enforces that originPath starts but does not end, with a /', (originPath) => { + const origin = new TestOrigin({ + domainName: 'www.example.com', + originPath, + }); + origin._bind(stack, { originIndex: 0 }); + + expect(origin._renderOrigin().originPath).toEqual('/api'); + }); }); +/** Used for testing common Origin functionality */ +class TestOrigin extends Origin { + constructor(props: OriginProps) { super(props); } + protected renderS3OriginConfig(): CfnDistribution.S3OriginConfigProperty | undefined { + return { originAccessIdentity: 'origin-access-identity/cloudfront/MyOAIName' }; + } +} \ No newline at end of file