From 98e913ce3e178769ef4624f6670113c72dd9ce68 Mon Sep 17 00:00:00 2001 From: Jordan Shatford Date: Fri, 29 Mar 2024 13:09:16 +1100 Subject: [PATCH] chore(tests): add snapshots of xhr, axios, node (core only) --- .../v3_axios/core/ApiError.ts.snap | 21 ++ .../v3_axios/core/ApiRequestOptions.ts.snap | 13 + .../v3_axios/core/ApiResult.ts.snap | 7 + .../v3_axios/core/CancelablePromise.ts.snap | 126 +++++++ .../v3_axios/core/OpenAPI.ts.snap | 54 +++ .../v3_axios/core/request.ts.snap | 335 ++++++++++++++++++ .../__snapshots__/v3_axios/core/types.ts.snap | 10 + test/__snapshots__/v3_axios/index.ts.snap | 4 + .../v3_node/core/ApiError.ts.snap | 21 ++ .../v3_node/core/ApiRequestOptions.ts.snap | 13 + .../v3_node/core/ApiResult.ts.snap | 7 + .../v3_node/core/CancelablePromise.ts.snap | 126 +++++++ .../v3_node/core/OpenAPI.ts.snap | 54 +++ .../v3_node/core/request.ts.snap | 321 +++++++++++++++++ test/__snapshots__/v3_node/core/types.ts.snap | 10 + test/__snapshots__/v3_node/index.ts.snap | 4 + .../v3_xhr/core/ApiError.ts.snap | 21 ++ .../v3_xhr/core/ApiRequestOptions.ts.snap | 13 + .../v3_xhr/core/ApiResult.ts.snap | 7 + .../v3_xhr/core/CancelablePromise.ts.snap | 126 +++++++ .../__snapshots__/v3_xhr/core/OpenAPI.ts.snap | 53 +++ .../__snapshots__/v3_xhr/core/request.ts.snap | 323 +++++++++++++++++ test/__snapshots__/v3_xhr/core/types.ts.snap | 10 + test/__snapshots__/v3_xhr/index.ts.snap | 4 + test/index.spec.ts | 67 +++- 25 files changed, 1736 insertions(+), 14 deletions(-) create mode 100644 test/__snapshots__/v3_axios/core/ApiError.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/ApiRequestOptions.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/ApiResult.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/CancelablePromise.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/OpenAPI.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/request.ts.snap create mode 100644 test/__snapshots__/v3_axios/core/types.ts.snap create mode 100644 test/__snapshots__/v3_axios/index.ts.snap create mode 100644 test/__snapshots__/v3_node/core/ApiError.ts.snap create mode 100644 test/__snapshots__/v3_node/core/ApiRequestOptions.ts.snap create mode 100644 test/__snapshots__/v3_node/core/ApiResult.ts.snap create mode 100644 test/__snapshots__/v3_node/core/CancelablePromise.ts.snap create mode 100644 test/__snapshots__/v3_node/core/OpenAPI.ts.snap create mode 100644 test/__snapshots__/v3_node/core/request.ts.snap create mode 100644 test/__snapshots__/v3_node/core/types.ts.snap create mode 100644 test/__snapshots__/v3_node/index.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/ApiError.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/ApiRequestOptions.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/ApiResult.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/CancelablePromise.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/OpenAPI.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/request.ts.snap create mode 100644 test/__snapshots__/v3_xhr/core/types.ts.snap create mode 100644 test/__snapshots__/v3_xhr/index.ts.snap diff --git a/test/__snapshots__/v3_axios/core/ApiError.ts.snap b/test/__snapshots__/v3_axios/core/ApiError.ts.snap new file mode 100644 index 000000000..2c11b4136 --- /dev/null +++ b/test/__snapshots__/v3_axios/core/ApiError.ts.snap @@ -0,0 +1,21 @@ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: unknown; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/test/__snapshots__/v3_axios/core/ApiRequestOptions.ts.snap b/test/__snapshots__/v3_axios/core/ApiRequestOptions.ts.snap new file mode 100644 index 000000000..e93003ee7 --- /dev/null +++ b/test/__snapshots__/v3_axios/core/ApiRequestOptions.ts.snap @@ -0,0 +1,13 @@ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/test/__snapshots__/v3_axios/core/ApiResult.ts.snap b/test/__snapshots__/v3_axios/core/ApiResult.ts.snap new file mode 100644 index 000000000..caa79c2ea --- /dev/null +++ b/test/__snapshots__/v3_axios/core/ApiResult.ts.snap @@ -0,0 +1,7 @@ +export type ApiResult = { + readonly body: TData; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; +}; diff --git a/test/__snapshots__/v3_axios/core/CancelablePromise.ts.snap b/test/__snapshots__/v3_axios/core/CancelablePromise.ts.snap new file mode 100644 index 000000000..e6b03b6a2 --- /dev/null +++ b/test/__snapshots__/v3_axios/core/CancelablePromise.ts.snap @@ -0,0 +1,126 @@ +export class CancelError extends Error { + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + private _isResolved: boolean; + private _isRejected: boolean; + private _isCancelled: boolean; + readonly cancelHandlers: (() => void)[]; + readonly promise: Promise; + private _resolve?: (value: T | PromiseLike) => void; + private _reject?: (reason?: unknown) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + onCancel: OnCancel + ) => void + ) { + this._isResolved = false; + this._isRejected = false; + this._isCancelled = false; + this.cancelHandlers = []; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isResolved = true; + if (this._resolve) this._resolve(value); + }; + + const onReject = (reason?: unknown): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isRejected = true; + if (this._reject) this._reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this.cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this._isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this._isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this._isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return 'Cancellable Promise'; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this.promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.promise.finally(onFinally); + } + + public cancel(): void { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isCancelled = true; + if (this.cancelHandlers.length) { + try { + for (const cancelHandler of this.cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.cancelHandlers.length = 0; + if (this._reject) this._reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this._isCancelled; + } +} diff --git a/test/__snapshots__/v3_axios/core/OpenAPI.ts.snap b/test/__snapshots__/v3_axios/core/OpenAPI.ts.snap new file mode 100644 index 000000000..2ec4abd9a --- /dev/null +++ b/test/__snapshots__/v3_axios/core/OpenAPI.ts.snap @@ -0,0 +1,54 @@ +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { TResult } from './types'; + +type Headers = Record; +type Middleware = (value: T) => T | Promise; +type Resolver = (options: ApiRequestOptions) => Promise; + +export class Interceptors { + _fns: Middleware[]; + + constructor() { + this._fns = []; + } + + eject(fn: Middleware) { + const index = this._fns.indexOf(fn); + if (index !== -1) { + this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; + } + } + + use(fn: Middleware) { + this._fns = [...this._fns, fn]; + } +} + +export type OpenAPIConfig = { + BASE: string; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + ENCODE_PATH?: ((path: string) => string) | undefined; + HEADERS?: Headers | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + RESULT?: TResult; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + VERSION: string; + WITH_CREDENTIALS: boolean; + interceptors: { request: Interceptors; response: Interceptors }; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://localhost:3000/base', + CREDENTIALS: 'include', + ENCODE_PATH: undefined, + HEADERS: undefined, + PASSWORD: undefined, + RESULT: 'body', + TOKEN: undefined, + USERNAME: undefined, + VERSION: '1.0', + WITH_CREDENTIALS: false, + interceptors: { request: new Interceptors(), response: new Interceptors() }, +}; diff --git a/test/__snapshots__/v3_axios/core/request.ts.snap b/test/__snapshots__/v3_axios/core/request.ts.snap new file mode 100644 index 000000000..8a1ff67ee --- /dev/null +++ b/test/__snapshots__/v3_axios/core/request.ts.snap @@ -0,0 +1,335 @@ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: unknown): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + value !== null && + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: unknown): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: unknown) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const encodePair = (key: string, value: unknown) => { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value)) { + value.forEach(v => encodePair(key, v)); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); + } else { + append(key, value); + } + }; + + Object.entries(params).forEach(([key, value]) => encodePair(key, value)); + + return qs.length ? `?${qs.join('&')}` : ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = config.BASE + path; + return options.query ? url + getQueryString(options.query) : url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: unknown) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([, value]) => value !== undefined && value !== null) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + formData?: FormData +): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = (typeof formData?.getHeaders === 'function' && formData?.getHeaders()) || {}; + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([, value]) => value !== undefined && value !== null) + .reduce( + (headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), + {} as Record + ); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): unknown => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: unknown, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const controller = new AbortController(); + + let requestConfig: AxiosRequestConfig = { + data: body ?? formData, + headers, + method: options.method, + signal: controller.signal, + url, + withCredentials: config.WITH_CREDENTIALS, + }; + + onCancel(() => controller.abort()); + + for (const fn of config.interceptors.request._fns) { + requestConfig = await fn(requestConfig); + } + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): unknown => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError( + options, + result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = ( + config: OpenAPIConfig, + options: ApiRequestOptions, + axiosClient: AxiosInstance = axios +): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + let response = await sendRequest( + config, + options, + url, + body, + formData, + headers, + onCancel, + axiosClient + ); + + for (const fn of config.interceptors.response._fns) { + response = await fn(response); + } + + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/test/__snapshots__/v3_axios/core/types.ts.snap b/test/__snapshots__/v3_axios/core/types.ts.snap new file mode 100644 index 000000000..e33a5d996 --- /dev/null +++ b/test/__snapshots__/v3_axios/core/types.ts.snap @@ -0,0 +1,10 @@ +import type { ApiResult } from './ApiResult'; + +export type TResult = 'body' | 'raw'; + +export type TApiResponse = + Exclude extends never ? ApiResult : ApiResult['body']; + +export type TConfig = { + _result?: T; +}; diff --git a/test/__snapshots__/v3_axios/index.ts.snap b/test/__snapshots__/v3_axios/index.ts.snap new file mode 100644 index 000000000..9eceb36e3 --- /dev/null +++ b/test/__snapshots__/v3_axios/index.ts.snap @@ -0,0 +1,4 @@ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; diff --git a/test/__snapshots__/v3_node/core/ApiError.ts.snap b/test/__snapshots__/v3_node/core/ApiError.ts.snap new file mode 100644 index 000000000..2c11b4136 --- /dev/null +++ b/test/__snapshots__/v3_node/core/ApiError.ts.snap @@ -0,0 +1,21 @@ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: unknown; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/test/__snapshots__/v3_node/core/ApiRequestOptions.ts.snap b/test/__snapshots__/v3_node/core/ApiRequestOptions.ts.snap new file mode 100644 index 000000000..e93003ee7 --- /dev/null +++ b/test/__snapshots__/v3_node/core/ApiRequestOptions.ts.snap @@ -0,0 +1,13 @@ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/test/__snapshots__/v3_node/core/ApiResult.ts.snap b/test/__snapshots__/v3_node/core/ApiResult.ts.snap new file mode 100644 index 000000000..caa79c2ea --- /dev/null +++ b/test/__snapshots__/v3_node/core/ApiResult.ts.snap @@ -0,0 +1,7 @@ +export type ApiResult = { + readonly body: TData; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; +}; diff --git a/test/__snapshots__/v3_node/core/CancelablePromise.ts.snap b/test/__snapshots__/v3_node/core/CancelablePromise.ts.snap new file mode 100644 index 000000000..e6b03b6a2 --- /dev/null +++ b/test/__snapshots__/v3_node/core/CancelablePromise.ts.snap @@ -0,0 +1,126 @@ +export class CancelError extends Error { + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + private _isResolved: boolean; + private _isRejected: boolean; + private _isCancelled: boolean; + readonly cancelHandlers: (() => void)[]; + readonly promise: Promise; + private _resolve?: (value: T | PromiseLike) => void; + private _reject?: (reason?: unknown) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + onCancel: OnCancel + ) => void + ) { + this._isResolved = false; + this._isRejected = false; + this._isCancelled = false; + this.cancelHandlers = []; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isResolved = true; + if (this._resolve) this._resolve(value); + }; + + const onReject = (reason?: unknown): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isRejected = true; + if (this._reject) this._reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this.cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this._isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this._isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this._isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return 'Cancellable Promise'; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this.promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.promise.finally(onFinally); + } + + public cancel(): void { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isCancelled = true; + if (this.cancelHandlers.length) { + try { + for (const cancelHandler of this.cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.cancelHandlers.length = 0; + if (this._reject) this._reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this._isCancelled; + } +} diff --git a/test/__snapshots__/v3_node/core/OpenAPI.ts.snap b/test/__snapshots__/v3_node/core/OpenAPI.ts.snap new file mode 100644 index 000000000..5739ba395 --- /dev/null +++ b/test/__snapshots__/v3_node/core/OpenAPI.ts.snap @@ -0,0 +1,54 @@ +import type { RequestInit, Response } from 'node-fetch'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { TResult } from './types'; + +type Headers = Record; +type Middleware = (value: T) => T | Promise; +type Resolver = (options: ApiRequestOptions) => Promise; + +export class Interceptors { + _fns: Middleware[]; + + constructor() { + this._fns = []; + } + + eject(fn: Middleware) { + const index = this._fns.indexOf(fn); + if (index !== -1) { + this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; + } + } + + use(fn: Middleware) { + this._fns = [...this._fns, fn]; + } +} + +export type OpenAPIConfig = { + BASE: string; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + ENCODE_PATH?: ((path: string) => string) | undefined; + HEADERS?: Headers | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + RESULT?: TResult; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + VERSION: string; + WITH_CREDENTIALS: boolean; + interceptors: { request: Interceptors; response: Interceptors }; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://localhost:3000/base', + CREDENTIALS: 'include', + ENCODE_PATH: undefined, + HEADERS: undefined, + PASSWORD: undefined, + RESULT: 'body', + TOKEN: undefined, + USERNAME: undefined, + VERSION: '1.0', + WITH_CREDENTIALS: false, + interceptors: { request: new Interceptors(), response: new Interceptors() }, +}; diff --git a/test/__snapshots__/v3_node/core/request.ts.snap b/test/__snapshots__/v3_node/core/request.ts.snap new file mode 100644 index 000000000..0b5069cd5 --- /dev/null +++ b/test/__snapshots__/v3_node/core/request.ts.snap @@ -0,0 +1,321 @@ +import fetch, { FormData, Headers } from 'node-fetch'; +import type { RequestInit, Response } from 'node-fetch'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: unknown): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + value !== null && + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: unknown): value is FormData => { + return value instanceof FormData; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: unknown) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const encodePair = (key: string, value: unknown) => { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value)) { + value.forEach(v => encodePair(key, v)); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); + } else { + append(key, value); + } + }; + + Object.entries(params).forEach(([key, value]) => encodePair(key, value)); + + return qs.length ? `?${qs.join('&')}` : ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = config.BASE + path; + return options.query ? url + getQueryString(options.query) : url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: unknown) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([, value]) => value !== undefined && value !== null) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + }) + .filter(([, value]) => value !== undefined && value !== null) + .reduce( + (headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), + {} as Record + ); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return new Headers(headers); +}; + +export const getRequestBody = (options: ApiRequestOptions): unknown => { + if (options.body !== undefined) { + if (options.mediaType?.includes('/json')) { + return JSON.stringify(options.body); + } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { + return options.body as unknown; + } else { + return JSON.stringify(options.body); + } + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Headers, + onCancel: OnCancel +): Promise => { + const controller = new AbortController(); + + let request: RequestInit = { + headers, + method: options.method, + body: body ?? formData, + signal: controller.signal, + }; + + for (const fn of config.interceptors.request._fns) { + request = await fn(request); + } + + onCancel(() => controller.abort()); + + return await fetch(url, request); +}; + +export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers.get(responseHeader); + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = async (response: Response): Promise => { + if (response.status !== 204) { + try { + const contentType = response.headers.get('Content-Type'); + if (contentType) { + const jsonTypes = ['application/json', 'application/problem+json']; + const binaryTypes = ['audio/', 'image/', 'video/']; + const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); + const isBinary = binaryTypes.some(type => contentType.toLowerCase().startsWith(type)); + if (isJSON) { + return await response.json(); + } else if (isBinary) { + return await response.blob(); + } else { + return await response.text(); + } + } + } catch (error) { + console.error(error); + } + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError( + options, + result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options); + + if (!onCancel.isCancelled) { + let response = await sendRequest(config, options, url, body, formData, headers, onCancel); + + for (const fn of config.interceptors.response._fns) { + response = await fn(response); + } + + const responseBody = await getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/test/__snapshots__/v3_node/core/types.ts.snap b/test/__snapshots__/v3_node/core/types.ts.snap new file mode 100644 index 000000000..e33a5d996 --- /dev/null +++ b/test/__snapshots__/v3_node/core/types.ts.snap @@ -0,0 +1,10 @@ +import type { ApiResult } from './ApiResult'; + +export type TResult = 'body' | 'raw'; + +export type TApiResponse = + Exclude extends never ? ApiResult : ApiResult['body']; + +export type TConfig = { + _result?: T; +}; diff --git a/test/__snapshots__/v3_node/index.ts.snap b/test/__snapshots__/v3_node/index.ts.snap new file mode 100644 index 000000000..9eceb36e3 --- /dev/null +++ b/test/__snapshots__/v3_node/index.ts.snap @@ -0,0 +1,4 @@ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; diff --git a/test/__snapshots__/v3_xhr/core/ApiError.ts.snap b/test/__snapshots__/v3_xhr/core/ApiError.ts.snap new file mode 100644 index 000000000..2c11b4136 --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/ApiError.ts.snap @@ -0,0 +1,21 @@ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: unknown; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/test/__snapshots__/v3_xhr/core/ApiRequestOptions.ts.snap b/test/__snapshots__/v3_xhr/core/ApiRequestOptions.ts.snap new file mode 100644 index 000000000..e93003ee7 --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/ApiRequestOptions.ts.snap @@ -0,0 +1,13 @@ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/test/__snapshots__/v3_xhr/core/ApiResult.ts.snap b/test/__snapshots__/v3_xhr/core/ApiResult.ts.snap new file mode 100644 index 000000000..caa79c2ea --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/ApiResult.ts.snap @@ -0,0 +1,7 @@ +export type ApiResult = { + readonly body: TData; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; +}; diff --git a/test/__snapshots__/v3_xhr/core/CancelablePromise.ts.snap b/test/__snapshots__/v3_xhr/core/CancelablePromise.ts.snap new file mode 100644 index 000000000..e6b03b6a2 --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/CancelablePromise.ts.snap @@ -0,0 +1,126 @@ +export class CancelError extends Error { + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + private _isResolved: boolean; + private _isRejected: boolean; + private _isCancelled: boolean; + readonly cancelHandlers: (() => void)[]; + readonly promise: Promise; + private _resolve?: (value: T | PromiseLike) => void; + private _reject?: (reason?: unknown) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + onCancel: OnCancel + ) => void + ) { + this._isResolved = false; + this._isRejected = false; + this._isCancelled = false; + this.cancelHandlers = []; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isResolved = true; + if (this._resolve) this._resolve(value); + }; + + const onReject = (reason?: unknown): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isRejected = true; + if (this._reject) this._reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this.cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this._isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this._isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this._isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return 'Cancellable Promise'; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this.promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.promise.finally(onFinally); + } + + public cancel(): void { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isCancelled = true; + if (this.cancelHandlers.length) { + try { + for (const cancelHandler of this.cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.cancelHandlers.length = 0; + if (this._reject) this._reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this._isCancelled; + } +} diff --git a/test/__snapshots__/v3_xhr/core/OpenAPI.ts.snap b/test/__snapshots__/v3_xhr/core/OpenAPI.ts.snap new file mode 100644 index 000000000..ec6d8fa1b --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/OpenAPI.ts.snap @@ -0,0 +1,53 @@ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { TResult } from './types'; + +type Headers = Record; +type Middleware = (value: T) => T | Promise; +type Resolver = (options: ApiRequestOptions) => Promise; + +export class Interceptors { + _fns: Middleware[]; + + constructor() { + this._fns = []; + } + + eject(fn: Middleware) { + const index = this._fns.indexOf(fn); + if (index !== -1) { + this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; + } + } + + use(fn: Middleware) { + this._fns = [...this._fns, fn]; + } +} + +export type OpenAPIConfig = { + BASE: string; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + ENCODE_PATH?: ((path: string) => string) | undefined; + HEADERS?: Headers | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + RESULT?: TResult; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + VERSION: string; + WITH_CREDENTIALS: boolean; + interceptors: { request: Interceptors; response: Interceptors }; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://localhost:3000/base', + CREDENTIALS: 'include', + ENCODE_PATH: undefined, + HEADERS: undefined, + PASSWORD: undefined, + RESULT: 'body', + TOKEN: undefined, + USERNAME: undefined, + VERSION: '1.0', + WITH_CREDENTIALS: false, + interceptors: { request: new Interceptors(), response: new Interceptors() }, +}; diff --git a/test/__snapshots__/v3_xhr/core/request.ts.snap b/test/__snapshots__/v3_xhr/core/request.ts.snap new file mode 100644 index 000000000..a65416e7c --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/request.ts.snap @@ -0,0 +1,323 @@ +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: unknown): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + value !== null && + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: unknown): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: unknown) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const encodePair = (key: string, value: unknown) => { + if (value === undefined || value === null) { + return; + } + + if (Array.isArray(value)) { + value.forEach(v => encodePair(key, v)); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); + } else { + append(key, value); + } + }; + + Object.entries(params).forEach(([key, value]) => encodePair(key, value)); + + return qs.length ? `?${qs.join('&')}` : ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = config.BASE + path; + return options.query ? url + getQueryString(options.query) : url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: unknown) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([, value]) => value !== undefined && value !== null) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + }) + .filter(([, value]) => value !== undefined && value !== null) + .reduce( + (headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), + {} as Record + ); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return new Headers(headers); +}; + +export const getRequestBody = (options: ApiRequestOptions): unknown => { + if (options.body !== undefined) { + if (options.mediaType?.includes('/json')) { + return JSON.stringify(options.body); + } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { + return options.body; + } else { + return JSON.stringify(options.body); + } + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Headers, + onCancel: OnCancel +): Promise => { + let xhr = new XMLHttpRequest(); + xhr.open(options.method, url, true); + xhr.withCredentials = config.WITH_CREDENTIALS; + + headers.forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); + + return new Promise(async (resolve, reject) => { + xhr.onload = () => resolve(xhr); + xhr.onabort = () => reject(new Error('Request aborted')); + xhr.onerror = () => reject(new Error('Network error')); + + for (const fn of config.interceptors.request._fns) { + xhr = await fn(xhr); + } + + xhr.send(body ?? formData); + + onCancel(() => xhr.abort()); + }); +}; + +export const getResponseHeader = (xhr: XMLHttpRequest, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = xhr.getResponseHeader(responseHeader); + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (xhr: XMLHttpRequest): unknown => { + if (xhr.status !== 204) { + try { + const contentType = xhr.getResponseHeader('Content-Type'); + if (contentType) { + const jsonTypes = ['application/json', 'application/problem+json']; + const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); + if (isJSON) { + return JSON.parse(xhr.responseText); + } else { + return xhr.responseText; + } + } + } catch (error) { + console.error(error); + } + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError( + options, + result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options); + + if (!onCancel.isCancelled) { + let response = await sendRequest(config, options, url, body, formData, headers, onCancel); + + for (const fn of config.interceptors.response._fns) { + response = await fn(response); + } + + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/test/__snapshots__/v3_xhr/core/types.ts.snap b/test/__snapshots__/v3_xhr/core/types.ts.snap new file mode 100644 index 000000000..e33a5d996 --- /dev/null +++ b/test/__snapshots__/v3_xhr/core/types.ts.snap @@ -0,0 +1,10 @@ +import type { ApiResult } from './ApiResult'; + +export type TResult = 'body' | 'raw'; + +export type TApiResponse = + Exclude extends never ? ApiResult : ApiResult['body']; + +export type TConfig = { + _result?: T; +}; diff --git a/test/__snapshots__/v3_xhr/index.ts.snap b/test/__snapshots__/v3_xhr/index.ts.snap new file mode 100644 index 000000000..9eceb36e3 --- /dev/null +++ b/test/__snapshots__/v3_xhr/index.ts.snap @@ -0,0 +1,4 @@ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; diff --git a/test/index.spec.ts b/test/index.spec.ts index d534a8d0f..0907e79e5 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -45,7 +45,7 @@ describe('OpenAPI v2', () => { describe('OpenAPI v3', () => { it.each([ { - description: 'Should generate', + description: 'Should generate fetch', name: 'v3', config: { client: 'fetch', @@ -57,6 +57,58 @@ describe('OpenAPI v3', () => { useOptions: true, } as UserConfig, }, + { + description: 'Should generate an angular client', + name: 'v3_angular', + config: { + client: 'angular', + enums: false, + exportCore: true, + exportModels: true, + exportSchemas: true, + exportServices: true, + useOptions: true, + } as UserConfig, + }, + { + description: 'Should generate an node client', + name: 'v3_node', + config: { + client: 'node', + enums: false, + exportCore: true, + exportModels: false, + exportSchemas: false, + exportServices: false, + useOptions: true, + } as UserConfig, + }, + { + description: 'Should generate an axios client', + name: 'v3_axios', + config: { + client: 'axios', + enums: false, + exportCore: true, + exportModels: false, + exportSchemas: false, + exportServices: false, + useOptions: true, + } as UserConfig, + }, + { + description: 'Should generate an XHR client', + name: 'v3_xhr', + config: { + client: 'xhr', + enums: false, + exportCore: true, + exportModels: false, + exportSchemas: false, + exportServices: false, + useOptions: true, + } as UserConfig, + }, { description: 'Should generate Date types', name: 'v3_date', @@ -114,19 +166,6 @@ describe('OpenAPI v3', () => { useLegacyEnums: true, } as UserConfig, }, - { - description: 'Should generate an angular client', - name: 'v3_angular', - config: { - client: 'angular', - enums: false, - exportCore: true, - exportModels: true, - exportSchemas: true, - exportServices: true, - useOptions: true, - } as UserConfig, - }, ])('$description', async ({ name, config }) => { const output = toOutputPath(name); await createClient({