@foundatiofx/fetchclient
Version:
A typed JSON fetch client with middleware support for Deno, Node and the browser.
544 lines (543 loc) • 20.2 kB
JavaScript
import { Counter } from "./Counter.js";
import { ProblemDetails } from "./ProblemDetails.js";
import { parseLinkHeader } from "./LinkHeader.js";
import { FetchClientProvider } from "./FetchClientProvider.js";
import { getCurrentProvider } from "./DefaultHelpers.js";
import { ObjectEvent } from "./ObjectEvent.js";
/**
* Represents a client for making HTTP requests using the Fetch API.
*/
export class FetchClient {
#provider;
#options;
#counter = new Counter();
#middleware = [];
#onLoading = new ObjectEvent();
/**
* Represents a FetchClient that handles HTTP requests using the Fetch API.
* @param options - The options to use for the FetchClient.
*/
constructor(optionsOrProvider) {
if (optionsOrProvider instanceof FetchClientProvider) {
this.#provider = optionsOrProvider;
}
else {
this.#provider = optionsOrProvider?.provider ?? getCurrentProvider();
if (optionsOrProvider) {
this.#options = {
...this.#provider.options,
...optionsOrProvider,
};
}
}
this.#counter.changed.on((e) => {
if (!e) {
throw new Error("Event data is required.");
}
if (e.value > 0 && e.previous == 0) {
this.#onLoading.trigger(true);
}
else if (e.value == 0 && e.previous > 0) {
this.#onLoading.trigger(false);
}
});
}
/**
* Gets the provider used by this FetchClient instance. The provider contains shared options that can be used by multiple FetchClient instances.
*/
get provider() {
return this.#provider;
}
/**
* Gets the options used by this FetchClient instance.
*/
get options() {
return this.#options ?? this.#provider.options;
}
/**
* Gets the cache used for storing HTTP responses.
*/
get cache() {
return this.#options?.cache ?? this.#provider.cache;
}
/**
* Gets the fetch implementation used for making HTTP requests.
*/
get fetch() {
return this.#options?.fetch ?? this.#provider.fetch;
}
/**
* Gets the number of inflight requests for this FetchClient instance.
*/
get requestCount() {
return this.#counter.count;
}
/**
* Gets a value indicating whether the client is currently loading.
* @returns {boolean} A boolean value indicating whether the client is loading.
*/
get isLoading() {
return this.requestCount > 0;
}
/**
* Gets an event that is triggered when the loading state changes.
*/
get loading() {
return this.#onLoading.expose();
}
/**
* Adds one or more middleware functions to the FetchClient's middleware pipeline.
* Middleware functions are executed in the order they are added.
*
* @param mw - The middleware functions to add.
*/
use(...mw) {
this.#middleware.push(...mw);
return this;
}
/**
* Sends a GET request to the specified URL.
*
* @param url - The URL to send the GET request to.
* @param options - The optional request options.
* @returns A promise that resolves to the response of the GET request.
*/
async get(url, options) {
options = {
...this.options.defaultRequestOptions,
...options,
};
const response = await this.fetchInternal(url, options, this.buildRequestInit("GET", undefined, options));
return response;
}
/**
* Sends a GET request to the specified URL and returns the response as JSON.
* @param url - The URL to send the GET request to.
* @param options - Optional request options.
* @returns A promise that resolves to the response as JSON.
*/
getJSON(url, options) {
return this.get(url, this.buildJsonRequestOptions(options));
}
/**
* Sends a POST request to the specified URL.
*
* @param url - The URL to send the request to.
* @param body - The request body, can be an object, a string, or FormData.
* @param options - Additional options for the request.
* @returns A promise that resolves to a FetchClientResponse object.
*/
async post(url, body, options) {
options = {
...this.options.defaultRequestOptions,
...options,
};
const response = await this.fetchInternal(url, options, this.buildRequestInit("POST", body, options));
return response;
}
/**
* Sends a POST request with JSON payload to the specified URL.
*
* @template T - The type of the response data.
* @param {string} url - The URL to send the request to.
* @param {object | string | FormData} [body] - The JSON payload or form data to send with the request.
* @param {RequestOptions} [options] - Additional options for the request.
* @returns {Promise<FetchClientResponse<T>>} - A promise that resolves to the response data.
*/
postJSON(url, body, options) {
return this.post(url, body, this.buildJsonRequestOptions(options));
}
/**
* Sends a PUT request to the specified URL with the given body and options.
* @param url - The URL to send the request to.
* @param body - The request body, can be an object, a string, or FormData.
* @param options - The request options.
* @returns A promise that resolves to a FetchClientResponse object.
*/
async put(url, body, options) {
options = {
...this.options.defaultRequestOptions,
...options,
};
const response = await this.fetchInternal(url, options, this.buildRequestInit("PUT", body, options));
return response;
}
/**
* Sends a PUT request with JSON payload to the specified URL.
*
* @template T - The type of the response data.
* @param {string} url - The URL to send the request to.
* @param {object | string} [body] - The JSON payload to send with the request.
* @param {RequestOptions} [options] - Additional options for the request.
* @returns {Promise<FetchClientResponse<T>>} - A promise that resolves to the response data.
*/
putJSON(url, body, options) {
return this.put(url, body, this.buildJsonRequestOptions(options));
}
/**
* Sends a PATCH request to the specified URL with the provided body and options.
* @param url - The URL to send the PATCH request to.
* @param body - The body of the request. It can be an object, a string, or FormData.
* @param options - The options for the request.
* @returns A Promise that resolves to the response of the PATCH request.
*/
async patch(url, body, options) {
options = {
...this.options.defaultRequestOptions,
...options,
};
const response = await this.fetchInternal(url, options, this.buildRequestInit("PATCH", body, options));
return response;
}
/**
* Sends a PATCH request with JSON payload to the specified URL.
*
* @template T - The type of the response data.
* @param {string} url - The URL to send the request to.
* @param {object | string} [body] - The JSON payload to send with the request.
* @param {RequestOptions} [options] - Additional options for the request.
* @returns {Promise<FetchClientResponse<T>>} - A promise that resolves to the response data.
*/
patchJSON(url, body, options) {
return this.patch(url, body, this.buildJsonRequestOptions(options));
}
/**
* Sends a DELETE request to the specified URL.
*
* @param url - The URL to send the DELETE request to.
* @param options - The options for the request.
* @returns A promise that resolves to a `FetchClientResponse` object.
*/
async delete(url, options) {
options = {
...this.options.defaultRequestOptions,
...options,
};
const response = await this.fetchInternal(url, options, this.buildRequestInit("DELETE", undefined, options));
return response;
}
/**
* Sends a DELETE request with JSON payload to the specified URL.
*
* @template T - The type of the response data.
* @param {string} url - The URL to send the request to.
* @param {RequestOptions} [options] - Additional options for the request.
* @returns {Promise<FetchClientResponse<T>>} - A promise that resolves to the response data.
*/
deleteJSON(url, options) {
return this.delete(url, this.buildJsonRequestOptions(options));
}
async validate(data, options) {
if (typeof data !== "object" ||
(options && options.shouldValidateModel === false))
return null;
if (this.options?.modelValidator === undefined) {
return null;
}
const problem = await this.options.modelValidator(data);
if (!problem)
return null;
return problem;
}
async fetchInternal(url, options, init) {
const { builtUrl, absoluteUrl } = this.buildUrl(url, options);
// if we have a body and it's not FormData, validate it before proceeding
if (init?.body && !(init?.body instanceof FormData)) {
const problem = await this.validate(init?.body, options);
if (problem) {
return this.problemToResponse(problem, url);
}
}
if (init?.body && typeof init.body === "object") {
init.body = JSON.stringify(init.body);
}
const accessToken = this.options.accessTokenFunc?.() ?? null;
if (accessToken !== null) {
init = {
...init,
...{
headers: { ...init?.headers, Authorization: `Bearer ${accessToken}` },
},
};
}
if (options?.signal) {
init = { ...init, signal: options.signal };
}
if (options?.timeout) {
let signal = AbortSignal.timeout(options.timeout);
if (init?.signal) {
signal = this.mergeAbortSignals(signal, init.signal);
}
init = { ...init, signal: signal };
}
const fetchMiddleware = async (ctx, next) => {
const getOptions = ctx.options;
if (getOptions?.cacheKey) {
const cachedResponse = this.cache.get(getOptions.cacheKey);
if (cachedResponse) {
ctx.response = cachedResponse;
return;
}
}
try {
const response = await (this.fetch ? this.fetch(ctx.request) : fetch(ctx.request));
if (ctx.request.headers.get("Accept")?.startsWith("application/json") ||
response?.headers.get("Content-Type")?.startsWith("application/problem+json")) {
ctx.response = await this.getJSONResponse(response, ctx.options);
}
else {
ctx.response = response;
ctx.response.data = null;
ctx.response.problem = new ProblemDetails();
}
ctx.response.meta = {
links: parseLinkHeader(response.headers.get("Link")) || {},
};
if (getOptions?.cacheKey) {
this.cache.set(getOptions.cacheKey, ctx.response, getOptions.cacheDuration);
}
}
catch (error) {
if (error instanceof Error && error.name === "TimeoutError") {
ctx.response = this.problemToResponse(Object.assign(new ProblemDetails(), {
status: 408,
title: "Request Timeout",
}), ctx.request.url);
}
else {
throw error;
}
}
await next();
};
const middleware = [
...this.options.middleware ?? [],
...this.#middleware,
fetchMiddleware,
];
this.#counter.increment();
this.#provider.counter.increment();
let request = null;
try {
request = new Request(builtUrl, init);
}
catch {
// try using absolute URL
request = new Request(absoluteUrl, init);
}
const context = {
options,
request: request,
response: null,
meta: {},
};
await this.invokeMiddleware(context, middleware);
this.#counter.decrement();
this.#provider.counter.decrement();
this.validateResponse(context.response, options);
return context.response;
}
async invokeMiddleware(context, middleware) {
if (!middleware.length)
return;
const mw = middleware[0];
return await mw(context, async () => {
await this.invokeMiddleware(context, middleware.slice(1));
});
}
mergeAbortSignals(...signals) {
const controller = new AbortController();
const onAbort = (event) => {
const originalSignal = event.target;
try {
controller.abort(originalSignal.reason);
}
catch {
// Just in case multiple signals abort nearly simultaneously
}
};
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener("abort", onAbort);
}
return controller.signal;
}
async getJSONResponse(response, options) {
let data = null;
let bodyText = "";
try {
bodyText = await response.text();
if (options.reviver || options.shouldParseDates) {
data = JSON.parse(bodyText, (key, value) => {
return this.reviveJsonValue(options, key, value);
});
}
else {
data = JSON.parse(bodyText);
}
}
catch (error) {
data = new ProblemDetails();
data.detail = bodyText;
data.title = `Unable to deserialize response data: ${error instanceof Error ? error.message : String(error)}`;
data.setErrorMessage(data.title);
}
const jsonResponse = response;
if (!response.ok ||
response.headers.get("Content-Type")?.startsWith("application/problem+json")) {
jsonResponse.problem = Object.assign(new ProblemDetails(), data);
jsonResponse.data = null;
return jsonResponse;
}
jsonResponse.problem = new ProblemDetails();
jsonResponse.data = data;
return jsonResponse;
}
reviveJsonValue(options, key, value) {
let revivedValued = value;
if (options.reviver) {
revivedValued = options.reviver.call(this, key, revivedValued);
}
if (options.shouldParseDates) {
revivedValued = this.tryParseDate(key, revivedValued);
}
return revivedValued;
}
tryParseDate(_key, value) {
if (typeof value !== "string") {
return value;
}
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
}
return value;
}
buildRequestInit(method, body, options) {
const isDefinitelyJsonBody = body !== undefined &&
body !== null &&
typeof body === "object";
const headers = {};
if (isDefinitelyJsonBody) {
headers["Content-Type"] = "application/json";
}
return {
method,
headers: {
...headers,
...options?.headers,
},
body,
};
}
buildJsonRequestOptions(options) {
return {
headers: {
"Accept": "application/json, application/problem+json",
...options?.headers,
},
...options,
};
}
problemToResponse(problem, url) {
const headers = new Headers();
headers.set("Content-Type", "application/problem+json");
return {
url,
status: problem.status ?? 422,
statusText: problem.title ?? "Unprocessable Entity",
body: null,
bodyUsed: true,
ok: false,
headers: headers,
redirected: false,
problem: problem,
data: null,
meta: { links: {} },
type: "basic",
json: () => new Promise((resolve) => resolve(problem)),
text: () => new Promise((resolve) => resolve(JSON.stringify(problem))),
arrayBuffer: () => new Promise((resolve) => resolve(new ArrayBuffer(0))),
// @ts-ignore: New in Deno 1.44
bytes: () => new Promise((resolve) => resolve(new Uint8Array())),
blob: () => new Promise((resolve) => resolve(new Blob())),
formData: () => new Promise((resolve) => resolve(new FormData())),
clone: () => {
throw new Error("Not implemented");
},
};
}
buildUrl(url, options) {
let builtUrl = url;
if (!builtUrl.startsWith("http") && this.options?.baseUrl) {
if (this.options.baseUrl.endsWith("/") || builtUrl.startsWith("/")) {
builtUrl = this.options.baseUrl + builtUrl;
}
else {
builtUrl = this.options.baseUrl + "/" + builtUrl;
}
}
const isAbsoluteUrl = builtUrl.startsWith("http");
let parsed = undefined;
if (isAbsoluteUrl) {
parsed = new URL(builtUrl);
}
else if (globalThis.location?.origin &&
globalThis.location?.origin.startsWith("http")) {
if (builtUrl.startsWith("/")) {
parsed = new URL(builtUrl, globalThis.location.origin);
}
else {
parsed = new URL(builtUrl, globalThis.location.origin + "/");
}
}
else {
if (builtUrl.startsWith("/")) {
parsed = new URL(builtUrl, "http://localhost");
}
else {
parsed = new URL(builtUrl, "http://localhost/");
}
}
if (options?.params) {
for (const [key, value] of Object.entries(options?.params)) {
if (value !== undefined && value !== null && !parsed.searchParams.has(key)) {
parsed.searchParams.set(key, value);
}
}
}
builtUrl = parsed.toString();
const result = isAbsoluteUrl
? builtUrl
: `${parsed.pathname}${parsed.search}`;
return { builtUrl: result, absoluteUrl: builtUrl };
}
validateResponse(response, options) {
if (!response) {
throw new Error("Response is null");
}
if (response.ok || options?.shouldThrowOnUnexpectedStatusCodes === false) {
return;
}
if (options?.expectedStatusCodes &&
options.expectedStatusCodes.includes(response.status)) {
return;
}
if (options?.errorCallback) {
const result = options.errorCallback(response);
if (result === true) {
return;
}
}
response.problem ??= new ProblemDetails();
response.problem.status = response.status;
response.problem.title = `Unexpected status code: ${response.status}`;
response.problem.setErrorMessage(response.problem.title);
throw response;
}
}