UNPKG

@unito/integration-sdk

Version:

Integration SDK

369 lines (368 loc) 17.9 kB
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); } }