• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

univapay / univapay-node / 15920081844

27 Jun 2025 06:50AM UTC coverage: 98.722% (+0.09%) from 98.635%
15920081844

push

github

phieronymus
Bump to version 4.0.115

487 of 517 branches covered (94.2%)

Branch coverage included in aggregate %.

5614 of 5663 relevant lines covered (99.13%)

750.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.69
/src/api/RestAPI.ts
1
/**
3✔
2
 *  @module SDK/API
3✔
3
 */
3✔
4

3✔
5
import { stringify } from "@apimatic/json-bigint";
3✔
6
import { EventEmitter } from "events";
3✔
7
import pTimeout from "p-timeout";
3✔
8
import { stringify as stringifyQuery } from "query-string";
3✔
9
import flatten from "flat";
3✔
10

3✔
11
import {
3✔
12
    DEFAULT_ENDPOINT,
3✔
13
    ENV_KEY_APP_ID,
3✔
14
    ENV_KEY_APPLICATION_JWT,
3✔
15
    ENV_KEY_ENDPOINT,
3✔
16
    ENV_KEY_SECRET,
3✔
17
    IDEMPOTENCY_KEY_HEADER,
3✔
18
    POLLING_INTERVAL,
3✔
19
    POLLING_TIMEOUT,
3✔
20
    MAX_INTERNAL_ERROR_RETRY,
3✔
21
} from "../common/constants.js";
3✔
22
import { RequestErrorCode, ResponseErrorCode } from "../errors/APIError.js";
3✔
23
import { fromError } from "../errors/parser.js";
3✔
24
import { ResponseError } from "../errors/RequestResponseError.js";
3✔
25
import { TimeoutError } from "../errors/TimeoutError.js";
3✔
26
import { ProcessingMode } from "../resources/common/enums.js";
3✔
27
import { checkStatus, parseJSON } from "../utils/fetch.js";
3✔
28
import { isBlob, toSnakeCase, transformKeys } from "../utils/object.js";
3✔
29

3✔
30
import { extractJWT, JWTPayload, parseJWT } from "./utils/JWT.js";
3✔
31
import { containsBinaryData, objectToFormData } from "./utils/payload.js";
3✔
32
import { userAgent } from "./utils/userAgent.js";
3✔
33

3✔
34
export type PollParams<Response> = {
3✔
35
    /**
3✔
36
     * Condition for which the response is considered to be successful and stop the polling
3✔
37
     */
3✔
38
    successCondition: (response: Response) => boolean;
3✔
39

3✔
40
    /**
3✔
41
     * Condition to cancel the polling without triggering error
3✔
42
     */
3✔
43
    cancelCondition?: (response: Response) => boolean;
3✔
44

3✔
45
    /**
3✔
46
     * Time after which a TimeoutError will be triggered and the poll will be canceled
3✔
47
     */
3✔
48
    timeout?: number;
3✔
49

3✔
50
    /**
3✔
51
     * Interval between polls or function to compute the interval
3✔
52
     */
3✔
53
    interval?: number | (() => number);
3✔
54

3✔
55
    /**
3✔
56
     * Callback to be triggered at each poll iteration
3✔
57
     */
3✔
58
    iterationCallback?: (response: Response) => void;
3✔
59

3✔
60
    /**
3✔
61
     * Boolean when the call should not be executed when the tab is inactive. Defaults to false.
3✔
62
     * Only works on a valid webbrowser environment
3✔
63
     */
3✔
64
    browserSkipCallForInactiveTabs?: boolean;
3✔
65
};
3✔
66

3✔
67
export type BodyTransferType = "entity" | "message";
3✔
68

3✔
69
export enum HTTPMethod {
3✔
70
    GET = "GET",
3✔
71
    POST = "POST",
3✔
72
    PATCH = "PATCH",
3✔
73
    PUT = "PUT",
3✔
74
    DELETE = "DELETE",
3✔
75
    OPTION = "OPTION",
3✔
76
    HEAD = "HEAD",
3✔
77
}
3✔
78

