Skip to content

Commit

Permalink
Merge pull request #356 from bryan-hunter/add-auto-create-domain
Browse files Browse the repository at this point in the history
add autoDomain and autoDomainWaitFor option to config
  • Loading branch information
jconstance-amplify committed Jul 14, 2020
2 parents 13649ca + 2cee458 commit a05d5e4
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 4 deletions.
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -71,6 +71,7 @@ custom:
endpointType: 'regional'
securityPolicy: tls_1_2
apiType: rest
autoDomain: false
```

Multiple API types mapped to different domains can also be supported with the following structure. The key is the API Gateway API type.
Expand Down Expand Up @@ -119,6 +120,8 @@ custom:
| enabled | true | Sometimes there are stages for which is not desired to have custom domain names. This flag allows the developer to disable the plugin for such cases. Accepts either `boolean` or `string` values and defaults to `true` for backwards compatibility. |
securityPolicy | tls_1_2 | The security policy to apply to the custom domain name. Accepts `tls_1_0` or `tls_1_2`|
allowPathMatching | false | When updating an existing api mapping this will match on the basePath instead of the API ID to find existing mappings for an upsate. This should only be used when changing API types. For example, migrating a REST API to an HTTP API. See Changing API Types for more information. |
| autoDomain | `false` | Toggles whether or not the plugin will run `create_domain/delete_domain` as part of `sls deploy/remove` so that multiple commands are not required. |
| autoDomainWaitFor | `120` | How long to wait for create_domain to finish before starting deployment if domain does not exist immediately. |


## Running
Expand Down
35 changes: 31 additions & 4 deletions src/index.ts
Expand Up @@ -5,7 +5,7 @@ import DomainConfig = require("./DomainConfig");
import DomainInfo = require("./DomainInfo");
import Globals from "./Globals";
import { ServerlessInstance, ServerlessOptions } from "./types";
import {getAWSPagedResults, throttledCall} from "./utils";
import {getAWSPagedResults, sleep, throttledCall} from "./utils";

const certStatuses = ["PENDING_VALIDATION", "ISSUED", "INACTIVE"];

Expand Down Expand Up @@ -54,7 +54,7 @@ class ServerlessCustomDomain {
this.hooks = {
"after:deploy:deploy": this.hookWrapper.bind(this, this.setupBasePathMappings),
"after:info:info": this.hookWrapper.bind(this, this.domainSummaries),
"before:deploy:deploy": this.hookWrapper.bind(this, this.updateCloudFormationOutputs),
"before:deploy:deploy": this.hookWrapper.bind(this, this.createOrGetDomainForCfOutputs),
"before:remove:remove": this.hookWrapper.bind(this, this.removeBasePathMappings),
"create_domain:create": this.hookWrapper.bind(this, this.createDomains),
"delete_domain:delete": this.hookWrapper.bind(this, this.deleteDomains),
Expand Down Expand Up @@ -130,12 +130,33 @@ class ServerlessCustomDomain {
}

/**
* Lifecycle function to add domain info to the CloudFormation stack's Outputs
* Lifecycle function to createDomain before deploy and add domain info to the CloudFormation stack's Outputs
*/
public async updateCloudFormationOutputs(): Promise<void> {
public async createOrGetDomainForCfOutputs(): Promise<void> {
const autoDomain = this.serverless.service.custom.customDomain.autoDomain;
if (autoDomain === true) {
this.serverless.cli.log("Creating domain name before deploy.");
await this.createDomains();
}

await this.getDomainInfo();

if (autoDomain === true) {
const atLeastOneDoesNotExist = () => this.domains.some((domain) => !domain.domainInfo);
const maxWaitFor = parseInt(this.serverless.service.custom.customDomain.autoDomainWaitFor, 10) || 120;
const pollInterval = 3;
for (let i = 0; i * pollInterval < maxWaitFor && atLeastOneDoesNotExist() === true; i++) {
this.serverless.cli.log(`
Poll #${i + 1}: polling every ${pollInterval} seconds
for domain to exist or until ${maxWaitFor} seconds
have elapsed before starting deployment
`);

await sleep(pollInterval);
await this.getDomainInfo();
}
}

await Promise.all(this.domains.map(async (domain) => {
this.addOutputs(domain);
}));
Expand Down Expand Up @@ -200,6 +221,12 @@ class ServerlessCustomDomain {
}
}
}));

const autoDomain = this.serverless.service.custom.customDomain.autoDomain;
if (autoDomain === true) {
this.serverless.cli.log("Deleting domain name after removing base path mapping.");
await this.deleteDomains();
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Expand Up @@ -25,6 +25,8 @@ export interface ServerlessInstance { // tslint:disable-line
hostedZonePrivate: boolean | undefined,
enabled: boolean | string | undefined,
securityPolicy: string | undefined,
autoDomain: boolean | undefined,
autoDomainWaitFor: string | undefined,
},
},
};
Expand Down
1 change: 1 addition & 0 deletions src/utils.ts
Expand Up @@ -76,6 +76,7 @@ async function sleep(seconds) {
}

export {
sleep,
getAWSPagedResults,
throttledCall,
};
16 changes: 16 additions & 0 deletions test/integration-tests/auto-domain/handler.js
@@ -0,0 +1,16 @@
"use strict";

module.exports.helloWorld = (event, context, callback) => {
const response = {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "*", // Required for CORS support to work
},
body: JSON.stringify({
message: "Go Serverless v1.0! Your function executed successfully!",
input: event,
}),
};

callback(null, response);
};
27 changes: 27 additions & 0 deletions test/integration-tests/auto-domain/serverless.yml
@@ -0,0 +1,27 @@
# create_domain should be run as part of deployment
service: auto-domain-${opt:RANDOM_STRING}
provider:
name: aws
runtime: nodejs12.x
region: us-west-2
stage: test
functions:
helloWorld:
handler: handler.helloWorld
events:
- http:
path: hello-world
method: get
cors: true
plugins:
- serverless-domain-manager
custom:
customDomain:
domainName: auto-domain-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}
basePath: ''
autoDomain: true
autoDomainWaitFor: 120

package:
exclude:
- node_modules/**
8 changes: 8 additions & 0 deletions test/integration-tests/integration.test.ts
Expand Up @@ -21,6 +21,14 @@ const RANDOM_STRING = randomstring.generate({
const TEMP_DIR = `~/tmp/domain-manager-test-${RANDOM_STRING}`;

const testCases = [
{
testBasePath: "(none)",
testDescription: "Creates domain as part of deploy",
testDomain: `auto-domain-${RANDOM_STRING}.${TEST_DOMAIN}`,
testEndpoint: "EDGE",
testFolder: "auto-domain",
testStage: "dev",
},
{
testBasePath: "(none)",
testDescription: "Enabled with default values",
Expand Down
169 changes: 169 additions & 0 deletions test/unit-tests/index.test.ts
Expand Up @@ -64,6 +64,8 @@ const constructPlugin = (customDomainOptions) => {
custom: {
customDomain: {
apiType: customDomainOptions.apiType,
autoDomain: customDomainOptions.autoDomain,
autoDomainWaitFor: customDomainOptions.autoDomainWaitFor,
basePath: customDomainOptions.basePath,
certificateArn: customDomainOptions.certificateArn,
certificateName: customDomainOptions.certificateName,
Expand Down Expand Up @@ -1576,4 +1578,171 @@ describe("Custom Domain Plugin", () => {
AWS.restore();
});
});

describe("autoDomain deploy", () => {
it("Should be disabled by default", () => {
const plugin = constructPlugin({ domainName: "test_domain" });
plugin.initializeVariables();
expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(undefined);
});

it("createOrGetDomainForCfOutputs should call createDomain when autoDomain is true", async () => {
AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => {
callback(null, params);
});
const plugin = constructPlugin({
autoDomain: true,
basePath: "test_basepath",
createRoute53Record: false,
domainName: "test_domain",
restApiId: "test_rest_api_id",
});
plugin.initializeVariables();

plugin.apigateway = new aws.APIGateway();
plugin.apigatewayV2 = new aws.ApiGatewayV2();
plugin.cloudformation = new aws.CloudFormation();

plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"};

const spy = chai.spy.on(plugin.apigatewayV2, "getDomainName");

await plugin.createOrGetDomainForCfOutputs();

expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true);
expect(spy).to.have.been.called();
});

it("createOrGetDomainForCfOutputs should not call createDomain when autoDomain is not true", async () => {
AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => {
callback(null, params);
});

const plugin = constructPlugin({
autoDomain: false,
basePath: "test_basepath",
createRoute53Record: false,
domainName: "test_domain",
restApiId: "test_rest_api_id",
});
plugin.initializeVariables();

plugin.apigateway = new aws.APIGateway();
plugin.apigatewayV2 = new aws.ApiGatewayV2();
plugin.cloudformation = new aws.CloudFormation();

plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"};

const spy1 = chai.spy.on(plugin.apigateway, "createDomainName");
const spy2 = chai.spy.on(plugin.apigatewayV2, "createDomainName");

await plugin.createOrGetDomainForCfOutputs();

expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(false);
expect(spy1).to.have.not.been.called();
expect(spy2).to.have.not.been.called();
});

it("removeBasePathMapping should call deleteDomain when autoDomain is true", async () => {
AWS.mock("CloudFormation", "describeStackResource", (params, callback) => {
callback(null, {
StackResourceDetail:
{
LogicalResourceId: "ApiGatewayRestApi",
PhysicalResourceId: "test_rest_api_id",
},
});
});
AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => {
callback(null, {
Items: [
{ ApiId: "test_rest_api_id", MappingKey: "test", ApiMappingId: "test_mapping_id", Stage: "test" },
],
});
});
AWS.mock("ApiGatewayV2", "deleteApiMapping", (params, callback) => {
callback(null, params);
});
AWS.mock("ApiGatewayV2", "deleteDomainName", (params, callback) => {
callback(null, params);
});
AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => {
callback(null, params);
});

const plugin = constructPlugin({
autoDomain: true,
basePath: "test_basepath",
createRoute53Record: false,
domainName: "test_domain",
restApiId: "test_rest_api_id",
});
plugin.initializeVariables();

plugin.apigatewayV2 = new aws.ApiGatewayV2();
plugin.cloudformation = new aws.CloudFormation();

plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"};

const spy = chai.spy.on(plugin.apigatewayV2, "deleteDomainName");

await plugin.removeBasePathMappings();

expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true);
expect(spy).to.have.been.called.with({DomainName: "test_domain"});
});

it("removeBasePathMapping should not call deleteDomain when autoDomain is not true", async () => {
AWS.mock("CloudFormation", "describeStackResource", (params, callback) => {
callback(null, {
StackResourceDetail:
{
LogicalResourceId: "ApiGatewayRestApi",
PhysicalResourceId: "test_rest_api_id",
},
});
});
AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => {
callback(null, {
Items: [
{ ApiId: "test_rest_api_id", MappingKey: "test", ApiMappingId: "test_mapping_id", Stage: "test" },
],
});
});
AWS.mock("ApiGatewayV2", "deleteApiMapping", (params, callback) => {
callback(null, params);
});
AWS.mock("ApiGatewayV2", "deleteDomainName", (params, callback) => {
callback(null, params);
});
AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => {
callback(null, params);
});

const plugin = constructPlugin({
autoDomain: false,
basePath: "test_basepath",
createRoute53Record: false,
domainName: "test_domain",
restApiId: "test_rest_api_id",
});
plugin.initializeVariables();

plugin.apigatewayV2 = new aws.ApiGatewayV2();
plugin.cloudformation = new aws.CloudFormation();

plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"};

const spy = chai.spy.on(plugin.apigatewayV2, "deleteDomainName");

await plugin.removeBasePathMappings();

expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(false);
expect(spy).to.have.not.been.called();
});

afterEach(() => {
consoleOutput = [];
});
});
});

0 comments on commit a05d5e4

Please sign in to comment.