@unito/integration-sdk
Version:
Integration SDK
1,540 lines (1,305 loc) • 68 kB
text/typescript
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import fs from 'fs';
import { Readable } from 'stream';
import nock from 'nock';
import https from 'https';
import { Provider } from '../../src/resources/provider.js';
import * as HttpErrors from '../../src/httpErrors.js';
import Logger from '../../src/resources/logger.js';
import { RequestMetrics } from '../../src/resources/requestMetrics.js';
import { OperationDeadline } from '../../src/resources/operationDeadline.js';
// There is currently an issue with node 20.12 and fetch mocking. A quick fix is to first call fetch so it's getter
// get properly instantiated, which allow it to be mocked properly.
// Issue: https://github.com/nodejs/node/issues/52015
// PR fix: https://github.com/nodejs/node/pull/52275
globalThis.fetch = fetch;
describe('Provider', () => {
const provider = new Provider({
prepareRequest: requestOptions => {
return {
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
};
},
rateLimiter: undefined,
});
const logger = new Logger();
// Helper to spy on https.request and capture options
const spyOnHttpsRequest = (): { getCapturedOptions: () => any; restore: () => void } => {
let capturedOptions: any;
const originalRequest = https.request;
(https as any).request = function (options: any, callback: any) {
capturedOptions = options;
return originalRequest.call(https, options, callback);
};
return {
getCapturedOptions: () => capturedOptions,
restore: () => {
(https as any).request = originalRequest;
},
};
};
it('get', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('passes the request operation-deadline signal to the rateLimiter options', async context => {
let capturedOptions: { credentials: unknown; logger: Logger; deadline: OperationDeadline } | undefined;
const rateLimitedProvider = new Provider({
prepareRequest: requestOptions => ({
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {},
}),
rateLimiter: async (options, targetFunction) => {
capturedOptions = options;
return targetFunction();
},
});
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const deadline = OperationDeadline.never();
await rateLimitedProvider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline,
});
assert.ok(capturedOptions, 'rateLimiter should have been invoked');
assert.equal(capturedOptions?.deadline, deadline);
});
it('accepts text/html type response', async context => {
const response = new Response('', {
status: 200,
headers: new Headers({ 'Content-Type': 'text/html; charset=UTF-8' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'text/html; charset=UTF-8' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'text/html; charset=UTF-8',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: '',
});
});
it('accepts application/schema+json type response', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/schema+json; charset=UTF-8' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/schema+json; charset=UTF-8' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/schema+json; charset=UTF-8',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('accepts application/swagger+json type response', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/swagger+json; charset=UTF-8' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/swagger+json; charset=UTF-8' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/swagger+json; charset=UTF-8',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('accepts application/vnd.oracle.resource+json type response', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('/endpoint', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('returns the raw response body if specified', async context => {
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
status: 200,
headers: new Headers({ 'Content-Type': 'image/png' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const providerResponse = await provider.streamingGet('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: {
Accept: 'application/json',
},
rawBody: true,
});
assert.ok(providerResponse);
// What matters: still returns a stream
assert.ok(providerResponse.body instanceof ReadableStream);
});
it('gets an endpoint which is an absolute url', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('https://my-cdn.my-domain.com/file.png', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'https://my-cdn.my-domain.com/file.png',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('gets on provider url', async context => {
const response = new Response('{"data": "value"}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.get('', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com',
{
method: 'GET',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('post with url encoded body', async context => {
const response = new Response('{"data": "value"}', {
status: 201,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.post(
'/endpoint',
{
data: 'createdItemInfo',
},
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Additional-Header': 'value1' },
},
);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'POST',
body: 'data=createdItemInfo',
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 201,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('accepts an array as body for post request', async context => {
const response = new Response('{"data": "value"}', {
status: 201,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.post(
'/endpoint',
[
{ data: '1', data2: '2' },
{ data: '3', data2: '4' },
],
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'Content-Type': 'application/json-patch+json', 'X-Additional-Header': 'value1' },
},
);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint',
{
method: 'POST',
body: '[{"data":"1","data2":"2"},{"data":"3","data2":"4"}]',
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/json-patch+json',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 201,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('put with json body', async context => {
const response = new Response('{"data": "value"}', {
status: 201,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
// Removing leading '/' on endpoint to make sure we support both cases
const actualResponse = await provider.put(
'endpoint/123',
{
data: 'updatedItemInfo',
},
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
},
);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint/123',
{
method: 'PUT',
body: JSON.stringify({ data: 'updatedItemInfo' }),
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 201,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('putBuffer with Buffer body', async context => {
const response = new Response('{"data": "value"}', {
status: 201,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const buffer = Buffer.from('binary data content');
// What matters is that the body of put is a buffer
const actualResponse = await provider.putBuffer('endpoint/123', buffer, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/octet-stream' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint/123',
{
method: 'PUT',
body: buffer,
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/octet-stream',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 201,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('patch with query params', async context => {
const response = new Response('{"data": "value"}', {
status: 201,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.patch(
'/endpoint/123',
{
data: 'updatedItemInfo',
},
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
queryParams: { param1: 'value1', param2: 'value2' },
additionnalheaders: { 'X-Additional-Header': 'value1' },
},
);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint/123?param1=value1¶m2=value2',
{
method: 'PATCH',
body: JSON.stringify({ data: 'updatedItemInfo' }),
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 201,
headers: Object.fromEntries(response.headers.entries()),
body: { data: 'value' },
});
});
it('delete', async context => {
const response = new Response(undefined, {
status: 204,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const actualResponse = await provider.delete('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
});
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint/123',
{
method: 'DELETE',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 204,
headers: Object.fromEntries(response.headers.entries()),
body: undefined,
});
});
it('deleteWithBody', async context => {
const response = new Response('{"success": true}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const requestBody = { webhookIds: [1, 2, 3] };
const actualResponse = await provider.delete(
'/webhook',
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
},
requestBody,
);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/webhook',
{
method: 'DELETE',
body: JSON.stringify(requestBody),
signal: OperationDeadline.never().signal,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 200,
headers: Object.fromEntries(response.headers.entries()),
body: { success: true },
});
});
it('uses rate limiter if provided', async context => {
const mockRateLimiter = context.mock.fn((_context, request) => Promise.resolve(request()));
const rateLimitedProvider = new Provider({
prepareRequest: requestOptions => {
return {
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
};
},
rateLimiter: mockRateLimiter,
});
const response = new Response(undefined, {
status: 204,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
const options = {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
};
const actualResponse = await rateLimitedProvider.delete('/endpoint/123', options);
assert.equal(mockRateLimiter.mock.calls.length, 1);
assert.deepEqual(mockRateLimiter.mock.calls[0]?.arguments[0]?.credentials, options.credentials);
assert.equal(fetchMock.mock.calls.length, 1);
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
'www.myApi.com/endpoint/123',
{
method: 'DELETE',
body: null,
signal: OperationDeadline.never().signal,
headers: {
Accept: 'application/json',
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': 'apikey#1111',
'X-Additional-Header': 'value1',
},
},
]);
assert.deepEqual(actualResponse, {
status: 204,
headers: Object.fromEntries(response.headers.entries()),
body: undefined,
});
});
it('uses custom error handler if provided', async context => {
const rateLimitedProvider = new Provider({
prepareRequest: requestOptions => {
return {
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
};
},
rateLimiter: undefined,
// Change from normal behavior 400 -> 429
customErrorHandler: (responseStatus: number) =>
responseStatus === 400 ? new HttpErrors.RateLimitExceededError('Weird provider behavior') : undefined,
});
const response = new Response(undefined, {
status: 400,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const options = {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
};
let error;
try {
await rateLimitedProvider.delete('/endpoint/123', options);
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.equal(error.message, 'Weird provider behavior');
});
it('contains the credential in the custom error handler', async context => {
const provider = new Provider({
prepareRequest: requestOptions => {
return {
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
};
},
rateLimiter: undefined,
customErrorHandler: (responseStatus: number, _message: string, options) => {
if (responseStatus === 400) {
// What matter is that we have access to the context in the error handler
throw new HttpErrors.BadRequestError(`Error with API key ${options?.credentials.apiKey}`);
}
return undefined;
},
});
const response = new Response(undefined, {
status: 400,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const options = {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
};
let error;
try {
await provider.delete('/endpoint/123', options);
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.equal(error.message, 'Error with API key apikey#1111');
});
it('passes the response headers to the custom error handler', async context => {
let receivedHeaders: Record<string, string> | undefined;
const provider = new Provider({
prepareRequest: requestOptions => ({
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
customErrorHandler: (_responseStatus, _message, _options, responseHeaders) => {
receivedHeaders = responseHeaders;
return undefined;
},
});
const response = new Response('rate limited', {
status: 429,
headers: new Headers({ 'Retry-After': '30' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
await assert.rejects(
provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
}),
);
assert.equal(receivedHeaders?.['retry-after'], '30');
});
it('uses default behavior if custom error handler returns undefined', async context => {
const rateLimitedProvider = new Provider({
prepareRequest: requestOptions => {
return {
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
};
},
rateLimiter: undefined,
// Custom Error Handler returning undefined (default behavior should apply)
customErrorHandler: () => undefined,
});
const response = new Response(undefined, {
status: 404,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const options = {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
additionnalheaders: { 'X-Additional-Header': 'value1' },
};
let error;
try {
await rateLimitedProvider.delete('/endpoint/123', options);
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.equal(error.message, 'Not found');
});
it('returns valid json response', async context => {
const response = new Response(`{ "validJson": true }`, {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json;charset=utf-8' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(providerResponse);
assert.ok(providerResponse.body);
assert.equal(providerResponse.body.validJson, true);
});
it('returns successfully on missing Content-Type header', async context => {
const response = new Response(undefined, {
status: 201,
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(providerResponse);
assert.equal(providerResponse.body, undefined);
});
it('returns streamable response on streaming get calls', async context => {
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
status: 200,
headers: new Headers({ 'Content-Type': 'video/mp4' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const providerResponse = await provider.streamingGet('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(providerResponse);
assert.ok(providerResponse.body instanceof ReadableStream);
});
it('returns successfully on unexpected content-type response with no body', async context => {
const response = new Response(null, {
status: 201,
headers: new Headers({ 'Content-Type': 'html/text' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const providerResponse = await provider.post(
'/endpoint/123',
{},
{
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
},
);
assert.ok(providerResponse);
assert.strictEqual(providerResponse.status, response.status);
assert.deepEqual(providerResponse.headers, Object.fromEntries(response.headers.entries()));
assert.strictEqual(providerResponse.body, undefined);
});
it('throws on invalid json response', async context => {
const response = new Response('{invalidJSON}', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.equal(error.message, 'Invalid JSON response');
});
it('throws on unexpected content-type response', async context => {
const response = new Response('text', {
status: 200,
headers: new Headers({ 'Content-Type': 'application/text' }),
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.equal(error.status, 500);
});
it('throws on status 400', async context => {
const response = new Response('response body', {
status: 400,
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.BadRequestError);
assert.equal(error.message, 'response body');
});
it('throws on timeout', async context => {
context.mock.method(global, 'fetch', () => {
const error = new Error();
error.name = 'TimeoutError';
throw error;
});
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.TimeoutError);
assert.equal(error.message, 'Request timeout');
});
it('throws on abort', async context => {
context.mock.method(global, 'fetch', () => {
const error = new Error();
error.name = 'AbortError';
throw error;
});
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.TimeoutError);
assert.equal(error.message, 'Request aborted');
});
it('throws on unknown errors', async context => {
context.mock.method(global, 'fetch', () => {
throw new TypeError('foo', { cause: new Error('bar') });
});
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.HttpError);
assert.ok(error.message.startsWith('Unexpected error while calling the provider.'));
assert.ok(error.message.includes('ErrorName: "TypeError"'));
assert.ok(error.message.includes('message: "foo"'));
assert.ok(error.message.includes('stack:'));
assert.ok(error.message.includes('cause:'));
assert.ok(error.message.includes('causeStack:'));
});
it('throws on status 429', async context => {
const response = new Response('response body', {
status: 429,
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
assert.equal(error.message, 'response body');
});
it('logs provider requests', async context => {
const response = new Response(undefined, { status: 201 });
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const loggerStub = context.mock.method(logger, 'log');
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
assert.equal(loggerStub.mock.callCount(), 1);
assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'info');
assert.match(
String(loggerStub.mock.calls[0]?.arguments[1]),
/Connector API Request GET www.myApi.com\/endpoint\/123 201 - \d+ ms/,
);
});
it('logs failed provider requests at error level', async context => {
const response = new Response('something went wrong', { status: 500 });
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const loggerStub = context.mock.method(logger, 'log');
await assert.rejects(
provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
}),
);
assert.equal(loggerStub.mock.callCount(), 1);
assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'error');
});
it('parses Retry-After into the thrown RateLimitExceededError', async context => {
const response = new Response('rate limited', {
status: 429,
headers: { 'Retry-After': '42' },
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
assert.equal(error.retryAfter, 42);
assert.equal(error.responseHeaders?.['retry-after'], '42');
});
it('parses an HTTP-date Retry-After into seconds', async context => {
const response = new Response('rate limited', {
status: 429,
headers: { 'Retry-After': new Date(Date.now() + 10_000).toUTCString() },
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
// ~10s in the future: must reflect the actual date math, not a clamp-to-zero bug.
assert.ok(typeof error.retryAfter === 'number' && error.retryAfter >= 8 && error.retryAfter <= 11);
});
it('ignores an unparseable Retry-After', async context => {
const response = new Response('rate limited', {
status: 429,
headers: { 'Retry-After': 'soon' },
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
assert.equal(error.retryAfter, undefined);
});
it('logs Retry-After for rate-limit responses', async context => {
const response = new Response('rate limited', {
status: 429,
headers: { 'Retry-After': '42' },
});
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const loggerStub = context.mock.method(logger, 'log');
await assert.rejects(
provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
}),
);
const metadata = loggerStub.mock.calls[0]?.arguments[2] as unknown as {
rateLimit?: { retryAfter?: number };
};
assert.equal(metadata.rateLimit?.retryAfter, 42);
});
it('does not attach rate-limit metadata to non-rate-limit errors', async context => {
const response = new Response('something went wrong', { status: 500 });
context.mock.method(global, 'fetch', () => Promise.resolve(response));
const loggerStub = context.mock.method(logger, 'log');
await assert.rejects(
provider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
}),
);
const metadata = loggerStub.mock.calls[0]?.arguments[2] as unknown as { rateLimit?: unknown };
assert.equal(metadata.rateLimit, undefined);
});
it('populates retryAfter and headers on a customErrorHandler error from the response', async context => {
const customErrorProvider = new Provider({
prepareRequest: requestOptions => ({
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: { 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string },
}),
rateLimiter: undefined,
customErrorHandler: () => new HttpErrors.RateLimitExceededError('custom rate limit'),
});
const response = new Response('rate limited', { status: 429, headers: { 'Retry-After': '30' } });
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await customErrorProvider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
assert.equal(error.message, 'custom rate limit');
assert.equal(error.retryAfter, 30);
assert.equal(error.responseHeaders?.['retry-after'], '30');
});
it('does not override a retryAfter the customErrorHandler set deliberately', async context => {
const customErrorProvider = new Provider({
prepareRequest: requestOptions => ({
url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: { 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string },
}),
rateLimiter: undefined,
customErrorHandler: () => new HttpErrors.RateLimitExceededError('custom', { retryAfter: 99 }),
});
const response = new Response('rate limited', { status: 429, headers: { 'Retry-After': '30' } });
context.mock.method(global, 'fetch', () => Promise.resolve(response));
let error;
try {
await customErrorProvider.get('/endpoint/123', {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
deadline: OperationDeadline.never(),
logger,
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.RateLimitExceededError);
assert.equal(error.retryAfter, 99);
});
// Stream upload will not load the data in memory and sending a binary in the body
it('postStream streams data without buffering', async () => {
const streamProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
});
const testData = 'test binary data for streaming';
const stream = Readable.from([testData]);
const scope = nock('https://www.myApi.com')
.post('/upload', testData)
.matchHeader('content-type', 'application/octet-stream')
.matchHeader('accept', 'application/json')
.matchHeader('x-custom-provider-header', 'value')
.matchHeader('x-provider-credential-header', 'apikey#1111')
.reply(201, { success: true, id: '12345' });
const response = await streamProvider.postStream('/upload', stream, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(scope.isDone(), 'HTTPS request should have been made');
assert.equal(response.status, 201);
assert.deepEqual(response.body, { success: true, id: '12345' });
});
it('postStream with query params', async () => {
const streamProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.myApi.com`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
});
const testData = 'data with params';
const stream = Readable.from([testData]);
const scope = nock('https://www.myApi.com')
.post('/upload?key=value&format=json', testData)
.reply(200, { uploaded: true });
const response = await streamProvider.postStream('/upload', stream, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
queryParams: { key: 'value', format: 'json' },
deadline: OperationDeadline.never(),
});
assert.ok(scope.isDone());
assert.equal(response.status, 200);
assert.deepEqual(response.body, { uploaded: true });
});
it('postStream handles error responses', async () => {
const streamProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.myApi.com`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
});
const stream = Readable.from(['error test data']);
nock('https://www.myApi.com').post('/upload').reply(400, 'Bad request error');
let error;
try {
await streamProvider.postStream('/upload', stream, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
} catch (e) {
error = e;
}
assert.ok(error instanceof HttpErrors.BadRequestError);
assert.equal(error.message, 'Bad request error');
});
it('postStream with rate limiter', async () => {
let rateLimiterCalled = false;
let rateLimiterCredentials;
const mockRateLimiter = async (options: any, request: () => Promise<any>) => {
rateLimiterCalled = true;
rateLimiterCredentials = options.credentials;
return request();
};
const rateLimitedProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.myApi.com`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: mockRateLimiter,
});
const testData = 'rate limited data';
const stream = Readable.from([testData]);
nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });
await rateLimitedProvider.postStream('/upload', stream, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(rateLimiterCalled, 'Rate limiter should have been called');
assert.deepEqual(rateLimiterCredentials, {
apiKey: 'apikey#1111',
unitoCredentialId: '123',
});
});
it('postStream sets timeout to 0 (no timeout)', async () => {
const streamProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.myApi.com`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
});
const testData = 'timeout test data';
const stream = Readable.from([testData]);
const spy = spyOnHttpsRequest();
const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });
try {
await streamProvider.postStream('/upload', stream, {
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
logger,
deadline: OperationDeadline.never(),
});
assert.ok(scope.isDone());
assert.equal(spy.getCapturedOptions().timeout, 0, 'Timeout should be set to 0 (no timeout)');
} finally {
spy.restore();
}
});
it('postStream handles AbortSignal for request cancellation', async () => {
const streamProvider = new Provider({
prepareRequest: requestOptions => ({
url: `https://www.myApi.com`,
headers: {
'X-Custom-Provider-Header': 'value',
'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
},
}),
rateLimiter: undefined,
});
const stream = Readable.from(['abort signal test']);
// Delay response longer than the deadline so the signal fires first
nock('https://www.myApi.com').post('/upload').delayConnection(200).reply(201, { success: true });
let error;
try {
await streamProvider.postStream('/upload', stream, {
crede