diff --git a/README.md b/README.md index 6eb0f6b2..fefd053b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,21 @@ custom: securityPolicy: tls_1_2 ``` +Or for multiple domains + +```yaml +custom: + customDomains: + - http: + domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN} + basePath: '' + endpointType: 'regional' + - http: + domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}.foo + basePath: '' + endpointType: 'regional' +``` + | Parameter Name | Default Value | Description | | --- | --- | --- | | domainName _(Required)_ | | The domain name to be created in API Gateway and Route53 (if enabled) for this API. | @@ -124,6 +139,7 @@ allowPathMatching | false | When updating an existing api mapping this will matc | autoDomainWaitFor | `120` | How long to wait for create_domain to finish before starting deployment if domain does not exist immediately. | + ## Running To create the custom domain: diff --git a/package-lock.json b/package-lock.json index c2947526..89139f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "serverless-domain-manager", - "version": "4.1.1", + "version": "4.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/DomainConfig.ts b/src/DomainConfig.ts index 09363675..3e7d443b 100644 --- a/src/DomainConfig.ts +++ b/src/DomainConfig.ts @@ -5,9 +5,12 @@ import * as AWS from "aws-sdk"; // imported for Types import DomainInfo = require("./DomainInfo"); import Globals from "./Globals"; +import { CustomDomain } from "./types"; class DomainConfig { + public acm: any; + public givenDomainName: string; public basePath: string | undefined; public stage: string | undefined; @@ -20,13 +23,15 @@ class DomainConfig { public hostedZonePrivate: boolean | undefined; public enabled: boolean | string | undefined; public securityPolicy: string | undefined; + public autoDomain: boolean | undefined; + public autoDomainWaitFor: string | undefined; public domainInfo: DomainInfo | undefined; public apiId: string | undefined; public apiMapping: AWS.ApiGatewayV2.GetApiMappingResponse; public allowPathMatching: boolean | false; - constructor(config: any) { + constructor(config: CustomDomain) { this.enabled = this.evaluateEnabled(config.enabled); this.givenDomainName = config.domainName; @@ -37,6 +42,8 @@ class DomainConfig { this.hostedZoneId = config.hostedZoneId; this.hostedZonePrivate = config.hostedZonePrivate; this.allowPathMatching = config.allowPathMatching; + this.autoDomain = config.autoDomain; + this.autoDomainWaitFor = config.autoDomainWaitFor; let basePath = config.basePath; if (basePath == null || basePath.trim() === "") { @@ -70,6 +77,11 @@ class DomainConfig { throw new Error(`${securityPolicyDefault} is not a supported securityPolicy, use tls_1_0 or tls_1_2.`); } this.securityPolicy = tlsVersionToUse; + + const region = this.endpointType === Globals.endpointTypes.regional ? + Globals.serverless.providers.aws.getRegion() : "us-east-1"; + const acmCredentials = Object.assign({}, Globals.serverless.providers.aws.getCredentials(), { region }); + this.acm = new Globals.serverless.providers.aws.sdk.ACM(acmCredentials); } /** diff --git a/src/index.ts b/src/index.ts index c4abf0fc..fb412a34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import DomainConfig = require("./DomainConfig"); import DomainInfo = require("./DomainInfo"); import Globals from "./Globals"; -import { ServerlessInstance, ServerlessOptions } from "./types"; +import { CustomDomain, ServerlessInstance, ServerlessOptions } from "./types"; import {getAWSPagedResults, sleep, throttledCall} from "./utils"; const certStatuses = ["PENDING_VALIDATION", "ISSUED", "INACTIVE"]; @@ -15,8 +15,6 @@ class ServerlessCustomDomain { public apigateway: any; public apigatewayV2: any; public route53: any; - public acm: any; - public acmRegion: string; public cloudformation: any; // Serverless specific properties @@ -133,29 +131,31 @@ class ServerlessCustomDomain { * Lifecycle function to createDomain before deploy and add domain info to the CloudFormation stack's Outputs */ public async createOrGetDomainForCfOutputs(): Promise { - 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(); + await Promise.all(this.domains.map(async (domain) => { + const autoDomain = domain.autoDomain; + if (autoDomain === true) { + this.serverless.cli.log("Creating domain name before deploy."); + await this.createDomains(); + } - 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 this.getDomainInfo(); + + if (autoDomain === true) { + const atLeastOneDoesNotExist = () => this.domains.some((d) => !d.domainInfo); + const maxWaitFor = parseInt(domain.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); @@ -220,13 +220,13 @@ class ServerlessCustomDomain { for domain ${domain.givenDomainName}`); } } - })); - 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(); - } + const autoDomain = domain.autoDomain; + if (autoDomain === true) { + this.serverless.cli.log("Deleting domain name after removing base path mapping."); + await this.deleteDomains(); + } + })); } /** @@ -254,7 +254,8 @@ class ServerlessCustomDomain { // Make sure customDomain configuration exists, stop if not if (typeof this.serverless.service.custom === "undefined" - || typeof this.serverless.service.custom.customDomain === "undefined") { + || ( typeof this.serverless.service.custom.customDomain === "undefined" + && typeof this.serverless.service.custom.customDomains === "undefined" )) { throw new Error("serverless-domain-manager: Plugin configuration is missing."); } @@ -269,31 +270,29 @@ class ServerlessCustomDomain { // Loop over the domain configurations and populate the domains array with DomainConfigs this.domains = []; - // If the key of the item in config is an api type it is using per api type domain structure - if (Globals.apiTypes[Object.keys(this.serverless.service.custom.customDomain)[0]]) { - for (const configApiType in this.serverless.service.custom.customDomain) { - if (Globals.apiTypes[configApiType]) { // If statement check to follow tslint - this.serverless.service.custom.customDomain[configApiType].apiType = configApiType; - this.domains.push(new DomainConfig(this.serverless.service.custom.customDomain[configApiType])); - } else { - throw Error(`Error: Invalid API Type, ${configApiType}`); + const customDomains: CustomDomain[] = this.serverless.service.custom.customDomains ? + this.serverless.service.custom.customDomains : + [ this.serverless.service.custom.customDomain ]; + + customDomains.forEach((d) => { + // If the key of the item in config is an api type it is using per api type domain structure + if (Globals.apiTypes[Object.keys(d)[0]]) { + for (const configApiType in d) { + if (Globals.apiTypes[configApiType]) { // If statement check to follow tslint + d[configApiType].apiType = configApiType; + this.domains.push(new DomainConfig(d[configApiType])); + } else { + throw Error(`Error: Invalid API Type, ${configApiType}`); + } } + } else { // Default to single domain config + this.domains.push(new DomainConfig(d)); } - } else { // Default to single domain config - this.domains.push(new DomainConfig(this.serverless.service.custom.customDomain)); - } + }); // Filter inactive domains this.domains = this.domains.filter((domain) => domain.enabled); - // Set ACM Region on the domain configs - for (const dc of this.domains) { - this.acmRegion = dc.endpointType === Globals.endpointTypes.regional ? - this.serverless.providers.aws.getRegion() : "us-east-1"; - const acmCredentials = Object.assign({}, credentials, { region: this.acmRegion }); - this.acm = new this.serverless.providers.aws.sdk.ACM(acmCredentials); - } - // Validate the domain configurations this.validateDomainConfigs(); } @@ -343,7 +342,7 @@ class ServerlessCustomDomain { try { const certificates = await getAWSPagedResults( - this.acm, + domain.acm, "listCertificates", "CertificateSummaryList", "NextToken", @@ -534,7 +533,7 @@ class ServerlessCustomDomain { public async getRoute53HostedZoneId(domain: DomainConfig): Promise { if (domain.hostedZoneId) { this.serverless.cli.log( - `Selected specific hostedZoneId ${this.serverless.service.custom.customDomain.hostedZoneId}`); + `Selected specific hostedZoneId ${domain.hostedZoneId}`); return domain.hostedZoneId; } diff --git a/src/types.ts b/src/types.ts index 34059e66..f429088b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,21 @@ +export interface CustomDomain { // tslint:disable-line + domainName: string; + basePath: string | undefined; + stage: string | undefined; + certificateName: string | undefined; + certificateArn: string | undefined; + createRoute53Record: boolean | undefined; + endpointType: string | undefined; + apiType: string | undefined; + hostedZoneId: string | undefined; + hostedZonePrivate: boolean | undefined; + enabled: boolean | string | undefined; + securityPolicy: string | undefined; + autoDomain: boolean | undefined; + autoDomainWaitFor: string | undefined; + allowPathMatching: boolean | undefined; +} + export interface ServerlessInstance { // tslint:disable-line service: { service: string @@ -12,22 +30,8 @@ export interface ServerlessInstance { // tslint:disable-line }, } custom: { - customDomain: { - domainName: string, - basePath: string | undefined, - stage: string | undefined, - certificateName: string | undefined, - certificateArn: string | undefined, - createRoute53Record: boolean | undefined, - endpointType: string | undefined, - apiType: string | undefined, - hostedZoneId: string | undefined, - hostedZonePrivate: boolean | undefined, - enabled: boolean | string | undefined, - securityPolicy: string | undefined, - autoDomain: boolean | undefined, - autoDomainWaitFor: string | undefined, - }, + customDomain?: CustomDomain | undefined, + customDomains?: CustomDomain[] | undefined, }, }; providers: { diff --git a/test/integration-tests/http-api-multiple/handler.js b/test/integration-tests/http-api-multiple/handler.js new file mode 100644 index 00000000..1bd222d6 --- /dev/null +++ b/test/integration-tests/http-api-multiple/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); +}; diff --git a/test/integration-tests/http-api-multiple/serverless.yml b/test/integration-tests/http-api-multiple/serverless.yml new file mode 100644 index 00000000..58b0c47e --- /dev/null +++ b/test/integration-tests/http-api-multiple/serverless.yml @@ -0,0 +1,29 @@ +service: http-api-${opt:RANDOM_STRING} +provider: + name: aws + runtime: nodejs12.x + region: us-west-2 + stage: dev +functions: + helloWorld: + handler: handler.connect + events: + - httpApi: + method: GET + path: /hello-world +plugins: + - serverless-domain-manager +custom: + customDomains: + - http: + domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN} + basePath: '' + endpointType: 'regional' + - http: + domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}.foo + basePath: '' + endpointType: 'regional' + +package: + exclude: + - node_modules/** diff --git a/test/integration-tests/integration.test.ts b/test/integration-tests/integration.test.ts index fd6a3204..c6d2cd15 100644 --- a/test/integration-tests/integration.test.ts +++ b/test/integration-tests/integration.test.ts @@ -94,6 +94,14 @@ const testCases = [ testFolder: "http-api", testStage: "$default", }, + { + testBasePath: "(none)", + testDescription: "Create HTTP API and domain name", + testDomain: `http-api-${RANDOM_STRING}.${TEST_DOMAIN}`, + testEndpoint: "REGIONAL", + testFolder: "http-api-multiple", + testStage: "$default", + }, { testBasePath: "(none)", testDescription: "Deploy regional domain with TLS 1.0", diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index d338253c..e81b4851 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -35,10 +35,28 @@ const testCreds = { sessionToken: "test_session", }; -const constructPlugin = (customDomainOptions) => { +const constructPlugin = (customDomainOptions, multiple: boolean = false) => { aws.config.update(testCreds); aws.config.region = "eu-west-1"; + const custom = { + allowPathMatching: customDomainOptions.allowPathMatching, + apiType: customDomainOptions.apiType, + autoDomain: customDomainOptions.autoDomain, + autoDomainWaitFor: customDomainOptions.autoDomainWaitFor, + basePath: customDomainOptions.basePath, + certificateArn: customDomainOptions.certificateArn, + certificateName: customDomainOptions.certificateName, + createRoute53Record: customDomainOptions.createRoute53Record, + domainName: customDomainOptions.domainName, + enabled: customDomainOptions.enabled, + endpointType: customDomainOptions.endpointType, + hostedZoneId: customDomainOptions.hostedZoneId, + hostedZonePrivate: customDomainOptions.hostedZonePrivate, + securityPolicy: customDomainOptions.securityPolicy, + stage: customDomainOptions.stage, + }; + const serverless = { cli: { log(str: string) { consoleOutput.push(str); }, @@ -62,22 +80,8 @@ const constructPlugin = (customDomainOptions) => { }, service: { custom: { - customDomain: { - apiType: customDomainOptions.apiType, - autoDomain: customDomainOptions.autoDomain, - autoDomainWaitFor: customDomainOptions.autoDomainWaitFor, - basePath: customDomainOptions.basePath, - certificateArn: customDomainOptions.certificateArn, - certificateName: customDomainOptions.certificateName, - createRoute53Record: customDomainOptions.createRoute53Record, - domainName: customDomainOptions.domainName, - enabled: customDomainOptions.enabled, - endpointType: customDomainOptions.endpointType, - hostedZoneId: customDomainOptions.hostedZoneId, - hostedZonePrivate: customDomainOptions.hostedZonePrivate, - securityPolicy: customDomainOptions.securityPolicy, - stage: customDomainOptions.stage, - }, + customDomain: multiple ? undefined : custom, + customDomains: multiple ? [custom] : undefined, }, provider: { apiGateway: { @@ -527,7 +531,8 @@ describe("Custom Domain Plugin", () => { endpointType: "REGIONAL", }; const plugin = constructPlugin(options); - plugin.acm = new aws.ACM(); + plugin.initializeVariables(); + plugin.domains[0].acm = new aws.ACM(); const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); @@ -540,7 +545,8 @@ describe("Custom Domain Plugin", () => { AWS.mock("ACM", "listCertificates", certTestData); const plugin = constructPlugin({ certificateName: "cert_name" }); - plugin.acm = new aws.ACM(); + plugin.initializeVariables(); + plugin.domains[0].acm = new aws.ACM(); const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); @@ -849,6 +855,7 @@ describe("Custom Domain Plugin", () => { basePath: "test_basepath", domainName: "test_domain", }); + plugin.initializeVariables(); plugin.cloudformation = new aws.CloudFormation(); plugin.serverless.service.provider.apiGateway.restApiId = "custom_test_rest_api_id"; @@ -1019,6 +1026,7 @@ describe("Custom Domain Plugin", () => { }); const plugin = constructPlugin({ domainName: "test_domain"}); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.route53 = new aws.Route53(); plugin.initializeVariables(); @@ -1043,9 +1051,12 @@ describe("Custom Domain Plugin", () => { }); const plugin = constructPlugin({ domainName: "test_domain" }); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.route53 = new aws.Route53(); - plugin.acm = new aws.ACM(); + plugin.domains.forEach((d) => { + d.acm = new aws.ACM(); + }); plugin.initializeVariables(); await plugin.createDomains(); @@ -1070,10 +1081,13 @@ describe("Custom Domain Plugin", () => { }); const plugin = constructPlugin({ domainName: "test_domain" }); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.route53 = new aws.Route53(); - plugin.acm = new aws.ACM(); plugin.initializeVariables(); + plugin.domains.forEach((d) => { + d.acm = new aws.ACM(); + }); await plugin.createDomains(); expect(consoleOutput[0]).to.equal(`Custom domain test_domain already exists.`); }); @@ -1289,8 +1303,10 @@ describe("Custom Domain Plugin", () => { domainName: "", }; const plugin = constructPlugin(options); - plugin.acm = new aws.ACM(); plugin.initializeVariables(); + plugin.domains.forEach((d) => { + d.acm = new aws.ACM(); + }); return plugin.getCertArn(plugin.domains[0]).then(() => { throw new Error("Test has failed. getCertArn did not catch errors."); @@ -1306,6 +1322,7 @@ describe("Custom Domain Plugin", () => { }); const plugin = constructPlugin({ domainName: "test_domain"}); + plugin.initializeVariables(); plugin.route53 = new aws.Route53(); plugin.initializeVariables(); @@ -1322,6 +1339,7 @@ describe("Custom Domain Plugin", () => { callback(null, null); }); const plugin = constructPlugin({ domainName: "test_domain"}); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.initializeVariables(); @@ -1479,12 +1497,12 @@ describe("Custom Domain Plugin", () => { expect(plugin.domains.length).to.equal(0); }); - it("Should throw an Error when passing a parameter that is not boolean", () => { + it("Should throw an Error when passing a parameter that is not boolean", async () => { const plugin = constructPlugin({ enabled: 0 }); let errored = false; try { - plugin.initializeVariables(); + await plugin.hookWrapper(null); } catch (err) { errored = true; expect(err.message).to.equal("serverless-domain-manager: Ambiguous enablement boolean: \"0\""); @@ -1492,12 +1510,12 @@ describe("Custom Domain Plugin", () => { expect(errored).to.equal(true); }); - it("Should throw an Error when passing a parameter that cannot be converted to boolean", () => { + it("Should throw an Error when passing a parameter that cannot be converted to boolean", async () => { const plugin = constructPlugin({ enabled: "yes" }); let errored = false; try { - plugin.initializeVariables(); + await plugin.hookWrapper(null); } catch (err) { errored = true; expect(err.message).to.equal("serverless-domain-manager: Ambiguous enablement boolean: \"yes\""); @@ -1525,6 +1543,20 @@ describe("Custom Domain Plugin", () => { expect(errored).to.equal(true); }); + it("Should thrown an Error when Serverless custom configuration object is missing for multiple domains", () => { + const plugin = constructPlugin({}, true); + delete plugin.serverless.service.custom.customDomains; + + let errored = false; + try { + plugin.initializeVariables(); + } catch (err) { + errored = true; + expect(err.message).to.equal("serverless-domain-manager: Plugin configuration is missing."); + } + expect(errored).to.equal(true); + }); + it("Should thrown an Error when Serverless custom configuration object is missing", () => { const plugin = constructPlugin({}); delete plugin.serverless.service.custom; @@ -1538,10 +1570,6 @@ describe("Custom Domain Plugin", () => { } expect(errored).to.equal(true); }); - - afterEach(() => { - consoleOutput = []; - }); }); describe("AWS paged results", () => {