Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/guide/usage-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import fc from "@foundatiofx/fetchclient";
// GET with typed JSON response
const { data: user } = await fc.getJSON<User>("/api/users/1");

// Safe, idempotent QUERY with body content
const { data: matches } = await fc.queryJSON<User[]>("/api/users/search", {
name: "Alice",
});

// POST with body
const { data: created } = await fc.postJSON<User>("/api/users", {
name: "Alice",
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +130,13 @@ const fetchClient = {
get: (url: string, options?: GetRequestOptions): ResponsePromise<unknown> =>
useFetchClient().get(url, options),

/** Sends a QUERY request. Use `.json<T>()` for typed JSON response. */
query: (
url: string,
body?: object | string | FormData,
options?: QueryRequestOptions,
): ResponsePromise<unknown> => useFetchClient().query(url, body, options),

/** Sends a POST request. Use `.json<T>()` for typed JSON response. */
post: (
url: string,
Expand Down Expand Up @@ -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,

Expand Down
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion src/DefaultHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -35,6 +39,23 @@ export function getJSON<T>(
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<T>(
url: string,
body?: object | string | FormData,
options?: QueryRequestOptions,
): Promise<FetchClientResponse<T>> {
return useFetchClient().queryJSON(url, body, options);
}

/**
* Sends a POST request with JSON payload using the default client and provider to the specified URL.
*
Expand Down
104 changes: 90 additions & 14 deletions src/FetchClient.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -163,6 +167,52 @@ export class FetchClient {
return await this.get(url, mergedOptions) as FetchClientResponse<T>;
}

/**
* 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<T>()` for typed JSON.
*/
query(
url: string,
body?: object | string | FormData,
options?: QueryRequestOptions,
): ResponsePromise<unknown> {
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<T>(
url: string,
body?: object | string | FormData,
options?: QueryRequestOptions,
): Promise<FetchClientResponse<T>> {
return await this.query(
url,
body,
this.buildJsonRequestOptions(options),
) as FetchClientResponse<T>;
}

/**
* Sends a POST request to the specified URL.
*
Expand Down Expand Up @@ -389,18 +439,14 @@ export class FetchClient {
): Promise<FetchClientResponse<T>> {
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<T>(problem, url);
}
}

if (
init?.body && typeof init.body === "object" &&
!(init.body instanceof FormData)
) {
if (this.isJsonLikeObject(init?.body)) {
init.body = JSON.stringify(init.body);
}

Expand Down Expand Up @@ -637,17 +683,12 @@ 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 {
const isFormData = typeof FormData !== "undefined" &&
body instanceof FormData;
const isJsonLikeObject = body !== undefined && body !== null &&
typeof body === "object" && !isFormData;

const headers: Record<string, string> = {};
if (isJsonLikeObject) {
if (this.isJsonLikeObject(body)) {
headers["Content-Type"] = "application/json";
}

Expand All @@ -661,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 {
Expand Down
8 changes: 8 additions & 0 deletions src/RequestOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 7 additions & 1 deletion src/RetryMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { FetchClientMiddleware } from "./FetchClientMiddleware.ts";
const DEFAULT_RETRY_METHODS = [
"GET",
"HEAD",
"QUERY",
"PUT",
"DELETE",
"OPTIONS",
Expand Down Expand Up @@ -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[];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -215,6 +217,10 @@ export class RetryMiddleware {

// Reset response for next attempt
context.response = null;
if (nextRequest) {
context.request = nextRequest;
nextRequest = nextRequest.clone();
}
attemptNumber++;
}
};
Expand Down
17 changes: 17 additions & 0 deletions src/mocks/MockRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -22,6 +23,10 @@ class MockHistoryImpl implements MockHistory {
return [...this.#head];
}

get query(): Request[] {
return [...this.#query];
}

get post(): Request[] {
return [...this.#post];
}
Expand Down Expand Up @@ -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;
Expand All @@ -70,6 +78,7 @@ class MockHistoryImpl implements MockHistory {
clear(): void {
this.#get = [];
this.#head = [];
this.#query = [];
this.#post = [];
this.#put = [];
this.#patch = [];
Expand Down Expand Up @@ -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<MockRegistry> {
return this.#addMock("QUERY", url);
}

/**
* Creates a mock for POST requests matching the given URL.
* @param url - URL string or RegExp to match
Expand Down
2 changes: 2 additions & 0 deletions src/mocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading