From cb02a604aecc5c9400eceddd42134987bc886ec6 Mon Sep 17 00:00:00 2001 From: Jason Venable Date: Sun, 16 Feb 2020 21:45:52 -0500 Subject: [PATCH 1/2] Add support for HTTP and WebSocket Domain Names --- DomainInfo.ts | 12 +- README.md | 2 + index.ts | 213 ++++++++++++++++++++++++---------- package-lock.json | 107 ++++++++++++----- package.json | 2 +- test/unit-tests/index.test.ts | 161 ++++++++++++++++++++++++- types.ts | 2 + 7 files changed, 396 insertions(+), 103 deletions(-) diff --git a/DomainInfo.ts b/DomainInfo.ts index 113bb75d..dbb1d24e 100644 --- a/DomainInfo.ts +++ b/DomainInfo.ts @@ -18,11 +18,13 @@ class DomainInfo { private defaultSecurityPolicy: string = "TLS_1_2"; constructor(data: any) { - this.domainName = data.distributionDomainName || data.regionalDomainName; - this.hostedZoneId = data.distributionHostedZoneId || - data.regionalHostedZoneId || - this.defaultHostedZoneId; - this.securityPolicy = data.securityPolicy || this.defaultSecurityPolicy; + this.domainName = data.distributionDomainName || data.regionalDomainName || data.DomainName; + this.hostedZoneId = data.distributionHostedZoneId || data.regionalHostedZoneId + || data.DomainNameConfigurations && data.DomainNameConfigurations[0].HostedZoneId + || this.defaultHostedZoneId; + this.securityPolicy = data.securityPolicy + || data.DomainNameConfigurations && data.DomainNameConfigurations[0].SecurityPolicy + || this.defaultSecurityPolicy; } } diff --git a/README.md b/README.md index 70333413..e259439d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 + apiType: http ``` | Parameter Name | Default Value | Description | @@ -81,6 +82,7 @@ custom: | certificateArn | `(none)` | The arn of a specific certificate from Certificate Manager to use with this API. | | createRoute53Record | `true` | Toggles whether or not the plugin will create an A Alias and AAAA Alias records in Route53 mapping the `domainName` to the generated distribution domain name. If false, does not create a record. | | endpointType | edge | Defines the endpoint type, accepts `regional` or `edge`. | +| apiType | rest | Defines the api type, accepts `rest`, `http` or `websocket`. | | hostedZoneId | | If hostedZoneId is set the route53 record set will be created in the matching zone, otherwise the hosted zone will be figured out from the domainName (hosted zone with matching domain). | | hostedZonePrivate | | If hostedZonePrivate is set to `true` then only private hosted zones will be used for route 53 records. If it is set to `false` then only public hosted zones will be used for route53 records. Setting this parameter is specially useful if you have multiple hosted zones with the same domain name (e.g. a public and a private one) | | 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. | diff --git a/index.ts b/index.ts index 9297286c..12ea0382 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,12 @@ const endpointTypes = { regional: "REGIONAL", }; +const apiTypes = { + http: "HTTP", + rest: "REST", + websocket: "WEBSOCKET", +}; + const tlsVersions = { tls_1_0: "TLS_1_0", tls_1_2: "TLS_1_2", @@ -20,6 +26,7 @@ class ServerlessCustomDomain { // AWS SDK resources public apigateway: any; + public apigatewayV2: any; public route53: any; public acm: any; public acmRegion: string; @@ -39,6 +46,7 @@ class ServerlessCustomDomain { private endpointType: string; private stage: string; private securityPolicy: string; + private apiType: string; constructor(serverless: ServerlessInstance, options: ServerlessOptions) { this.serverless = serverless; @@ -135,11 +143,12 @@ class ServerlessCustomDomain { */ public async setupBasePathMapping(): Promise { // check if basepathmapping exists - const restApiId = await this.getRestApiId(); - const currentBasePath = await this.getBasePathMapping(restApiId); - // if basepath that matches restApiId exists, update; else, create + const apiId = await this.getApiId(); + const currentBasePath = await this.getBasePathMapping(apiId); + + // if basepath that matches apiId exists, update; else, create if (!currentBasePath) { - await this.createBasePathMapping(restApiId); + await this.createBasePathMapping(apiId); } else { await this.updateBasePathMapping(currentBasePath); } @@ -180,6 +189,7 @@ class ServerlessCustomDomain { this.serverless.providers.aws.sdk.config.update({maxRetries: 20}); this.apigateway = new this.serverless.providers.aws.sdk.APIGateway(credentials); + this.apigatewayV2 = new this.serverless.providers.aws.sdk.ApiGatewayV2(credentials); this.route53 = new this.serverless.providers.aws.sdk.Route53(credentials); this.cloudformation = new this.serverless.providers.aws.sdk.CloudFormation(credentials); @@ -204,6 +214,14 @@ class ServerlessCustomDomain { } this.endpointType = endpointTypeToUse; + const apiTypeWithDefault = this.serverless.service.custom.customDomain.apiType || + apiTypes.rest; + const apiTypeToUse = apiTypes[apiTypeWithDefault.toLowerCase()]; + if (!apiTypeToUse) { + throw new Error(`${apiTypeWithDefault} is not supported api type, use REST, HTTP or WEBSOCKET.`); + } + this.apiType = apiTypeToUse; + const securityPolicyDefault = this.serverless.service.custom.customDomain.securityPolicy || tlsVersions.tls_1_2; const tlsVersionToUse = tlsVersions[securityPolicyDefault.toLowerCase()]; @@ -324,30 +342,56 @@ class ServerlessCustomDomain { * @param certificateArn: Certificate ARN to use for custom domain */ public async createCustomDomain(certificateArn: string): Promise { - // Set up parameters - const params = { - certificateArn, - domainName: this.givenDomainName, - endpointConfiguration: { - types: [this.endpointType], - }, - regionalCertificateArn: certificateArn, - securityPolicy: this.securityPolicy, - }; - if (this.endpointType === endpointTypes.edge) { - params.regionalCertificateArn = undefined; - } else if (this.endpointType === endpointTypes.regional) { - params.certificateArn = undefined; - } - // Make API call let createdDomain = {}; - try { - createdDomain = await this.apigateway.createDomainName(params).promise(); - } catch (err) { - this.logIfDebug(err); - throw new Error(`Error: Failed to create custom domain ${this.givenDomainName}\n`); + + // Gateway API is completely different for v1 and v2 so seperating into two blocks + if (this.apiType === "REST") { + // Set up parameters + const params = { + certificateArn, + domainName: this.givenDomainName, + endpointConfiguration: { + types: [this.endpointType], + }, + regionalCertificateArn: certificateArn, + securityPolicy: this.securityPolicy, + }; + if (this.endpointType === endpointTypes.edge) { + params.regionalCertificateArn = undefined; + } else if (this.endpointType === endpointTypes.regional) { + params.certificateArn = undefined; + } + + // Make API call to create domain + try { + // If creating REST api use v1 of api gateway, else use v2 for HTTP and Websocket + createdDomain = await this.apigateway.createDomainName(params).promise(); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Failed to create custom domain ${this.givenDomainName}\n`); + } + + } else if (this.apiType === "HTTP" || this.apiType === "WEBSOCKET") { + const params = { + DomainName: this.givenDomainName, + DomainNameConfigurations: [{ + CertificateArn: certificateArn, + EndpointType: this.endpointType, + SecurityPolicy: this.securityPolicy, + }], + }; + + // Make API call to create domain + try { + // If creating REST api use v1 of api gateway, else use v2 for HTTP and Websocket + createdDomain = await this.apigatewayV2.createDomainName(params).promise(); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Failed to create custom domain ${this.givenDomainName}\n`); + } } + return new DomainInfo(createdDomain); } @@ -477,45 +521,86 @@ class ServerlessCustomDomain { } public async getBasePathMapping(restApiId: string): Promise { - const params = { - domainName: this.givenDomainName, - }; let basepathInfo; let currentBasePath; - try { - basepathInfo = await this.apigateway.getBasePathMappings(params).promise(); - } catch (err) { - this.logIfDebug(err); - throw new Error(`Error: Unable to get BasePathMappings for ${this.givenDomainName}`); - } - if (basepathInfo.items !== undefined && basepathInfo.items instanceof Array) { - for (const basepathObj of basepathInfo.items) { - if (basepathObj.restApiId === restApiId) { - currentBasePath = basepathObj.basePath; - break; + + if (this.apiType === "REST") { + const params = { + domainName: this.givenDomainName, + }; + try { + basepathInfo = await this.apigateway.getBasePathMappings(params).promise(); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Unable to get BasePathMappings for ${this.givenDomainName}`); + } + if (basepathInfo.items !== undefined && basepathInfo.items instanceof Array) { + for (const basepathObj of basepathInfo.items) { + if (basepathObj.restApiId === restApiId) { + currentBasePath = basepathObj.basePath; + break; + } } } + return currentBasePath; + + } else if (this.apiType === "HTTP" || this.apiType === "WEBSOCKET") { // V2 HTTP and WEBSOCKET + const params = { + DomainName: this.givenDomainName, + }; + try { + basepathInfo = await this.apigatewayV2.getApiMappings(params).promise(); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Unable to get BasePathMappings for ${this.givenDomainName}`); + } + if (basepathInfo.Items !== undefined && basepathInfo.Items instanceof Array) { + for (const basepathObj of basepathInfo.Items) { + if (basepathObj.ApiId === restApiId) { + currentBasePath = basepathObj.ApiMappingKey; + break; + } + } + } + return currentBasePath; } - return currentBasePath; } /** * Creates basepath mapping */ public async createBasePathMapping(restApiId: string): Promise { - const params = { - basePath: this.basePath, - domainName: this.givenDomainName, - restApiId, - stage: this.stage, - }; - // Make API call - try { - await this.apigateway.createBasePathMapping(params).promise(); - this.serverless.cli.log("Created basepath mapping."); - } catch (err) { - this.logIfDebug(err); - throw new Error(`Error: Unable to create basepath mapping.\n`); + if (this.apiType === "REST") { + const params = { + basePath: this.basePath, + domainName: this.givenDomainName, + restApiId, + stage: this.stage, + }; + // Make API call + try { + await this.apigateway.createBasePathMapping(params).promise(); + this.serverless.cli.log("Created basepath mapping."); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Unable to create basepath mapping.\n`); + } + + } else if (this.apiType === "HTTP" || this.apiType === "WEBSOCKET") { // V2 HTTP and WEBSOCKET + const params = { + ApiId: restApiId, + ApiMappingKey: this.basePath, + DomainName: this.givenDomainName, + Stage: this.stage, + }; + // Make API call + try { + await this.apigatewayV2.createApiMapping(params).promise(); + this.serverless.cli.log("Created basepath mapping."); + } catch (err) { + this.logIfDebug(err); + throw new Error(`Error: Unable to create basepath mapping.\n`); + } } } @@ -547,16 +632,25 @@ class ServerlessCustomDomain { /** * Gets rest API id from CloudFormation stack */ - public async getRestApiId(): Promise { + public async getApiId(): Promise { if (this.serverless.service.provider.apiGateway && this.serverless.service.provider.apiGateway.restApiId) { this.serverless.cli.log(`Mapping custom domain to existing API ${this.serverless.service.provider.apiGateway.restApiId}.`); return this.serverless.service.provider.apiGateway.restApiId; } + const stackName = this.serverless.service.provider.stackName || `${this.serverless.service.service}-${this.stage}`; + + let LogicalResourceId = "ApiGatewayRestApi"; + if (this.apiType === "HTTP") { + LogicalResourceId = "HttpApi"; + } else if (this.apiType === "WEBSOCKET") { + LogicalResourceId = "WebsocketsApi"; + } + const params = { - LogicalResourceId: "ApiGatewayRestApi", + LogicalResourceId, StackName: stackName, }; @@ -567,11 +661,12 @@ class ServerlessCustomDomain { this.logIfDebug(err); throw new Error(`Error: Failed to find CloudFormation resources for ${this.givenDomainName}\n`); } - const restApiId = response.StackResourceDetail.PhysicalResourceId; - if (!restApiId) { - throw new Error(`Error: No RestApiId associated with CloudFormation stack ${stackName}`); + + const apiId = response.StackResourceDetail.PhysicalResourceId; + if (!apiId) { + throw new Error(`Error: No ApiId associated with CloudFormation stack ${stackName}`); } - return restApiId; + return apiId; } /** diff --git a/package-lock.json b/package-lock.json index 4aa7992f..c66bcd2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "serverless-domain-manager", - "version": "3.3.0", + "version": "3.3.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -671,23 +671,28 @@ } }, "es-abstract": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", - "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz", + "integrity": "sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==", "dev": true, "requires": { - "es-to-primitive": "^1.2.0", + "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", "has": "^1.0.3", - "is-callable": "^1.1.4", - "is-regex": "^1.0.4", - "object-keys": "^1.0.12" + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" } }, "es-to-primitive": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", - "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, "requires": { "is-callable": "^1.1.4", @@ -1032,9 +1037,9 @@ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, "hasha": { @@ -1121,15 +1126,15 @@ "dev": true }, "is-callable": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", - "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", "dev": true }, "is-date-object": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", "dev": true }, "is-fullwidth-code-point": { @@ -1145,12 +1150,12 @@ "dev": true }, "is-regex": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", - "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", "dev": true, "requires": { - "has": "^1.0.1" + "has": "^1.0.3" } }, "is-stream": { @@ -1160,12 +1165,12 @@ "dev": true }, "is-symbol": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", - "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "dev": true, "requires": { - "has-symbols": "^1.0.0" + "has-symbols": "^1.0.1" } }, "is-typedarray": { @@ -1766,20 +1771,38 @@ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "dev": true }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, "object.entries": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz", - "integrity": "sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.1.tgz", + "integrity": "sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==", "dev": true, "requires": { "define-properties": "^1.1.3", - "es-abstract": "^1.12.0", + "es-abstract": "^1.17.0-next.1", "function-bind": "^1.1.1", "has": "^1.0.3" } @@ -2370,6 +2393,26 @@ } } }, + "string.prototype.trimleft": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", + "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string.prototype.trimright": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", + "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", diff --git a/package.json b/package.json index 102f8230..e7717b49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-domain-manager", - "version": "3.3.1", + "version": "3.3.2", "engines": { "node": ">=4.0" }, diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index 27bb5c0c..d4b9efc7 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -49,6 +49,7 @@ const constructPlugin = (customDomainOptions) => { sdk: { ACM: aws.ACM, APIGateway: aws.APIGateway, + ApiGatewayV2: aws.ApiGatewayV2, CloudFormation: aws.CloudFormation, Route53: aws.Route53, config: { @@ -60,6 +61,7 @@ const constructPlugin = (customDomainOptions) => { service: { custom: { customDomain: { + apiType: customDomainOptions.apiType, basePath: customDomainOptions.basePath, certificateArn: customDomainOptions.certificateArn, certificateName: customDomainOptions.certificateName, @@ -116,10 +118,24 @@ describe("Custom Domain Plugin", () => { } expect(errored).to.equal(true); }); + + it("Unsupported api type throw exception", () => { + const plugin = constructPlugin({ apiType: "notSupported" }); + + let errored = false; + try { + plugin.initializeVariables(); + } catch (err) { + errored = true; + expect(err.message).to.equal("notSupported is not supported api type, use REST, HTTP or WEBSOCKET."); + } + expect(errored).to.equal(true); + }); + }); describe("Set Domain Name and Base Path", () => { - it("Creates basepath mapping", async () => { + it("Creates basepath mapping for REST api", async () => { AWS.mock("APIGateway", "createBasePathMapping", (params, callback) => { callback(null, params); }); @@ -142,7 +158,7 @@ describe("Custom Domain Plugin", () => { }); }); - it("Updates basepath mapping", async () => { + it("Updates basepath mapping for api", async () => { AWS.mock("APIGateway", "updateBasePathMapping", (params, callback) => { callback(null, params); }); @@ -170,6 +186,30 @@ describe("Custom Domain Plugin", () => { }); }); + it("Creates basepath mapping for HTTP api", async () => { + AWS.mock("ApiGatewayV2", "createApiMapping", (params, callback) => { + callback(null, params); + }); + const plugin = constructPlugin({ + apiType: "http", + basePath: "test_basepath", + domainName: "test_domain", + }); + plugin.initializeVariables(); + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; + plugin.basePath = plugin.serverless.service.custom.customDomain.basePath; + const spy = chai.spy.on(plugin.apigatewayV2, "createApiMapping"); + + await plugin.createBasePathMapping("test_rest_api_id"); + expect(spy).to.have.been.called.with({ + ApiId: "test_rest_api_id", + ApiMappingKey: "test_basepath", + DomainName: "test_domain", + Stage: "test", + }); + }); + it("Add Domain Name and HostedZoneId to stack output", () => { const plugin = constructPlugin({ domainName: "test_domain", @@ -313,6 +353,7 @@ describe("Custom Domain Plugin", () => { }); const plugin = constructPlugin({ domainName: "test_domain"}); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; @@ -322,12 +363,29 @@ describe("Custom Domain Plugin", () => { expect(result.securityPolicy).to.equal("TLS_1_2"); }); + it("Create an HTTP domain name", async () => { + AWS.mock("ApiGatewayV2", "createDomainName", (params, callback) => { + callback(null, { DomainName: "foo", DomainNameConfigurations: [{SecurityPolicy: "TLS_1_2"}]}); + }); + + const plugin = constructPlugin({ domainName: "test_domain", apiType: "http"}); + plugin.initializeVariables(); + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; + + const result = await plugin.createCustomDomain("fake_cert"); + + expect(result.domainName).to.equal("foo"); + expect(result.securityPolicy).to.equal("TLS_1_2"); + }); + it("Create a domain name with specific TLS version", async () => { AWS.mock("APIGateway", "createDomainName", (params, callback) => { callback(null, { distributionDomainName: "foo", securityPolicy: "TLS_1_2"}); }); const plugin = constructPlugin({ domainName: "test_domain", securityPolicy: "tls_1_2"}); + plugin.initializeVariables(); plugin.apigateway = new aws.APIGateway(); plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; @@ -411,7 +469,7 @@ describe("Custom Domain Plugin", () => { }); describe("Gets existing basepath mappings correctly", () => { - it("Returns undefined if no basepaths map to current restApiId", async () => { + it("Returns undefined if no basepaths map to current REST api", async () => { AWS.mock("APIGateway", "getBasePathMappings", (params, callback) => { callback(null, { items: [ @@ -431,7 +489,28 @@ describe("Custom Domain Plugin", () => { expect(result).to.equal(undefined); }); - it("Returns current api", async () => { + it("Returns undefined if no basepaths map to current HTTP api", async () => { + AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => { + callback(null, { + Items: [ + { ApiMappingKey: "(none)", ApiId: "test_rest_api_id_one", Stage: "test" }, + ], + }); + }); + + const plugin = constructPlugin({ + apiType: "http", + domainName: "test_domain", + }); + plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; + plugin.basePath = plugin.serverless.service.custom.customDomain.basePath; + plugin.initializeVariables(); + + const result = await plugin.getBasePathMapping("test_rest_api_id_two"); + expect(result).to.equal(undefined); + }); + + it("Returns current REST api", async () => { AWS.mock("APIGateway", "getBasePathMappings", (params, callback) => { callback(null, { items: [ @@ -452,6 +531,28 @@ describe("Custom Domain Plugin", () => { expect(result).to.equal("api"); }); + it("Returns current HTTP api", async () => { + AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => { + callback(null, { + Items: [ + { ApiMappingKey: "api", ApiId: "test_http_api_id", Stage: "test" }, + ], + }); + }); + + const plugin = constructPlugin({ + apiType: "http", + basePath: "api", + domainName: "test_domain", + }); + plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; + plugin.basePath = plugin.serverless.service.custom.customDomain.basePath; + plugin.initializeVariables(); + + const result = await plugin.getBasePathMapping("test_http_api_id"); + expect(result).to.equal("api"); + }); + afterEach(() => { AWS.restore(); consoleOutput = []; @@ -473,12 +574,59 @@ describe("Custom Domain Plugin", () => { basePath: "test_basepath", domainName: "test_domain", }); + plugin.initializeVariables(); plugin.cloudformation = new aws.CloudFormation(); - const result = await plugin.getRestApiId(); + const result = await plugin.getApiId(); expect(result).to.equal("test_rest_api_id"); }); + // The tests below can be uncommited ater aws-sdk-mock is updated to new version that supports .remock() + + // it("Fetches correct HTTP apiId", async () => { + // AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { + // callback(null, { + // StackResourceDetail: + // { + // LogicalResourceId: "HttpApi", + // PhysicalResourceId: "test_http_api_id", + // }, + // }); + // }); + // const plugin = constructPlugin({ + // apiType: "http", + // basePath: "test_basepath", + // domainName: "test_domain", + // }); + // plugin.initializeVariables(); + // plugin.cloudformation = new aws.CloudFormation(); + + // const result = await plugin.getApiId(); + // expect(result).to.equal("test_http_api_id"); + // }); + + // it("Fetches correct WebSocket apiId", async () => { + // AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { + // callback(null, { + // StackResourceDetail: + // { + // LogicalResourceId: "WebsocketsApi", + // PhysicalResourceId: "test_ws_api_id", + // }, + // }); + // }); + // const plugin = constructPlugin({ + // apiType: "websocket", + // basePath: "test_basepath", + // domainName: "test_domain", + // }); + // plugin.initializeVariables(); + // plugin.cloudformation = new aws.CloudFormation(); + + // const result = await plugin.getApiId(); + // expect(result).to.equal("test_ws_api_id"); + // }); + it("serverless.yml defines explicitly the apiGateway", async () => { AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { callback(null, { @@ -497,7 +645,7 @@ describe("Custom Domain Plugin", () => { plugin.cloudformation = new aws.CloudFormation(); plugin.serverless.service.provider.apiGateway.restApiId = "custom_test_rest_api_id"; - const result = await plugin.getRestApiId(); + const result = await plugin.getApiId(); expect(result).to.equal("custom_test_rest_api_id"); }); @@ -682,6 +830,7 @@ describe("Custom Domain Plugin", () => { plugin.route53 = new aws.Route53(); plugin.acm = new aws.ACM(); plugin.givenDomainName = plugin.serverless.service.custom.customDomain.domainName; + plugin.initializeVariables(); await plugin.createDomain(); expect(consoleOutput[0]).to.equal(`Custom domain ${plugin.givenDomainName} was created. New domains may take up to 40 minutes to be initialized.`); diff --git a/types.ts b/types.ts index 5d612493..0e8c089c 100644 --- a/types.ts +++ b/types.ts @@ -20,6 +20,7 @@ export interface ServerlessInstance { // tslint:disable-line certificateArn: string | undefined, createRoute53Record: boolean | undefined, endpointType: string | undefined, + apiType: string | undefined, hostedZoneId: string | undefined, hostedZonePrivate: boolean | undefined, enabled: boolean | string | undefined, @@ -31,6 +32,7 @@ export interface ServerlessInstance { // tslint:disable-line aws: { sdk: { APIGateway: any, + ApiGatewayV2: any, Route53: any, CloudFormation: any, ACM: any, From 735e57f8ba391b68df7606c34f8cc424ea39123b Mon Sep 17 00:00:00 2001 From: Katafalkas Date: Mon, 24 Jun 2019 17:12:37 +0300 Subject: [PATCH 2/2] Added stack search in Nested Stacks --- index.ts | 53 ++++++++++++++++++++++++++--------- test/unit-tests/index.test.ts | 30 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/index.ts b/index.ts index 12ea0382..ab647a18 100644 --- a/index.ts +++ b/index.ts @@ -579,11 +579,11 @@ class ServerlessCustomDomain { }; // Make API call try { - await this.apigateway.createBasePathMapping(params).promise(); + const resp = await this.apigateway.createBasePathMapping(params).promise(); this.serverless.cli.log("Created basepath mapping."); } catch (err) { this.logIfDebug(err); - throw new Error(`Error: Unable to create basepath mapping.\n`); + throw new Error(`Error: Unable to create basepath mapping.\n API Gateway: ${err.message}`); } } else if (this.apiType === "HTTP" || this.apiType === "WEBSOCKET") { // V2 HTTP and WEBSOCKET @@ -639,7 +639,7 @@ class ServerlessCustomDomain { return this.serverless.service.provider.apiGateway.restApiId; } - const stackName = this.serverless.service.provider.stackName || + const stackFamilyName = this.serverless.service.provider.stackName || `${this.serverless.service.service}-${this.stage}`; let LogicalResourceId = "ApiGatewayRestApi"; @@ -649,22 +649,47 @@ class ServerlessCustomDomain { LogicalResourceId = "WebsocketsApi"; } - const params = { - LogicalResourceId, - StackName: stackName, - }; - + const allStackDescriptions = []; + let nextToken = true; + this.serverless.cli.log("Searching for apiId in stacks"); + while (nextToken) { + try { + const params = { NextToken: undefined }; + if (typeof nextToken === "string") { + params.NextToken = nextToken; + } + const describeStacksResponse = await this.cloudformation.describeStacks(params).promise(); + for (const stackDescription of describeStacksResponse.Stacks) { + allStackDescriptions.push(stackDescription); + } + nextToken = describeStacksResponse.NextToken; + } catch (err) { + this.logIfDebug(err); + } + } + const familyStackNames = allStackDescriptions + .map((stack) => stack.StackName) + .filter((stackName) => stackName.includes(stackFamilyName)); let response; - try { - response = await this.cloudformation.describeStackResource(params).promise(); - } catch (err) { - this.logIfDebug(err); - throw new Error(`Error: Failed to find CloudFormation resources for ${this.givenDomainName}\n`); + for (const familyStackName of familyStackNames) { + try { + response = await this.cloudformation.describeStackResource({ + LogicalResourceId, + StackName: familyStackName, + }).promise(); + break; + } catch (err) { + this.logIfDebug(err); + } + } + if (!response) { + throw new Error(`Error: Failed to find a stack ${stackFamilyName}\n`); } const apiId = response.StackResourceDetail.PhysicalResourceId; + this.serverless.cli.log(`Found apiId: ${apiId}`); if (!apiId) { - throw new Error(`Error: No ApiId associated with CloudFormation stack ${stackName}`); + throw new Error(`Error: No ApiId associated with CloudFormation stack ${stackFamilyName}`); } return apiId; } diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index d4b9efc7..57d8cf95 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -561,6 +561,21 @@ describe("Custom Domain Plugin", () => { describe("Gets Rest API correctly", () => { it("Fetches restApiId correctly when no ApiGateway specified", async () => { + AWS.mock("CloudFormation", "describeStacks", (params, callback) => { + callback(null, { + Stacks: [ + { + StackName: "custom-stage-name-NestedStackOne-U89W84TQIHJK", + }, + { + StackName: "custom-stage-name-NestedStackTwo-U89W84TQIHJK", + }, + { + StackName: "outside-stack-NestedStackZERO-U89W84TQIHJK", + }, + ], + }); + }); AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { callback(null, { StackResourceDetail: @@ -767,6 +782,21 @@ describe("Custom Domain Plugin", () => { AWS.mock("APIGateway", "createBasePathMapping", (params, callback) => { callback(null, params); }); + AWS.mock("CloudFormation", "describeStacks", (params, callback) => { + callback(null, { + Stacks: [ + { + StackName: "custom-stage-name-NestedStackOne-U89W84TQIHJK", + }, + { + StackName: "custom-stage-name-NestedStackTwo-U89W84TQIHJK", + }, + { + StackName: "outside-stack-NestedStackZERO-U89W84TQIHJK", + }, + ], + }); + }); AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { callback(null, { StackResourceDetail: