diff --git a/README.md b/README.md index bb0a338e..c30efc7d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ securityPolicy | tls_1_2 | The security policy to apply to the custom domain nam 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. | +| preserveExternalPathMappings | `false` | When `autoDomain` is set to true, and a deployment is removed, setting this wto `true` checks for additional API Gateway base path mappings before automatically deleting the domain, and avoids doing so if they exist. | diff --git a/src/DomainConfig.ts b/src/DomainConfig.ts index ebf22254..714d3703 100644 --- a/src/DomainConfig.ts +++ b/src/DomainConfig.ts @@ -25,6 +25,7 @@ class DomainConfig { public securityPolicy: string | undefined; public autoDomain: boolean | undefined; public autoDomainWaitFor: string | undefined; + public preserveExternalPathMappings: boolean | undefined; public domainInfo: DomainInfo | undefined; public apiId: string | undefined; @@ -44,6 +45,7 @@ class DomainConfig { this.allowPathMatching = config.allowPathMatching; this.autoDomain = config.autoDomain; this.autoDomainWaitFor = config.autoDomainWaitFor; + this.preserveExternalPathMappings = config.preserveExternalPathMappings; let basePath = config.basePath; if (basePath == null || basePath.trim() === "") { diff --git a/src/aws/api-gateway-wrapper.ts b/src/aws/api-gateway-wrapper.ts index ea01763a..f7cdc452 100644 --- a/src/aws/api-gateway-wrapper.ts +++ b/src/aws/api-gateway-wrapper.ts @@ -242,6 +242,26 @@ class APIGatewayWrapper { Globals.logInfo(`Unable to remove basepath mapping for ${domain.givenDomainName}`); } } + + /** + * Checks for presence of external basepath mappings + */ + public async checkExternalPathMappings(domain: DomainConfig): Promise { + Globals.logInfo(`Checking for additional base path mappings on ${domain.givenDomainName}...`); + const mappings = await getAWSPagedResults( + this.apiGatewayV2, + "getApiMappings", + "Items", + "NextToken", + "NextToken", + {DomainName: domain.givenDomainName}, + ); + return mappings.filter((mapping) => + !(mapping.ApiId === domain.apiId || + (mapping.ApiMappingKey === domain.basePath && domain.allowPathMatching) + ), + ).length > 0; + } } export = APIGatewayWrapper; diff --git a/src/index.ts b/src/index.ts index bad5dc12..e337f775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -305,6 +305,8 @@ class ServerlessCustomDomain { */ public async removeBasePathMappings(): Promise { await Promise.all(this.domains.map(async (domain) => { + const preserveExternalPathMappings = !!domain.preserveExternalPathMappings; + let noExternalPathMappingsOnDomain = !preserveExternalPathMappings; try { domain.apiId = await this.getApiId(domain); @@ -315,7 +317,13 @@ class ServerlessCustomDomain { } else { domain.apiMapping = await this.apiGatewayWrapper.getBasePathMapping(domain); await this.apiGatewayWrapper.deleteBasePathMapping(domain); + if (preserveExternalPathMappings) { + noExternalPathMappingsOnDomain = ( + await this.apiGatewayWrapper.checkExternalPathMappings(domain) === false + ); + } } + } catch (err) { if (err.message.indexOf("Failed to find CloudFormation") > -1) { Globals.logInfo(`Unable to find Cloudformation Stack for ${domain.givenDomainName}, @@ -329,7 +337,7 @@ class ServerlessCustomDomain { } const autoDomain = domain.autoDomain; - if (autoDomain === true) { + if (autoDomain === true && noExternalPathMappingsOnDomain) { Globals.logInfo("Deleting domain name after removing base path mapping."); await this.deleteDomain(domain); } diff --git a/src/types.ts b/src/types.ts index a7e2d974..cab84ce8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface CustomDomain { // tslint:disable-line autoDomain: boolean | undefined; autoDomainWaitFor: string | undefined; allowPathMatching: boolean | undefined; + preserveExternalPathMappings: boolean | undefined; } export interface ServerlessInstance { // tslint:disable-line diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index bfbc4198..f9962bb8 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -53,6 +53,7 @@ const constructPlugin = (customDomainOptions, multiple: boolean = false) => { endpointType: customDomainOptions.endpointType, hostedZoneId: customDomainOptions.hostedZoneId, hostedZonePrivate: customDomainOptions.hostedZonePrivate, + preserveExternalPathMappings: customDomainOptions.preserveExternalPathMappings, securityPolicy: customDomainOptions.securityPolicy, stage: customDomainOptions.stage, }; @@ -1840,7 +1841,114 @@ describe("Custom Domain Plugin", () => { expect(spy).to.have.not.been.called(); }); + it("removeBasePathMapping should not call deleteDomain when preserveExternalPathMappings is true and " + + "external mappings exist", 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"}, + { + ApiId: "test_rest_api_id_2", + ApiMappingId: "test_mapping_id", + MappingKey: "test", + 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", + preserveExternalPathMappings: true, + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + plugin.initAWSResources(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGatewayV2, "deleteDomainName"); + + await plugin.removeBasePathMappings(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true); + expect(plugin.serverless.service.custom.customDomain.preserveExternalPathMappings).to.equal(true); + expect(spy).to.have.not.been.called(); + }); + + it("removeBasePathMapping should call deleteDomain when preserveExternalPathMappings is true and " + + "external mappings don't exist", 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", + preserveExternalPathMappings: true, + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + plugin.initAWSResources(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGatewayV2, "deleteDomainName"); + + await plugin.removeBasePathMappings(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true); + expect(plugin.serverless.service.custom.customDomain.preserveExternalPathMappings).to.equal(true); + expect(spy).to.have.been.called(); + }); + afterEach(() => { + AWS.restore(); consoleOutput = []; }); });