3✔
79
export interface RestAPIOptions {
3✔
80
    endpoint?: string;
3✔
81
    jwt?: string;
3✔
82
    handleUpdateJWT?: (jwt: string) => void;
3✔
83
    secret?: string;
3✔
84
    origin?: string;
3✔
85
    authParams?: DefaultAuthParams;
3✔
86

3✔
87
    // Deprecated
3✔
88
    authToken?: string;
3✔
89
    appId?: string;
3✔
90
}
3✔
91

3✔
92
export interface ApplicationToken {
3✔
93
    sub: "app_token";
3✔
94
}
3✔
95

3✔
96
export interface StoreToken extends ApplicationToken {
3✔
97
    storeId: string;
3✔
98
    mode: ProcessingMode;
3✔
99
    domains: string[];
3✔
100
}
3✔
101

3✔
102
export interface SubError {
3✔
103
    reason: RequestErrorCode | ResponseErrorCode;
3✔
104
    rawError?: boolean | number | string | SubError | ValidationError;
3✔
105
}
3✔
106

3✔
107
export interface ValidationError extends SubError {
3✔
108
    field: string;
3✔
109
}
3✔
110

3✔
111
export interface ErrorResponse {
3✔
112
    httpCode?: number;
3✔
113
    status: string;
3✔
114
    code: ResponseErrorCode | RequestErrorCode;
3✔
115
    errors: (SubError | ValidationError)[];
3✔
116
}
3✔
117

3✔
118
export interface AuthParams {
3✔
119
    jwt?: string;
3✔
120
    secret?: string;
3✔
121
    idempotentKey?: string;
3✔
122
    origin?: string;
3✔
123
    useCredentials?: boolean;
3✔
124

3✔
125
    // Deprecated
3✔
126
    authToken?: string;
3✔
127
    appId?: string;
3✔
128
}
3✔
129

3✔
130
export type DefaultAuthParams = {
3✔
131
    useCredentials?: boolean;
3✔
132
};
3✔
133

3✔
134
export type PollData = {
3✔
135
    polling?: boolean;
3✔
136
};
3✔
137

3✔
138
export type PollExecute<A> = () => Promise<A>;
3✔
139

3✔
140
export type SendData<Data> = Data;
3✔
141

3✔
142
export type ApiSendOptions = {
3✔
143
    /**
3✔
144
     * Validate the JWT before calling the route and adds the authorization header to the request.
3✔
145
     */
3✔
146
    requireAuth?: boolean;
3✔
147

3✔
148
    /**
3✔
149
     * Add the custom type to the header. Defaults to `application/json`
3✔
150
     */
3✔
151
    acceptType?: string;
3✔
152

3✔
153
    /**
3✔
154
     * Custom formatter to format the request object to the API (skipped for blobs)
3✔
155
     */
3✔
156
    keyFormatter?: (key: string) => string;
3✔
157

3✔
158
    /**
3✔
159
     * List of key to ignore formatting on when parsing the response
3✔
160
     */
3✔
161
    ignoreKeysFormatting?: string[];
3✔
162

3✔
163
    /**
3✔
164
     * Overrides default body transfer encoding of the request.
3✔
165
     *
3✔
166
     * Note: Can not for GET and HEAD request
3✔
167
     *
3✔
168
     * Default is "message-body" for DELETE, HEAD and GET and "entity-body" for all other call methods.
3✔
169
     */
3✔
170
    bodyTransferEncoding?: BodyTransferType;
3✔
171

3✔
172
    /**
3✔
173
     * Some of the services require query impersonate to be present.
3✔
174
     */
3✔
175
    queryImpersonate?: string;
3✔
176

3✔
177
    /**
3✔
178
     * Fetch request init parameters
3✔
179
     *
3✔
180
     * Using `redirect` as `manual` will automatically do the redirect from the SDK to preserve the origin
3✔
181
     */
3✔
182
    requestInit?: RequestInit;
3✔
183

3✔
184
    /**
3✔
185
     * Boolean when debug logs should be shown for debugging purpose
3✔
186
     */
3✔
187
    debug?: boolean;
3✔
188
};
3✔
189

3✔
190
const getRequestBody = <Data>(
3✔
191
    data: SendData<Data>,
138✔
192
    keyFormatter = toSnakeCase,
138✔
193
    ignoreKeysFormatting: string[],
138✔
194
): string | FormData | Blob =>
138✔
195
    isBlob(data)
138✔
196
        ? data
138!
197
        : containsBinaryData(data)
