Skip to content

Commit

Permalink
feat(cloudfront): Custom origins and more origin properties (aws#9137)
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 aws#9106


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
njlynch authored and Chriscbr committed Jul 23, 2020
1 parent 76773b3 commit 7704f95
Show file tree
Hide file tree
Showing 3 changed files with 265 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
134 changes: 126 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, Token } 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,26 @@ 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 (Token.isUnresolved(originPath)) { return originPath; }
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 +244,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 +283,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 7704f95

Please sign in to comment.