diff --git a/src/base-commands/twilio-client-command.js b/src/base-commands/twilio-client-command.js index 95bd8c19..bcc15df2 100644 --- a/src/base-commands/twilio-client-command.js +++ b/src/base-commands/twilio-client-command.js @@ -111,6 +111,7 @@ class TwilioClientCommand extends BaseCommand { buildClient(ClientClass) { return new ClientClass(this.currentProfile.apiKey, this.currentProfile.apiSecret, { accountSid: this.flags[CliFlags.ACCOUNT_SID] || this.currentProfile.accountSid, + edge: process.env.TWILIO_EDGE || this.userConfig.edge, region: this.currentProfile.region, httpClient: this.httpClient }); diff --git a/src/services/config.js b/src/services/config.js index 76b7e8e4..3a13d13b 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -1,6 +1,5 @@ const fs = require('fs-extra'); const path = require('path'); -const shell = require('shelljs'); const MessageTemplates = require('./messaging/templates'); const CLI_NAME = 'twilio-cli'; @@ -15,6 +14,7 @@ class ConfigDataProfile { class ConfigData { constructor() { + this.edge = undefined; this.email = {}; this.prompts = {}; this.profiles = []; @@ -22,7 +22,13 @@ class ConfigData { } getProfileFromEnvironment() { - const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_API_KEY, TWILIO_API_SECRET } = process.env; + const { + TWILIO_ACCOUNT_SID, + TWILIO_AUTH_TOKEN, + TWILIO_API_KEY, + TWILIO_API_SECRET, + TWILIO_REGION + } = process.env; if (!TWILIO_ACCOUNT_SID) return; if (TWILIO_API_KEY && TWILIO_API_SECRET) @@ -31,7 +37,8 @@ class ConfigData { id: '${TWILIO_API_KEY}/${TWILIO_API_SECRET}', accountSid: TWILIO_ACCOUNT_SID, apiKey: TWILIO_API_KEY, - apiSecret: TWILIO_API_SECRET + apiSecret: TWILIO_API_SECRET, + region: TWILIO_REGION }; if (TWILIO_AUTH_TOKEN) @@ -40,7 +47,8 @@ class ConfigData { id: '${TWILIO_ACCOUNT_SID}/${TWILIO_AUTH_TOKEN}', accountSid: TWILIO_ACCOUNT_SID, apiKey: TWILIO_ACCOUNT_SID, - apiSecret: TWILIO_AUTH_TOKEN + apiSecret: TWILIO_AUTH_TOKEN, + region: TWILIO_REGION }; } @@ -130,6 +138,7 @@ class ConfigData { } loadFromObject(configObj) { + this.edge = configObj.edge; this.email = configObj.email || {}; this.prompts = configObj.prompts || {}; // Note the historical 'projects' naming. @@ -163,6 +172,7 @@ class Config { async save(configData) { configData = { + edge: configData.edge, email: configData.email, prompts: configData.prompts, // Note the historical 'projects' naming. @@ -170,8 +180,7 @@ class Config { activeProject: configData.activeProfile }; - // Migrate to 'fs.mkdirSync' with 'recursive: true' when no longer supporting Node8. - shell.mkdir('-p', this.configDir); + fs.mkdirSync(this.configDir, { recursive: true }); await fs.writeJSON(this.filePath, configData, { flag: 'w' }); return MessageTemplates.configSaved({ path: this.filePath }); diff --git a/src/services/open-api-client.js b/src/services/open-api-client.js index 7cae1f39..ca00e849 100644 --- a/src/services/open-api-client.js +++ b/src/services/open-api-client.js @@ -1,3 +1,4 @@ +const url = require('url'); const { logger } = require('./messaging/logging'); const { doesObjectHaveProperty } = require('./javascript-utilities'); const JsonSchemaConverter = require('./api-schema/json-converter'); @@ -42,20 +43,13 @@ class OpenApiClient { if (!opts.host) { opts.host = path.server; } - - if (opts.region) { - const parts = opts.host.split('.'); - - // From 'https://api.twilio.com/' to 'https://api.{region}.twilio.com/' - if (parts.length > 1 && parts[1] !== opts.region) { - parts.splice(1, 0, opts.region); - opts.host = parts.join('.'); - } - } - opts.uri = opts.host + opts.uri; } + const uri = new url.URL(opts.uri); + uri.hostname = this.getHost(uri.hostname, opts); + opts.uri = uri.href; + opts.params = (isPost ? null : params); opts.data = (isPost ? params : null); @@ -97,6 +91,22 @@ class OpenApiClient { }); } + getHost(host, opts) { + if (opts.region || opts.edge) { + const domain = host.split('.').slice(-2).join('.'); + const prefix = host.split('.' + domain)[0]; + let [product, edge, region] = prefix.split('.'); + if (edge && !region) { + region = edge; + edge = undefined; + } + edge = opts.edge || edge; + region = opts.region || region || (opts.edge && 'us1'); + return [product, edge, region, domain].filter(part => part).join('.'); + } + return host; + } + parseResponse(domain, operation, response, requestOpts) { if (response.body) { const responseSchema = this.getResponseSchema(domain, operation, response.statusCode, requestOpts.headers.Accept); diff --git a/src/services/twilio-api/twilio-client.js b/src/services/twilio-api/twilio-client.js index 43ac4181..3ebe90ed 100644 --- a/src/services/twilio-api/twilio-client.js +++ b/src/services/twilio-api/twilio-client.js @@ -20,6 +20,7 @@ class TwilioApiClient { this.username = username; this.password = password; this.accountSid = opts.accountSid || this.username; + this.edge = opts.edge; this.region = opts.region; this.apiClient = new OpenApiClient({ @@ -149,7 +150,8 @@ class TwilioApiClient { * @param {string} opts.method - The http method * @param {string} opts.path - The request path * @param {string} [opts.host] - The request host - * @param {string} [opts.region] - The request region + * @param {string} [opts.edge] - The request edge. Defaults to none. + * @param {string} [opts.region] - The request region. Default to us1 if edge defined * @param {string} [opts.uri] - The request uri * @param {string} [opts.username] - The username used for auth * @param {string} [opts.password] - The password used for auth @@ -164,7 +166,6 @@ class TwilioApiClient { opts.username = opts.username || this.username; opts.password = opts.password || this.password; - opts.region = opts.region || this.region; opts.headers = opts.headers || {}; opts.data = opts.data || {}; opts.pathParams = opts.pathParams || {}; @@ -186,6 +187,8 @@ class TwilioApiClient { } } + opts.edge = opts.edge || this.edge; + opts.region = opts.region || this.region; return this.apiClient.request(opts); } } diff --git a/test/base-commands/twilio-client-command.test.js b/test/base-commands/twilio-client-command.test.js index b6890ef9..ea1aaca3 100644 --- a/test/base-commands/twilio-client-command.test.js +++ b/test/base-commands/twilio-client-command.test.js @@ -2,6 +2,8 @@ const { expect, test, constants } = require('@twilio/cli-test'); const TwilioClientCommand = require('../../src/base-commands/twilio-client-command'); const { Config, ConfigData } = require('../../src/services/config'); +const ORIGINAL_ENV = process.env; + describe('base-commands', () => { describe('twilio-client-command', () => { class TestClientCommand extends TwilioClientCommand { @@ -65,6 +67,7 @@ describe('base-commands', () => { expect(ctx.testCmd.twilioClient.username).to.equal(constants.FAKE_API_KEY); expect(ctx.testCmd.twilioClient.password).to.equal(constants.FAKE_API_SECRET + 'MyFirstProfile'); expect(ctx.testCmd.twilioClient.region).to.equal(undefined); + expect(ctx.testCmd.twilioClient.edge).to.equal(undefined); }); setUpTest(['-l', 'debug', '--account-sid', 'ACbaccbaccbaccbaccbaccbaccbaccbacc'], { commandClass: AccountSidClientCommand }).it( @@ -75,6 +78,7 @@ describe('base-commands', () => { expect(ctx.testCmd.twilioClient.username).to.equal(constants.FAKE_API_KEY); expect(ctx.testCmd.twilioClient.password).to.equal(constants.FAKE_API_SECRET + 'MyFirstProfile'); expect(ctx.testCmd.twilioClient.region).to.equal(undefined); + expect(ctx.testCmd.twilioClient.edge).to.equal(undefined); } ); @@ -230,5 +234,71 @@ describe('base-commands', () => { expect(ctx.stderr).to.contain('A fake API error'); }); }); + + describe('regional and edge support', () => { + const envTest = ( + args = [], + { envRegion, envEdge, configRegion = 'configRegion', configEdge } = {} + ) => { + return test + .do(ctx => { + ctx.userConfig = new ConfigData(); + ctx.userConfig.edge = configEdge; + + if (envRegion) { + process.env.TWILIO_REGION = envRegion; + process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID; + process.env.TWILIO_AUTH_TOKEN = constants.FAKE_API_SECRET; + } + if (envEdge) { + process.env.TWILIO_EDGE = envEdge; + } + + ctx.userConfig.addProfile('default-profile', constants.FAKE_ACCOUNT_SID); + ctx.userConfig.addProfile('region-edge-testing', constants.FAKE_ACCOUNT_SID, configRegion); + }) + .twilioCliEnv(Config) + .do(async ctx => { + ctx.testCmd = new TwilioClientCommand(args, ctx.fakeConfig); + ctx.testCmd.secureStorage = + { + async getCredentials(profileId) { + return { + apiKey: constants.FAKE_API_KEY, + apiSecret: constants.FAKE_API_SECRET + profileId + }; + } + }; + + // This is essentially what oclif does behind the scenes. + try { + await ctx.testCmd.run(); + } catch (error) { + await ctx.testCmd.catch(error); + } + process.env = ORIGINAL_ENV; + }); + }; + + envTest([], { configEdge: 'edge' }).it('should use the config edge when defined', ctx => { + expect(ctx.testCmd.twilioApiClient.edge).to.equal('edge'); + expect(ctx.testCmd.twilioApiClient.region).to.be.undefined; + }); + + envTest(['-p', 'region-edge-testing']).it('should use the config region when defined', ctx => { + expect(ctx.testCmd.twilioApiClient.region).to.equal('configRegion'); + expect(ctx.testCmd.twilioApiClient.edge).to.be.undefined; + }); + + envTest([], { envRegion: 'region' }).it('should use the env region over a config region', ctx => { + expect(ctx.testCmd.twilioApiClient.region).to.equal('region'); + expect(ctx.testCmd.twilioApiClient.edge).to.be.undefined; + }); + + envTest([], { configEdge: 'configEdge', envEdge: 'edge', envRegion: 'region' }).it('should use the env edge over a config edge', ctx => { + expect(ctx.testCmd.twilioApiClient.edge).to.equal('edge'); + expect(ctx.testCmd.twilioApiClient.region).to.equal('region'); + }); + }); }); }); diff --git a/test/services/config.test.js b/test/services/config.test.js index 7b3bd623..d89f2fd3 100644 --- a/test/services/config.test.js +++ b/test/services/config.test.js @@ -35,6 +35,7 @@ describe('services', () => { const profile = configData.getProfileById('DOES_NOT_EXIST'); expect(profile).to.be.undefined; }); + test.it('should return undefined if no profiles, even with env vars', () => { const configData = new ConfigData(); process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID; @@ -43,6 +44,7 @@ describe('services', () => { const profile = configData.getProfileById('DOES_NOT_EXIST'); expect(profile).to.be.undefined; }); + test.it('should return first profile if it exists, and no env vars', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); @@ -52,6 +54,7 @@ describe('services', () => { expect(profile.apiKey).to.be.undefined; expect(profile.apiSecret).to.be.undefined; }); + test.it('return the active profile if there are multiple profiles', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); @@ -64,6 +67,7 @@ describe('services', () => { expect(profile.apiKey).to.be.undefined; expect(profile.apiSecret).to.be.undefined; }); + test.it('should return profile populated from AccountSid/AuthToken env vars', () => { const configData = new ConfigData(); configData.addProfile('envProfile', constants.FAKE_ACCOUNT_SID); @@ -91,6 +95,21 @@ describe('services', () => { expect(profile.apiKey).to.equal(constants.FAKE_API_KEY); expect(profile.apiSecret).to.equal(constants.FAKE_API_SECRET); }); + + test.it('should return profile populated with region env var', () => { + const configData = new ConfigData(); + configData.addProfile('envProfile', constants.FAKE_ACCOUNT_SID); + + process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID; + process.env.TWILIO_AUTH_TOKEN = FAKE_AUTH_TOKEN; + process.env.TWILIO_REGION = 'region'; + + const profile = configData.getProfileById(); + expect(profile.accountSid).to.equal(constants.FAKE_ACCOUNT_SID); + expect(profile.apiKey).to.equal(constants.FAKE_ACCOUNT_SID); + expect(profile.apiSecret).to.equal(FAKE_AUTH_TOKEN); + expect(profile.region).to.equal('region'); + }); }); describe('ConfigData.activeProfile', () => { @@ -104,6 +123,7 @@ describe('services', () => { expect(active.id).to.equal('firstProfile'); expect(active.accountSid).to.equal(constants.FAKE_ACCOUNT_SID); }); + test.it('should return active profile when active profile has been set', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); @@ -115,12 +135,14 @@ describe('services', () => { expect(active.id).to.equal('secondProfile'); expect(active.accountSid).to.equal('new_account_SID'); }); + test.it('should not allow the active profile to not exist', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); expect(configData.setActiveProfile('secondProfile')).to.be.undefined; expect(configData.getActiveProfile().id).to.equal('firstProfile'); }); + test.it('should return undefined if profile does not exist and there are no profiles configured', () => { const configData = new ConfigData(); const active = configData.getActiveProfile(); @@ -143,6 +165,7 @@ describe('services', () => { expect(configData.profiles.length).to.equal(originalLength); }); + test.it('removes profile', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); @@ -154,6 +177,7 @@ describe('services', () => { expect(configData.profiles[1].id).to.equal('thirdProfile'); expect(configData.profiles[1].accountSid).to.equal('newest_account_SID'); }); + test.it('removes active profile', () => { const configData = new ConfigData(); configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID); diff --git a/test/services/twilio-api/twilio-client.test.js b/test/services/twilio-api/twilio-client.test.js index c1363531..a0e27ac4 100644 --- a/test/services/twilio-api/twilio-client.test.js +++ b/test/services/twilio-api/twilio-client.test.js @@ -265,6 +265,230 @@ describe('services', () => { expect(options.PageSize).to.be.undefined; }); }); + + describe('regional and edge support', () => { + const defaultRegionTest = test + .nock('https://api.edge.us1.twilio.com', api => { + api.post(`/2010-04-01/Accounts/${accountSid}/Messages.json`).reply(201, { + status: 'queued' + }); + }); + + defaultRegionTest + .it('uses the default region if only edge is defined', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'edge' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json' + }); + expect(response).to.eql({ status: 'queued' }); + }); + + defaultRegionTest + .it('uses the default region if edge is provided', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + edge: 'edge' + }); + expect(response).to.eql({ status: 'queued' }); + }); + + const regionTest = test.nock('https://api.region.twilio.com', api => { + api.post(`/2010-04-01/Accounts/${accountSid}/Messages.json`).reply(201, { + status: 'queued' + }); + }); + + regionTest.it('uses the client region if defined', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, region: 'region' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + // Should ignore the region in the uri + uri: `https://api.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + regionTest.it('uses the provided region if client region defined and region is provided', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, region: 'region2' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + region: 'region', + // Should ignore the region in the uri + uri: `https://api.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + regionTest.it('uses the provided region', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + region: 'region', + // Should ignore the region in the uri + uri: `https://api.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + const edgeRegionTest = + test.nock('https://api.edge.region.twilio.com', api => { + api.post(`/2010-04-01/Accounts/${accountSid}/Messages.json`).reply(201, { + status: 'queued' + }); + }); + + edgeRegionTest.it('should set the region and edge properly', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'edge', region: 'region' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + // Should ignore the edge and region in the uri + uri: `https://api.uriEdge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest.it('uses the client region and provided edge when edge provided', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'clientEdge', region: 'region' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + edge: 'edge', + // Should ignore the edge and region in the uri + uri: `https://api.uriEdge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest.it('uses the provided region and client edge when region provided', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'edge', region: 'clientRegion' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + region: 'region', + // Should ignore the edge and region in the uri + uri: `https://api.uriEdge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest.it('uses the provided region and edge', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + edge: 'edge', + region: 'region', + // Should ignore the edge and region in the uri + uri: `https://api.uriEdge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest + .it('uses the provided region if only edge is defined and region is provided', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'edge' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + region: 'region', + // Should ignore the edge and region in the uri + uri: `https://api.uriEdge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest + .it('uses the uri region and client edge', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, edge: 'edge' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + // Should ignore the edge in the uri + uri: `https://api.uriEdge.region.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest.it('uses the client region and uri edge', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient, region: 'region' } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + // Should ignore the region in the uri + uri: `https://api.edge.uriRegion.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + + edgeRegionTest.it('uses the uri region and edge', async () => { + const client = new TwilioApiClient( + constants.FAKE_API_KEY, constants.FAKE_API_SECRET, + { accountSid, httpClient } + ); + + const response = await client.create({ + domain: 'api', + path: '/2010-04-01/Accounts/{AccountSid}/Messages.json', + uri: `https://api.edge.region.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + }); + expect(response).to.eql({ status: 'queued' }); + }); + }); }); }); });