@unito/integration-sdk
Version:
Integration SDK
369 lines (368 loc) • 17.9 kB
JavaScript
import https from 'https';
import { buildHttpError } from '../errors.js';
/**
* The Provider class is a wrapper around the fetch function to call a provider's HTTP API.
*
* Defines methods for the following HTTP methods: GET, POST, PUT, PATCH, DELETE.
*
* Needs to be initialized with a prepareRequest function to define the Provider's base URL and any specific headers to
* add to the requests, can also be configured to use a provided rate limiting function, and custom error handler.
*
* Multiple `Provider` instances can be created, with different configurations to call different providers APIs with
* different rateLimiting functions, as needed.
* @see {@link RateLimiter}
* @see {@link prepareRequest}
* @see {@link customErrorHandler}
*/
export class Provider {
/**
* The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
*/
rateLimiter = undefined;
/**
* Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
*
* This is applied at large to all requests made to the provider. If you need to add specific headers to a single request,
* pass it through the RequestOptions object when calling the Provider's methods.
*/
prepareRequest;
/**
* (Optional) Custom error handler to handle specific errors returned by the provider.
*
* If provided, this method should only care about custom errors returned by the provider and return the corresponding
* HttpError from the SDK. If the error encountered is a standard error, it should return undefined and let the SDK handle it.
*
* @see buildHttpError for the list of standard errors the SDK can handle.
*/
customErrorHandler;
/**
* Initializes a Provider with the given options.
*
* @property {@link prepareRequest} - function to define the Provider's base URL and specific headers to add to the request.
* @property {@link RateLimiter} - function to limit the rate of calls to the provider based on the caller's credentials.
* @property {@link customErrorHandler} - function to handle specific errors returned by the provider.
*/
constructor(options) {
this.prepareRequest = options.prepareRequest;
this.rateLimiter = options.rateLimiter;
this.customErrorHandler = options.customErrorHandler;
}
/**
* Performs a GET request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async get(endpoint, options) {
return this.fetchWrapper(endpoint, null, {
...options,
method: 'GET',
defaultHeaders: {
Accept: 'application/json',
},
});
}
/**
* Performs a GET request to the provider and return the response as a ReadableStream.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Accept: application/octet-stream
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (e.g. used to override default headers).
* @returns The streaming {@link Response} extracted from the provider.
*/
async streamingGet(endpoint, options) {
return this.fetchWrapper(endpoint, null, {
...options,
method: 'GET',
defaultHeaders: {
Accept: 'application/octet-stream',
},
rawBody: true,
});
}
/**
* Performs a POST request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Content-Type: application/json',
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async post(endpoint, body, options) {
return this.fetchWrapper(endpoint, body, {
...options,
method: 'POST',
defaultHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
async postForm(endpoint, form, options) {
const { url: providerUrl, headers: providerHeaders } = await this.prepareRequest(options);
const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
const headers = { ...form.getHeaders(), ...providerHeaders, ...options.additionnalheaders };
const reqOptions = {
method: 'POST',
headers,
};
/**
* For some obscure reason we can't use the fetch API to send a form data, so we have to use the native https module
* It seems that there is a miscalculation of the Content-Length headers that generates an error :
* --> headers length is different from the actual body length
* The goto solution recommended across the internet for this, is to simply drop the header.
* However, some integrations like Servicenow, will not accept the request if it doesn't contain that header
*/
const callToProvider = async () => {
return new Promise((resolve, reject) => {
try {
const request = https.request(absoluteUrl, reqOptions, response => {
response.setEncoding('utf8');
let responseBody = '';
response.on('data', chunk => {
responseBody += chunk;
});
response.on('end', () => {
try {
const body = JSON.parse(responseBody);
if (body.error) {
reject(this.handleError(400, body.error.message, options));
}
resolve({ status: 201, headers: response.headers, body: body });
}
catch (error) {
reject(this.handleError(500, `Failed to parse response body: "${error}"`, options));
}
});
});
request.on('error', error => {
reject(this.handleError(400, `Error while calling the provider: "${error}"`, options));
});
form.pipe(request);
}
catch (error) {
reject(this.handleError(500, `Unexpected error while calling the provider: "${error}"`, options));
}
});
};
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
}
/**
* Performs a PUT request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Content-Type: application/json',
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async put(endpoint, body, options) {
return this.fetchWrapper(endpoint, body, {
...options,
method: 'PUT',
defaultHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
/**
* Performs a PUT request to the provider with a Buffer body, typically used for sending binary data.
*
* IMPORTANT: This method should ONLY be used as a last resort when FormData cannot be used.
* It bypasses normal form handling and is used to **manually send chunked** binary data, which may not be appropriate
* for all providers. Always be mindful not to load entire binary files in memory!
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Content-Type: application/octet-stream
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param body The Buffer containing the binary data to be sent.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async putBuffer(endpoint, body, options) {
return this.fetchWrapper(endpoint, body, {
...options,
method: 'PUT',
defaultHeaders: {
'Content-Type': 'application/octet-stream',
Accept: 'application/json',
},
});
}
/**
* Performs a PATCH request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Content-Type: application/json',
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async patch(endpoint, body, options) {
return this.fetchWrapper(endpoint, body, {
...options,
method: 'PATCH',
defaultHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
/**
* Performs a DELETE request to the provider.
*
* Uses the prepareRequest function to get the base URL and any specific headers to add to the request and by default
* adds the following headers:
* - Accept: application/json
*
* @param endpoint Path to the provider's resource. Will be added to the URL returned by the prepareRequest function.
* @param options RequestOptions used to adjust the call made to the provider (use to override default headers).
* @returns The {@link Response} extracted from the provider.
*/
async delete(endpoint, options) {
return this.fetchWrapper(endpoint, null, {
...options,
method: 'DELETE',
defaultHeaders: {
Accept: 'application/json',
},
});
}
generateAbsoluteUrl(providerUrl, endpoint, queryParams) {
let absoluteUrl;
if (/^https?:\/\//.test(endpoint)) {
absoluteUrl = endpoint;
}
else {
absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
}
if (queryParams) {
absoluteUrl = `${absoluteUrl}?${new URLSearchParams(queryParams)}`;
}
return absoluteUrl;
}
async fetchWrapper(endpoint, body, options) {
const { url: providerUrl, headers: providerHeaders } = await this.prepareRequest(options);
const absoluteUrl = this.generateAbsoluteUrl(providerUrl, endpoint, options.queryParams);
const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
let fetchBody = null;
if (body) {
if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
fetchBody = new URLSearchParams(body).toString();
}
else if (headers['Content-Type'] === 'application/json' ||
headers['Content-Type'] === 'application/json-patch+json') {
fetchBody = JSON.stringify(body);
}
else if (headers['Content-Type'] === 'application/octet-stream' && body instanceof Buffer) {
fetchBody = body;
}
else {
throw this.handleError(400, `Content type not supported: ${headers['Content-Type']}`, options);
}
}
const callToProvider = async () => {
const beforeRequestTimestamp = process.hrtime.bigint();
let response;
try {
response = await fetch(absoluteUrl, {
method: options.method,
headers,
body: fetchBody,
...(options.signal ? { signal: options.signal } : {}),
});
}
catch (error) {
if (error instanceof Error) {
switch (error.name) {
case 'AbortError':
throw this.handleError(408, 'Request aborted', options);
case 'TimeoutError':
throw this.handleError(408, 'Request timeout', options);
}
throw this.handleError(500, `Unexpected error while calling the provider. ErrorName: "${error.name}" \n message: "${error.message}" \n stack: ${error.stack} \n cause: ${error.cause} \n causeStack: ${error.cause?.stack}`, options);
}
throw this.handleError(500, 'Unexpected error while calling the provider - this is not normal, investigate', options);
}
const afterRequestTimestamp = process.hrtime.bigint();
const requestDurationInNS = Number(afterRequestTimestamp - beforeRequestTimestamp);
const requestDurationInMs = (requestDurationInNS / 1_000_000) | 0;
options.logger.info(`Connector API Request ${options.method} ${absoluteUrl} ${response.status} - ${requestDurationInMs} ms`, {
duration: requestDurationInNS,
http: {
method: options.method,
status_code: response.status,
content_type: headers['Content-Type'],
url_details: {
path: absoluteUrl,
},
},
});
if (response.status >= 400) {
const textResult = await response.text();
throw this.handleError(response.status, textResult, options);
}
else if (response.status === 204 || response.body === null) {
// No content: return without inspecting the body
return { status: response.status, headers: response.headers, body: undefined };
}
const responseContentType = response.headers.get('content-type');
let body;
if (options.rawBody || headers.Accept === 'application/octet-stream') {
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it
body = response.body;
}
else if (headers.Accept?.match(/application\/.*json/)) {
// Validate that the response content type is at least similar to what we expect
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
// Default to application/json if no Content-Type header is provided
if (responseContentType && !responseContentType.match(/application\/.*json/)) {
const textResult = await response.text();
throw this.handleError(500, `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
Original response (${response.status}): "${textResult}"`, options);
}
try {
body = response.body ? await response.json() : undefined;
}
catch (err) {
throw this.handleError(500, `Invalid JSON response`, options);
}
}
else if (headers.Accept?.includes('text/html')) {
// Accept text based content types
body = (await response.text());
}
else {
throw this.handleError(500, 'Unsupported Content-Type', options);
}
return { status: response.status, headers: response.headers, body };
};
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
}
handleError(responseStatus, message, options) {
const customError = this.customErrorHandler?.(responseStatus, message, options);
return customError ?? buildHttpError(responseStatus, message);
}
}