@taskcluster/client-web 88.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Client.js ADDED
@@ -0,0 +1,385 @@
1
+ import { withRootUrl } from 'taskcluster-lib-urls';
2
+ import { stringify } from 'query-string';
3
+ import hawk from 'hawk';
4
+ import fetch from './fetch';
5
+
6
+ export default class Client {
7
+ constructor(options = {}) {
8
+ const defaults = {
9
+ credentials: null,
10
+ authorizedScopes: null,
11
+ timeout: 30 * 1000,
12
+ retries: 5,
13
+ delayFactor: 100,
14
+ randomizationFactor: 0.25,
15
+ maxDelay: 30 * 1000,
16
+ serviceName: '',
17
+ serviceVersion: 'v1',
18
+ exchangePrefix: '',
19
+ credentialAgent: null,
20
+ };
21
+
22
+ this.options = {
23
+ ...defaults,
24
+ ...options,
25
+ };
26
+
27
+ if (!this.options.rootUrl) {
28
+ throw new Error('Missing required option "rootUrl"');
29
+ }
30
+
31
+ if (
32
+ this.options.randomizationFactor < 0 ||
33
+ this.options.randomizationFactor >= 1
34
+ ) {
35
+ throw new Error('options.randomizationFactor must be between 0 and 1');
36
+ }
37
+
38
+ if (this.options.accessToken) {
39
+ throw new Error(
40
+ 'options.accessToken is no longer supported; use options.credentials',
41
+ );
42
+ }
43
+
44
+ const { reference } = options;
45
+
46
+ if (reference) {
47
+ if (reference.serviceName) {
48
+ this.options.serviceName = reference.serviceName;
49
+ }
50
+
51
+ if (reference.serviceVersion) {
52
+ this.options.serviceVersion = reference.serviceVersion;
53
+ }
54
+
55
+ if (reference.exchangePrefix) {
56
+ this.options.exchangePrefix = reference.exchangePrefix;
57
+ }
58
+
59
+ if (reference.entries) {
60
+ reference.entries.forEach(entry => {
61
+ if (entry.type === 'function') {
62
+ // eslint-disable-next-line func-names
63
+ this[entry.name] = function(...args) {
64
+ this.validate(entry, args);
65
+
66
+ return this.request(entry, args);
67
+ };
68
+
69
+ this[entry.name].entry = entry;
70
+ }
71
+
72
+ if (entry.type === 'topic-exchange') {
73
+ // eslint-disable-next-line func-names
74
+ this[entry.name] = function(pattern) {
75
+ return this.normalizePattern(entry, pattern);
76
+ };
77
+ }
78
+ });
79
+ }
80
+ }
81
+ }
82
+
83
+ use(optionsUpdates) {
84
+ const options = { ...this.options, ...optionsUpdates };
85
+
86
+ return new this.constructor(options);
87
+ }
88
+
89
+ getMethodExpectedArity({ input, args }) {
90
+ return input ? args.length + 1 : args.length;
91
+ }
92
+
93
+ /* eslint-disable consistent-return */
94
+ buildExtraData(credentials) {
95
+ if (!credentials) {
96
+ return;
97
+ }
98
+
99
+ const { authorizedScopes } = this.options;
100
+ const { clientId, accessToken, certificate } = credentials;
101
+
102
+ if (!clientId || !accessToken) {
103
+ return;
104
+ }
105
+
106
+ const extra = {};
107
+
108
+ // If there is a certificate we have temporary credentials, and we
109
+ // must provide the certificate
110
+ if (certificate) {
111
+ extra.certificate =
112
+ typeof certificate === 'string' ? JSON.parse(certificate) : certificate;
113
+ }
114
+
115
+ // If set of authorized scopes is provided, we'll restrict the request
116
+ // to only use these scopes
117
+ if (Array.isArray(authorizedScopes)) {
118
+ extra.authorizedScopes = authorizedScopes;
119
+ }
120
+
121
+ // If extra has any keys, base64 encode it
122
+ if (Object.keys(extra).length) {
123
+ return window.btoa(JSON.stringify(extra));
124
+ }
125
+ }
126
+ /* eslint-enable consistent-return */
127
+
128
+ buildEndpoint(entry, args) {
129
+ return entry.route.replace(/<([^<>]+)>/g, (text, arg) => {
130
+ const index = entry.args.indexOf(arg);
131
+
132
+ // Preserve original
133
+ if (index === -1) {
134
+ return text;
135
+ }
136
+
137
+ const param = args[index];
138
+ const type = typeof param;
139
+
140
+ if (type !== 'string' && type !== 'number') {
141
+ throw new Error(
142
+ `URL parameter \`${arg}\` expected a string but was provided type "${type}"`,
143
+ );
144
+ }
145
+
146
+ return encodeURIComponent(param);
147
+ });
148
+ }
149
+
150
+ buildUrl(method, ...args) {
151
+ if (!method) {
152
+ throw new Error('buildUrl is missing required `method` argument');
153
+ }
154
+
155
+ // Find the method
156
+ const { entry } = method;
157
+
158
+ if (!entry || entry.type !== 'function') {
159
+ throw new Error(
160
+ 'Method in buildUrl must be an API method from the same object',
161
+ );
162
+ }
163
+
164
+ // Get the query string options taken
165
+ const optionKeys = entry.query || [];
166
+ const supportsOptions = optionKeys.length !== 0;
167
+ const arity = entry.args.length;
168
+
169
+ if (
170
+ args.length !== arity &&
171
+ (!supportsOptions || args.length !== arity + 1)
172
+ ) {
173
+ throw new Error(
174
+ `Method \`${entry.name}.buildUrl\` expected ${arity +
175
+ 1} argument(s) but received ${args.length + 1}`,
176
+ );
177
+ }
178
+
179
+ const endpoint = this.buildEndpoint(entry, args);
180
+
181
+ if (args[arity]) {
182
+ Object.keys(args[arity]).forEach(key => {
183
+ if (!optionKeys.includes(key)) {
184
+ throw new Error(
185
+ `Method \`${entry.name}\` expected options ${optionKeys.join(
186
+ ', ',
187
+ )} but received ${key}`,
188
+ );
189
+ }
190
+ });
191
+ }
192
+
193
+ const queryArgs = args[arity] && stringify(args[arity]);
194
+ const query = queryArgs ? `?${queryArgs}` : '';
195
+
196
+ return withRootUrl(this.options.rootUrl).api(
197
+ this.options.serviceName,
198
+ this.options.serviceVersion,
199
+ `${endpoint}${query}`,
200
+ );
201
+ }
202
+
203
+ async buildSignedUrl(method, ...args) {
204
+ const credentials = this.options.credentialAgent
205
+ ? await this.options.credentialAgent.getCredentials()
206
+ : this.options.credentials;
207
+ return this._buildSignedUrlSync(method, credentials, args);
208
+ }
209
+
210
+ buildSignedUrlSync(method, ...args) {
211
+ if (this.options.credentialAgent) {
212
+ throw new Error('buildSignedUrlSync cannot be used with a credentialAgent');
213
+ }
214
+ return this._buildSignedUrlSync(method, this.options.credentials, args);
215
+ }
216
+
217
+ _buildSignedUrlSync(method, credentials, args) {
218
+ if (!method) {
219
+ throw new Error('buildSignedUrl is missing required `method` argument');
220
+ }
221
+
222
+ // Find reference entry
223
+ const { entry } = method;
224
+
225
+ if (entry.method.toLowerCase() !== 'get') {
226
+ throw new Error('buildSignedUrl only works for GET requests');
227
+ }
228
+
229
+ // Default to 15 minutes before expiration
230
+ let expiration = 15 * 60;
231
+ // Check if method supports query-string options
232
+ const supportsOptions = (entry.query || []).length !== 0;
233
+ // if longer than method + args, then we have options too
234
+ const arity = entry.args.length + (supportsOptions ? 1 : 0);
235
+
236
+ if (args.length > arity) {
237
+ // Get request options
238
+ const options = args.pop();
239
+
240
+ if (options.expiration) {
241
+ // eslint-disable-next-line prefer-destructuring
242
+ expiration = options.expiration;
243
+ }
244
+
245
+ if (typeof expiration !== 'number') {
246
+ throw new Error('options.expiration must be a number');
247
+ }
248
+ }
249
+
250
+ const url = this.buildUrl(method, ...args);
251
+
252
+ if (!credentials) {
253
+ throw new Error('buildSignedUrl missing required credentials');
254
+ }
255
+
256
+ const { clientId, accessToken } = credentials;
257
+
258
+ if (!clientId) {
259
+ throw new Error('buildSignedUrl missing required credentials clientId');
260
+ }
261
+
262
+ if (!accessToken) {
263
+ throw new Error(
264
+ 'buildSignedUrl missing required credentials accessToken',
265
+ );
266
+ }
267
+
268
+ const bewit = hawk.uri.getBewit(url, {
269
+ credentials: {
270
+ id: clientId,
271
+ key: accessToken,
272
+ algorithm: 'sha256',
273
+ },
274
+ ttlSec: expiration,
275
+ ext: this.buildExtraData(credentials),
276
+ });
277
+
278
+ return url.includes('?')
279
+ ? `${url}&bewit=${bewit}`
280
+ : `${url}?bewit=${bewit}`;
281
+ }
282
+
283
+ validate(entry, args = []) {
284
+ const expectedArity = this.getMethodExpectedArity(entry);
285
+ const queryOptions = entry.query || [];
286
+ const arity = args.length;
287
+
288
+ if (
289
+ arity !== expectedArity &&
290
+ (queryOptions.length === 0 || arity !== expectedArity + 1)
291
+ ) {
292
+ throw new Error(
293
+ `${
294
+ entry.name
295
+ } expected ${expectedArity} arguments but only received ${arity}`,
296
+ );
297
+ }
298
+
299
+ Object.keys(args[expectedArity] || {}).forEach(key => {
300
+ if (!queryOptions.includes(key)) {
301
+ throw new Error(`${key} is not a valid option for ${entry.name}.
302
+ Valid options include: ${queryOptions.join(', ')}`);
303
+ }
304
+ });
305
+ }
306
+
307
+ normalizePattern(entry, pattern) {
308
+ const initialPattern = pattern || {};
309
+
310
+ if (!(initialPattern instanceof Object)) {
311
+ throw new Error('routingKeyPattern must be an object');
312
+ }
313
+
314
+ const routingKeyPattern = entry.routingKey
315
+ .map(key => {
316
+ const value = key.constant || initialPattern[key.name];
317
+
318
+ if (typeof value === 'number') {
319
+ return `${value}`;
320
+ }
321
+
322
+ if (typeof value === 'string') {
323
+ if (value.includes('.') && !key.multipleWords) {
324
+ throw new Error(
325
+ `routingKeyPattern "${value}" for ${
326
+ key.name
327
+ } cannot contain dots since it does not hold multiple words`,
328
+ );
329
+ }
330
+
331
+ return value;
332
+ }
333
+
334
+ if (value != null) {
335
+ throw new Error(
336
+ `routingKey value "${value}" is not a valid pattern for ${key.name}`,
337
+ );
338
+ }
339
+
340
+ return key.multipleWords ? '#' : '*';
341
+ })
342
+ .join('.');
343
+
344
+ return {
345
+ routingKeyPattern,
346
+ routingKeyReference: entry.routingKey.map(item => ({ ...item })),
347
+ exchange: `${this.options.exchangePrefix}${entry.exchange}`,
348
+ };
349
+ }
350
+
351
+ async request(entry, args) {
352
+ const expectedArity = this.getMethodExpectedArity(entry);
353
+ const endpoint = this.buildEndpoint(entry, args);
354
+ const query = args[expectedArity]
355
+ ? `?${stringify(args[expectedArity])}`
356
+ : '';
357
+ const url = withRootUrl(this.options.rootUrl).api(
358
+ this.options.serviceName,
359
+ this.options.serviceVersion,
360
+ `${endpoint}${query}`,
361
+ );
362
+ const options = { method: entry.method };
363
+ const credentials = this.options.credentialAgent
364
+ ? await this.options.credentialAgent.getCredentials()
365
+ : this.options.credentials;
366
+
367
+ if (entry.input) {
368
+ options.body = JSON.stringify(args[expectedArity - 1]);
369
+ }
370
+
371
+ if (credentials) {
372
+ options.credentials = credentials;
373
+ options.extra = this.buildExtraData(credentials);
374
+ }
375
+
376
+ return fetch(url, options);
377
+ }
378
+ }
379
+
380
+ Client.create = (reference) =>
381
+ class extends Client {
382
+ constructor(options) {
383
+ super({ ...options, reference });
384
+ }
385
+ };