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
43 changes: 40 additions & 3 deletions packages/net/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,24 @@ export async function fetch(
}) as UndiciResponse;
}

export type GetResponse<T = unknown> = {
export type DataResponse<T = unknown> = {
data: T;
response: UndiciResponse;
};

// Keep GetResponse as an alias for backward compatibility
export type GetResponse<T = unknown> = DataResponse<T>;

/**
* Perform a GET request to a URL with optional request options.
* @param {string} url The URL to fetch.
* @param {Omit<FetchOptions, 'method'>} options Optional request options. The `cache` property is required.
* @returns {Promise<GetResponse<T>>} The typed data and response from the fetch.
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
export async function get<T = unknown>(
url: string,
options: Omit<FetchOptions, "method">,
): Promise<GetResponse<T>> {
): Promise<DataResponse<T>> {
const response = await fetch(url, { ...options, method: "GET" });
const text = await response.text();
let data: T;
Expand All @@ -102,5 +105,39 @@ export async function get<T = unknown>(
};
}

/**
* Perform a POST request to a URL with optional request options.
* @param {string} url The URL to fetch.
* @param {Omit<FetchOptions, 'method'>} options Optional request options. The `cache` property is required.
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
export async function post<T = unknown>(
url: string,
options: Omit<FetchOptions, "method">,
): Promise<DataResponse<T>> {
const response = await fetch(url, { ...options, method: "POST" });
const text = await response.text();
let data: T;

try {
data = JSON.parse(text) as T;
} catch {
// If not JSON, return as is
data = 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,
response: newResponse,
};
}

export type Response = UndiciResponse;
export type { RequestInit as FetchRequestInit } from "undici";
42 changes: 39 additions & 3 deletions packages/net/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Cacheable, type CacheableOptions } from "cacheable";
import { Hookified, type HookifiedOptions } from "hookified";
import {
type DataResponse,
type FetchOptions,
type FetchRequestInit,
type Response as FetchResponse,
fetch,
type GetResponse,
} from "./fetch.js";

export type CacheableNetOptions = {
Expand Down Expand Up @@ -56,12 +56,12 @@ export class CacheableNet extends Hookified {
* Perform a GET request to a URL with optional request options. Will use the cache that is already set in the instance.
* @param {string} url The URL to fetch.
* @param {Omit<FetchRequestInit, 'method'>} options Optional request options (method will be set to GET).
* @returns {Promise<GetResponse<T>>} The typed data and response from the fetch.
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
public async get<T = unknown>(
url: string,
options?: Omit<FetchRequestInit, "method">,
): Promise<GetResponse<T>> {
): Promise<DataResponse<T>> {
const response = await this.fetch(url, { ...options, method: "GET" });
const text = await response.text();
let data: T;
Expand All @@ -85,14 +85,50 @@ export class CacheableNet extends Hookified {
response: newResponse,
};
}

/**
* Perform a POST request to a URL with optional request options. Will use the cache that is already set in the instance.
* @param {string} url The URL to fetch.
* @param {Omit<FetchRequestInit, 'method'>} options Optional request options (method will be set to POST).
* @returns {Promise<DataResponse<T>>} The typed data and response from the fetch.
*/
public async post<T = unknown>(
url: string,
options?: Omit<FetchRequestInit, "method">,
): Promise<DataResponse<T>> {
const response = await this.fetch(url, { ...options, method: "POST" });
const text = await response.text();
let data: T;

try {
data = JSON.parse(text) as T;
} catch {
// If not JSON, return as is
data = 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,
response: newResponse,
};
}
}

export const Net = CacheableNet;
export {
type DataResponse,
type FetchOptions,
type FetchRequestInit,
fetch,
type GetResponse,
get,
post,
type Response as FetchResponse,
} from "./fetch.js";
70 changes: 69 additions & 1 deletion packages/net/test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import process from "node:process";
import { Cacheable } from "cacheable";
import { describe, expect, test } from "vitest";
import { type FetchOptions, fetch, get } from "../src/fetch.js";
import { type FetchOptions, fetch, get, post } from "../src/fetch.js";

