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
89 changes: 88 additions & 1 deletion packages/net/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ export async function fetch(
cache: "no-cache",
};

// Skip caching for POST, PATCH, and HEAD requests
// Skip caching for POST, PATCH, DELETE, and HEAD requests
if (
options.method === "POST" ||
options.method === "PATCH" ||
options.method === "DELETE" ||
options.method === "HEAD"
) {
const response = await undiciFetch(url, fetchOptions);
Expand Down Expand Up @@ -243,6 +244,92 @@ export async function patch<T = unknown>(
};
}

/**
* Perform a DELETE request to a URL with optional data and request options.
* @param {string} url The URL to fetch.
* @param {unknown} data Optional data to send in the request body.
* @param {Omit<FetchOptions, 'method' | 'body'>} options Optional request options. The `cache` property is required.
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
export async function del<T = unknown>(
url: string,
data?: unknown,
options?: Omit<FetchOptions, "method" | "body">,
): Promise<DataResponse<T>> {
// Handle the case where data is not provided (second param is options)
let actualData: unknown;
let actualOptions: Omit<FetchOptions, "method" | "body">;

if (
data !== undefined &&
typeof data === "object" &&
data !== null &&
"cache" in data
) {
// Second parameter is options, not data
actualData = undefined;
actualOptions = data as Omit<FetchOptions, "method" | "body">;
} else if (options) {
// Normal case: data and options provided
actualData = data;
actualOptions = options;
} else {
// No options provided
throw new Error("Fetch options must include a cache instance or options.");
}

// Automatically stringify data if it's provided and set appropriate headers
let body: BodyInit | undefined;
const headers = { ...actualOptions.headers } as Record<string, string>;

if (actualData !== undefined) {
if (typeof actualData === "string") {
body = actualData;
} else if (
actualData instanceof FormData ||
actualData instanceof URLSearchParams ||
actualData instanceof Blob
) {
body = actualData as BodyInit;
} else {
// Assume it's JSON data
body = JSON.stringify(actualData);
// Set Content-Type to JSON if not already set
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
}
}

const response = await fetch(url, {
...actualOptions,
headers,
body: body as RequestInit["body"],
method: "DELETE",
});
const text = await response.text();
let responseData: T;

try {
responseData = JSON.parse(text) as T;
} catch {
// If not JSON, return as is
responseData = text as T;
}

// Create a new response with the text already consumed
const newResponse = new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers as HeadersInit,
}) as UndiciResponse;

return {
data: responseData,
response: newResponse,
};
}

/**
* Perform a HEAD request to a URL with optional request options.
* @param {string} url The URL to fetch.
Expand Down
65 changes: 65 additions & 0 deletions packages/net/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,76 @@ export class CacheableNet extends Hookified {
response: newResponse,
};
}

/**
* Perform a DELETE request to a URL with optional data and request options. Will use the cache that is already set in the instance.
* @param {string} url The URL to fetch.
* @param {unknown} data Optional data to send in the request body.
* @param {Omit<FetchRequestInit, 'method' | 'body'>} options Optional request options (method and body will be set).
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
public async delete<T = unknown>(
url: string,
data?: unknown,
options?: Omit<FetchRequestInit, "method" | "body">,
): Promise<DataResponse<T>> {
// Automatically stringify data if it's provided and set appropriate headers
let body: BodyInit | undefined;
const headers = { ...options?.headers } as Record<string, string>;

if (data !== undefined) {
if (typeof data === "string") {
body = data;
} else if (
data instanceof FormData ||
data instanceof URLSearchParams ||
data instanceof Blob
) {
body = data as BodyInit;
} else {
// Assume it's JSON data
body = JSON.stringify(data);
// Set Content-Type to JSON if not already set
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
}
}

const response = await this.fetch(url, {
...options,
headers,
body: body as FetchRequestInit["body"],
method: "DELETE",
});
const text = await response.text();
let responseData: T;

try {
responseData = JSON.parse(text) as T;
} catch {
// If not JSON, return as is
responseData = text as T;
}

// Create a new response with the text already consumed
const newResponse = new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers as HeadersInit,
}) as FetchResponse;

return {
data: responseData,
response: newResponse,
};
}
}

export const Net = CacheableNet;
export {
type DataResponse,
del,
type FetchOptions,
type FetchRequestInit,
fetch,
Expand Down
165 changes: 165 additions & 0 deletions packages/net/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import process from "node:process";
import { Cacheable } from "cacheable";
import { describe, expect, test } from "vitest";
import {
del,
type FetchOptions,
fetch,
get,
Expand Down Expand Up @@ -405,4 +406,168 @@ describe("Fetch", () => {
expect(error).toBeDefined();
}
});

test(
"should fetch data using delete helper",
async () => {
const url = `${testUrl}/delete`;
const data = { id: "123" };
const options = {
cache: new Cacheable(),
};
const result = await del(url, data, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
expect(result.response.status).toBe(200);
},
testTimeout,
);

test(
"should fetch data using delete helper without data",
async () => {
const url = `${testUrl}/delete`;
const options = {
cache: new Cacheable(),
};
try {
const result = await del(url, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
// May succeed or fail depending on endpoint requirements
} catch (error) {
// Some endpoints require data for DELETE
expect((error as Error).message).toContain("400");
}
},
testTimeout,
);

test(
"should throw error when delete is called without options",
async () => {
const url = `${testUrl}/delete`;
await expect(del(url)).rejects.toThrow(
"Fetch options must include a cache instance or options.",
);
},
testTimeout,
);

test(
"should not cache data using delete helper (DELETE requests are not cached)",
async () => {
const cache = new Cacheable({ stats: true });
const url = `${testUrl}/delete`;
const data = { id: "123" };
const options = {
cache,
};
const result1 = await del(url, data, options);
const result2 = await del(url, data, options);
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(cache.stats).toBeDefined();
// DELETE requests should not be cached, so expect 0 hits
expect(cache.stats.hits).toBe(0);
// Verify response objects are valid
expect(result1.response.status).toBe(200);
expect(result2.response.status).toBe(200);
},
testTimeout,
);

test(
"should handle non-JSON response in delete helper",
async () => {
const cache = new Cacheable();
// Use mockhttp.org/plain which now accepts DELETE and returns plain text
const url = `${testUrl}/plain`;
const data = "test data";
const options = {
cache,
headers: {
"Content-Type": "text/plain",
},
};
try {
const result = await del(url, data, options);
expect(result).toBeDefined();
// If the plain endpoint accepts DELETE, should return text
expect(result.data).toBeDefined();
expect(typeof result.data).toBe("string");
expect(result.response).toBeDefined();
} catch (error) {
// If plain doesn't accept DELETE, that's okay
expect(error).toBeDefined();
}
},
testTimeout,
);

test("should handle string data in delete helper", async () => {
const cache = new Cacheable();
const url = `${testUrl}/delete`;
const data = JSON.stringify({ id: "123" });
const options = {
cache,
headers: {
"Content-Type": "application/json",
},
};
const result = await del(url, data, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
expect(result.response.status).toBe(200);
});

test("should handle FormData in delete helper", async () => {
const cache = new Cacheable();
const url = `${testUrl}/delete`;
const formData = new FormData();
formData.append("id", "123");

// Since the server might not handle FormData properly, we'll just verify it doesn't crash
try {
const result = await del(url, formData, { cache });
expect(result).toBeDefined();
} catch (error) {
// If server doesn't accept FormData, that's okay - we're testing the client code
expect(error).toBeDefined();
}
});

test("should handle URLSearchParams in delete helper", async () => {
const cache = new Cacheable();
const url = `${testUrl}/delete`;
const params = new URLSearchParams();
params.append("id", "123");

// Since the server might not handle URLSearchParams properly, we'll just verify it doesn't crash
try {
const result = await del(url, params, { cache });
expect(result).toBeDefined();
} catch (error) {
// If server doesn't accept URLSearchParams, that's okay - we're testing the client code
expect(error).toBeDefined();
}
});

test("should handle Blob in delete helper", async () => {
const cache = new Cacheable();
const url = `${testUrl}/delete`;
const blob = new Blob(["test data"], { type: "text/plain" });

// Since the server might not handle Blob properly, we'll just verify it doesn't crash
try {
const result = await del(url, blob, { cache });
expect(result).toBeDefined();
} catch (error) {
// If server doesn't accept Blob, that's okay - we're testing the client code
expect(error).toBeDefined();
}
});
});
Loading