@zimic/fetch
Version:
Next-gen TypeScript-first Fetch client
244 lines (202 loc) • 9.25 kB
text/typescript
import {
HttpSchema,
HttpSchemaMethod,
HttpSchemaPath,
HttpRequest,
HttpRequestBodySchema,
HttpRequestHeadersSchema,
HttpHeaders,
HttpSearchParams,
HttpMethod,
AllowAnyStringInPathParams,
LiteralHttpSchemaPathFromNonLiteral,
HttpHeadersInit,
HttpSearchParamsInit,
HttpHeadersSchema,
} from '@zimic/http';
import { Default, PossiblePromise } from '@zimic/utils/types';
import { excludeNonPathParams, joinURL } from '@zimic/utils/url';
import { Fetch, FetchInput } from '../types/public';
import { getOrSetBoundBodyMethod, isBodyMethod, withIncludedBodyIfAvailable } from '../utils/objects';
import { FetchRequestBodySchema, FetchRequestInit, FetchRequestObject, FetchRequestObjectOptions } from './types';
/** @see {@link https://zimic.dev/docs/fetch/api/fetch-request `FetchRequest` API reference} */
export interface FetchRequest<
Schema extends HttpSchema,
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.Literal<Schema, Method>,
> extends HttpRequest<
HttpRequestBodySchema<Default<Schema[Path][Method]>>,
Default<HttpRequestHeadersSchema<Default<Schema[Path][Method]>>>
> {
raw: Request;
path: AllowAnyStringInPathParams<Path>;
method: Method;
clone: () => FetchRequest<Schema, Method, Path>;
/** @see {@link https://zimic.dev/docs/fetch/api/fetch-request#requesttoobject `request.toObject()` API reference} */
toObject: ((options: FetchRequestObjectOptions.WithBody) => Promise<FetchRequestObject>) &
((options?: FetchRequestObjectOptions.WithoutBody) => FetchRequestObject) &
((options?: FetchRequestObjectOptions) => PossiblePromise<FetchRequestObject>);
}
export namespace FetchRequest {
/** A loosely typed version of a {@link FetchRequest `FetchRequest`}. */
export interface Loose extends Request {
raw: Request;
path: string;
method: HttpMethod;
clone: () => Loose;
/** @see {@link https://zimic.dev/docs/fetch/api/fetch-request#requesttoobject `request.toObject()` API reference} */
toObject: ((options: FetchRequestObjectOptions.WithBody) => Promise<FetchRequestObject>) &
((options?: FetchRequestObjectOptions.WithoutBody) => FetchRequestObject) &
((options?: FetchRequestObjectOptions) => PossiblePromise<FetchRequestObject>);
}
}
interface FetchRequestClass {
new <
Schema extends HttpSchema,
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
>(
fetch: Fetch<Schema>,
input: FetchInput<Schema, Method, Path>,
init?: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>,
): FetchRequest<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>;
prototype: Request;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[Symbol.hasInstance]: (instance: unknown) => instance is FetchRequest<any, any, any>;
}
const FETCH_REQUEST_BRAND = Symbol.for('FetchRequest');
const FETCH_REQUEST_EXTRA_PROPERTIES = [FETCH_REQUEST_BRAND, 'raw', 'path', 'toObject'] as const;
type FetchRequestExtraProperty = (typeof FETCH_REQUEST_EXTRA_PROPERTIES)[number];
function createFetchRequestClass() {
const FetchRequestClass = function FetchRequest<
Schema extends HttpSchema,
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
>(
fetch: Fetch<Schema>,
input: FetchInput<Schema, Method, Path>,
init?: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>> & {
headers?: HttpHeadersInit<Default<Schema[Path][Method]>>;
searchParams?: HttpSearchParamsInit<Default<Schema[Path][Method]>>;
body?: FetchRequestBodySchema<Default<Default<Schema[Path][Method]>['request']>>;
},
) {
let actualInput: URL | Request;
const actualInit = {
baseURL: init?.baseURL ?? fetch.baseURL,
method: init?.method ?? fetch.method,
headers: new HttpHeaders(fetch.headers),
searchParams: new HttpSearchParams(fetch.searchParams),
body: (init?.body ?? fetch.body) as BodyInit | null,
mode: init?.mode ?? fetch.mode,
cache: init?.cache ?? fetch.cache,
credentials: init?.credentials ?? fetch.credentials,
integrity: init?.integrity ?? fetch.integrity,
keepalive: init?.keepalive ?? fetch.keepalive,
priority: init?.priority ?? fetch.priority,
redirect: init?.redirect ?? fetch.redirect,
referrer: init?.referrer ?? fetch.referrer,
referrerPolicy: init?.referrerPolicy ?? fetch.referrerPolicy,
signal: init?.signal ?? fetch.signal,
window: init?.window === undefined ? fetch.window : init.window,
duplex: init?.duplex ?? fetch.duplex,
};
if (init?.headers !== undefined) {
actualInit.headers.assign(new HttpHeaders(init.headers));
}
let url: URL;
const baseURL = new URL(actualInit.baseURL);
if (input instanceof Request) {
const request = input as Request;
actualInit.headers.assign(new HttpHeaders<FetchRequestInit.DefaultHeaders<Schema>>(request.headers));
url = new URL(input.url);
actualInput = request instanceof FetchRequestClass ? request.raw : request;
} else {
url = new URL(input instanceof URL ? input : joinURL(baseURL, input));
actualInit.searchParams.assign(
new HttpSearchParams<FetchRequestInit.DefaultSearchParams<Schema>>(url.searchParams),
);
if (init?.searchParams !== undefined) {
actualInit.searchParams.assign(new HttpSearchParams(init.searchParams));
}
url.search = actualInit.searchParams.toString();
actualInput = url;
}
const request = new Request(actualInput, actualInit);
const baseURLWithoutTrailingSlash = baseURL.toString().replace(/\/$/, '');
const path = excludeNonPathParams(url).toString().replace(baseURLWithoutTrailingSlash, '');
function clone() {
return new FetchRequestClass(fetch, request.clone() as FetchInput<Schema, Method, Path>);
}
function toObject(options: { includeBody: true }): Promise<FetchRequestObject>;
function toObject(options?: { includeBody?: false }): FetchRequestObject;
function toObject(options?: { includeBody?: boolean }): PossiblePromise<FetchRequestObject>;
function toObject(options?: { includeBody?: boolean }): PossiblePromise<FetchRequestObject> {
const requestObject: FetchRequestObject = {
url: request.url,
path,
method: request.method as HttpMethod,
headers: HttpHeaders.prototype.toObject.call(request.headers) as HttpHeadersSchema,
cache: request.cache,
destination: request.destination,
credentials: request.credentials,
integrity: request.integrity,
keepalive: request.keepalive,
mode: request.mode,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
};
if (!options?.includeBody) {
return requestObject;
}
return withIncludedBodyIfAvailable(request, requestObject);
}
type FetchRequestInstance = FetchRequest<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>;
const fetchRequest = new Proxy(request, {
get(target, property) {
if (property === FETCH_REQUEST_BRAND) {
return true;
}
if (property === ('raw' satisfies keyof FetchRequestInstance)) {
return request satisfies FetchRequestInstance['raw'];
}
if (property === ('path' satisfies keyof FetchRequestInstance)) {
return path as AllowAnyStringInPathParams<
LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>
> satisfies FetchRequestInstance['path'];
}
if (property === ('clone' satisfies keyof FetchRequestInstance)) {
return clone satisfies FetchRequestInstance['clone'];
}
if (property === ('toObject' satisfies keyof FetchRequestInstance)) {
return toObject satisfies FetchRequestInstance['toObject'];
}
// Fallback other properties to the original `Request` instance.
const value = Reflect.get(target, property, target) as unknown;
if (isBodyMethod(property, value)) {
return getOrSetBoundBodyMethod(request, property, value);
}
return value;
},
has(target, property) {
return (
FETCH_REQUEST_EXTRA_PROPERTIES.includes(property as FetchRequestExtraProperty) ||
Reflect.has(target, property)
);
},
}) as unknown as FetchRequest<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>;
return fetchRequest;
} as unknown as FetchRequestClass;
Object.defineProperty(FetchRequestClass, Symbol.hasInstance, {
value(instance: unknown): boolean {
return instance instanceof Request && FETCH_REQUEST_BRAND in instance && instance[FETCH_REQUEST_BRAND] === true;
},
writable: false,
enumerable: false,
configurable: false,
});
Object.setPrototypeOf(FetchRequestClass.prototype, Request.prototype);
return FetchRequestClass;
}
export const FetchRequest = createFetchRequestClass();