const testUrl = process.env.TEST_URL ?? "https://mockhttp.org";
const testTimeout = 10_000; // 10 seconds
Expand Down Expand Up @@ -134,4 +134,72 @@ describe("Fetch", () => {
},
testTimeout,
);

test(
"should fetch data using post helper",
async () => {
const url = `${testUrl}/post`;
const options = {
cache: new Cacheable(),
body: JSON.stringify({ test: "data" }),
headers: {
"Content-Type": "application/json",
},
};
const result = await post(url, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
expect(result.response.status).toBe(200);
},
testTimeout,
);

test(
"should cache data using post helper",
async () => {
const cache = new Cacheable({ stats: true });
const url = `${testUrl}/post`;
const options = {
cache,
body: JSON.stringify({ test: "data" }),
headers: {
"Content-Type": "application/json",
},
};
const result1 = await post(url, options);
const result2 = await post(url, options);
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(cache.stats).toBeDefined();
expect(cache.stats.hits).toBe(1);
// Verify that both responses have the same data
expect(result1.data).toEqual(result2.data);
// 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 post helper",
async () => {
const cache = new Cacheable();
// Use httpbin's status endpoint that accepts POST and returns non-JSON
const url = "https://httpbin.org/status/201";
const options = {
cache,
body: "test data",
};
const result = await post(url, options);
expect(result).toBeDefined();
// Status endpoint returns empty body, which will be parsed as empty string
expect(result.data).toBe("");
expect(typeof result.data).toBe("string");
expect(result.response).toBeDefined();
expect(result.response.status).toBe(201);
},
testTimeout,
);
});
87 changes: 87 additions & 0 deletions packages/net/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
fetch,
get,
Net,
post,
} from "../src/index.js";

const testUrl = process.env.TEST_URL ?? "https://mockhttp.org";
Expand Down Expand Up @@ -147,4 +148,90 @@ describe("Cacheable Net", () => {
},
testTimeout,
);

test(
"should fetch data using CacheableNet post method",
async () => {
const net = new Net();
const url = `${testUrl}/post`;
const options = {
body: JSON.stringify({ test: "data" }),
headers: {
"Content-Type": "application/json",
},
};
const result = await net.post(url, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
expect(result.response.status).toBe(200);
},
testTimeout,
);

test(
"should fetch data using standalone post function",
async () => {
const url = `${testUrl}/post`;
const options = {
cache: new Cacheable(),
body: JSON.stringify({ test: "data" }),
headers: {
"Content-Type": "application/json",
},
};
const result = await post(url, options);
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
expect(result.response.status).toBe(200);
},
testTimeout,
);

test(
"should handle non-JSON response in CacheableNet post method",
async () => {
const net = new Net();
// Use httpbin's status endpoint that returns non-JSON
const url = "https://httpbin.org/status/201";
const options = {
body: "test data",
};
const result = await net.post(url, options);
expect(result).toBeDefined();
// Status endpoint returns empty body
expect(result.data).toBe("");
expect(typeof result.data).toBe("string");
expect(result.response).toBeDefined();
expect(result.response.status).toBe(201);
},
testTimeout,
);

test(
"should fetch typed data using post with generics",
async () => {
interface PostResponse {
method: string;
data: string;
}
const net = new Net();
const url = `${testUrl}/post`;
const result = await net.post<PostResponse>(url, {
body: JSON.stringify({ test: "data" }),
headers: {
"Content-Type": "application/json",
},
});
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.response).toBeDefined();
// TypeScript will ensure result.data has the PostResponse type
if (typeof result.data === "object" && result.data !== null) {
expect(result.data).toHaveProperty("method");
}
},
testTimeout,
);
});