diff --git a/package-lock.json b/package-lock.json index b2b0777..0602189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "MIT", "dependencies": { "@apimatic/schema": "^0.6.0", - "axios": "^0.21.4", - "detect-node": "^2.1.0", + "axios": "^0.27.2", + "detect-node": "^2.0.4", "form-data": "^3.0.0", "json-bigint": "^1.0.0", "lodash.flatmap": "^4.5.0", @@ -2772,11 +2772,25 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" } }, "node_modules/axobject-query": { @@ -5096,9 +5110,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.14.8", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz", - "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", "funding": [ { "type": "individual", @@ -14571,11 +14585,24 @@ "dev": true }, "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", "requires": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } } }, "axobject-query": { @@ -16410,9 +16437,9 @@ "dev": true }, "follow-redirects": { - "version": "1.14.8", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz", - "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==" + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" }, "for-in": { "version": "1.0.2", diff --git a/package.json b/package.json index f1c0f67..59fd510 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ }, "dependencies": { "@apimatic/schema": "^0.6.0", - "axios": "^0.21.4", - "detect-node": "^2.1.0", + "axios": "^0.27.2", + "detect-node": "^2.0.4", "form-data": "^3.0.0", "json-bigint": "^1.0.0", "lodash.flatmap": "^4.5.0", diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index b132535..37bc6d6 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -4,182 +4,185 @@ * This file was automatically generated by APIMATIC v2.0 ( https://apimatic.io ). */ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; -import isNode from 'detect-node'; -import FormData from 'form-data'; -import { isBlob } from '../apiHelper'; -import { AbortError } from '../errors/abortError'; -import { isFileWrapper } from '../fileWrapper'; -import { - CONTENT_TYPE_HEADER, - FORM_URLENCODED_CONTENT_TYPE, - mergeHeaders, - setHeader, - setHeaderIfNotSet, -} from './httpHeaders'; -import { HttpRequest } from './httpRequest'; -import { HttpResponse } from './httpResponse'; -import { urlEncodeKeyValuePairs } from './queryString'; - -export const DEFAULT_AXIOS_CONFIG_OVERRIDES: AxiosRequestConfig = { - transformResponse: [], -}; - -export const DEFAULT_TIMEOUT = 30 * 1000; - -/** - * HTTP client implementation. - * - * This implementation is a wrapper over the Axios client. - */ -export class HttpClient { - private _axiosInstance: AxiosInstance; - private _timeout: number; - - constructor({ - clientConfigOverrides, - timeout = DEFAULT_TIMEOUT, - }: { clientConfigOverrides?: AxiosRequestConfig; timeout?: number } = {}) { - this._timeout = timeout; - this._axiosInstance = axios.create({ - ...DEFAULT_AXIOS_CONFIG_OVERRIDES, - ...clientConfigOverrides, - }); - } - - /** Converts an HttpRequest object to an Axios request. */ - public convertHttpRequest(req: HttpRequest): AxiosRequestConfig { - const newRequest: AxiosRequestConfig = { - method: req.method, - url: req.url, - responseType: 'text', - headers: { ...req.headers }, - }; - - if (req.auth) { - // Set basic auth credentials if provided - newRequest.auth = { - username: req.auth.username, - password: req.auth.password || '', - }; - } - - const requestBody = req.body; - if (requestBody?.type === 'text') { - newRequest.data = requestBody.content; - } else if ( - requestBody?.type === 'form-data' && - requestBody.content.some(item => isFileWrapper(item.value)) - ) { - // Create multipart request if a file is present - const form = new FormData(); - for (const iter of requestBody.content) { - if (isFileWrapper(iter.value)) { - let fileData = iter.value.file; - - // Make sure Blob has the correct content type if provided - if (isBlob(fileData) && iter.value.options?.contentType) { - fileData = new Blob([fileData], { - type: iter.value.options.contentType, - }); - } - - form.append(iter.key, fileData, iter.value.options); - } else { - form.append(iter.key, iter.value); - } - } - - newRequest.data = form; - mergeHeaders(newRequest.headers, form.getHeaders()); - } else if ( - requestBody?.type === 'form-data' || - requestBody?.type === 'form' - ) { - // Create form-urlencoded request - setHeader( - newRequest.headers, - CONTENT_TYPE_HEADER, - FORM_URLENCODED_CONTENT_TYPE - ); - newRequest.data = urlEncodeKeyValuePairs(requestBody.content); - } else if (requestBody?.type === 'stream') { - let contentType = 'application/octet-stream'; - if (isBlob(requestBody.content.file) && requestBody.content.file.type) { - // Set Blob mime type as the content-type header if present - contentType = requestBody.content.file.type; - } else if (requestBody.content.options?.contentType) { - // Otherwise, use the content type if available. - contentType = requestBody.content.options.contentType; - } - setHeaderIfNotSet(newRequest.headers, CONTENT_TYPE_HEADER, contentType); - newRequest.data = requestBody.content.file; - } else if (requestBody?.type !== undefined) { - throw new Error( - `HTTP client encountered unknown body type '${requestBody?.type}'. Could not execute HTTP request.` - ); - } - - if (req.responseType === 'stream') { - newRequest.responseType = isNode ? 'stream' : 'blob'; - } - - // Prevent superagent from converting any status code to error - newRequest.validateStatus = () => true; - - // Set 30 seconds timeout - newRequest.timeout = this._timeout; - - return newRequest; - } - - /** Converts an Axios response to an HttpResponse object. */ - public convertHttpResponse(resp: AxiosResponse): HttpResponse { - return { - body: resp.data, - headers: resp.headers, - statusCode: resp.status, - }; - } - - /** - * Executes the HttpRequest with the given options and returns the HttpResponse - * or throws an error. - */ - public async executeRequest( - request: HttpRequest, - requestOptions?: { abortSignal?: AbortSignal } - ): Promise { - const axiosRequest = this.convertHttpRequest(request); - - if (requestOptions?.abortSignal) { - // throw if already aborted; do not place HTTP call - if (requestOptions.abortSignal.aborted) { - throw this.abortError(); - } - - const cancelToken = axios.CancelToken.source(); - axiosRequest.cancelToken = cancelToken.token; - - // attach abort event handler - requestOptions.abortSignal.addEventListener('abort', () => { - cancelToken.cancel(); - }); - } - - try { - return this.convertHttpResponse(await this._axiosInstance(axiosRequest)); - } catch (error) { - // abort error should be thrown as the AbortError - if (axios.isCancel(error)) { - throw this.abortError(); - } - - throw error; - } - } - - private abortError() { - return new AbortError('The HTTP call was aborted.'); - } -} + import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; + import isNode from 'detect-node'; + import FormData from 'form-data'; + import { isBlob } from '../apiHelper'; + import { AbortError } from '../errors/abortError'; + import { isFileWrapper } from '../fileWrapper'; + import { + CONTENT_TYPE_HEADER, + FORM_URLENCODED_CONTENT_TYPE, + mergeHeaders, + setHeader, + setHeaderIfNotSet, + } from './httpHeaders'; + import { HttpRequest } from './httpRequest'; + import { HttpResponse } from './httpResponse'; + import { urlEncodeKeyValuePairs } from './queryString'; + + export const DEFAULT_AXIOS_CONFIG_OVERRIDES: AxiosRequestConfig = { + transformResponse: [], + }; + + export const DEFAULT_TIMEOUT = 30 * 1000; + + /** + * HTTP client implementation. + * + * This implementation is a wrapper over the Axios client. + */ + export class HttpClient { + private _axiosInstance: AxiosInstance; + private _timeout: number; + + constructor({ + clientConfigOverrides, + timeout = DEFAULT_TIMEOUT, + }: { clientConfigOverrides?: AxiosRequestConfig; timeout?: number } = {}) { + this._timeout = timeout; + this._axiosInstance = axios.create({ + ...DEFAULT_AXIOS_CONFIG_OVERRIDES, + ...clientConfigOverrides, + }); + } + + /** Converts an HttpRequest object to an Axios request. */ + public convertHttpRequest(req: HttpRequest): AxiosRequestConfig { + const newRequest: AxiosRequestConfig = { + method: req.method, + url: req.url, + responseType: 'text', + headers: { ...req.headers }, + }; + + if (req.auth) { + // Set basic auth credentials if provided + newRequest.auth = { + username: req.auth.username, + password: req.auth.password || '', + }; + } + + const requestBody = req.body; + if (requestBody?.type === 'text') { + newRequest.data = requestBody.content; + } else if ( + requestBody?.type === 'form-data' && + requestBody.content.some(item => isFileWrapper(item.value)) + ) { + // Create multipart request if a file is present + const form = new FormData(); + for (const iter of requestBody.content) { + if (isFileWrapper(iter.value)) { + let fileData = iter.value.file; + + // Make sure Blob has the correct content type if provided + if (isBlob(fileData) && iter.value.options?.contentType) { + fileData = new Blob([fileData], { + type: iter.value.options.contentType, + }); + } + + form.append(iter.key, fileData, iter.value.options); + } else { + form.append(iter.key, iter.value); + } + } + + newRequest.data = form; + mergeHeaders(newRequest.headers || {} , form.getHeaders()); + } else if ( + requestBody?.type === 'form-data' || + requestBody?.type === 'form' + ) { + // Create form-urlencoded request + setHeader( + newRequest.headers || {}, + CONTENT_TYPE_HEADER, + FORM_URLENCODED_CONTENT_TYPE + ); + newRequest.data = urlEncodeKeyValuePairs(requestBody.content); + } else if (requestBody?.type === 'stream') { + let contentType = 'application/octet-stream'; + if (isBlob(requestBody.content.file) && requestBody.content.file.type) { + // Set Blob mime type as the content-type header if present + contentType = requestBody.content.file.type; + } else if (requestBody.content.options?.contentType) { + // Otherwise, use the content type if available. + contentType = requestBody.content.options.contentType; + } + setHeaderIfNotSet(newRequest.headers || {}, CONTENT_TYPE_HEADER, contentType); + newRequest.data = requestBody.content.file; + } + else if (requestBody && typeof(requestBody['type']) !== 'undefined') { + + throw new Error( + `HTTP client encountered unknown body type '${requestBody["type"]}'. Could not execute HTTP request.` + ); + } + + if (req.responseType === 'stream') { + newRequest.responseType = isNode ? 'stream' : 'blob'; + } + + // Prevent superagent from converting any status code to error + newRequest.validateStatus = () => true; + + // Set 30 seconds timeout + newRequest.timeout = this._timeout; + + return newRequest; + } + + /** Converts an Axios response to an HttpResponse object. */ + public convertHttpResponse(resp: AxiosResponse): HttpResponse { + return { + body: resp.data, + headers: resp.headers, + statusCode: resp.status, + }; + } + + /** + * Executes the HttpRequest with the given options and returns the HttpResponse + * or throws an error. + */ + public async executeRequest( + request: HttpRequest, + requestOptions?: { abortSignal?: AbortSignal } + ): Promise { + const axiosRequest = this.convertHttpRequest(request); + + if (requestOptions?.abortSignal) { + // throw if already aborted; do not place HTTP call + if (requestOptions.abortSignal.aborted) { + throw this.abortError(); + } + + const cancelToken = axios.CancelToken.source(); + axiosRequest.cancelToken = cancelToken.token; + + // attach abort event handler + requestOptions.abortSignal.addEventListener('abort', () => { + cancelToken.cancel(); + }); + } + + try { + return this.convertHttpResponse(await this._axiosInstance(axiosRequest)); + } catch (error) { + // abort error should be thrown as the AbortError + if (axios.isCancel(error)) { + throw this.abortError(); + } + + throw error; + } + } + + private abortError() { + return new AbortError('The HTTP call was aborted.'); + } + } + \ No newline at end of file diff --git a/src/http/httpHeaders.ts b/src/http/httpHeaders.ts index 7b01081..663d776 100644 --- a/src/http/httpHeaders.ts +++ b/src/http/httpHeaders.ts @@ -13,20 +13,20 @@ * @param name Header name * @param value Header value */ -export function setHeader( - headers: Record, + export function setHeader( + headers: Record, name: string, - value?: string + value?: string | number | boolean | undefined ): void { const realHeaderName = lookupCaseInsensitive(headers, name); setHeaderInternal(headers, realHeaderName, name, value); } function setHeaderInternal( - headers: Record, + headers: Record, realHeaderName: string | null, name: string, - value: string | undefined + value: string | number | boolean | undefined ): void { if (realHeaderName) { delete headers[realHeaderName]; @@ -46,9 +46,9 @@ function setHeaderInternal( * @param value Header value */ export function setHeaderIfNotSet( - headers: Record, + headers: Record, name: string, - value?: string + value?: string | number | boolean | undefined ): void { const realHeaderName = lookupCaseInsensitive(headers, name); if (!realHeaderName) { @@ -111,8 +111,8 @@ export function lookupCaseInsensitive( * @param headersToMerge Headers to set */ export function mergeHeaders( - headers: Record, - headersToMerge: Record + headers: Record, + headersToMerge: Record ): void { const headerKeys: Record = {}; diff --git a/tests/headers.test.js b/tests/headers.test.js new file mode 100644 index 0000000..803fffb --- /dev/null +++ b/tests/headers.test.js @@ -0,0 +1,41 @@ +import * as headers from '../src/http/httpHeaders' + +describe('headers', () => { + it('skips "undefined" value', () => { + let test_headers = {'test': 1} + headers.setHeader(test_headers, 'somethingundefined', undefined) + + expect(test_headers.somethingundefined).toBeUndefined(); + }); + + it('sets header internally if it does not exist', () => { + let test_headers = {} + headers.setHeader(test_headers, 'test', 2) + + expect(test_headers.test).toEqual(2) + }); + + it('sets header internally overring it if exists', () => { + let test_headers = {'test': 1} + headers.setHeader(test_headers, 'test', '22') + + expect(test_headers.test).toEqual('22') + }); + + //current functionality won;t allow false values! + it('sets header internally for boolean values', () => { + let test_headers = {'test': 1} + headers.setHeader(test_headers, 'test', true) + + expect(test_headers.test).toEqual(true) + }); + + it('sets header if not already set', () => { + let test_headers = {'test': 1} + headers.setHeaderIfNotSet(test_headers, 'test', '22') + expect(test_headers.test).toEqual(1) + + headers.setHeaderIfNotSet(test_headers, 'testnew', '22') + expect(test_headers.testnew).toEqual('22') + }); +}) \ No newline at end of file diff --git a/tests/media.test.js b/tests/media.test.js index 043e50d..d73eeab 100644 --- a/tests/media.test.js +++ b/tests/media.test.js @@ -1,4 +1,6 @@ import { Client, ApiController, FileWrapper } from '../src'; +import { HttpClient } from '../src/http/httpClient'; + let controller; @@ -11,6 +13,28 @@ beforeEach(() => { controller = new ApiController(client); }); +describe('http client', () => { + const httpClient = new HttpClient(); + it('should throw error on unknown body type', async () => { + const httpRequest = { + body: { + type: "somethingmadeup" + } + }; + expect(() => httpClient.convertHttpRequest(httpRequest)).toThrow("HTTP client encountered unknown body type 'somethingmadeup'. Could not execute HTTP request."); + }); + + it('should not throw error on known body type', async () => { + const httpRequest = { + body: { + type: "text" + } + }; + expect(httpClient.convertHttpRequest(httpRequest)).toBeDefined(); + }); +}); + + describe('media', () => { it('should upload and download media', async () => { const content = 'Hello world!'; diff --git a/tests/messaging.test.js b/tests/messaging.test.js index 854d311..72c6f92 100644 --- a/tests/messaging.test.js +++ b/tests/messaging.test.js @@ -1,4 +1,6 @@ import { Client, ApiController, FileWrapperi, MessagingExceptionError, MessageRequest } from '../src'; +import { HttpClient } from '../src/http/httpClient'; + let controller; @@ -11,6 +13,28 @@ beforeEach(() => { controller = new ApiController(client); }); +describe('http client', () => { + const httpClient = new HttpClient(); + it('should throw error on unknown body type', async () => { + const httpRequest = { + body: { + type: "somethingmadeup" + } + }; + expect(() => httpClient.convertHttpRequest(httpRequest)).toThrow("HTTP client encountered unknown body type 'somethingmadeup'. Could not execute HTTP request."); + }); + + it('should not throw error on known body type', async () => { + const httpRequest = { + body: { + type: "text" + } + }; + expect(httpClient.convertHttpRequest(httpRequest)).toBeDefined(); + }); +}); + + describe('messaging', () => { it('should create message with proper values', async () => {