Skip to content

Commit 42d7d6b

Browse files
authored
feat: add regional and edge support (twilio#86)
1 parent 09d20da commit 42d7d6b

File tree

7 files changed

+360
-19
lines changed

7 files changed

+360
-19
lines changed

src/base-commands/twilio-client-command.js

+1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class TwilioClientCommand extends BaseCommand {
111111
buildClient(ClientClass) {
112112
return new ClientClass(this.currentProfile.apiKey, this.currentProfile.apiSecret, {
113113
accountSid: this.flags[CliFlags.ACCOUNT_SID] || this.currentProfile.accountSid,
114+
edge: process.env.TWILIO_EDGE || this.userConfig.edge,
114115
region: this.currentProfile.region,
115116
httpClient: this.httpClient
116117
});

src/services/config.js

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
const fs = require('fs-extra');
22
const path = require('path');
3-
const shell = require('shelljs');
43
const MessageTemplates = require('./messaging/templates');
54

65
const CLI_NAME = 'twilio-cli';
@@ -15,14 +14,21 @@ class ConfigDataProfile {
1514

1615
class ConfigData {
1716
constructor() {
17+
this.edge = undefined;
1818
this.email = {};
1919
this.prompts = {};
2020
this.profiles = [];
2121
this.activeProfile = null;
2222
}
2323

2424
getProfileFromEnvironment() {
25-
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_API_KEY, TWILIO_API_SECRET } = process.env;
25+
const {
26+
TWILIO_ACCOUNT_SID,
27+
TWILIO_AUTH_TOKEN,
28+
TWILIO_API_KEY,
29+
TWILIO_API_SECRET,
30+
TWILIO_REGION
31+
} = process.env;
2632
if (!TWILIO_ACCOUNT_SID) return;
2733

2834
if (TWILIO_API_KEY && TWILIO_API_SECRET)
@@ -31,7 +37,8 @@ class ConfigData {
3137
id: '${TWILIO_API_KEY}/${TWILIO_API_SECRET}',
3238
accountSid: TWILIO_ACCOUNT_SID,
3339
apiKey: TWILIO_API_KEY,
34-
apiSecret: TWILIO_API_SECRET
40+
apiSecret: TWILIO_API_SECRET,
41+
region: TWILIO_REGION
3542
};
3643

3744
if (TWILIO_AUTH_TOKEN)
@@ -40,7 +47,8 @@ class ConfigData {
4047
id: '${TWILIO_ACCOUNT_SID}/${TWILIO_AUTH_TOKEN}',
4148
accountSid: TWILIO_ACCOUNT_SID,
4249
apiKey: TWILIO_ACCOUNT_SID,
43-
apiSecret: TWILIO_AUTH_TOKEN
50+
apiSecret: TWILIO_AUTH_TOKEN,
51+
region: TWILIO_REGION
4452
};
4553
}
4654

@@ -130,6 +138,7 @@ class ConfigData {
130138
}
131139

132140
loadFromObject(configObj) {
141+
this.edge = configObj.edge;
133142
this.email = configObj.email || {};
134143
this.prompts = configObj.prompts || {};
135144
// Note the historical 'projects' naming.
@@ -163,15 +172,15 @@ class Config {
163172

164173
async save(configData) {
165174
configData = {
175+
edge: configData.edge,
166176
email: configData.email,
167177
prompts: configData.prompts,
168178
// Note the historical 'projects' naming.
169179
projects: configData.profiles,
170180
activeProject: configData.activeProfile
171181
};
172182

173-
// Migrate to 'fs.mkdirSync' with 'recursive: true' when no longer supporting Node8.
174-
shell.mkdir('-p', this.configDir);
183+
fs.mkdirSync(this.configDir, { recursive: true });
175184
await fs.writeJSON(this.filePath, configData, { flag: 'w' });
176185

177186
return MessageTemplates.configSaved({ path: this.filePath });

src/services/open-api-client.js

+21-11
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const url = require('url');
12
const { logger } = require('./messaging/logging');
23
const { doesObjectHaveProperty } = require('./javascript-utilities');
34
const JsonSchemaConverter = require('./api-schema/json-converter');
@@ -42,20 +43,13 @@ class OpenApiClient {
4243
if (!opts.host) {
4344
opts.host = path.server;
4445
}
45-
46-
if (opts.region) {
47-
const parts = opts.host.split('.');
48-
49-
// From 'https://api.twilio.com/' to 'https://api.{region}.twilio.com/'
50-
if (parts.length > 1 && parts[1] !== opts.region) {
51-
parts.splice(1, 0, opts.region);
52-
opts.host = parts.join('.');
53-
}
54-
}
55-
5646
opts.uri = opts.host + opts.uri;
5747
}
5848

49+
const uri = new url.URL(opts.uri);
50+
uri.hostname = this.getHost(uri.hostname, opts);
51+
opts.uri = uri.href;
52+
5953
opts.params = (isPost ? null : params);
6054
opts.data = (isPost ? params : null);
6155

@@ -97,6 +91,22 @@ class OpenApiClient {
9791
});
9892
}
9993

94+
getHost(host, opts) {
95+
if (opts.region || opts.edge) {
96+
const domain = host.split('.').slice(-2).join('.');
97+
const prefix = host.split('.' + domain)[0];
98+
let [product, edge, region] = prefix.split('.');
99+
if (edge && !region) {
100+
region = edge;
101+
edge = undefined;
102+
}
103+
edge = opts.edge || edge;
104+
region = opts.region || region || (opts.edge && 'us1');
105+
return [product, edge, region, domain].filter(part => part).join('.');
106+
}
107+
return host;
108+
}
109+
100110
parseResponse(domain, operation, response, requestOpts) {
101111
if (response.body) {
102112
const responseSchema = this.getResponseSchema(domain, operation, response.statusCode, requestOpts.headers.Accept);

src/services/twilio-api/twilio-client.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class TwilioApiClient {
2020
this.username = username;
2121
this.password = password;
2222
this.accountSid = opts.accountSid || this.username;
23+
this.edge = opts.edge;
2324
this.region = opts.region;
2425

2526
this.apiClient = new OpenApiClient({
@@ -149,7 +150,8 @@ class TwilioApiClient {
149150
* @param {string} opts.method - The http method
150151
* @param {string} opts.path - The request path
151152
* @param {string} [opts.host] - The request host
152-
* @param {string} [opts.region] - The request region
153+
* @param {string} [opts.edge] - The request edge. Defaults to none.
154+
* @param {string} [opts.region] - The request region. Default to us1 if edge defined
153155
* @param {string} [opts.uri] - The request uri
154156
* @param {string} [opts.username] - The username used for auth
155157
* @param {string} [opts.password] - The password used for auth
@@ -164,7 +166,6 @@ class TwilioApiClient {
164166

165167
opts.username = opts.username || this.username;
166168
opts.password = opts.password || this.password;
167-
opts.region = opts.region || this.region;
168169
opts.headers = opts.headers || {};
169170
opts.data = opts.data || {};
170171
opts.pathParams = opts.pathParams || {};
@@ -186,6 +187,8 @@ class TwilioApiClient {
186187
}
187188
}
188189

190+
opts.edge = opts.edge || this.edge;
191+
opts.region = opts.region || this.region;
189192
return this.apiClient.request(opts);
190193
}
191194
}

test/base-commands/twilio-client-command.test.js

+70
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ const { expect, test, constants } = require('@twilio/cli-test');
22
const TwilioClientCommand = require('../../src/base-commands/twilio-client-command');
33
const { Config, ConfigData } = require('../../src/services/config');
44

5+
const ORIGINAL_ENV = process.env;
6+
57
describe('base-commands', () => {
68
describe('twilio-client-command', () => {
79
class TestClientCommand extends TwilioClientCommand {
@@ -65,6 +67,7 @@ describe('base-commands', () => {
6567
expect(ctx.testCmd.twilioClient.username).to.equal(constants.FAKE_API_KEY);
6668
expect(ctx.testCmd.twilioClient.password).to.equal(constants.FAKE_API_SECRET + 'MyFirstProfile');
6769
expect(ctx.testCmd.twilioClient.region).to.equal(undefined);
70+
expect(ctx.testCmd.twilioClient.edge).to.equal(undefined);
6871
});
6972

7073
setUpTest(['-l', 'debug', '--account-sid', 'ACbaccbaccbaccbaccbaccbaccbaccbacc'], { commandClass: AccountSidClientCommand }).it(
@@ -75,6 +78,7 @@ describe('base-commands', () => {
7578
expect(ctx.testCmd.twilioClient.username).to.equal(constants.FAKE_API_KEY);
7679
expect(ctx.testCmd.twilioClient.password).to.equal(constants.FAKE_API_SECRET + 'MyFirstProfile');
7780
expect(ctx.testCmd.twilioClient.region).to.equal(undefined);
81+
expect(ctx.testCmd.twilioClient.edge).to.equal(undefined);
7882
}
7983
);
8084

@@ -230,5 +234,71 @@ describe('base-commands', () => {
230234
expect(ctx.stderr).to.contain('A fake API error');
231235
});
232236
});
237+
238+
describe('regional and edge support', () => {
239+
const envTest = (
240+
args = [],
241+
{ envRegion, envEdge, configRegion = 'configRegion', configEdge } = {}
242+
) => {
243+
return test
244+
.do(ctx => {
245+
ctx.userConfig = new ConfigData();
246+
ctx.userConfig.edge = configEdge;
247+
248+
if (envRegion) {
249+
process.env.TWILIO_REGION = envRegion;
250+
process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID;
251+
process.env.TWILIO_AUTH_TOKEN = constants.FAKE_API_SECRET;
252+
}
253+
if (envEdge) {
254+
process.env.TWILIO_EDGE = envEdge;
255+
}
256+
257+
ctx.userConfig.addProfile('default-profile', constants.FAKE_ACCOUNT_SID);
258+
ctx.userConfig.addProfile('region-edge-testing', constants.FAKE_ACCOUNT_SID, configRegion);
259+
})
260+
.twilioCliEnv(Config)
261+
.do(async ctx => {
262+
ctx.testCmd = new TwilioClientCommand(args, ctx.fakeConfig);
263+
ctx.testCmd.secureStorage =
264+
{
265+
async getCredentials(profileId) {
266+
return {
267+
apiKey: constants.FAKE_API_KEY,
268+
apiSecret: constants.FAKE_API_SECRET + profileId
269+
};
270+
}
271+
};
272+
273+
// This is essentially what oclif does behind the scenes.
274+
try {
275+
await ctx.testCmd.run();
276+
} catch (error) {
277+
await ctx.testCmd.catch(error);
278+
}
279+
process.env = ORIGINAL_ENV;
280+
});
281+
};
282+
283+
envTest([], { configEdge: 'edge' }).it('should use the config edge when defined', ctx => {
284+
expect(ctx.testCmd.twilioApiClient.edge).to.equal('edge');
285+
expect(ctx.testCmd.twilioApiClient.region).to.be.undefined;
286+
});
287+
288+
envTest(['-p', 'region-edge-testing']).it('should use the config region when defined', ctx => {
289+
expect(ctx.testCmd.twilioApiClient.region).to.equal('configRegion');
290+
expect(ctx.testCmd.twilioApiClient.edge).to.be.undefined;
291+
});
292+
293+
envTest([], { envRegion: 'region' }).it('should use the env region over a config region', ctx => {
294+
expect(ctx.testCmd.twilioApiClient.region).to.equal('region');
295+
expect(ctx.testCmd.twilioApiClient.edge).to.be.undefined;
296+
});
297+
298+
envTest([], { configEdge: 'configEdge', envEdge: 'edge', envRegion: 'region' }).it('should use the env edge over a config edge', ctx => {
299+
expect(ctx.testCmd.twilioApiClient.edge).to.equal('edge');
300+
expect(ctx.testCmd.twilioApiClient.region).to.equal('region');
301+
});
302+
});
233303
});
234304
});

test/services/config.test.js

+24
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe('services', () => {
3535
const profile = configData.getProfileById('DOES_NOT_EXIST');
3636
expect(profile).to.be.undefined;
3737
});
38+
3839
test.it('should return undefined if no profiles, even with env vars', () => {
3940
const configData = new ConfigData();
4041
process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID;
@@ -43,6 +44,7 @@ describe('services', () => {
4344
const profile = configData.getProfileById('DOES_NOT_EXIST');
4445
expect(profile).to.be.undefined;
4546
});
47+
4648
test.it('should return first profile if it exists, and no env vars', () => {
4749
const configData = new ConfigData();
4850
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);
@@ -52,6 +54,7 @@ describe('services', () => {
5254
expect(profile.apiKey).to.be.undefined;
5355
expect(profile.apiSecret).to.be.undefined;
5456
});
57+
5558
test.it('return the active profile if there are multiple profiles', () => {
5659
const configData = new ConfigData();
5760
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);
@@ -64,6 +67,7 @@ describe('services', () => {
6467
expect(profile.apiKey).to.be.undefined;
6568
expect(profile.apiSecret).to.be.undefined;
6669
});
70+
6771
test.it('should return profile populated from AccountSid/AuthToken env vars', () => {
6872
const configData = new ConfigData();
6973
configData.addProfile('envProfile', constants.FAKE_ACCOUNT_SID);
@@ -91,6 +95,21 @@ describe('services', () => {
9195
expect(profile.apiKey).to.equal(constants.FAKE_API_KEY);
9296
expect(profile.apiSecret).to.equal(constants.FAKE_API_SECRET);
9397
});
98+
99+
test.it('should return profile populated with region env var', () => {
100+
const configData = new ConfigData();
101+
configData.addProfile('envProfile', constants.FAKE_ACCOUNT_SID);
102+
103+
process.env.TWILIO_ACCOUNT_SID = constants.FAKE_ACCOUNT_SID;
104+
process.env.TWILIO_AUTH_TOKEN = FAKE_AUTH_TOKEN;
105+
process.env.TWILIO_REGION = 'region';
106+
107+
const profile = configData.getProfileById();
108+
expect(profile.accountSid).to.equal(constants.FAKE_ACCOUNT_SID);
109+
expect(profile.apiKey).to.equal(constants.FAKE_ACCOUNT_SID);
110+
expect(profile.apiSecret).to.equal(FAKE_AUTH_TOKEN);
111+
expect(profile.region).to.equal('region');
112+
});
94113
});
95114

96115
describe('ConfigData.activeProfile', () => {
@@ -104,6 +123,7 @@ describe('services', () => {
104123
expect(active.id).to.equal('firstProfile');
105124
expect(active.accountSid).to.equal(constants.FAKE_ACCOUNT_SID);
106125
});
126+
107127
test.it('should return active profile when active profile has been set', () => {
108128
const configData = new ConfigData();
109129
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);
@@ -115,12 +135,14 @@ describe('services', () => {
115135
expect(active.id).to.equal('secondProfile');
116136
expect(active.accountSid).to.equal('new_account_SID');
117137
});
138+
118139
test.it('should not allow the active profile to not exist', () => {
119140
const configData = new ConfigData();
120141
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);
121142
expect(configData.setActiveProfile('secondProfile')).to.be.undefined;
122143
expect(configData.getActiveProfile().id).to.equal('firstProfile');
123144
});
145+
124146
test.it('should return undefined if profile does not exist and there are no profiles configured', () => {
125147
const configData = new ConfigData();
126148
const active = configData.getActiveProfile();
@@ -143,6 +165,7 @@ describe('services', () => {
143165

144166
expect(configData.profiles.length).to.equal(originalLength);
145167
});
168+
146169
test.it('removes profile', () => {
147170
const configData = new ConfigData();
148171
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);
@@ -154,6 +177,7 @@ describe('services', () => {
154177
expect(configData.profiles[1].id).to.equal('thirdProfile');
155178
expect(configData.profiles[1].accountSid).to.equal('newest_account_SID');
156179
});
180+
157181
test.it('removes active profile', () => {
158182
const configData = new ConfigData();
159183
configData.addProfile('firstProfile', constants.FAKE_ACCOUNT_SID);

0 commit comments

Comments
 (0)