138✔
198
          ? objectToFormData(data, keyFormatter, ignoreKeysFormatting)
138!
199
          : stringify(transformKeys(data, keyFormatter, ignoreKeysFormatting));
3✔
200

3✔
201
const stringifyParams = (data: unknown): string => {
3✔
202
    const query = stringifyQuery(flatten(transformKeys(data, toSnakeCase), { safe: true }), {
9,706✔
203
        arrayFormat: "bracket",
9,706✔
204
    });
9,706✔
205

9,706✔
206
    return query ? `?${query}` : "";
9,706✔
207
};
9,706✔
208

3✔
209
const execRequest = async <Response>(execute: () => Promise<Response>): Promise<Response> => {
3✔
210
    try {
9,823✔
211
        const response = await execute();
9,823✔
212

9,679✔
213
        return response;
9,679✔
214
    } catch (error) {
9,823✔
215
        const formattedError =
144✔
216
            error instanceof TimeoutError || error instanceof ResponseError ? error : fromError(error);
144✔
217

144✔
218
        throw formattedError;
144✔
219
    }
144✔
220
};
9,823✔
221

3✔
222
export class RestAPI extends EventEmitter {
3✔
223
    endpoint: string;
3✔
224
    jwt: JWTPayload<unknown>;
3✔
225
    origin: string;
3✔
226
    secret: string;
3✔
227

3✔
228
    defaultAuthParams?: DefaultAuthParams;
3✔
229

3✔
230
    /**
3✔
231
     *  @deprecated
3✔
232
     */
3✔
233
    appId: string;
3✔
234

3✔
235
    /**
3✔
236
     *  @deprecated
3✔
237
     */
3✔
238
    authToken: string;
3✔
239

3✔
240
    private _jwtRaw: string = null;
3✔
241

3✔
242
    protected handleUpdateJWT: (jwt: string) => void;
3✔
243

3✔
244
    constructor(options: RestAPIOptions = {}) {
3✔
245
        super();
560✔
246

560✔
247
        this.endpoint = options.endpoint || process.env[ENV_KEY_ENDPOINT] || DEFAULT_ENDPOINT;
560!
248
        this.origin = options.origin || this.origin;
560✔
249
        this.jwtRaw = options.jwt || process.env[ENV_KEY_APPLICATION_JWT];
560✔
250
        this.handleUpdateJWT = options.handleUpdateJWT || undefined;
560✔
251
        this.defaultAuthParams = options.authParams;
560✔
252

560✔
253
        this.appId = options.appId || process.env[ENV_KEY_APP_ID];
560✔
254
        this.secret = options.secret || process.env[ENV_KEY_SECRET];
560✔
255
        this.authToken = options.authToken;
560✔
256
    }
560✔
257

3✔
258
    set jwtRaw(jwtRaw: string) {
3✔
259
        this._jwtRaw = jwtRaw;
578✔
260
        this.jwt = parseJWT(jwtRaw);
578✔
261
    }
578✔
262

3✔
263
    get jwtRaw(): string | null {
3✔
264
        return this._jwtRaw;
9,727✔
265
    }
9,727✔
266

3✔
267
    /**
3✔
268
     * @internal
3✔
269
     */
3✔
270
    async send<ResponseBody, Data = unknown>(
3✔
271
        method: HTTPMethod,
9,706✔
272
        uri: string,
9,706✔
273
        data?: SendData<Data>,
9,706✔
274
        auth?: AuthParams,
9,706✔
275
        options: ApiSendOptions = {},
9,706✔
276
    ): Promise<ResponseBody | string | Blob | FormData> {
9,706✔
277
        const authParams = { ...this.defaultAuthParams, ...auth };
9,706✔
278
        const {
9,706✔
279
            acceptType,
9,706✔
280
            keyFormatter = toSnakeCase,
9,706✔
281
            ignoreKeysFormatting = ["metadata"],
9,706✔
282
            bodyTransferEncoding,
9,706✔
283
            queryImpersonate,
9,706✔
284
            requestInit,
9,706✔
285
        } = options;
9,706✔
286

9,706✔
287
        const payload: boolean =
9,706✔
288
            bodyTransferEncoding === "entity"
9,706✔
289
                ? ![HTTPMethod.GET, HTTPMethod.HEAD].includes(method)
9,706✔
290
                : bodyTransferEncoding === "message"
9,706✔
291
                  ? false
9,685✔
292
                  : ![HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.DELETE].includes(method);
9,706✔
293

9,706✔
294
        const params: RequestInit = {
9,706✔
295
            headers: this.getHeaders(data, authParams, payload, acceptType),
9,706✔
296
            method,
9,706✔
297
            ...(authParams?.useCredentials ? { credentials: "include" } : {}),
9,706✔
298
        };
9,706✔
299

9,706✔
300
        const query = stringifyParams({
9,706✔
301
            ...(payload || !data ? {} : data),
9,706✔
302
            ...(queryImpersonate ? { impersonate: queryImpersonate } : {}),
9,706✔
303
        });
9,706✔
304
        const request: Request = new Request(
9,706✔
305
            `${/^https?:\/\//.test(uri) ? uri : `${this.endpoint}${uri}`}${query}`,
9,706✔
306
            payload ? { ...params, body: getRequestBody(data, keyFormatter, ignoreKeysFormatting) } : params,
9,706✔
307
        );
9,706✔
308

9,706✔
309
        this.emit("request", request);
9,706✔
310

9,706✔
311
        const debug = options.debug ? (execute: () => void) => execute() : () => undefined;
9,706!
312

9,706✔
313
        return execRequest<ResponseBody | string | Blob | FormData>(async () => {
9,706✔
314
            debug(() => console.info(`Excecuting ${method} ${uri}`, data, auth, options));
9,706✔
315
            const response = await fetch(request, requestInit);
9,706✔
316
            debug(() => console.info("Executed with response", response));
9,706✔
317

9,706✔
318
            this.emit("response", response);
9,706✔
319

9,706✔
320
            const jwt = extractJWT(response);
9,706✔
321

9,706✔
322
            if (jwt) {
9,706✔
323
                debug(() => console.info("Refresh jwt", jwt));
18✔
324
                this.jwtRaw = jwt;
18✔
325
                this.handleUpdateJWT?.(jwt);
18✔
326
            }
18✔
327

9,706✔
328
            debug(() => console.info("Validating response status", response.status));
9,706✔
329
            await checkStatus(response);
9,706✔
330
            debug(() => console.info("Validated response status", response.status));
9,607✔
331

9,607✔
332
            const noContentStatus = 204;
9,607✔
333
            if (response.status === noContentStatus) {
9,706✔
334
                debug(() => console.info("No body status found. Early returns body as empty."));
21✔
335
                return "";
21✔
336
            }
21✔
337

9,586✔
338
            if (response.status === 303 && options?.requestInit?.redirect === "manual") {
9,706✔
339
                const redirect = response.headers.get("location");
3✔
340
                debug(() => console.info(`Redirecting to ${redirect} after 303 call`, response.headers));
3✔
341
                return this.send(method, redirect, data, auth, options);
3✔
342
            }
3✔
343

9,583✔
344
            const contentType = response.headers.get("content-type");
9,583✔
345
            debug(() => console.info(`Parsing body with content type ${contentType}`, response.headers));
9,583✔
346
            if (contentType) {
9,706✔
347
                if (contentType.indexOf("application/json") !== -1) {
9,580✔
348
                    return parseJSON<ResponseBody>(response, ignoreKeysFormatting);
9,571✔
349
                } else if (contentType.indexOf("text/") === 0) {
9,580✔
350
                    return response.text();
3✔
351
                } else if (contentType.indexOf("multipart/") === 0) {
9!
352
                    return response.formData();
×
353
                }
×
354
            }
9,580✔
355

9✔
356
            return response.blob();
9✔
357
        });
9,706✔
358
    }
9,706✔
359

3✔
360
    protected getHeaders<Data = unknown>(
3✔
361
        data: SendData<Data>,
9,706✔
362
        auth: AuthParams,
9,706✔
363
        payload: boolean,
9,706✔
364
        acceptType = "application/json",
9,706✔
365
    ): Headers {
9,706✔
366
        const headers: Headers = new Headers();
9,706✔
367
        const isFormData = containsBinaryData(data);
9,706✔
368

9,706✔
369
        headers.append("Accept", acceptType);
9,706✔
370

9,706✔
371
        if (!isFormData && payload) {
9,706✔
372
            headers.append("Content-Type", "application/json");
138✔
373
        }
138✔
374

9,706✔
375
        const {
9,706✔
376
            origin = this.origin,
9,706✔
377
            idempotentKey = null,
9,706✔
378
            authToken = this.authToken,
9,706✔
379
            appId = this.appId,
9,706✔
380
            secret = this.secret,
9,706✔
381
            jwt = this.jwtRaw,
9,706✔
382
        } = auth || {};
9,706!
383

9,706✔
384
        headers.append("User-Agent", userAgent());
9,706✔
385

9,706✔
386
        if (origin) {
9,706✔
387
            headers.append("Origin", origin);
9✔
388
        }
9✔
389

9,706✔
390
        if (idempotentKey) {
9,706✔
391
            headers.append(IDEMPOTENCY_KEY_HEADER, idempotentKey);
3✔
392
        }
3✔
393

9,706✔
394
        if (authToken) {
9,706✔
395
            headers.append("Authorization", `Token ${authToken}`);
9✔
396
        } else if (appId) {
9,706✔
397
            headers.append("Authorization", `ApplicationToken ${appId}|${secret || ""}`);
18✔
398
        } else if (jwt) {
9,697✔
399
            if (secret) {
24✔
400
                headers.append("Authorization", `Bearer ${secret}.${jwt}`);
6✔
401
            } else {
24✔
402
                headers.append("Authorization", `Bearer ${jwt}`);
18✔
403
            }
18✔
404
        }
24✔
405

9,706✔
406
        return headers;
9,706✔
407
    }
9,706✔
408

3✔
409
    /**
3✔
410
     * @internal
3✔
411
     */
3✔
412
    async longPolling<Response>(execute: PollExecute<Response>, pollParams: PollParams<Response>): Promise<Response> {
3✔
413
        const {
117✔
414
            successCondition,
117✔
415
            cancelCondition,
117✔
416
            iterationCallback,
117✔
417
            timeout = POLLING_TIMEOUT,
117✔
418
            interval = POLLING_INTERVAL,
117✔
419
            browserSkipCallForInactiveTabs = false,
117✔
420
        } = pollParams;
117✔
421

117✔
422
        return execRequest(async () => {
117✔
423
            let internalErrorCount = 0;
117✔
424

117✔
425
            const computedInterval = typeof interval === "number" ? interval : interval();
117!
426
            const sleepInterval = () => new Promise((resolve) => setTimeout(resolve, computedInterval));
117✔
427

117✔
428
            const repeater = async (): Promise<Response> => {
117✔
429
                if (browserSkipCallForInactiveTabs && document?.hidden) {
9,234!
430
                    await sleepInterval();
×
431
                    return repeater();
×
432
                }
×
433

9,234✔
434
                try {
9,234✔
435
                    const result = await execute();
9,234✔
436

9,144✔
437
                    iterationCallback?.(result);
9,234!
438

9,234✔
439
                    if (cancelCondition?.(result)) {
9,234✔
440
                        return null;
12✔
441
                    }
12✔
442

9,132✔
443
                    if (!successCondition(result)) {
9,234✔
444
                        await sleepInterval();
9,072✔
445
                        return repeater();
9,057✔
446
                    }
9,057✔
447

60✔
448
                    return result;
60✔
449
                } catch (error) {
9,234✔
450
                    // Use retry mechanism for 500 as internal server error on API side do not always mean failure
90✔
451
                    if (error.errorResponse?.httpCode !== 500 || internalErrorCount >= MAX_INTERNAL_ERROR_RETRY) {
90✔
452
                        throw error;
30✔
453
                    }
30✔
454

60✔
455
                    internalErrorCount++;
60✔
456
                    await sleepInterval();
60✔
457
                    return repeater();
60✔
458
                }
60✔
459
            };
117✔
460

117✔
461
            return pTimeout(repeater(), timeout, new TimeoutError(timeout));
117✔
462
        });
117✔
463
    }
117✔
464

3✔
465
    async ping(): Promise<void> {
3✔
466
        await this.send(HTTPMethod.GET, "/heartbeat", null, null, { requireAuth: false });
6✔
467
    }
6✔
468
}
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc