Skip to content

Commit

Permalink
feat(elbv2): add fixed response support for application load balancers (
Browse files Browse the repository at this point in the history
#2328)

It follows the same pattern as the one used for `addTargetGroups` with the check on `priority`.

This allows for the following scenario: an infrastructure stack exporting an application listener with
a default fixed response and multiple application stacks registering their services with specific
paths/headers on the imported application listener.
  • Loading branch information
jogold authored and rix0rrr committed May 6, 2019
1 parent b3792aa commit 750bc8b
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 7 deletions.
11 changes: 11 additions & 0 deletions packages/@aws-cdk/aws-elasticloadbalancingv2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ listener.addTargets('ApplicationFleet', {
The security groups of the load balancer and the target are automatically
updated to allow the network traffic.

Use the `addFixedResponse()` method to add fixed response rules on the listener:

```ts
listener.addFixedResponse('Fixed', {
pathPattern: '/ok',
contentType: elbv2.ContentType.TEXT_PLAIN,
messageBody: 'OK',
statusCode: '200'
});
```

#### Conditions

It's possible to route traffic to targets based on conditions in the incoming
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ export interface BaseApplicationListenerRuleProps {
readonly priority: number;

/**
* Target groups to forward requests to
* Target groups to forward requests to. Only one of `targetGroups` or
* `fixedResponse` can be specified.
*/
readonly targetGroups?: IApplicationTargetGroup[];

/**
* Fixed response to return. Only one of `fixedResponse` or
* `targetGroups` can be specified.
*/
readonly fixedResponse?: FixedResponse;

/**
* Rule applies if the requested host matches the indicated host
*
Expand Down Expand Up @@ -54,6 +61,41 @@ export interface ApplicationListenerRuleProps extends BaseApplicationListenerRul
readonly listener: IApplicationListener;
}

/**
* The content type for a fixed response
*/
export enum ContentType {
TEXT_PLAIN = 'text/plain',
TEXT_CSS = 'text/css',
TEXT_HTML = 'text/html',
APPLICATION_JAVASCRIPT = 'application/javascript',
APPLICATION_JSON = 'application/json'
}

/**
* A fixed response
*/
export interface FixedResponse {
/**
* The HTTP response code (2XX, 4XX or 5XX)
*/
readonly statusCode: string;

/**
* The content type
*
* @default text/plain
*/
readonly contentType?: ContentType;

/**
* The message
*
* @default no message
*/
readonly messageBody?: string;
}

/**
* Define a new listener rule
*/
Expand All @@ -75,6 +117,10 @@ export class ApplicationListenerRule extends cdk.Construct {
throw new Error(`At least one of 'hostHeader' or 'pathPattern' is required when defining a load balancing rule.`);
}

if (props.targetGroups && props.fixedResponse) {
throw new Error('Cannot combine `targetGroups` with `fixedResponse`.');
}

this.listener = props.listener;

const resource = new CfnListenerRule(this, 'Resource', {
Expand All @@ -93,6 +139,10 @@ export class ApplicationListenerRule extends cdk.Construct {

(props.targetGroups || []).forEach(this.addTargetGroup.bind(this));

if (props.fixedResponse) {
this.addFixedResponse(props.fixedResponse);
}

this.listenerRuleArn = resource.ref;
}

Expand All @@ -114,6 +164,18 @@ export class ApplicationListenerRule extends cdk.Construct {
targetGroup.registerListener(this.listener, this);
}

/**
* Add a fixed response
*/
public addFixedResponse(fixedResponse: FixedResponse) {
validateFixedResponse(fixedResponse);

this.actions.push({
fixedResponseConfig: fixedResponse,
type: 'fixed-response'
});
}

/**
* Validate the rule
*/
Expand All @@ -137,3 +199,18 @@ export class ApplicationListenerRule extends cdk.Construct {
return ret;
}
}

/**
* Validate the status code and message body of a fixed response
*
* @internal
*/
export function validateFixedResponse(fixedResponse: FixedResponse) {
if (fixedResponse.statusCode && !/^(2|4|5)\d\d$/.test(fixedResponse.statusCode)) {
throw new Error('`statusCode` must be 2XX, 4XX or 5XX.');
}

if (fixedResponse.messageBody && fixedResponse.messageBody.length > 1024) {
throw new Error('`messageBody` cannot have more than 1024 characters.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { HealthCheck } from '../shared/base-target-group';
import { ApplicationProtocol, SslPolicy } from '../shared/enums';
import { determineProtocolAndPort } from '../shared/util';
import { ApplicationListenerCertificate } from './application-listener-certificate';
import { ApplicationListenerRule } from './application-listener-rule';
import { ApplicationListenerRule, FixedResponse, validateFixedResponse } from './application-listener-rule';
import { IApplicationLoadBalancer } from './application-load-balancer';
import { ApplicationTargetGroup, IApplicationLoadBalancerTarget, IApplicationTargetGroup } from './application-target-group';

Expand Down Expand Up @@ -153,9 +153,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis
* At least one TargetGroup must be added without conditions.
*/
public addTargetGroups(id: string, props: AddApplicationTargetGroupsProps): void {
if ((props.hostHeader !== undefined || props.pathPattern !== undefined) !== (props.priority !== undefined)) {
throw new Error(`Setting 'pathPattern' or 'hostHeader' also requires 'priority', and vice versa`);
}
checkAddRuleProps(props);

if (props.priority !== undefined) {
// New rule
Expand Down Expand Up @@ -215,6 +213,35 @@ export class ApplicationListener extends BaseListener implements IApplicationLis
return group;
}

/**
* Add a fixed response
*/
public addFixedResponse(id: string, props: AddFixedResponseProps) {
checkAddRuleProps(props);

const fixedResponse: FixedResponse = {
statusCode: props.statusCode,
contentType: props.contentType,
messageBody: props.messageBody
};

validateFixedResponse(fixedResponse);

if (props.priority) {
new ApplicationListenerRule(this, id + 'Rule', {
listener: this,
priority: props.priority,
fixedResponse,
...props
});
} else {
this._addDefaultAction({
fixedResponseConfig: fixedResponse,
type: 'fixed-response'
});
}
}

/**
* Register that a connectable that has been added to this load balancer.
*
Expand Down Expand Up @@ -539,3 +566,15 @@ export interface AddApplicationTargetsProps extends AddRuleProps {
*/
readonly healthCheck?: HealthCheck;
}

/**
* Properties for adding a fixed response to a listener
*/
export interface AddFixedResponseProps extends AddRuleProps, FixedResponse {
}

function checkAddRuleProps(props: AddRuleProps) {
if ((props.hostHeader !== undefined || props.pathPattern !== undefined) !== (props.priority !== undefined)) {
throw new Error(`Setting 'pathPattern' or 'hostHeader' also requires 'priority', and vice versa`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ITargetGroup } from './base-target-group';
*/
export abstract class BaseListener extends cdk.Construct {
public readonly listenerArn: string;
private readonly defaultActions: any[] = [];
private readonly defaultActions: CfnListener.ActionProperty[] = [];

constructor(scope: cdk.Construct, id: string, additionalProps: any) {
super(scope, id);
Expand All @@ -30,12 +30,20 @@ export abstract class BaseListener extends cdk.Construct {
return [];
}

/**
* Add an action to the list of default actions of this listener
* @internal
*/
protected _addDefaultAction(action: CfnListener.ActionProperty) {
this.defaultActions.push(action);
}

/**
* Add a TargetGroup to the list of default actions of this listener
* @internal
*/
protected _addDefaultTargetGroup(targetGroup: ITargetGroup) {
this.defaultActions.push({
this._addDefaultAction({
targetGroupArn: targetGroup.targetGroupArn,
type: 'forward'
});
Expand Down
118 changes: 118 additions & 0 deletions packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/test.listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,124 @@ export = {

test.done();
},

'Can add fixed responses'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.VpcNetwork(stack, 'VPC');
const lb = new elbv2.ApplicationLoadBalancer(stack, 'LoadBalancer', {
vpc
});
const listener = lb.addListener('Listener', {
port: 80
});

// WHEN
listener.addFixedResponse('Default', {
contentType: elbv2.ContentType.TEXT_PLAIN,
messageBody: 'Not Found',
statusCode: '404'
});
listener.addFixedResponse('Hello', {
priority: 10,
pathPattern: '/hello',
statusCode: '503'
});

// THEN
expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::Listener', {
DefaultActions: [
{
FixedResponseConfig: {
ContentType: 'text/plain',
MessageBody: 'Not Found',
StatusCode: '404'
},
Type: 'fixed-response'
}
]
}));

expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', {
Actions: [
{
FixedResponseConfig: {
StatusCode: '503'
},
Type: 'fixed-response'
}
]
}));

test.done();
},

'Throws with bad fixed responses': {

'status code'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.VpcNetwork(stack, 'VPC');
const lb = new elbv2.ApplicationLoadBalancer(stack, 'LoadBalancer', {
vpc
});
const listener = lb.addListener('Listener', {
port: 80
});

// THEN
test.throws(() => listener.addFixedResponse('Default', {
statusCode: '301'
}), /`statusCode`/);

test.done();
},

'message body'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.VpcNetwork(stack, 'VPC');
const lb = new elbv2.ApplicationLoadBalancer(stack, 'LoadBalancer', {
vpc
});
const listener = lb.addListener('Listener', {
port: 80
});

// THEN
test.throws(() => listener.addFixedResponse('Default', {
messageBody: 'a'.repeat(1025),
statusCode: '500'
}), /`messageBody`/);

test.done();
}
},

'Throws when specifying both target groups and fixed reponse'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.VpcNetwork(stack, 'VPC');
const lb = new elbv2.ApplicationLoadBalancer(stack, 'LoadBalancer', {
vpc
});
const listener = lb.addListener('Listener', {
port: 80
});

// THEN
test.throws(() => new elbv2.ApplicationListenerRule(stack, 'Rule', {
listener,
priority: 10,
pathPattern: '/hello',
targetGroups: [new elbv2.ApplicationTargetGroup(stack, 'TargetGroup', { vpc, port: 80 })],
fixedResponse: {
statusCode: '500'
}
}), /`targetGroups`.*`fixedResponse`/);

test.done();
}
};

class ResourceWithLBDependency extends cdk.CfnResource {
Expand Down

0 comments on commit 750bc8b

Please sign in to comment.