Skip to content

Commit

Permalink
feat(cloudfront): Custom origins and more origin properties
Browse files Browse the repository at this point in the history
Adds support to the new Distribution construct for custom HTTP origins and
more properties on both S3 and HTTP-based origins.

fixes #9106
  • Loading branch information
njlynch committed Jul 17, 2020
1 parent e8c0b58 commit 1198cf4
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 13 deletions.
11 changes: 11 additions & 0 deletions packages/@aws-cdk/aws-cloudfront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 125 additions & 8 deletions packages/@aws-cdk/aws-cloudfront/lib/origin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, string>;
}

/**
Expand Down Expand Up @@ -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<string, string>;

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;
}

/**
Expand Down Expand Up @@ -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,
};
Expand All @@ -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;
}

}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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}.`);
}
}
133 changes: 128 additions & 5 deletions packages/@aws-cdk/aws-cloudfront/test/origin.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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',
});
Expand All @@ -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' };
}
}

0 comments on commit 1198cf4

Please sign in to comment.