@openapi-qraft/react
Version:
OpenAPI client for React, providing type-safe requests and dynamic TanStack Query React Hooks via a modular, Proxy-based architecture.
320 lines (282 loc) • 9.66 kB
text/typescript
import type {
HeadersOptions,
OperationSchema,
RequestFnInfo,
RequestFnOptions,
RequestFnResponse,
} from '@openapi-qraft/tanstack-query-react-types';
import { processResponse, resolveResponse } from './responseUtils.js';
/**
* This function is used to make a request to a specified endpoint.
*
* @template T The expected return type of the request.
*
* @param schema The schema of the operation to be performed. It includes the OpenAPI path, HTTP method and media type.
* @param requestInfo The information required to make the request. It includes parameters, headers, body, etc.
* @param [options] Optional. Additional options for the request. It includes custom urlSerializer, bodySerializer, and fetch function.
*
* @returns {Promise<T>} Returns a promise that resolves with the response of the request.
*
* @throws {error: object|string, response: Response} Throws an error if the request fails. The error includes the error message and the response from the server.
*/
export async function requestFn<TData, TError>(
schema: OperationSchema,
requestInfo: RequestFnInfo,
options?: RequestFnOptions
): Promise<RequestFnResponse<TData, TError>> {
return baseRequestFn<TData, TError>(schema, requestInfo, {
urlSerializer,
bodySerializer,
...options,
});
}
/**
* This function is used to make a request to a specified endpoint.
* It's necessary to create a custom `requestFn` with a custom `urlSerializer`
* and `bodySerializer`, with the tree-shaking of the default `requestFn`
* and its serializers.
*
* @template T The expected return type of the request.
*
* @param requestSchema The schema of the operation to be performed. It includes the OpenAPI path, HTTP method and media type.
* @param requestInfo The information required to make the request. It includes parameters, headers, body, etc.
* @param options The options for the request. It includes urlSerializer, bodySerializer, and fetch function. The 'urlSerializer' and 'bodySerializer' are required.
*
* @returns {Promise<T>} Returns a promise that resolves with the response of the request.
*
* @throws {error: object|string, response: Response} Throws an error if the request fails. The error includes the error message and the response from the server.
*/
export async function baseRequestFn<TData, TError>(
requestSchema: OperationSchema,
requestInfo: RequestFnInfo,
options: WithRequired<RequestFnOptions, 'urlSerializer' | 'bodySerializer'>
): Promise<RequestFnResponse<TData, TError>> {
const { parameters, headers, body, ...requestInfoRest } = requestInfo;
const requestPayload = options.bodySerializer(requestSchema, body);
const baseFetch = options.fetch ?? fetch;
return baseFetch(options.urlSerializer(requestSchema, requestInfo), {
method: requestSchema.method.toUpperCase(),
body: requestPayload?.body,
headers: mergeHeaders(
{ Accept: 'application/json' },
requestPayload?.headers,
headers,
parameters?.header
),
...requestInfoRest,
})
.then(processResponse as typeof processResponse<TData, TError>)
.catch(resolveResponse as typeof resolveResponse<TData, TError>);
}
/**
* Serializes the given schema and request payload into a URL string.
*
* This function is implemented according to the URI Template standard
* defined in RFC 6570. It supports the expansion of path and query parameters,
* correctly handling empty arrays, `null`, and `undefined` values by ignoring
* them during the expansion process, as specified by the standard.
*
* For more information, refer to the official documentation:
* https://datatracker.ietf.org/doc/html/rfc6570
*
* @param schema - The operation schema containing the URL template and method.
* @param info - The request payload including baseUrl, path parameters, and query parameters.
* @returns The fully constructed URL string.
*/
export function urlSerializer(
schema: OperationSchema,
info: Pick<RequestFnInfo, 'baseUrl' | 'parameters'>
): string {
const path = schema.url.replace(
/{(.*?)}/g,
(substring: string, group: string) => {
if (
info.parameters?.path &&
Object.prototype.hasOwnProperty.call(info.parameters.path, group)
) {
return encodeURIComponent(String(info.parameters?.path[group]));
}
return substring;
}
);
const baseUrl = info.baseUrl ?? '';
if (info.parameters?.query) {
return `${baseUrl}${path}${getQueryString(info.parameters.query)}`;
}
return `${baseUrl}${path}`;
}
function getQueryString(params: Record<string, any>): string {
const search = new URLSearchParams();
const walk = (prefix: string, value: any) => {
if (value == null) return;
if (value instanceof Date)
return search.append(prefix, value.toISOString());
if (Array.isArray(value)) return value.forEach((v) => walk(prefix, v));
if (typeof value === 'object') {
return Object.entries(value).forEach(([k, v]) =>
walk(`${prefix}[${k}]`, v)
);
}
search.append(prefix, String(value));
};
Object.entries(params).forEach(([k, v]) => walk(k, v));
const searchString = search.toString();
return searchString ? `?${searchString}` : '';
}
export function mergeHeaders(...allHeaders: (HeadersOptions | undefined)[]) {
const headers = new Headers();
for (const headerSet of allHeaders) {
if (!headerSet || typeof headerSet !== 'object') {
continue;
}
const headersIterator =
headerSet instanceof Headers
? headerSet.entries()
: Object.entries(headerSet);
for (const [headerKey, headerValue] of headersIterator) {
if (headerValue === null) {
headers.delete(headerKey);
} else if (headerValue !== undefined) {
headers.set(headerKey, headerValue);
}
}
}
return headers;
}
export function bodySerializer(
schema: OperationSchema,
body: RequestFnInfo['body']
) {
if (isReadOnlyOperation(schema)) return undefined;
if (body === undefined || body === null) return undefined;
if (typeof body === 'string') {
return {
body,
headers: {
'Content-Type':
// prefer text/* media type
schema.mediaType?.find((mediaType) => mediaType.includes('text/')) ??
// prefer JSON media type, assume that body is serialized as JSON
schema.mediaType?.find((mediaType) => mediaType.includes('/json')) ??
'text/plain',
},
};
}
if (body instanceof FormData) {
return {
body,
headers: {
// remove `Content-Type` if the serialized body is FormData;
// the browser will correctly set Content-Type & boundary expression
'Content-Type': null,
},
};
}
if (body instanceof Blob) {
return {
body,
headers: {
'Content-Type':
body.type ||
schema.mediaType?.find(
(mediaType) =>
!(
mediaType.includes('text/') &&
mediaType.includes('/form-data') &&
mediaType.includes('/json')
)
) ||
'application/octet-stream',
},
};
}
let jsonMediaType: string | null = null;
let formDataMediaType: string | null = null;
if (schema.mediaType) {
for (let i = 0; i < schema.mediaType.length; i++) {
const mediaType = schema.mediaType[i];
if (mediaType.includes('/json')) jsonMediaType = mediaType;
else if (mediaType.includes('/form-data')) formDataMediaType = mediaType;
}
}
if (formDataMediaType) {
if (
!jsonMediaType ||
// Prefer FormData serialization if one of the fields is a Blob
Object.values(body).some((value) => value instanceof Blob)
) {
return {
body: getRequestBodyFormData(body),
headers: {
// remove `Content-Type` if the serialized body is FormData;
// the browser will correctly set Content-Type & boundary expression
'Content-Type': null,
},
};
}
}
return {
body: JSON.stringify(body),
headers: {
'Content-Type': jsonMediaType ?? 'application/json',
},
};
}
function getRequestBodyFormData(
body: NonNullable<RequestFnInfo['body']>
): FormData {
if (body instanceof FormData) return body;
if (typeof body !== 'object')
throw new Error(`Unsupported body type ${typeof body} in form-data.`);
const formData = new FormData();
const process = (key: string, value: any) => {
if (typeof value === 'string' || value instanceof Blob) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(body)
.filter(([_, value]) => typeof value !== 'undefined' && value !== null)
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
type WithRequired<T, K extends keyof T> = T & {
[_ in K]: {};
};
export type {
RequestFn,
RequestFnResponse,
HeadersOptions,
RequestFnOptions,
RequestFnInfo,
OperationSchema,
} from '@openapi-qraft/tanstack-query-react-types';
/**
* @deprecated use `RequestFnInfo` instead
*/
export type RequestFnPayload = RequestFnInfo;
function isReadOnlyOperation(operation: {
readonly method:
| 'get'
| 'put'
| 'post'
| 'patch'
| 'delete'
| 'options'
| 'head'
| 'trace';
}) {
return (
operation.method === 'get' ||
operation.method === 'head' ||
operation.method === 'trace' ||
operation.method === 'options'
);
}