@percy/client 1.0.0 → 1.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/dist/client.js +348 -0
- package/dist/index.js +1 -0
- package/dist/proxy.js +205 -0
- package/dist/utils.js +204 -0
- package/package.json +6 -6
- package/test/helpers.js +152 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import PercyEnv from '@percy/env';
|
|
3
|
+
import { git } from '@percy/env/utils';
|
|
4
|
+
import logger from '@percy/logger';
|
|
5
|
+
import { pool, request, sha256hash, base64encode, getPackageJSON } from './utils.js'; // Default client API URL can be set with an env var for API development
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
PERCY_CLIENT_API_URL = 'https://percy.io/api/v1'
|
|
9
|
+
} = process.env;
|
|
10
|
+
const pkg = getPackageJSON(import.meta.url); // Validate build ID arguments
|
|
11
|
+
|
|
12
|
+
function validateBuildId(id) {
|
|
13
|
+
if (!id) throw new Error('Missing build ID');
|
|
14
|
+
|
|
15
|
+
if (!(typeof id === 'string' || typeof id === 'number')) {
|
|
16
|
+
throw new Error('Invalid build ID');
|
|
17
|
+
}
|
|
18
|
+
} // Validate project path arguments
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function validateProjectPath(path) {
|
|
22
|
+
if (!path) throw new Error('Missing project path');
|
|
23
|
+
|
|
24
|
+
if (!/^[^/]+?\/.+/.test(path)) {
|
|
25
|
+
throw new Error(`Invalid project path. Expected "org/project" but received "${path}"`);
|
|
26
|
+
}
|
|
27
|
+
} // PercyClient is used to communicate with the Percy API to create and finalize
|
|
28
|
+
// builds and snapshot. Uses @percy/env to collect environment information used
|
|
29
|
+
// during build creation.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export class PercyClient {
|
|
33
|
+
log = logger('client');
|
|
34
|
+
env = new PercyEnv(process.env);
|
|
35
|
+
clientInfo = new Set();
|
|
36
|
+
environmentInfo = new Set();
|
|
37
|
+
|
|
38
|
+
constructor({
|
|
39
|
+
// read or write token, defaults to PERCY_TOKEN environment variable
|
|
40
|
+
token,
|
|
41
|
+
// initial user agent info
|
|
42
|
+
clientInfo,
|
|
43
|
+
environmentInfo,
|
|
44
|
+
// versioned api url
|
|
45
|
+
apiUrl = PERCY_CLIENT_API_URL
|
|
46
|
+
} = {}) {
|
|
47
|
+
Object.assign(this, {
|
|
48
|
+
token,
|
|
49
|
+
apiUrl
|
|
50
|
+
});
|
|
51
|
+
this.addClientInfo(clientInfo);
|
|
52
|
+
this.addEnvironmentInfo(environmentInfo);
|
|
53
|
+
} // Adds additional unique client info.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
addClientInfo(info) {
|
|
57
|
+
for (let i of [].concat(info)) {
|
|
58
|
+
if (i) this.clientInfo.add(i);
|
|
59
|
+
}
|
|
60
|
+
} // Adds additional unique environment info.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
addEnvironmentInfo(info) {
|
|
64
|
+
for (let i of [].concat(info)) {
|
|
65
|
+
if (i) this.environmentInfo.add(i);
|
|
66
|
+
}
|
|
67
|
+
} // Stringifies client and environment info.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
userAgent() {
|
|
71
|
+
let client = new Set([`Percy/${/\w+$/.exec(this.apiUrl)}`].concat(`${pkg.name}/${pkg.version}`, ...this.clientInfo).filter(Boolean));
|
|
72
|
+
let environment = new Set([...this.environmentInfo].concat(`node/${process.version}`, this.env.info).filter(Boolean));
|
|
73
|
+
return `${[...client].join(' ')} (${[...environment].join('; ')})`;
|
|
74
|
+
} // Checks for a Percy token and returns it.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
getToken() {
|
|
78
|
+
let token = this.token || this.env.token;
|
|
79
|
+
if (!token) throw new Error('Missing Percy token');
|
|
80
|
+
return token;
|
|
81
|
+
} // Returns common headers used for each request with additional
|
|
82
|
+
// headers. Throws an error when the token is missing, which is a required
|
|
83
|
+
// authorization header.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
headers(headers) {
|
|
87
|
+
return Object.assign({
|
|
88
|
+
Authorization: `Token token=${this.getToken()}`,
|
|
89
|
+
'User-Agent': this.userAgent()
|
|
90
|
+
}, headers);
|
|
91
|
+
} // Performs a GET request for an API endpoint with appropriate headers.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
get(path) {
|
|
95
|
+
return request(`${this.apiUrl}/${path}`, {
|
|
96
|
+
headers: this.headers(),
|
|
97
|
+
method: 'GET'
|
|
98
|
+
});
|
|
99
|
+
} // Performs a POST request to a JSON API endpoint with appropriate headers.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
post(path, body = {}) {
|
|
103
|
+
return request(`${this.apiUrl}/${path}`, {
|
|
104
|
+
headers: this.headers({
|
|
105
|
+
'Content-Type': 'application/vnd.api+json'
|
|
106
|
+
}),
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body
|
|
109
|
+
});
|
|
110
|
+
} // Creates a build with optional build resources. Only one build can be
|
|
111
|
+
// created at a time per instance so snapshots and build finalization can be
|
|
112
|
+
// done more seemlessly without manually tracking build ids
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async createBuild({
|
|
116
|
+
resources = []
|
|
117
|
+
} = {}) {
|
|
118
|
+
this.log.debug('Creating a new build...');
|
|
119
|
+
return this.post('builds', {
|
|
120
|
+
data: {
|
|
121
|
+
type: 'builds',
|
|
122
|
+
attributes: {
|
|
123
|
+
branch: this.env.git.branch,
|
|
124
|
+
'target-branch': this.env.target.branch,
|
|
125
|
+
'target-commit-sha': this.env.target.commit,
|
|
126
|
+
'commit-sha': this.env.git.sha,
|
|
127
|
+
'commit-committed-at': this.env.git.committedAt,
|
|
128
|
+
'commit-author-name': this.env.git.authorName,
|
|
129
|
+
'commit-author-email': this.env.git.authorEmail,
|
|
130
|
+
'commit-committer-name': this.env.git.committerName,
|
|
131
|
+
'commit-committer-email': this.env.git.committerEmail,
|
|
132
|
+
'commit-message': this.env.git.message,
|
|
133
|
+
'pull-request-number': this.env.pullRequest,
|
|
134
|
+
'parallel-nonce': this.env.parallel.nonce,
|
|
135
|
+
'parallel-total-shards': this.env.parallel.total,
|
|
136
|
+
partial: this.env.partial
|
|
137
|
+
},
|
|
138
|
+
relationships: {
|
|
139
|
+
resources: {
|
|
140
|
+
data: resources.map(r => ({
|
|
141
|
+
type: 'resources',
|
|
142
|
+
id: r.sha || sha256hash(r.content),
|
|
143
|
+
attributes: {
|
|
144
|
+
'resource-url': r.url,
|
|
145
|
+
'is-root': r.root || null,
|
|
146
|
+
mimetype: r.mimetype || null
|
|
147
|
+
}
|
|
148
|
+
}))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} // Finalizes the active build. When `all` is true, `all-shards=true` is
|
|
154
|
+
// added as a query param so the API finalizes all other build shards.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async finalizeBuild(buildId, {
|
|
158
|
+
all = false
|
|
159
|
+
} = {}) {
|
|
160
|
+
validateBuildId(buildId);
|
|
161
|
+
let qs = all ? 'all-shards=true' : '';
|
|
162
|
+
this.log.debug(`Finalizing build ${buildId}...`);
|
|
163
|
+
return this.post(`builds/${buildId}/finalize?${qs}`);
|
|
164
|
+
} // Retrieves build data by id. Requires a read access token.
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async getBuild(buildId) {
|
|
168
|
+
validateBuildId(buildId);
|
|
169
|
+
this.log.debug(`Get build ${buildId}`);
|
|
170
|
+
return this.get(`builds/${buildId}`);
|
|
171
|
+
} // Retrieves project builds optionally filtered. Requires a read access token.
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async getBuilds(project, filters = {}) {
|
|
175
|
+
validateProjectPath(project);
|
|
176
|
+
let qs = Object.keys(filters).map(k => Array.isArray(filters[k]) ? filters[k].map(v => `filter[${k}][]=${v}`).join('&') : `filter[${k}]=${filters[k]}`).join('&');
|
|
177
|
+
this.log.debug(`Fetching builds for ${project}`);
|
|
178
|
+
return this.get(`projects/${project}/builds?${qs}`);
|
|
179
|
+
} // Resolves when the build has finished and is no longer pending or
|
|
180
|
+
// processing. By default, will time out if no update after 10 minutes.
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
waitForBuild({
|
|
184
|
+
build,
|
|
185
|
+
project,
|
|
186
|
+
commit,
|
|
187
|
+
timeout = 10 * 60 * 1000,
|
|
188
|
+
interval = 1000
|
|
189
|
+
}, onProgress) {
|
|
190
|
+
if (commit && !project) {
|
|
191
|
+
throw new Error('Missing project path for commit');
|
|
192
|
+
} else if (!commit && !build) {
|
|
193
|
+
throw new Error('Missing build ID or commit SHA');
|
|
194
|
+
} else if (project) {
|
|
195
|
+
validateProjectPath(project);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let sha = commit && (git(`rev-parse ${commit}`) || commit);
|
|
199
|
+
|
|
200
|
+
let fetchData = async () => build ? (await this.getBuild(build)).data : (await this.getBuilds(project, {
|
|
201
|
+
sha
|
|
202
|
+
})).data[0];
|
|
203
|
+
|
|
204
|
+
this.log.debug(`Waiting for build ${build || `${project} (${commit})`}...`); // recursively poll every second until the build finishes
|
|
205
|
+
|
|
206
|
+
return new Promise((resolve, reject) => async function poll(last, t) {
|
|
207
|
+
try {
|
|
208
|
+
let data = await fetchData();
|
|
209
|
+
let state = data === null || data === void 0 ? void 0 : data.attributes.state;
|
|
210
|
+
let pending = !state || state === 'pending' || state === 'processing';
|
|
211
|
+
let updated = JSON.stringify(data) !== JSON.stringify(last); // new data received
|
|
212
|
+
|
|
213
|
+
if (updated) {
|
|
214
|
+
t = Date.now(); // no new data within the timeout
|
|
215
|
+
} else if (Date.now() - t >= timeout) {
|
|
216
|
+
throw new Error('Timeout exceeded without an update');
|
|
217
|
+
} // call progress every update after the first update
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if ((last || pending) && updated) {
|
|
221
|
+
onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
|
|
222
|
+
} // not finished, poll again
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if (pending) {
|
|
226
|
+
return setTimeout(poll, interval, data, t); // build finished
|
|
227
|
+
} else {
|
|
228
|
+
// ensure progress is called at least once
|
|
229
|
+
if (!last) onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
|
|
230
|
+
resolve({
|
|
231
|
+
data
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
reject(err);
|
|
236
|
+
}
|
|
237
|
+
}(null, Date.now()));
|
|
238
|
+
} // Uploads a single resource to the active build. If `filepath` is provided,
|
|
239
|
+
// `content` is read from the filesystem. The sha is optional and will be
|
|
240
|
+
// created from `content` if one is not provided.
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async uploadResource(buildId, {
|
|
244
|
+
url,
|
|
245
|
+
sha,
|
|
246
|
+
filepath,
|
|
247
|
+
content
|
|
248
|
+
} = {}) {
|
|
249
|
+
validateBuildId(buildId);
|
|
250
|
+
this.log.debug(`Uploading resource: ${url}...`);
|
|
251
|
+
if (filepath) content = fs.readFileSync(filepath);
|
|
252
|
+
return this.post(`builds/${buildId}/resources`, {
|
|
253
|
+
data: {
|
|
254
|
+
type: 'resources',
|
|
255
|
+
id: sha || sha256hash(content),
|
|
256
|
+
attributes: {
|
|
257
|
+
'base64-content': base64encode(content)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
} // Uploads resources to the active build concurrently, two at a time.
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async uploadResources(buildId, resources) {
|
|
265
|
+
validateBuildId(buildId);
|
|
266
|
+
this.log.debug(`Uploading resources for ${buildId}...`);
|
|
267
|
+
return pool(function* () {
|
|
268
|
+
for (let resource of resources) {
|
|
269
|
+
yield this.uploadResource(buildId, resource);
|
|
270
|
+
}
|
|
271
|
+
}, this, 2);
|
|
272
|
+
} // Creates a snapshot for the active build using the provided attributes.
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async createSnapshot(buildId, {
|
|
276
|
+
name,
|
|
277
|
+
widths,
|
|
278
|
+
minHeight,
|
|
279
|
+
enableJavaScript,
|
|
280
|
+
clientInfo,
|
|
281
|
+
environmentInfo,
|
|
282
|
+
resources = []
|
|
283
|
+
} = {}) {
|
|
284
|
+
validateBuildId(buildId);
|
|
285
|
+
this.addClientInfo(clientInfo);
|
|
286
|
+
this.addEnvironmentInfo(environmentInfo);
|
|
287
|
+
|
|
288
|
+
if (!this.clientInfo.size || !this.environmentInfo.size) {
|
|
289
|
+
this.log.warn('Warning: Missing `clientInfo` and/or `environmentInfo` properties');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.log.debug(`Creating snapshot: ${name}...`);
|
|
293
|
+
return this.post(`builds/${buildId}/snapshots`, {
|
|
294
|
+
data: {
|
|
295
|
+
type: 'snapshots',
|
|
296
|
+
attributes: {
|
|
297
|
+
name: name || null,
|
|
298
|
+
widths: widths || null,
|
|
299
|
+
'minimum-height': minHeight || null,
|
|
300
|
+
'enable-javascript': enableJavaScript || null
|
|
301
|
+
},
|
|
302
|
+
relationships: {
|
|
303
|
+
resources: {
|
|
304
|
+
data: resources.map(r => ({
|
|
305
|
+
type: 'resources',
|
|
306
|
+
id: r.sha || sha256hash(r.content),
|
|
307
|
+
attributes: {
|
|
308
|
+
'resource-url': r.url || null,
|
|
309
|
+
'is-root': r.root || null,
|
|
310
|
+
mimetype: r.mimetype || null
|
|
311
|
+
}
|
|
312
|
+
}))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
} // Finalizes a snapshot.
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async finalizeSnapshot(snapshotId) {
|
|
321
|
+
if (!snapshotId) throw new Error('Missing snapshot ID');
|
|
322
|
+
this.log.debug(`Finalizing snapshot ${snapshotId}...`);
|
|
323
|
+
return this.post(`snapshots/${snapshotId}/finalize`);
|
|
324
|
+
} // Convenience method for creating a snapshot for the active build, uploading
|
|
325
|
+
// missing resources for the snapshot, and finalizing the snapshot.
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async sendSnapshot(buildId, options) {
|
|
329
|
+
var _snapshot$data$relati, _snapshot$data$relati2;
|
|
330
|
+
|
|
331
|
+
let snapshot = await this.createSnapshot(buildId, options);
|
|
332
|
+
let missing = (_snapshot$data$relati = snapshot.data.relationships) === null || _snapshot$data$relati === void 0 ? void 0 : (_snapshot$data$relati2 = _snapshot$data$relati['missing-resources']) === null || _snapshot$data$relati2 === void 0 ? void 0 : _snapshot$data$relati2.data;
|
|
333
|
+
|
|
334
|
+
if (missing !== null && missing !== void 0 && missing.length) {
|
|
335
|
+
let resources = options.resources.reduce((acc, r) => Object.assign(acc, {
|
|
336
|
+
[r.sha]: r
|
|
337
|
+
}), {});
|
|
338
|
+
await this.uploadResources(buildId, missing.map(({
|
|
339
|
+
id
|
|
340
|
+
}) => resources[id]));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await this.finalizeSnapshot(snapshot.data.id);
|
|
344
|
+
return snapshot;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
}
|
|
348
|
+
export default PercyClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, PercyClient } from './client.js';
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import tls from 'tls';
|
|
3
|
+
import http from 'http';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import logger from '@percy/logger';
|
|
6
|
+
const CRLF = '\r\n';
|
|
7
|
+
const STATUS_REG = /^HTTP\/1.[01] (\d*)/; // Returns true if the URL hostname matches any patterns
|
|
8
|
+
|
|
9
|
+
export function hostnameMatches(patterns, url) {
|
|
10
|
+
let subject = new URL(url);
|
|
11
|
+
/* istanbul ignore next: only strings are provided internally by the client proxy; core (which
|
|
12
|
+
* borrows this util) sometimes provides an array of patterns or undefined */
|
|
13
|
+
|
|
14
|
+
patterns = typeof patterns === 'string' ? patterns.split(/[\s,]+/) : [].concat(patterns);
|
|
15
|
+
|
|
16
|
+
for (let pattern of patterns) {
|
|
17
|
+
if (pattern === '*') return true;
|
|
18
|
+
if (!pattern) continue; // parse pattern
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
groups: rule
|
|
22
|
+
} = pattern.match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/); // missing a hostname or ports do not match
|
|
23
|
+
|
|
24
|
+
if (!rule.hostname || rule.port && rule.port !== subject.port) {
|
|
25
|
+
continue;
|
|
26
|
+
} // wildcards are treated the same as leading dots
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
rule.hostname = rule.hostname.replace(/^\*/, ''); // hostnames are equal or end with a wildcard rule
|
|
30
|
+
|
|
31
|
+
if (rule.hostname === subject.hostname || rule.hostname.startsWith('.') && subject.hostname.endsWith(rule.hostname)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
} // Returns the port number of a URL object. Defaults to port 443 for https
|
|
38
|
+
// protocols or port 80 otherwise.
|
|
39
|
+
|
|
40
|
+
export function port(options) {
|
|
41
|
+
if (options.port) return options.port;
|
|
42
|
+
return options.protocol === 'https:' ? 443 : 80;
|
|
43
|
+
} // Returns a string representation of a URL-like object
|
|
44
|
+
|
|
45
|
+
export function href(options) {
|
|
46
|
+
let {
|
|
47
|
+
protocol,
|
|
48
|
+
hostname,
|
|
49
|
+
path,
|
|
50
|
+
pathname,
|
|
51
|
+
search,
|
|
52
|
+
hash
|
|
53
|
+
} = options;
|
|
54
|
+
return `${protocol}//${hostname}:${port(options)}` + (path || `${pathname || ''}${search || ''}${hash || ''}`);
|
|
55
|
+
}
|
|
56
|
+
; // Returns the proxy URL for a set of request options
|
|
57
|
+
|
|
58
|
+
export function getProxy(options) {
|
|
59
|
+
let proxyUrl = options.protocol === 'https:' && (process.env.https_proxy || process.env.HTTPS_PROXY) || process.env.http_proxy || process.env.HTTP_PROXY;
|
|
60
|
+
let shouldProxy = !!proxyUrl && !hostnameMatches(process.env.no_proxy || process.env.NO_PROXY, href(options));
|
|
61
|
+
|
|
62
|
+
if (shouldProxy) {
|
|
63
|
+
proxyUrl = new URL(proxyUrl);
|
|
64
|
+
let isHttps = proxyUrl.protocol === 'https:';
|
|
65
|
+
|
|
66
|
+
if (!isHttps && proxyUrl.protocol !== 'http:') {
|
|
67
|
+
throw new Error(`Unsupported proxy protocol: ${proxyUrl.protocol}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let proxy = {
|
|
71
|
+
isHttps
|
|
72
|
+
};
|
|
73
|
+
proxy.auth = !!proxyUrl.username && 'Basic ' + (proxyUrl.password ? Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`) : Buffer.from(proxyUrl.username)).toString('base64');
|
|
74
|
+
proxy.host = proxyUrl.hostname;
|
|
75
|
+
proxy.port = port(proxyUrl);
|
|
76
|
+
|
|
77
|
+
proxy.connect = () => (isHttps ? tls : net).connect({
|
|
78
|
+
rejectUnauthorized: options.rejectUnauthorized,
|
|
79
|
+
host: proxy.host,
|
|
80
|
+
port: proxy.port
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return proxy;
|
|
84
|
+
}
|
|
85
|
+
} // Proxified http agent
|
|
86
|
+
|
|
87
|
+
export class ProxyHttpAgent extends http.Agent {
|
|
88
|
+
// needed for https proxies
|
|
89
|
+
httpsAgent = new https.Agent({
|
|
90
|
+
keepAlive: true
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
addRequest(request, options) {
|
|
94
|
+
var _request$outputData;
|
|
95
|
+
|
|
96
|
+
let proxy = getProxy(options);
|
|
97
|
+
if (!proxy) return super.addRequest(request, options);
|
|
98
|
+
logger('client:proxy').debug(`Proxying request: ${options.href}`); // modify the request for proxying
|
|
99
|
+
|
|
100
|
+
request.path = href(options);
|
|
101
|
+
|
|
102
|
+
if (proxy.auth) {
|
|
103
|
+
request.setHeader('Proxy-Authorization', proxy.auth);
|
|
104
|
+
} // regenerate headers since we just changed things
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
delete request._header;
|
|
108
|
+
|
|
109
|
+
request._implicitHeader();
|
|
110
|
+
|
|
111
|
+
if (((_request$outputData = request.outputData) === null || _request$outputData === void 0 ? void 0 : _request$outputData.length) > 0) {
|
|
112
|
+
let first = request.outputData[0].data;
|
|
113
|
+
let endOfHeaders = first.indexOf(CRLF.repeat(2)) + 4;
|
|
114
|
+
request.outputData[0].data = request._header + first.substring(endOfHeaders);
|
|
115
|
+
} // coerce the connection to the proxy
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
options.port = proxy.port;
|
|
119
|
+
options.host = proxy.host;
|
|
120
|
+
delete options.path;
|
|
121
|
+
|
|
122
|
+
if (proxy.isHttps) {
|
|
123
|
+
// use the underlying https agent to complete the connection
|
|
124
|
+
request.agent = this.httpsAgent;
|
|
125
|
+
return this.httpsAgent.addRequest(request, options);
|
|
126
|
+
} else {
|
|
127
|
+
return super.addRequest(request, options);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
} // Proxified https agent
|
|
132
|
+
|
|
133
|
+
export class ProxyHttpsAgent extends https.Agent {
|
|
134
|
+
constructor(options) {
|
|
135
|
+
// default keep-alive
|
|
136
|
+
super({
|
|
137
|
+
keepAlive: true,
|
|
138
|
+
...options
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
createConnection(options, callback) {
|
|
143
|
+
let proxy = getProxy(options);
|
|
144
|
+
if (!proxy) return super.createConnection(options, callback);
|
|
145
|
+
logger('client:proxy').debug(`Proxying request: ${href(options)}`); // generate proxy connect message
|
|
146
|
+
|
|
147
|
+
let host = `${options.hostname}:${port(options)}`;
|
|
148
|
+
let connectMessage = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
|
|
149
|
+
|
|
150
|
+
if (proxy.auth) {
|
|
151
|
+
connectMessage.push(`Proxy-Authorization: ${proxy.auth}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
connectMessage = connectMessage.join(CRLF);
|
|
155
|
+
connectMessage += CRLF.repeat(2); // start the proxy connection and setup listeners
|
|
156
|
+
|
|
157
|
+
let socket = proxy.connect();
|
|
158
|
+
|
|
159
|
+
let handleError = err => {
|
|
160
|
+
socket.destroy(err);
|
|
161
|
+
callback(err);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let handleClose = () => handleError(new Error('Connection closed while sending request to upstream proxy'));
|
|
165
|
+
|
|
166
|
+
let buffer = '';
|
|
167
|
+
|
|
168
|
+
let handleData = data => {
|
|
169
|
+
var _buffer$match;
|
|
170
|
+
|
|
171
|
+
buffer += data.toString(); // haven't received end of headers yet, keep buffering
|
|
172
|
+
|
|
173
|
+
if (!buffer.includes(CRLF.repeat(2))) return; // stop listening after end of headers
|
|
174
|
+
|
|
175
|
+
socket.off('data', handleData);
|
|
176
|
+
|
|
177
|
+
if (((_buffer$match = buffer.match(STATUS_REG)) === null || _buffer$match === void 0 ? void 0 : _buffer$match[1]) !== '200') {
|
|
178
|
+
return handleError(new Error('Error establishing proxy connection. ' + `Response from server was: ${buffer}`));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
options.socket = socket;
|
|
182
|
+
options.servername = options.hostname; // callback not passed in so not to be added as a listener
|
|
183
|
+
|
|
184
|
+
callback(null, super.createConnection(options));
|
|
185
|
+
}; // send and handle the connect message
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
socket.on('error', handleError).on('close', handleClose).on('data', handleData).write(connectMessage);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
}
|
|
192
|
+
export function proxyAgentFor(url, options) {
|
|
193
|
+
let cache = proxyAgentFor.cache || (proxyAgentFor.cache = new Map());
|
|
194
|
+
let {
|
|
195
|
+
protocol,
|
|
196
|
+
hostname
|
|
197
|
+
} = new URL(url);
|
|
198
|
+
let cachekey = `${protocol}//${hostname}`;
|
|
199
|
+
|
|
200
|
+
if (!cache.has(cachekey)) {
|
|
201
|
+
cache.set(cachekey, protocol === 'https:' ? new ProxyHttpsAgent(options) : new ProxyHttpAgent(options));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return cache.get(cachekey);
|
|
205
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto'; // Returns a sha256 hash of a string.
|
|
6
|
+
|
|
7
|
+
export function sha256hash(content) {
|
|
8
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
9
|
+
} // Returns a base64 encoding of a string or buffer.
|
|
10
|
+
|
|
11
|
+
export function base64encode(content) {
|
|
12
|
+
return Buffer.from(content).toString('base64');
|
|
13
|
+
} // Returns the package.json content at the package path.
|
|
14
|
+
|
|
15
|
+
export function getPackageJSON(rel) {
|
|
16
|
+
/* istanbul ignore else: sanity check */
|
|
17
|
+
if (rel.startsWith('file:')) rel = url.fileURLToPath(rel);
|
|
18
|
+
let pkg = path.join(rel, 'package.json');
|
|
19
|
+
if (fs.existsSync(pkg)) return JSON.parse(fs.readFileSync(pkg));
|
|
20
|
+
let dir = path.dirname(rel);
|
|
21
|
+
/* istanbul ignore else: sanity check */
|
|
22
|
+
|
|
23
|
+
if (dir !== rel && dir !== os.homedir()) return getPackageJSON(dir);
|
|
24
|
+
} // Creates a concurrent pool of promises created by the given generator.
|
|
25
|
+
// Resolves when the generator's final promise resolves and rejects when any
|
|
26
|
+
// generated promise rejects.
|
|
27
|
+
|
|
28
|
+
export function pool(generator, context, concurrency) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let iterator = generator.call(context);
|
|
31
|
+
let queue = 0;
|
|
32
|
+
let ret = [];
|
|
33
|
+
let err; // generates concurrent promises
|
|
34
|
+
|
|
35
|
+
let proceed = () => {
|
|
36
|
+
while (queue < concurrency) {
|
|
37
|
+
let {
|
|
38
|
+
done,
|
|
39
|
+
value: promise
|
|
40
|
+
} = iterator.next();
|
|
41
|
+
|
|
42
|
+
if (done || err) {
|
|
43
|
+
if (!queue && err) reject(err);
|
|
44
|
+
if (!queue) resolve(ret);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
queue++;
|
|
49
|
+
promise.then(value => {
|
|
50
|
+
queue--;
|
|
51
|
+
ret.push(value);
|
|
52
|
+
proceed();
|
|
53
|
+
}).catch(error => {
|
|
54
|
+
queue--;
|
|
55
|
+
err = error;
|
|
56
|
+
proceed();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}; // start generating promises
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
proceed();
|
|
63
|
+
});
|
|
64
|
+
} // Returns a promise that resolves or rejects when the provided function calls
|
|
65
|
+
// `resolve` or `reject` respectively. The third function argument, `retry`,
|
|
66
|
+
// will recursively call the function at the specified interval until retries
|
|
67
|
+
// are exhausted, at which point the promise will reject with the last error
|
|
68
|
+
// passed to `retry`.
|
|
69
|
+
|
|
70
|
+
export function retry(fn, {
|
|
71
|
+
retries = 5,
|
|
72
|
+
interval = 50
|
|
73
|
+
}) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let run = () => fn(resolve, reject, retry); // wait an interval to try again or reject with the error
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
let retry = err => {
|
|
79
|
+
if (retries-- > 0) {
|
|
80
|
+
setTimeout(run, interval);
|
|
81
|
+
} else {
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
}; // start trying
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
run();
|
|
88
|
+
});
|
|
89
|
+
} // Used by the request util when retrying specific errors
|
|
90
|
+
|
|
91
|
+
const RETRY_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN']; // Proxified request function that resolves with the response body when the request is successful
|
|
92
|
+
// and rejects when a non-successful response is received. The rejected error contains response data
|
|
93
|
+
// and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by
|
|
94
|
+
// default, and 404 errors may also be optionally retried. If a callback is provided, it is called
|
|
95
|
+
// with the parsed response body and response details. If the callback returns a value, that value
|
|
96
|
+
// will be returned in the final resolved promise instead of the response body.
|
|
97
|
+
|
|
98
|
+
export async function request(url, options = {}, callback) {
|
|
99
|
+
// accept `request(url, callback)`
|
|
100
|
+
if (typeof options === 'function') [options, callback] = [{}, options]; // gather request options
|
|
101
|
+
|
|
102
|
+
let {
|
|
103
|
+
body,
|
|
104
|
+
headers,
|
|
105
|
+
retries,
|
|
106
|
+
retryNotFound,
|
|
107
|
+
interval,
|
|
108
|
+
noProxy,
|
|
109
|
+
...requestOptions
|
|
110
|
+
} = options;
|
|
111
|
+
let {
|
|
112
|
+
protocol,
|
|
113
|
+
hostname,
|
|
114
|
+
port,
|
|
115
|
+
pathname,
|
|
116
|
+
search,
|
|
117
|
+
hash
|
|
118
|
+
} = new URL(url); // reference the default export so tests can mock it
|
|
119
|
+
|
|
120
|
+
let {
|
|
121
|
+
default: http
|
|
122
|
+
} = await import(protocol === 'https:' ? 'https' : 'http');
|
|
123
|
+
let {
|
|
124
|
+
proxyAgentFor
|
|
125
|
+
} = await import('./proxy.js'); // automatically stringify body content
|
|
126
|
+
|
|
127
|
+
if (body && typeof body !== 'string') {
|
|
128
|
+
headers = {
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
...headers
|
|
131
|
+
};
|
|
132
|
+
body = JSON.stringify(body);
|
|
133
|
+
} // combine request options
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
Object.assign(requestOptions, {
|
|
137
|
+
agent: requestOptions.agent || !noProxy && proxyAgentFor(url) || null,
|
|
138
|
+
path: pathname + search + hash,
|
|
139
|
+
protocol,
|
|
140
|
+
hostname,
|
|
141
|
+
headers,
|
|
142
|
+
port
|
|
143
|
+
});
|
|
144
|
+
return retry((resolve, reject, retry) => {
|
|
145
|
+
let handleError = error => {
|
|
146
|
+
if (handleError.handled) return;
|
|
147
|
+
handleError.handled = true; // maybe retry 404s, always retry 500s, or retry specific errors
|
|
148
|
+
|
|
149
|
+
let shouldRetry = error.response ? retryNotFound && error.response.statusCode === 404 || error.response.statusCode >= 500 && error.response.statusCode < 600 : !!error.code && RETRY_ERROR_CODES.includes(error.code);
|
|
150
|
+
return shouldRetry ? retry(error) : reject(error);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let handleFinished = async (body, res) => {
|
|
154
|
+
let {
|
|
155
|
+
statusCode,
|
|
156
|
+
headers
|
|
157
|
+
} = res;
|
|
158
|
+
let raw = body; // attempt to parse the body as json
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
body = JSON.parse(body);
|
|
162
|
+
} catch (e) {}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
166
|
+
var _callback;
|
|
167
|
+
|
|
168
|
+
resolve((await ((_callback = callback) === null || _callback === void 0 ? void 0 : _callback(body, res))) ?? body);
|
|
169
|
+
} else {
|
|
170
|
+
var _body, _body$errors, _body$errors$find;
|
|
171
|
+
|
|
172
|
+
let err = (_body = body) === null || _body === void 0 ? void 0 : (_body$errors = _body.errors) === null || _body$errors === void 0 ? void 0 : (_body$errors$find = _body$errors.find(e => e.detail)) === null || _body$errors$find === void 0 ? void 0 : _body$errors$find.detail;
|
|
173
|
+
throw new Error(err || `${statusCode} ${res.statusMessage || raw}`);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
let response = {
|
|
177
|
+
statusCode,
|
|
178
|
+
headers,
|
|
179
|
+
body
|
|
180
|
+
};
|
|
181
|
+
handleError(Object.assign(error, {
|
|
182
|
+
response
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let handleResponse = res => {
|
|
188
|
+
let body = '';
|
|
189
|
+
res.setEncoding('utf-8');
|
|
190
|
+
res.on('data', chunk => body += chunk);
|
|
191
|
+
res.on('end', () => handleFinished(body, res));
|
|
192
|
+
res.on('error', handleError);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
let req = http.request(requestOptions);
|
|
196
|
+
req.on('response', handleResponse);
|
|
197
|
+
req.on('error', handleError);
|
|
198
|
+
req.end(body);
|
|
199
|
+
}, {
|
|
200
|
+
retries,
|
|
201
|
+
interval
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export { hostnameMatches, ProxyHttpAgent, ProxyHttpsAgent, proxyAgentFor } from './proxy.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"node": ">=14"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"dist",
|
|
18
|
+
"test/helpers.js"
|
|
19
19
|
],
|
|
20
20
|
"main": "./dist/index.js",
|
|
21
21
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test:coverage": "yarn test --coverage"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@percy/env": "1.0.
|
|
35
|
-
"@percy/logger": "1.0.
|
|
34
|
+
"@percy/env": "1.0.1",
|
|
35
|
+
"@percy/logger": "1.0.1"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "38917e6027299d6cd86008e2ccd005d90bbf89c0"
|
|
38
38
|
}
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import EventEmitter from 'events';
|
|
2
|
+
import url from 'url';
|
|
3
|
+
|
|
4
|
+
// Mock response class used for mock requests
|
|
5
|
+
export class MockResponse extends EventEmitter {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
Object.assign(super(), options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
resume() {}
|
|
11
|
+
setEncoding() {}
|
|
12
|
+
pipe = stream => this
|
|
13
|
+
.on('data', d => stream.write(d))
|
|
14
|
+
.on('end', () => stream.end());
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Mock request class automates basic mocking necessities
|
|
18
|
+
export class MockRequest extends EventEmitter {
|
|
19
|
+
constructor(reply, url, opts, cb) {
|
|
20
|
+
// handle optional url string
|
|
21
|
+
if (url && typeof url === 'string') {
|
|
22
|
+
let { protocol, hostname, port, pathname, search, hash } = new URL(url);
|
|
23
|
+
opts = { ...opts, protocol, hostname, port, path: pathname + search + hash };
|
|
24
|
+
} else if (typeof url !== 'string') {
|
|
25
|
+
opts = url;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Object.assign(super(), opts, { reply });
|
|
29
|
+
if (cb) this.on('response', cb);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// useful for logs/tests
|
|
33
|
+
get url() {
|
|
34
|
+
return new URL(this.path, url.format(this)).href;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// kick off a reply response on request end
|
|
38
|
+
end(body) {
|
|
39
|
+
// process async but return sync
|
|
40
|
+
(async () => {
|
|
41
|
+
try { this.body = JSON.parse(body); } catch {}
|
|
42
|
+
let [statusCode, data = '', headers = {}] = await this.reply?.(this) ?? [];
|
|
43
|
+
|
|
44
|
+
if (data && typeof data !== 'string') {
|
|
45
|
+
// handle common json data
|
|
46
|
+
headers['content-type'] = headers['content-type'] || 'application/json';
|
|
47
|
+
data = JSON.stringify(data);
|
|
48
|
+
} else if (!statusCode) {
|
|
49
|
+
// no status code was mocked
|
|
50
|
+
data = `Not mocked ${this.url}`;
|
|
51
|
+
statusCode = 404;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// automate content-length header
|
|
55
|
+
if (data != null && !headers['content-length']) {
|
|
56
|
+
headers['content-length'] = Buffer.byteLength(data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// create and trigger a mock response
|
|
60
|
+
let res = new MockResponse({ statusCode, headers });
|
|
61
|
+
this.emit('response', res);
|
|
62
|
+
|
|
63
|
+
// maybe delay response data
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
res.emit('data', data);
|
|
66
|
+
res.emit('end');
|
|
67
|
+
}, this.delay);
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mock request responses using jasmine spies
|
|
75
|
+
export async function mockRequests(baseUrl, defaultReply = () => [200]) {
|
|
76
|
+
let { protocol, hostname, pathname } = new URL(baseUrl);
|
|
77
|
+
let { default: http } = await import(protocol === 'https:' ? 'https' : 'http');
|
|
78
|
+
|
|
79
|
+
if (!jasmine.isSpy(http.request)) {
|
|
80
|
+
spyOn(http, 'request').and.callFake((...a) => new MockRequest(null, ...a));
|
|
81
|
+
spyOn(http, 'get').and.callFake((...a) => new MockRequest(null, ...a).end());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let any = jasmine.anything();
|
|
85
|
+
let match = o => o.hostname === hostname &&
|
|
86
|
+
(o.path ?? o.pathname).startsWith(pathname);
|
|
87
|
+
let reply = jasmine.createSpy('reply').and.callFake(defaultReply);
|
|
88
|
+
|
|
89
|
+
http.request.withArgs({ asymmetricMatch: match })
|
|
90
|
+
.and.callFake((...a) => new MockRequest(reply, ...a));
|
|
91
|
+
http.get.withArgs({ asymmetricMatch: u => match(new URL(u)) }, any, any)
|
|
92
|
+
.and.callFake((...a) => new MockRequest(reply, ...a).end());
|
|
93
|
+
|
|
94
|
+
return reply;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Group of helpers to mock Percy API requests
|
|
98
|
+
export const api = {
|
|
99
|
+
DEFAULT_REPLIES: {
|
|
100
|
+
'/builds': () => [201, {
|
|
101
|
+
data: {
|
|
102
|
+
id: '123',
|
|
103
|
+
attributes: {
|
|
104
|
+
'build-number': 1,
|
|
105
|
+
'web-url': 'https://percy.io/test/test/123'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}],
|
|
109
|
+
|
|
110
|
+
'/builds/123/snapshots': ({ body }) => [201, {
|
|
111
|
+
data: {
|
|
112
|
+
id: '4567',
|
|
113
|
+
attributes: body.attributes,
|
|
114
|
+
relationships: {
|
|
115
|
+
'missing-resources': {
|
|
116
|
+
data: body.data.relationships.resources
|
|
117
|
+
.data.map(({ id }) => ({ id }))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}]
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async mock({ delay = 10 } = {}) {
|
|
125
|
+
this.replies = {};
|
|
126
|
+
this.requests = {};
|
|
127
|
+
|
|
128
|
+
await mockRequests('https://percy.io/api/v1', req => {
|
|
129
|
+
let path = req.path.replace('/api/v1', '');
|
|
130
|
+
|
|
131
|
+
let reply = (this.replies[path] && (
|
|
132
|
+
this.replies[path].length > 1
|
|
133
|
+
? this.replies[path].shift()
|
|
134
|
+
: this.replies[path][0]
|
|
135
|
+
)) || this.DEFAULT_REPLIES[path];
|
|
136
|
+
|
|
137
|
+
this.requests[path] = this.requests[path] || [];
|
|
138
|
+
this.requests[path].push(req);
|
|
139
|
+
|
|
140
|
+
if (delay) req.delay = delay;
|
|
141
|
+
return reply?.(req) ?? [200];
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
reply(path, handler) {
|
|
146
|
+
this.replies[path] = this.replies[path] || [];
|
|
147
|
+
this.replies[path].push(handler);
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default api;
|