@zimic/fetch
Version:
Next-gen TypeScript-first Fetch client
267 lines (219 loc) • 9.25 kB
text/typescript
import {
HttpSchemaPath,
HttpSchemaMethod,
HttpSearchParams,
LiteralHttpSchemaPathFromNonLiteral,
HttpSchema,
HttpHeaders,
} from '@zimic/http';
import createRegexFromPath from '@zimic/utils/url/createRegexFromPath';
import excludeNonPathParams from '@zimic/utils/url/excludeNonPathParams';
import joinURL from '@zimic/utils/url/joinURL';
import FetchResponseError from './errors/FetchResponseError';
import { FetchInput, FetchOptions, Fetch, FetchDefaults } from './types/public';
import { FetchRequestConstructor, FetchRequestInit, FetchRequest, FetchResponse } from './types/requests';
class FetchClient<Schema extends HttpSchema> implements Omit<Fetch<Schema>, 'loose' | 'Request' | keyof FetchDefaults> {
fetch: Fetch<Schema>;
constructor({ headers = {}, searchParams = {}, ...otherOptions }: FetchOptions<Schema>) {
this.fetch = this.createFetchFunction();
this.fetch.headers = headers;
this.fetch.searchParams = searchParams;
Object.assign(this.fetch, otherOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.fetch.loose = this.fetch as Fetch<any> as Fetch.Loose;
this.fetch.Request = this.createRequestClass(this.fetch);
}
get defaults(): FetchDefaults {
return this.fetch;
}
private createFetchFunction() {
const fetch = async <
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
>(
input: FetchInput<Schema, Method, Path>,
init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>,
) => {
const request = await this.createFetchRequest<Method, Path>(input, init);
const requestClone = request.clone();
const rawResponse = await globalThis.fetch(
// Optimize type checking by narrowing the type of request
requestClone as Request,
);
const response = await this.createFetchResponse<
Method,
LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>
>(request, rawResponse);
return response;
};
Object.setPrototypeOf(fetch, this);
return fetch as Fetch<Schema>;
}
private async createFetchRequest<
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
>(
input: FetchInput<Schema, Method, Path>,
init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>,
) {
let request = input instanceof Request ? input : new this.fetch.Request(input, init);
if (this.fetch.onRequest) {
const requestAfterInterceptor = await this.fetch.onRequest(
// Optimize type checking by narrowing the type of request
request as FetchRequest.Loose,
);
if (requestAfterInterceptor !== request) {
const isFetchRequest = requestAfterInterceptor instanceof this.fetch.Request;
request = isFetchRequest
? (requestAfterInterceptor as Request as typeof request)
: new this.fetch.Request(requestAfterInterceptor as FetchInput<Schema, Method, Path>, init);
}
}
return request;
}
private async createFetchResponse<
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.Literal<Schema, Method>,
>(fetchRequest: FetchRequest<Schema, Method, Path>, rawResponse: Response) {
let response = this.defineFetchResponseProperties<Method, Path>(fetchRequest, rawResponse);
if (this.fetch.onResponse) {
const responseAfterInterceptor = await this.fetch.onResponse(
// Optimize type checking by narrowing the type of response
response as FetchResponse.Loose,
);
const isFetchResponse =
responseAfterInterceptor instanceof Response &&
'request' in responseAfterInterceptor &&
responseAfterInterceptor.request instanceof this.fetch.Request;
response = isFetchResponse
? (responseAfterInterceptor as typeof response)
: this.defineFetchResponseProperties<Method, Path>(fetchRequest, responseAfterInterceptor);
}
return response;
}
private defineFetchResponseProperties<
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.Literal<Schema, Method>,
>(fetchRequest: FetchRequest<Schema, Method, Path>, response: Response) {
const fetchResponse = response as FetchResponse<Schema, Method, Path>;
Object.defineProperty(fetchResponse, 'request', {
value: fetchRequest,
writable: false,
enumerable: true,
configurable: false,
});
let responseError: FetchResponse.Loose['error'] | undefined;
Object.defineProperty(fetchResponse, 'error', {
get() {
if (responseError === undefined) {
responseError = fetchResponse.ok
? null
: new FetchResponseError(
fetchRequest,
fetchResponse as FetchResponse<Schema, Method, Path, true, 'manual'>,
);
}
return responseError;
},
enumerable: true,
configurable: false,
});
return fetchResponse;
}
private createRequestClass(fetch: Fetch<Schema>) {
class Request<Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.NonLiteral<Schema, Method>>
extends globalThis.Request
{
path: LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>;
constructor(input: FetchInput<Schema, Method, Path>, init?: FetchRequestInit.Loose) {
let actualInput: URL | globalThis.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,
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) {
actualInit.headers.assign(new HttpHeaders(init.headers));
}
let url: URL;
const baseURL = new URL(actualInit.baseURL);
if (input instanceof globalThis.Request) {
const request = input as globalThis.Request;
actualInit.headers.assign(new HttpHeaders(request.headers));
url = new URL(input.url);
actualInput = request;
} else {
url = new URL(input instanceof URL ? input : joinURL(baseURL, input));
actualInit.searchParams.assign(new HttpSearchParams(url.searchParams));
if (init?.searchParams) {
actualInit.searchParams.assign(new HttpSearchParams(init.searchParams));
}
url.search = actualInit.searchParams.toString();
actualInput = url;
}
super(actualInput, actualInit);
const baseURLWithoutTrailingSlash = baseURL.toString().replace(/\/$/, '');
this.path = excludeNonPathParams(url)
.toString()
.replace(baseURLWithoutTrailingSlash, '') as LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>;
}
clone(): this {
const rawClone = super.clone();
return new Request(rawClone as FetchInput<Schema, Method, Path>) as this;
}
}
return Request as FetchRequestConstructor<Schema>;
}
isRequest<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>(
request: unknown,
method: Method,
path: Path,
): request is FetchRequest<Schema, Method, Path> {
return (
request instanceof Request &&
request.method === method &&
'path' in request &&
typeof request.path === 'string' &&
createRegexFromPath(path).test(request.path)
);
}
isResponse<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>(
response: unknown,
method: Method,
path: Path,
): response is FetchResponse<Schema, Method, Path> {
return (
response instanceof Response &&
'request' in response &&
this.isRequest(response.request, method, path) &&
'error' in response &&
(response.error === null || response.error instanceof FetchResponseError)
);
}
isResponseError<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>(
error: unknown,
method: Method,
path: Path,
): error is FetchResponseError<Schema, Method, Path> {
return (
error instanceof FetchResponseError &&
this.isRequest(error.request, method, path) &&
this.isResponse(error.response, method, path)
);
}
}
export default FetchClient;