Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Request and Response handling #46

Merged
merged 16 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Fast, lightweight and reusable data fetching
[npm-url]: https://npmjs.org/package/axios-multi-api
[npm-image]: http://img.shields.io/npm/v/axios-multi-api.svg

[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/axios-multi-api) [![Code Coverage](https://badgen.now.sh/badge/coverage/94.53/blue)](https://github.com/MattCCC/axios-multi-api) [![npm downloads](https://img.shields.io/npm/dm/axios-multi-api.svg?style=flat-square)](http://npm-stat.com/charts.html?package=axios-multi-api) [![gzip size](https://img.shields.io/bundlephobia/minzip/axios-multi-api)](https://bundlephobia.com/result?p=axios-multi-api)
[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/axios-multi-api) [![Code Coverage](https://badgen.now.sh/badge/coverage/92.53/blue)](https://github.com/MattCCC/axios-multi-api) [![npm downloads](https://img.shields.io/npm/dm/axios-multi-api.svg?style=flat-square)](http://npm-stat.com/charts.html?package=axios-multi-api) [![gzip size](https://img.shields.io/bundlephobia/minzip/axios-multi-api)](https://bundlephobia.com/result?p=axios-multi-api)

## Why?

Expand Down Expand Up @@ -98,6 +98,7 @@ Note:
`axios-multi-api` is designed to seamlessly integrate with any popular libraries like React, Vue, React Query and SWR. It is written in pure JS so you can effortlessly manage API requests with minimal setup, and without any dependencies.

### 🌊 Using with React

You can implement a `useApi()` hook to handle the data fetching. Since this package has everything included, you don't really need anything more than a simple hook to utilize.

```typescript
Expand Down Expand Up @@ -150,7 +151,6 @@ const ProfileComponent = ({ id }) => {

```


#### Using with React Query

Integrate `axios-multi-api` with React Query to streamline your data fetching:
Expand Down
2 changes: 2 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
workerThreads: true,
coverageReporters: ['lcov', 'text', 'html'],
};
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
"type": "git",
"url": "https://github.com/MattCCC/axios-multi-api.git"
},
"main": "dist/browser/index.mjs",
"main": "dist/node/index.js",
"browser": "dist/browser/index.mjs",
"module": "dist/browser/index.mjs",
"typings": "dist/index.d.ts",
"keywords": [
"axios-api",
"axios-api-handler",
"axios-multi-api",
"fetchf",
"fetch-wrapper",
"fetch",
"api",
"api-handler",
"browser",
Expand Down Expand Up @@ -40,7 +45,6 @@
"singleQuote": true,
"trailingComma": "all"
},
"module": "dist/browser/index.mjs",
"size-limit": [
{
"path": "dist/browser/index.mjs",
Expand Down
8 changes: 4 additions & 4 deletions src/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
ApiHandlerMethods,
ApiHandlerReturnType,
APIResponse,
QueryParams,
QueryParamsOrBody,
UrlPathParams,
} from './types/api-handler';

Expand Down Expand Up @@ -101,14 +101,14 @@ function createApiFetcher<
* It considers settings in following order: per-request settings, global per-endpoint settings, global settings.
*
* @param {string} endpointName - The name of the API endpoint to call.
* @param {QueryParams} [queryParams={}] - Query parameters to include in the request.
* @param {QueryParamsOrBody} [data={}] - Query parameters to include in the request.
* @param {UrlPathParams} [urlPathParams={}] - URI parameters to include in the request.
* @param {EndpointConfig} [requestConfig={}] - Additional configuration for the request.
* @returns {Promise<Response & FetchResponse>} - A promise that resolves with the response from the API provider.
*/
async function request<Response = APIResponse>(
endpointName: keyof EndpointsMethods | string,
queryParams: QueryParams = {},
data: QueryParamsOrBody = {},
urlPathParams: UrlPathParams = {},
requestConfig: RequestConfig = {},
): Promise<Response & FetchResponse<Response>> {
Expand All @@ -118,7 +118,7 @@ function createApiFetcher<

const responseData = await requestHandler.request<Response>(
endpointSettings.url,
queryParams,
data,
{
...endpointSettings,
...requestConfig,
Expand Down
6 changes: 1 addition & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ export async function fetchf<ResponseData = APIResponse>(
url: string,
config: RequestHandlerConfig = {},
): Promise<ResponseData & FetchResponse<ResponseData>> {
return new RequestHandler(config).request<ResponseData>(
url,
config.body || config.data || config.params,
config,
);
return new RequestHandler(config).request<ResponseData>(url, null, config);
}

export * from './types';
Expand Down
185 changes: 92 additions & 93 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,51 +200,56 @@ export class RequestHandler {
/**
* Build request configuration
*
* @param {string} url Request url
* @param {QueryParamsOrBody} data Request data
* @param {RequestConfig} config Request config
* @returns {RequestConfig} Provider's instance
* @param {string} url - Request url
* @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise
* @param {RequestConfig} config - Request config passed when making the request
* @returns {RequestConfig} - Provider's instance
*/
protected buildConfig(
url: string,
data: QueryParamsOrBody,
config: RequestConfig,
): RequestConfig {
const method = config.method || this.method;
const methodLowerCase = method.toLowerCase();
const isGetAlikeMethod =
methodLowerCase === 'get' || methodLowerCase === 'head';
const method = (config.method || this.method).toUpperCase();
const isGetAlikeMethod = method === 'GET' || method === 'HEAD';

const dynamicUrl = replaceUrlPathParams(
url,
config.urlPathParams || this.config.urlPathParams,
);

// Bonus: Specifying it here brings support for "body" in Axios
const configData =
// The explicitly passed "params"
const explicitParams = config.params || this.config.params;

// The explicitly passed "body" or "data"
const explicitBodyData =
config.body || config.data || this.config.body || this.config.data;

// Axios compatibility
// For convenience, in POST requests the body payload is the "data"
// In edge cases we want to use Query Params in the POST requests
// and use explicitly passed "body" or "data" from request config
const shouldTreatDataAsParams =
data && (isGetAlikeMethod || explicitBodyData) ? true : false;

// Final body data
let body: RequestConfig['data'];

// Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH'
if (!isGetAlikeMethod) {
body = explicitBodyData || data;
}

if (this.isCustomFetcher()) {
return {
...config,
method,
url: dynamicUrl,
method: methodLowerCase,

...(isGetAlikeMethod ? { params: data } : {}),

// For POST requests body payload is the first param for convenience ("data")
// In edge cases we want to split so to treat it as query params, and use "body" coming from the config instead
...(!isGetAlikeMethod && data && configData ? { params: data } : {}),

// Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH'
...(!isGetAlikeMethod && data && !configData ? { data } : {}),
...(!isGetAlikeMethod && configData ? { data: configData } : {}),
params: shouldTreatDataAsParams ? data : explicitParams,
data: body,
};
}

// Native fetch
const payload = configData || data;
const credentials =
config.withCredentials || this.config.withCredentials
? 'include'
Expand All @@ -254,46 +259,36 @@ export class RequestHandler {
delete config.withCredentials;

const urlPath =
(!isGetAlikeMethod && data && !config.body) || !data
? dynamicUrl
: appendQueryParams(dynamicUrl, data);
explicitParams || shouldTreatDataAsParams
? appendQueryParams(dynamicUrl, explicitParams || data)
: dynamicUrl;
const isFullUrl = urlPath.includes('://');
const baseURL = isFullUrl
? ''
: typeof config.baseURL !== 'undefined'
? config.baseURL
: this.baseURL;
const baseURL = isFullUrl ? '' : config.baseURL || this.baseURL;

// Automatically stringify request body, if possible and when not dealing with strings
if (
body &&
typeof body !== 'string' &&
!(body instanceof URLSearchParams) &&
isJSONSerializable(body)
) {
body = JSON.stringify(body);
}

return {
...config,
credentials,
body,
method,

// Native fetch generally requires query params to be appended in the URL
// Do not append query params only if it's a POST-alike request with only "data" specified as it's treated as body payload
url: baseURL + urlPath,

// Uppercase method name
method: method.toUpperCase(),

// For convenience, add the same default headers as Axios does
// Add sensible defaults
headers: {
Accept: APPLICATION_JSON + ', text/plain, */*',
'Content-Type': APPLICATION_JSON + ';charset=utf-8',
...(config.headers || this.config.headers || {}),
},

// Automatically JSON stringify request bodies, if possible and when not dealing with strings
...(!isGetAlikeMethod
? {
body:
!(payload instanceof URLSearchParams) &&
isJSONSerializable(payload)
? typeof payload === 'string'
? payload
: JSON.stringify(payload)
: payload,
}
: {}),
};
}

Expand Down Expand Up @@ -457,9 +452,8 @@ export class RequestHandler {
* Handle Request depending on used strategy
*
* @param {string} url - Request url
* @param {QueryParamsOrBody} data - Request data
* @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise
* @param {RequestConfig} config - Request config
* @param {RequestConfig} payload.config Request config
* @throws {ResponseError}
* @returns {Promise<ResponseData & FetchResponse<ResponseData>>} Response Data
*/
Expand Down Expand Up @@ -570,47 +564,47 @@ export class RequestHandler {
public async parseData<ResponseData = APIResponse>(
response: FetchResponse<ResponseData>,
): Promise<any> {
// Bail early when body is empty
if (!response.body) {
return null;
}

const contentType = String(
(response as Response).headers?.get('Content-Type') || '',
);
let data;
).split(';')[0]; // Correctly handle charset

// Handle edge case of no content type being provided... We assume JSON here.
if (!contentType) {
const responseClone = response.clone();
try {
data = await responseClone.json();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
// JSON parsing failed, fallback to null
data = null;
}
}
let data;

if (typeof data === 'undefined') {
try {
if (
contentType.includes(APPLICATION_JSON) ||
contentType.includes('+json')
) {
data = await response.json(); // Parse JSON response
} else if (contentType.includes('multipart/form-data')) {
data = await response.formData(); // Parse as FormData
} else if (contentType.includes('application/octet-stream')) {
data = await response.blob(); // Parse as blob
} else if (contentType.includes('application/x-www-form-urlencoded')) {
data = await response.formData(); // Handle URL-encoded forms
} else if (typeof response.text === 'function') {
data = await response.text(); // Parse as text
} else {
try {
if (
contentType.includes(APPLICATION_JSON) ||
contentType.includes('+json')
) {
data = await response.json(); // Parse JSON response
} else if (contentType.includes('multipart/form-data')) {
data = await response.formData(); // Parse as FormData
} else if (contentType.includes('application/octet-stream')) {
data = await response.blob(); // Parse as blob
} else if (contentType.includes('application/x-www-form-urlencoded')) {
data = await response.formData(); // Handle URL-encoded forms
} else if (contentType.includes('text/')) {
data = await response.text(); // Parse as text
} else {
try {
const responseClone = response.clone();

// Handle edge case of no content type being provided... We assume JSON here.
data = await responseClone.json();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) {
// Handle streams
data = response.body || response.data || null;
data = await response.text();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
// Parsing failed, fallback to null
data = null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
// Parsing failed, fallback to null
data = null;
}

return data;
Expand All @@ -619,21 +613,26 @@ export class RequestHandler {
public processHeaders<ResponseData>(
response: FetchResponse<ResponseData>,
): HeadersObject {
if (!response.headers) {
const headers = response.headers;

if (!headers) {
return {};
}

let headersObject: HeadersObject = {};
const headers = response.headers;
const headersObject: HeadersObject = {};

// Handle Headers object with entries() method
if (headers instanceof Headers) {
for (const [key, value] of (headers as any).entries()) {
headers.forEach((value, key) => {
headersObject[key] = value;
}
} else {
});
} else if (typeof headers === 'object' && headers !== null) {
// Handle plain object
headersObject = { ...(headers as HeadersObject) };
for (const [key, value] of Object.entries(headers)) {
// Normalize keys to lowercase as per RFC 2616 4.2
// https://datatracker.ietf.org/doc/html/rfc2616#section-4.2
headersObject[key.toLowerCase()] = value;
}
}

return headersObject;
Expand Down
3 changes: 3 additions & 0 deletions src/types/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import type {
} from './request-handler';

// Common type definitions
type NameValuePair = { name: string; value: string };

export declare type QueryParams<T = unknown> =
| Record<string, T>
| URLSearchParams
| NameValuePair[]
| null;
export declare type BodyPayload<T = unknown> = Record<string, T> | null;
export declare type QueryParamsOrBody<T = unknown> =
Expand Down
Loading
Loading