From 1505d3da4c869268fd197f12b5982dad568a7200 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 4 May 2024 15:22:08 -0500 Subject: [PATCH] Add FetchClientOptions --- src/FetchClient.test.ts | 88 +++++++++------------- src/FetchClient.ts | 146 ++++++++++++++++++++++++++++--------- src/FetchClientProvider.ts | 99 ++++++++++++++----------- src/RequestOptions.ts | 18 ----- 4 files changed, 201 insertions(+), 150 deletions(-) diff --git a/src/FetchClient.test.ts b/src/FetchClient.test.ts index 6537b57..f2505a2 100644 --- a/src/FetchClient.test.ts +++ b/src/FetchClient.test.ts @@ -1,5 +1,33 @@ import { assert, assertEquals, assertFalse } from "@std/assert"; -import { defaultProvider as provider, ProblemDetails } from "../mod.ts"; +import { + defaultProvider as provider, + FetchClient, + ProblemDetails, +} from "../mod.ts"; + +Deno.test("can getJSON", async () => { + const api = new FetchClient(); + const res = await api.getJSON<{ + products: Array<{ id: number; name: string }>; + }>( + `https://dummyjson.com/products/search?q=iphone&limit=10`, + ); + assertEquals(res.status, 200); + assert(res.data?.products); +}); + +Deno.test("can getJSON with baseUrl", async () => { + const api = new FetchClient({ + baseUrl: "https://dummyjson.com", + }); + const res = await api.getJSON<{ + products: Array<{ id: number; name: string }>; + }>( + `/products/search?q=iphone&limit=10`, + ); + assertEquals(res.status, 200); + assert(res.data?.products); +}); Deno.test("can getJSON with middleware", async () => { const fakeFetch = (): Promise => @@ -16,6 +44,10 @@ Deno.test("can getJSON with middleware", async () => { provider.fetch = fakeFetch; const client = provider.getFetchClient(); + const res = await client.getJSON( + "https://dummyjson.com/products/search?q=iphone&limit=10", + ); + console.log("hi", res.data); let called = false; client.use(async (ctx, next) => { assert(ctx); @@ -300,54 +332,6 @@ Deno.test("can abort getJSON", () => { controller.abort(); }); -Deno.test("will validate postJSON model", async () => { - let called = false; - const fakeFetch = (): Promise => - new Promise((resolve) => { - called = true; - resolve(new Response()); - }); - - provider.fetch = fakeFetch; - const client = provider.getFetchClient(); - const data = { - email: "test@test", - password: "test", - }; - // deno-lint-ignore require-await - const modelValidator = async (data: object | null) => { - // use zod or class validator - const problem = new ProblemDetails(); - const d = data as { password: string }; - if (d?.password?.length < 6) { - problem.errors.password = [ - "Password must be longer than or equal to 6 characters.", - ]; - } - - return problem; - }; - const response = await client.postJSON( - "https://jsonplaceholder.typicode.com/todos/1", - data, - { - modelValidator: modelValidator, - }, - ); - assertEquals(response.ok, false); - assertEquals(called, false); - assertEquals(response.status, 422); - assertFalse(response.data); - assert(response.problem); - assert(response.problem!.errors); - assert(response.problem!.errors.password); - assertEquals(response.problem!.errors.password!.length, 1); - assertEquals( - response.problem!.errors.password![0], - "Password must be longer than or equal to 6 characters.", - ); -}); - Deno.test("will validate postJSON model with default model validator", async () => { let called = false; const fakeFetch = (): Promise => @@ -356,8 +340,6 @@ Deno.test("will validate postJSON model with default model validator", async () resolve(new Response()); }); - provider.fetch = fakeFetch; - const client = provider.getFetchClient(); const data = { email: "test@test", password: "test", @@ -374,6 +356,8 @@ Deno.test("will validate postJSON model with default model validator", async () } return problem; }); + provider.fetch = fakeFetch; + const client = provider.getFetchClient(); const response = await client.postJSON( "https://jsonplaceholder.typicode.com/todos/1", data, @@ -405,7 +389,6 @@ Deno.test("can use global middleware", async () => { }); provider.fetch = fakeFetch; - const client = provider.getFetchClient(); let called = false; provider.useMiddleware(async (ctx, next) => { assert(ctx); @@ -415,6 +398,7 @@ Deno.test("can use global middleware", async () => { await next(); assert(ctx.response); }); + const client = provider.getFetchClient(); assert(client); type Todo = { userId: number; id: number; title: string; completed: boolean }; diff --git a/src/FetchClient.ts b/src/FetchClient.ts index c864e11..47ed314 100644 --- a/src/FetchClient.ts +++ b/src/FetchClient.ts @@ -5,38 +5,91 @@ import type { FetchClientResponse } from "./FetchClientResponse.ts"; import type { FetchClientMiddleware, Next } from "./FetchClientMiddleware.ts"; import type { FetchClientContext } from "./FetchClientContext.ts"; import { parseLinkHeader } from "./LinkHeader.ts"; -import { FetchClientProvider } from "./FetchClientProvider.ts"; -import { defaultProvider } from "../mod.ts"; +import { FetchClientCache } from "./FetchClientCache.ts"; type Fetch = typeof globalThis.fetch; +/** + * Fetch client options to use for making HTTP requests. + */ +export type FetchClientOptions = { + /** + * The default request options to use for requests. If specified, these options will be merged with the + * options from the FetchClientProvider and the options provided in each request. + */ + defaultRequestOptions?: RequestOptions; + + /** + * The cache to use for storing HTTP responses. + */ + cache?: FetchClientCache; + + /** + * The fetch implementation to use for making HTTP requests. + * If not provided, the global fetch function will be used. + */ + fetch?: Fetch; + + /** + * An array of middleware functions to be applied to the request. + */ + middleware?: FetchClientMiddleware[]; + + /** + * The base URL for making HTTP requests. + */ + baseUrl?: string; + + /** + * A function that validates the model before making the request. + * Returns a Promise that resolves to a ProblemDetails object if validation fails, or null if validation succeeds. + */ + modelValidator?: (model: object | null) => Promise; + + /** + * A function that returns the access token to use for making requests. + */ + accessTokenFunc?: () => string | null; + + /** + * Counter for tracking the number of inflight requests at the provider level + */ + providerCounter?: Counter; +}; + /** * Represents a client for making HTTP requests using the Fetch API. */ export class FetchClient { - #provider: FetchClientProvider; + #options: FetchClientOptions; + #defaultRequestOptions?: RequestOptions; + #cache: FetchClientCache; + #fetch: Fetch; #middleware: FetchClientMiddleware[] = []; #counter = new Counter(); + #providerCounter: Counter; /** * Represents a FetchClient that handles HTTP requests using the Fetch API. - * @param fetchOrProvider - An optional Fetch implementation to use for making HTTP requests. If not provided, the global `fetch` function will be used. + * @param options - The options to use for the FetchClient. */ - constructor(fetchOrProvider?: Fetch | FetchClientProvider) { - if (fetchOrProvider instanceof FetchClientProvider) { - this.#provider = fetchOrProvider; - } else if (fetchOrProvider instanceof Function) { - this.#provider = new FetchClientProvider(fetchOrProvider); - } else { - this.#provider = defaultProvider; + constructor(options?: FetchClientOptions) { + this.#options = options ?? {}; + this.#defaultRequestOptions = options?.defaultRequestOptions ?? {}; + this.#cache = options?.cache ?? new FetchClientCache(); + this.#fetch = options?.fetch ?? globalThis.fetch; + if (!this.#fetch) { + throw new Error("No fetch implementation available"); } + this.#providerCounter = options?.providerCounter ?? new Counter(); + this.use(...(options?.middleware ?? [])); } /** - * Gets the number of inflight requests for this FetchClient instance. + * Gets the cache used for storing HTTP responses. */ - public get provider(): FetchClientProvider { - return this.#provider; + public get cache(): FetchClientCache { + return this.#cache; } /** @@ -75,7 +128,10 @@ export class FetchClient { url: string, options?: GetRequestOptions, ): Promise> { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; const response = await this.fetchInternal( url, options, @@ -117,7 +173,10 @@ export class FetchClient { body?: object | string, options?: RequestOptions, ): Promise> { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; const problem = await this.validate(body, options); if (problem) return this.problemToResponse(problem, url); @@ -147,7 +206,10 @@ export class FetchClient { formData: FormData, options?: RequestOptions, ): Promise> { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; const response = await this.fetchInternal( url, options, @@ -190,7 +252,10 @@ export class FetchClient { body?: object | string, options?: RequestOptions, ): Promise> { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; const problem = await this.validate(body, options); if (problem) return this.problemToResponse(problem, url); @@ -236,7 +301,10 @@ export class FetchClient { body?: object | string, options?: RequestOptions, ): Promise { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; const problem = await this.validate(body, options); if (problem) return this.problemToResponse(problem, url); @@ -281,7 +349,10 @@ export class FetchClient { url: string, options?: RequestOptions, ): Promise> { - options = { ...this.#provider.defaultOptions, ...options }; + options = { + ...this.#defaultRequestOptions, + ...options, + }; return await this.fetchInternal( url, options, @@ -301,11 +372,11 @@ export class FetchClient { (options && options.shouldValidateModel === false) ) return null; - if (options?.modelValidator === undefined) { + if (this.#options?.modelValidator === undefined) { return null; } - const problem = await options.modelValidator(data as object); + const problem = await this.#options.modelValidator(data as object); if (!problem) return null; return problem; @@ -318,7 +389,7 @@ export class FetchClient { ): Promise> { url = this.buildUrl(url, options); - const accessToken = this.#provider.accessToken; + const accessToken = this.#options.accessTokenFunc?.() ?? null; if (accessToken !== null) { init = { ...init, @@ -333,16 +404,16 @@ export class FetchClient { } const fetchMiddleware = async (ctx: FetchClientContext, next: Next) => { - const getOptions = options as GetRequestOptions; + const getOptions = ctx.options as GetRequestOptions; if (getOptions?.cacheKey) { - const cachedResponse = this.#provider.cache.get(getOptions.cacheKey); + const cachedResponse = this.#cache.get(getOptions.cacheKey); if (cachedResponse) { ctx.response = cachedResponse as FetchClientResponse; return; } } - const response = await this.#provider.fetch(ctx.request); + const response = await this.#fetch(ctx.request); if ( ctx.request.headers.get("Content-Type")?.startsWith( "application/json", @@ -364,7 +435,7 @@ export class FetchClient { }; if (getOptions?.cacheKey) { - this.#provider.cache.set( + this.#cache.set( getOptions.cacheKey, ctx.response, getOptions.cacheDuration, @@ -375,12 +446,11 @@ export class FetchClient { }; const middleware = [ ...this.#middleware, - ...(options?.middleware ?? []), fetchMiddleware, ]; this.#counter.increment(); - this.#provider.counter.increment(); + this.#providerCounter.increment(); const context: FetchClientContext = { options, @@ -391,7 +461,7 @@ export class FetchClient { await this.invokeMiddleware(context, middleware); this.#counter.decrement(); - this.#provider.counter.decrement(); + this.#providerCounter.decrement(); this.validateResponse(context.response, options); @@ -473,17 +543,20 @@ export class FetchClient { } private buildUrl(url: string, options: RequestOptions | undefined): string { - const isAbsoluteUrl = url.startsWith("http"); - if (url.startsWith("/")) { url = url.substring(1); } - if (!url.startsWith("http") && options?.baseUrl) { - url = options.baseUrl + "/" + url; + if (!url.startsWith("http") && this.#options?.baseUrl) { + url = this.#options.baseUrl + "/" + url; } - const origin = isAbsoluteUrl ? undefined : window.location.origin ?? ""; + const isAbsoluteUrl = url.startsWith("http"); + + const origin = isAbsoluteUrl + ? undefined + : globalThis.window?.location?.origin ?? undefined; + const parsed = new URL(url, origin); if (options?.params) { @@ -496,7 +569,8 @@ export class FetchClient { url = parsed.toString(); } - return isAbsoluteUrl ? url : `${parsed.pathname}${parsed.search}`; + const result = isAbsoluteUrl ? url : `${parsed.pathname}${parsed.search}`; + return result; } private validateResponse( diff --git a/src/FetchClientProvider.ts b/src/FetchClientProvider.ts index 9805b84..c1963a9 100644 --- a/src/FetchClientProvider.ts +++ b/src/FetchClientProvider.ts @@ -1,5 +1,4 @@ -import { FetchClient } from "./FetchClient.ts"; -import type { RequestOptions } from "./RequestOptions.ts"; +import { FetchClient, type FetchClientOptions } from "./FetchClient.ts"; import { Counter } from "./Counter.ts"; import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; import type { ProblemDetails } from "./ProblemDetails.ts"; @@ -11,8 +10,7 @@ type Fetch = typeof globalThis.fetch; * Represents a provider for creating instances of the FetchClient class with shared default options and cache. */ export class FetchClientProvider { - #getAccessToken: () => string | null = () => null; - #defaultOptions: RequestOptions = {}; + #options: FetchClientOptions = {}; #fetch: Fetch; #cache: FetchClientCache; #counter = new Counter(); @@ -27,45 +25,45 @@ export class FetchClientProvider { } /** - * Gets a value indicating whether there are ongoing requests. + * Gets the fetch function used for making requests. */ - public get isLoading(): boolean { - return this.#counter.count > 0; + public get fetch(): Fetch { + return this.#fetch; } /** - * Gets the number of ongoing requests. + * Sets the fetch function used for making requests. */ - public get requestCount(): number { - return this.#counter.count; + public set fetch(value: Fetch) { + this.#fetch = value; } /** - * Gets the ongoing request counter. + * Gets a value indicating whether there are ongoing requests. */ - public get counter(): Counter { - return this.#counter; + public get isLoading(): boolean { + return this.#counter.count > 0; } /** - * Gets the default options used for FetchClient instances. + * Gets the number of ongoing requests. */ - public get defaultOptions(): RequestOptions { - return this.#defaultOptions; + public get requestCount(): number { + return this.#counter.count; } /** - * Gets the fetch function used for making HTTP requests. + * Gets the options used for FetchClient instances. */ - public get fetch(): Fetch { - return this.#fetch; + public get options(): FetchClientOptions { + return this.#options; } /** - * Sets the fetch function used for making HTTP requests. + * Sets the options used for FetchClient instances. */ - public set fetch(value: Fetch) { - this.#fetch = value; + public set options(value: FetchClientOptions) { + this.#options = value; } /** @@ -75,35 +73,42 @@ export class FetchClientProvider { return this.#cache; } - /** - * Gets the access token used for authentication. - */ - public get accessToken(): string | null { - return this.#getAccessToken(); - } - /** * Creates a new instance of FetchClient using the current provider. * @returns A new instance of FetchClient. */ public getFetchClient(): FetchClient { - return new FetchClient(this); + return new FetchClient({ + defaultRequestOptions: this.#options.defaultRequestOptions, + baseUrl: this.#options.baseUrl, + cache: this.#cache, + fetch: this.#fetch, + middleware: this.#options.middleware, + modelValidator: this.#options.modelValidator, + accessTokenFunc: this.#options.accessTokenFunc, + providerCounter: this.#counter, + }); } /** - * Sets the function used for retrieving the access token. - * @param accessTokenFunc - The function that retrieves the access token. + * Applies the specified options by merging with the current options. */ - public setAccessTokenFunc(accessTokenFunc: () => string | null) { - this.#getAccessToken = accessTokenFunc; + public applyOptions(options: FetchClientOptions) { + this.#options = { + ...this.#options, + ...options, + }; } /** - * Sets the default request options used for creating FetchClient instances. - * @param options - The default request options. + * Sets the function used for retrieving the access token. + * @param accessTokenFunc - The function that retrieves the access token. */ - public setDefaultRequestOptions(options: RequestOptions) { - this.#defaultOptions = { ...this.#defaultOptions, ...options }; + public setAccessTokenFunc(accessTokenFunc: () => string | null) { + this.#options = { + ...this.#options, + accessTokenFunc: accessTokenFunc, + }; } /** @@ -113,8 +118,8 @@ export class FetchClientProvider { public setDefaultModelValidator( validate: (model: object | null) => Promise, ) { - this.#defaultOptions = { - ...this.#defaultOptions, + this.#options = { + ...this.#options, modelValidator: validate, }; } @@ -124,7 +129,10 @@ export class FetchClientProvider { * @param url - The URL to set as the default base URL. */ public setDefaultBaseUrl(url: string) { - this.#defaultOptions = { ...this.#defaultOptions, baseUrl: url }; + this.#options = { + ...this.#options, + baseUrl: url, + }; } /** @@ -132,9 +140,12 @@ export class FetchClientProvider { * @param middleware - The middleware function to be added. */ public useMiddleware(middleware: FetchClientMiddleware) { - this.#defaultOptions = { - ...this.#defaultOptions, - middleware: [...(this.#defaultOptions.middleware ?? []), middleware], + this.#options = { + ...this.#options, + middleware: [ + ...(this.#options.middleware ?? []), + middleware, + ], }; } } diff --git a/src/RequestOptions.ts b/src/RequestOptions.ts index fecb177..09e42ad 100644 --- a/src/RequestOptions.ts +++ b/src/RequestOptions.ts @@ -1,27 +1,14 @@ -import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; -import type { ProblemDetails } from "./ProblemDetails.ts"; import type { CacheKey } from "./FetchClientCache.ts"; /** * Represents the options for making a request using the FetchClient. */ export type RequestOptions = { - /** - * The base URL for the request. - */ - baseUrl?: string; - /** * Specifies whether the model should be validated before making the request. */ shouldValidateModel?: boolean; - /** - * A function that validates the model before making the request. - * Returns a Promise that resolves to a ProblemDetails object if validation fails, or null if validation succeeds. - */ - modelValidator?: (model: object | null) => Promise; - /** * Additional parameters to be included in the request. */ @@ -42,11 +29,6 @@ export type RequestOptions = { */ errorCallback?: (error: Response) => void; - /** - * An array of middleware functions to be applied to the request. - */ - middleware?: FetchClientMiddleware[]; - /** * An AbortSignal object that can be used to cancel the request. */