From bd54d0f30265ad59f6e7c55e4ac725ce5f947162 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 15 Jul 2026 23:12:27 -0500 Subject: [PATCH 1/4] Add QUERY HTTP method support --- docs/guide/usage-examples.md | 5 +++ docs/index.md | 2 +- mod.ts | 17 ++++++++- readme.md | 4 +-- src/DefaultHelpers.ts | 23 ++++++++++++- src/FetchClient.ts | 54 +++++++++++++++++++++++++++-- src/RequestOptions.ts | 8 +++++ src/RetryMiddleware.ts | 8 ++++- src/mocks/MockRegistry.ts | 17 +++++++++ src/mocks/types.ts | 2 ++ src/tests/HttpMethods.test.ts | 57 +++++++++++++++++++++++++++++++ src/tests/RetryMiddleware.test.ts | 21 ++++++++++++ 12 files changed, 210 insertions(+), 8 deletions(-) diff --git a/docs/guide/usage-examples.md b/docs/guide/usage-examples.md index 64c7c55..41020a4 100644 --- a/docs/guide/usage-examples.md +++ b/docs/guide/usage-examples.md @@ -12,6 +12,11 @@ import fc from "@foundatiofx/fetchclient"; // GET with typed JSON response const { data: user } = await fc.getJSON("/api/users/1"); +// Safe, idempotent QUERY with body content +const { data: matches } = await fc.queryJSON("/api/users/search", { + name: "Alice", +}); + // POST with body const { data: created } = await fc.postJSON("/api/users", { name: "Alice", diff --git a/docs/index.md b/docs/index.md index 867e7c5..bf442b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ hero: features: - icon: ⚡ title: Typed JSON Helpers - details: getJSON, postJSON, putJSON, patchJSON, deleteJSON with full TypeScript support. + details: getJSON, queryJSON, postJSON, putJSON, patchJSON, deleteJSON with full TypeScript support. - icon: 🎯 title: Two API Styles details: Use simple functions or classes - your choice. Both have full access to all features. diff --git a/mod.ts b/mod.ts index cac2668..b1a4560 100644 --- a/mod.ts +++ b/mod.ts @@ -10,7 +10,10 @@ export { type CacheTag, FetchClientCache, } from "./src/FetchClientCache.ts"; -export type { RequestOptions } from "./src/RequestOptions.ts"; +export type { + QueryRequestOptions, + RequestOptions, +} from "./src/RequestOptions.ts"; export type { FetchClientMiddleware } from "./src/FetchClientMiddleware.ts"; export type { FetchClientContext } from "./src/FetchClientContext.ts"; export { @@ -65,11 +68,13 @@ import { patchJSON, postJSON, putJSON, + queryJSON, useFetchClient, useMiddleware, } from "./src/DefaultHelpers.ts"; import type { GetRequestOptions, + QueryRequestOptions, RequestOptions, } from "./src/RequestOptions.ts"; import type { ResponsePromise } from "./src/ResponsePromise.ts"; @@ -125,6 +130,13 @@ const fetchClient = { get: (url: string, options?: GetRequestOptions): ResponsePromise => useFetchClient().get(url, options), + /** Sends a QUERY request. Use `.json()` for typed JSON response. */ + query: ( + url: string, + body?: object | string | FormData, + options?: QueryRequestOptions, + ): ResponsePromise => useFetchClient().query(url, body, options), + /** Sends a POST request. Use `.json()` for typed JSON response. */ post: ( url: string, @@ -157,6 +169,9 @@ const fetchClient = { /** Sends a GET request and returns parsed JSON in response.data */ getJSON, + /** Sends a QUERY request and returns parsed JSON in response.data */ + queryJSON, + /** Sends a POST request and returns parsed JSON in response.data */ postJSON, diff --git a/readme.md b/readme.md index 258f122..8b0d772 100644 --- a/readme.md +++ b/readme.md @@ -12,8 +12,8 @@ handling. ## Features -- **Typed JSON helpers** - `getJSON`, `postJSON`, `putJSON`, `patchJSON`, - `deleteJSON` +- **Typed JSON helpers** - `getJSON`, `queryJSON`, `postJSON`, `putJSON`, + `patchJSON`, `deleteJSON` - **Two API styles** - Functional or class-based - your choice - **Response caching** - TTL-based caching with tags for grouped invalidation - **Middleware** - Intercept requests/responses for logging, auth, transforms diff --git a/src/DefaultHelpers.ts b/src/DefaultHelpers.ts index 6f61378..e719c29 100644 --- a/src/DefaultHelpers.ts +++ b/src/DefaultHelpers.ts @@ -10,7 +10,11 @@ import type { FetchClientResponse } from "./FetchClientResponse.ts"; import type { ProblemDetails } from "./ProblemDetails.ts"; import type { RateLimitMiddlewareOptions } from "./RateLimitMiddleware.ts"; import type { CircuitBreakerMiddlewareOptions } from "./CircuitBreakerMiddleware.ts"; -import type { GetRequestOptions, RequestOptions } from "./RequestOptions.ts"; +import type { + GetRequestOptions, + QueryRequestOptions, + RequestOptions, +} from "./RequestOptions.ts"; let getCurrentProviderFunc: () => FetchClientProvider | null = () => null; @@ -35,6 +39,23 @@ export function getJSON( return useFetchClient().getJSON(url, options); } +/** + * Sends a QUERY request with JSON content using the default client and provider. + * + * @template T - The type of the response data. + * @param url - The URL to send the request to. + * @param body - The query content to send with the request. + * @param options - Additional options for the request. + * @returns A promise that resolves to the response with parsed JSON in `data`. + */ +export function queryJSON( + url: string, + body?: object | string | FormData, + options?: QueryRequestOptions, +): Promise> { + return useFetchClient().queryJSON(url, body, options); +} + /** * Sends a POST request with JSON payload using the default client and provider to the specified URL. * diff --git a/src/FetchClient.ts b/src/FetchClient.ts index dc136da..7ab70b5 100644 --- a/src/FetchClient.ts +++ b/src/FetchClient.ts @@ -1,5 +1,9 @@ import { Counter } from "./Counter.ts"; -import type { GetRequestOptions, RequestOptions } from "./RequestOptions.ts"; +import type { + GetRequestOptions, + QueryRequestOptions, + RequestOptions, +} from "./RequestOptions.ts"; import { ProblemDetails } from "./ProblemDetails.ts"; import type { FetchClientResponse } from "./FetchClientResponse.ts"; import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; @@ -163,6 +167,52 @@ export class FetchClient { return await this.get(url, mergedOptions) as FetchClientResponse; } + /** + * Sends a safe, idempotent QUERY request to the specified URL. + * + * @param url - The URL to send the request to. + * @param body - The query content, which can be an object, a string, or FormData. + * @param options - Additional options for the request. + * @returns A ResponsePromise that resolves to the response. Can use `.json()` for typed JSON. + */ + query( + url: string, + body?: object | string | FormData, + options?: QueryRequestOptions, + ): ResponsePromise { + const mergedOptions = this.mergeWithDefaultRequestOptions(options); + + const responsePromise = this.fetchInternal( + url, + mergedOptions, + this.buildRequestInit("QUERY", body, mergedOptions), + ); + + return new ResponsePromise(responsePromise, mergedOptions); + } + + /** + * Sends a QUERY request with JSON content to the specified URL. + * The response will have the parsed JSON in `response.data`. + * + * @template T - The type of the response data. + * @param url - The URL to send the request to. + * @param body - The query content to send with the request. + * @param options - Additional options for the request. + * @returns A promise that resolves to the response with parsed JSON in `data`. + */ + async queryJSON( + url: string, + body?: object | string | FormData, + options?: QueryRequestOptions, + ): Promise> { + return await this.query( + url, + body, + this.buildJsonRequestOptions(options), + ) as FetchClientResponse; + } + /** * Sends a POST request to the specified URL. * @@ -637,7 +687,7 @@ export class FetchClient { } private buildRequestInit( - method: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE", + method: "GET" | "HEAD" | "QUERY" | "POST" | "PUT" | "PATCH" | "DELETE", body: object | string | FormData | undefined, options: RequestOptions | undefined, ): RequestInitWithObjectBody { diff --git a/src/RequestOptions.ts b/src/RequestOptions.ts index ae8091a..39aceec 100644 --- a/src/RequestOptions.ts +++ b/src/RequestOptions.ts @@ -79,3 +79,11 @@ export type GetRequestOptions = RequestOptions & { */ cacheTags?: CacheTag[]; }; + +/** + * Represents the options for a QUERY request. + * QUERY responses are cacheable, so these options include the same explicit + * cache controls as GET requests. A caller-provided cache key must distinguish + * requests with different query content. + */ +export type QueryRequestOptions = GetRequestOptions; diff --git a/src/RetryMiddleware.ts b/src/RetryMiddleware.ts index fed3468..1da67dc 100644 --- a/src/RetryMiddleware.ts +++ b/src/RetryMiddleware.ts @@ -8,6 +8,7 @@ import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts"; const DEFAULT_RETRY_METHODS = [ "GET", "HEAD", + "QUERY", "PUT", "DELETE", "OPTIONS", @@ -39,7 +40,7 @@ export interface RetryMiddlewareOptions { /** * HTTP methods eligible for retry. - * @default ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'] + * @default ['GET', 'HEAD', 'QUERY', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'] */ methods?: string[]; @@ -161,6 +162,7 @@ export class RetryMiddleware { } let attemptNumber = 0; + let nextRequest = context.request.body ? context.request.clone() : null; while (true) { // Store retry metadata in context for observability @@ -215,6 +217,10 @@ export class RetryMiddleware { // Reset response for next attempt context.response = null; + if (nextRequest) { + context.request = nextRequest; + nextRequest = nextRequest.clone(); + } attemptNumber++; } }; diff --git a/src/mocks/MockRegistry.ts b/src/mocks/MockRegistry.ts index aefe9ae..646af2d 100644 --- a/src/mocks/MockRegistry.ts +++ b/src/mocks/MockRegistry.ts @@ -8,6 +8,7 @@ type Fetch = typeof globalThis.fetch; class MockHistoryImpl implements MockHistory { #get: Request[] = []; #head: Request[] = []; + #query: Request[] = []; #post: Request[] = []; #put: Request[] = []; #patch: Request[] = []; @@ -22,6 +23,10 @@ class MockHistoryImpl implements MockHistory { return [...this.#head]; } + get query(): Request[] { + return [...this.#query]; + } + get post(): Request[] { return [...this.#post]; } @@ -52,6 +57,9 @@ class MockHistoryImpl implements MockHistory { case "HEAD": this.#head.push(request); break; + case "QUERY": + this.#query.push(request); + break; case "POST": this.#post.push(request); break; @@ -70,6 +78,7 @@ class MockHistoryImpl implements MockHistory { clear(): void { this.#get = []; this.#head = []; + this.#query = []; this.#post = []; this.#put = []; this.#patch = []; @@ -128,6 +137,14 @@ export class MockRegistry { return this.#addMock("HEAD", url); } + /** + * Creates a mock for QUERY requests matching the given URL. + * @param url - URL string or RegExp to match + */ + onQuery(url: string | RegExp): MockResponseBuilder { + return this.#addMock("QUERY", url); + } + /** * Creates a mock for POST requests matching the given URL. * @param url - URL string or RegExp to match diff --git a/src/mocks/types.ts b/src/mocks/types.ts index 72a83b6..688cd8d 100644 --- a/src/mocks/types.ts +++ b/src/mocks/types.ts @@ -36,6 +36,8 @@ export interface MockHistory { readonly get: Request[]; /** HEAD requests */ readonly head: Request[]; + /** QUERY requests */ + readonly query: Request[]; /** POST requests */ readonly post: Request[]; /** PUT requests */ diff --git a/src/tests/HttpMethods.test.ts b/src/tests/HttpMethods.test.ts index c90c231..b548159 100644 --- a/src/tests/HttpMethods.test.ts +++ b/src/tests/HttpMethods.test.ts @@ -54,6 +54,36 @@ Deno.test("can getJSON with client middleware", async () => { assertFalse(provider.isLoading); }); +Deno.test("can queryJSON with client middleware", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/todos/search").reply(200, [{ id: 1, title: "Match" }]); + + const client = new FetchClient(); + mocks.install(client); + + let called = false; + client.use(async (ctx, next) => { + assertEquals(ctx.request.method, "QUERY"); + assertEquals(ctx.request.headers.get("Content-Type"), "application/json"); + assertEquals( + ctx.request.headers.get("Accept"), + "application/json, application/problem+json", + ); + assertEquals(await ctx.request.clone().json(), { completed: false }); + called = true; + await next(); + }); + + const response = await client.queryJSON>>( + "https://example.com/todos/search", + { completed: false }, + ); + + assert(called); + assertEquals(response.data, [{ id: 1, title: "Match" }]); + assertEquals(mocks.history.query.length, 1); +}); + Deno.test("can postJSON with client middleware", async () => { const mocks = new MockRegistry(); mocks.onPost("/todos/1").reply(200, { @@ -471,6 +501,33 @@ Deno.test("default export fc.getJSON() works", async () => { assertEquals(response.data?.name, "Another User"); }); +Deno.test("default and named QUERY exports work", async () => { + const { + default: fc, + defaultProviderInstance, + queryJSON, + } = await import("../../mod.ts"); + + const mocks = new MockRegistry(); + mocks.onQuery("/api/search").reply(200, [{ id: 1 }]); + mocks.onQuery("/api/count").reply(200, { count: 1 }); + mocks.install(defaultProviderInstance); + + const searchResponse = await queryJSON>( + "https://example.com/api/search", + { term: "test" }, + ); + const count = await fc.query( + "https://example.com/api/count", + { term: "test" }, + { headers: { Accept: "application/json" } }, + ).json<{ count: number }>(); + + assertEquals(searchResponse.data, [{ id: 1 }]); + assertEquals(count, { count: 1 }); + assertEquals(mocks.history.query.length, 2); +}); + Deno.test("default export fc.use(fc.middleware.retry()) works", async () => { const { default: fc, defaultProviderInstance } = await import("../../mod.ts"); diff --git a/src/tests/RetryMiddleware.test.ts b/src/tests/RetryMiddleware.test.ts index 20024ab..576f088 100644 --- a/src/tests/RetryMiddleware.test.ts +++ b/src/tests/RetryMiddleware.test.ts @@ -364,6 +364,27 @@ Deno.test("RetryMiddleware - HEAD method is retried by default", async () => { assertEquals(mocks.history.head.length, 2); }); +Deno.test("RetryMiddleware - QUERY method is retried by default", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/api/search").replyOnce(503, { error: "Unavailable" }); + mocks.onQuery("/api/search").reply(200, { results: [1] }); + + const provider = new FetchClientProvider(); + provider.useRetry({ limit: 2, jitter: 0, delay: () => 10 }); + mocks.install(provider); + + const client = provider.getFetchClient(); + const response = await client.queryJSON<{ results: number[] }>( + "https://example.com/api/search", + { term: "test" }, + { expectedStatusCodes: [503] }, + ); + + assertEquals(response.status, 200); + assertEquals(response.data, { results: [1] }); + assertEquals(mocks.history.query.length, 2); +}); + Deno.test("RetryMiddleware - does not retry on 4xx status by default", async () => { const mocks = new MockRegistry(); mocks.onGet("/api/data").reply(404, { error: "Not Found" }); From 2fac6618b4594de0c95464b28f4653a504201712 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 15 Jul 2026 23:19:44 -0500 Subject: [PATCH 2/4] Add failing QUERY regression tests --- src/tests/HttpMethods.test.ts | 68 ++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/tests/HttpMethods.test.ts b/src/tests/HttpMethods.test.ts index b548159..d9b1364 100644 --- a/src/tests/HttpMethods.test.ts +++ b/src/tests/HttpMethods.test.ts @@ -1,4 +1,4 @@ -import { assert, assertEquals, assertFalse } from "@std/assert"; +import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; import { FetchClient } from "../FetchClient.ts"; import { FetchClientProvider } from "../FetchClientProvider.ts"; import { MockRegistry } from "../mocks/MockRegistry.ts"; @@ -84,6 +84,72 @@ Deno.test("can queryJSON with client middleware", async () => { assertEquals(mocks.history.query.length, 1); }); +Deno.test("queryJSON sends string content as application/json", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/todos/search").reply(200, []); + + const client = new FetchClient(); + mocks.install(client); + + await client.queryJSON( + "https://example.com/todos/search", + JSON.stringify({ completed: false }), + ); + + const request = mocks.history.query[0]; + assertEquals(request.headers.get("Content-Type"), "application/json"); + assertEquals(await request.text(), '{"completed":false}'); +}); + +Deno.test("query preserves URLSearchParams content", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/todos/search").reply(200, []); + + const client = new FetchClient(); + mocks.install(client); + + await client.query( + "https://example.com/todos/search", + new URLSearchParams({ completed: "false" }), + ); + + const request = mocks.history.query[0]; + assertEquals( + request.headers.get("Content-Type"), + "application/x-www-form-urlencoded;charset=UTF-8", + ); + assertEquals(await request.text(), "completed=false"); +}); + +Deno.test("query preserves Blob content", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/todos/search").reply(200, []); + + const client = new FetchClient(); + mocks.install(client); + + await client.query( + "https://example.com/todos/search", + new Blob(["completed = false"], { type: "application/sql" }), + ); + + const request = mocks.history.query[0]; + assertEquals(request.headers.get("Content-Type"), "application/sql"); + assertEquals(await request.text(), "completed = false"); +}); + +Deno.test("query rejects requests without content", async () => { + const mocks = new MockRegistry(); + mocks.onQuery("/todos/search").reply(200, []); + + const client = new FetchClient(); + mocks.install(client); + + await assertRejects(async () => { + await client.query("https://example.com/todos/search"); + }); +}); + Deno.test("can postJSON with client middleware", async () => { const mocks = new MockRegistry(); mocks.onPost("/todos/1").reply(200, { From a8d2fed667d5c9ed3b29f97d9f3af7616fcaf8d5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 15 Jul 2026 23:24:12 -0500 Subject: [PATCH 3/4] Align QUERY tests with RFC examples --- src/tests/HttpMethods.test.ts | 51 ++++++++--------------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/src/tests/HttpMethods.test.ts b/src/tests/HttpMethods.test.ts index d9b1364..d560e68 100644 --- a/src/tests/HttpMethods.test.ts +++ b/src/tests/HttpMethods.test.ts @@ -1,4 +1,4 @@ -import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; +import { assert, assertEquals, assertFalse } from "@std/assert"; import { FetchClient } from "../FetchClient.ts"; import { FetchClientProvider } from "../FetchClientProvider.ts"; import { MockRegistry } from "../mocks/MockRegistry.ts"; @@ -84,33 +84,16 @@ Deno.test("can queryJSON with client middleware", async () => { assertEquals(mocks.history.query.length, 1); }); -Deno.test("queryJSON sends string content as application/json", async () => { +Deno.test("query sends application/x-www-form-urlencoded content", async () => { const mocks = new MockRegistry(); - mocks.onQuery("/todos/search").reply(200, []); - - const client = new FetchClient(); - mocks.install(client); - - await client.queryJSON( - "https://example.com/todos/search", - JSON.stringify({ completed: false }), - ); - - const request = mocks.history.query[0]; - assertEquals(request.headers.get("Content-Type"), "application/json"); - assertEquals(await request.text(), '{"completed":false}'); -}); - -Deno.test("query preserves URLSearchParams content", async () => { - const mocks = new MockRegistry(); - mocks.onQuery("/todos/search").reply(200, []); + mocks.onQuery("/feed").reply(200, []); const client = new FetchClient(); mocks.install(client); await client.query( - "https://example.com/todos/search", - new URLSearchParams({ completed: "false" }), + "https://example.com/feed", + new URLSearchParams({ q: "foo", limit: "10", sort: "-published" }), ); const request = mocks.history.query[0]; @@ -118,36 +101,24 @@ Deno.test("query preserves URLSearchParams content", async () => { request.headers.get("Content-Type"), "application/x-www-form-urlencoded;charset=UTF-8", ); - assertEquals(await request.text(), "completed=false"); + assertEquals(await request.text(), "q=foo&limit=10&sort=-published"); }); -Deno.test("query preserves Blob content", async () => { +Deno.test("query sends application/sql content", async () => { const mocks = new MockRegistry(); - mocks.onQuery("/todos/search").reply(200, []); + mocks.onQuery("/rfc-index.xml").reply(200, []); const client = new FetchClient(); mocks.install(client); await client.query( - "https://example.com/todos/search", - new Blob(["completed = false"], { type: "application/sql" }), + "https://example.com/rfc-index.xml", + new Blob(["SELECT * FROM rfc_index"], { type: "application/sql" }), ); const request = mocks.history.query[0]; assertEquals(request.headers.get("Content-Type"), "application/sql"); - assertEquals(await request.text(), "completed = false"); -}); - -Deno.test("query rejects requests without content", async () => { - const mocks = new MockRegistry(); - mocks.onQuery("/todos/search").reply(200, []); - - const client = new FetchClient(); - mocks.install(client); - - await assertRejects(async () => { - await client.query("https://example.com/todos/search"); - }); + assertEquals(await request.text(), "SELECT * FROM rfc_index"); }); Deno.test("can postJSON with client middleware", async () => { From 21d8c8af50827670d0ac086f9fa49ede10db13c8 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 15 Jul 2026 23:26:16 -0500 Subject: [PATCH 4/4] Preserve native QUERY request bodies --- src/FetchClient.ts | 50 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/FetchClient.ts b/src/FetchClient.ts index 7ab70b5..57fe650 100644 --- a/src/FetchClient.ts +++ b/src/FetchClient.ts @@ -439,18 +439,14 @@ export class FetchClient { ): Promise> { 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)) { + if (this.isJsonLikeObject(init?.body)) { const problem = await this.validate(init?.body, options); if (problem) { return this.problemToResponse(problem, url); } } - if ( - init?.body && typeof init.body === "object" && - !(init.body instanceof FormData) - ) { + if (this.isJsonLikeObject(init?.body)) { init.body = JSON.stringify(init.body); } @@ -691,13 +687,8 @@ export class FetchClient { body: object | string | FormData | undefined, options: RequestOptions | undefined, ): RequestInitWithObjectBody { - const isFormData = typeof FormData !== "undefined" && - body instanceof FormData; - const isJsonLikeObject = body !== undefined && body !== null && - typeof body === "object" && !isFormData; - const headers: Record = {}; - if (isJsonLikeObject) { + if (this.isJsonLikeObject(body)) { headers["Content-Type"] = "application/json"; } @@ -711,6 +702,41 @@ export class FetchClient { }; } + private isJsonLikeObject(body: unknown): body is object { + if (body === null || typeof body !== "object") { + return false; + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + return false; + } + + if (typeof Blob !== "undefined" && body instanceof Blob) { + return false; + } + + if ( + typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams + ) { + return false; + } + + if ( + typeof ArrayBuffer !== "undefined" && + (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) + ) { + return false; + } + + if ( + typeof ReadableStream !== "undefined" && body instanceof ReadableStream + ) { + return false; + } + + return true; + } + private buildJsonRequestOptions( options: RequestOptions | undefined, ): RequestOptions {