UNPKG

@foundatiofx/fetchclient

Version:

A typed JSON fetch client with middleware support for Deno, Node and the browser.

544 lines (543 loc) 20.2 kB
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; } }