Skip to content

Commit 4ba8e4c

Browse files
committed
feat: add http module
1 parent a4c2d79 commit 4ba8e4c

File tree

2 files changed

+189
-0
lines changed

2 files changed

+189
-0
lines changed

src/__tests__/http.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Err } from 'neverthrow';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import * as datetime from '../datetime.js';
5+
import { safeFetch, waitForServer } from '../http.js';
6+
7+
const fetch = vi.hoisted(() => vi.fn());
8+
const networkError = new Error('Network Error');
9+
10+
vi.stubGlobal('fetch', fetch);
11+
12+
afterEach(() => {
13+
vi.resetAllMocks();
14+
});
15+
16+
describe('safeFetch', () => {
17+
it('should return an Ok result when the request succeeds', async () => {
18+
const mockResponse = { ok: true, status: 200, statusText: 'OK' };
19+
fetch.mockResolvedValueOnce(mockResponse);
20+
const result = await safeFetch('http://localhost:6789', { method: 'GET' });
21+
expect(result.isOk()).toBe(true);
22+
if (result.isOk()) {
23+
expect(result.value).toBe(mockResponse);
24+
}
25+
});
26+
27+
it('should return an Err result with HTTP_ERROR when the response is not ok', async () => {
28+
const mockResponse = { ok: false, status: 404, statusText: 'Not Found' };
29+
fetch.mockResolvedValueOnce(mockResponse);
30+
const result = await safeFetch('http://localhost:6789', { method: 'GET' });
31+
expect(result.isErr()).toBe(true);
32+
if (result.isErr()) {
33+
expect(result.error).toMatchObject({
34+
details: {
35+
kind: 'HTTP_ERROR',
36+
status: 404,
37+
statusText: 'Not Found',
38+
url: 'http://localhost:6789'
39+
},
40+
name: 'FetchException'
41+
});
42+
}
43+
});
44+
45+
it('should return an Err result with NETWORK_ERROR when fetch throws', async () => {
46+
fetch.mockRejectedValueOnce(networkError);
47+
const result = await safeFetch('http://localhost:6789', { method: 'GET' });
48+
expect(result.isErr()).toBe(true);
49+
if (result.isErr()) {
50+
expect(result.error).toMatchObject({
51+
cause: networkError,
52+
details: {
53+
kind: 'NETWORK_ERROR',
54+
url: 'http://localhost:6789'
55+
},
56+
name: 'FetchException'
57+
});
58+
}
59+
});
60+
61+
it('should pass through the RequestInit options to fetch', async () => {
62+
const mockResponse = { ok: true, status: 200, statusText: 'OK' };
63+
fetch.mockResolvedValueOnce(mockResponse);
64+
const init = {
65+
body: JSON.stringify({ test: 'data' }),
66+
headers: { 'Content-Type': 'application/json' },
67+
method: 'POST'
68+
};
69+
await safeFetch('http://localhost:6789', init);
70+
expect(fetch).toHaveBeenCalledWith('http://localhost:6789', init);
71+
});
72+
});
73+
74+
describe('waitForServer', () => {
75+
it('should return an Ok result when server becomes available', async () => {
76+
fetch.mockRejectedValueOnce(networkError);
77+
fetch.mockRejectedValueOnce(networkError);
78+
fetch.mockResolvedValueOnce({ ok: true });
79+
const time = Date.now();
80+
const result = await waitForServer('http://localhost:6789', { interval: 0.1, timeout: 0.3 });
81+
expect(result.isOk()).toBe(true);
82+
expect(Date.now() - time).toBeGreaterThanOrEqual(200);
83+
expect(Date.now() - time).toBeLessThan(300);
84+
expect(fetch).toHaveBeenCalledTimes(3);
85+
});
86+
87+
it('should return an Err result after the timeout if the server never becomes available', async () => {
88+
fetch.mockRejectedValue(networkError);
89+
const result = (await waitForServer('http://localhost:6789', { interval: 0.1, timeout: 0.3 })) as Err<void, Error>;
90+
expect(result.error.message).toBe("Failed to connect to 'http://localhost:6789' after timeout of 0.3s");
91+
});
92+
93+
it('should use a default timeout of 10 seconds and interval of 1 second', async () => {
94+
fetch.mockRejectedValue(networkError);
95+
vi.spyOn(datetime, 'sleep').mockResolvedValue();
96+
const result = (await waitForServer('http://localhost:6789')) as Err<void, Error>;
97+
expect(result.error.message).toBe("Failed to connect to 'http://localhost:6789' after timeout of 10s");
98+
expect(fetch).toHaveBeenCalledTimes(10);
99+
});
100+
101+
it('should return an Err result if the HTTP request succeeds, but is not a 200-level response', async () => {
102+
fetch.mockResolvedValueOnce({ ok: false, status: 500 });
103+
const result = (await waitForServer('http://localhost:6789', { interval: 0.1, timeout: 0.3 })) as Err<void, Error>;
104+
expect(result.error.message).toBe("Request to 'http://localhost:6789' returned status code 500");
105+
});
106+
});

src/http.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ok, ResultAsync } from 'neverthrow';
2+
3+
import { sleep } from './datetime.js';
4+
import { ExceptionBuilder, RuntimeException } from './exception.js';
5+
import { asyncResultify } from './result.js';
6+
7+
import type { ExceptionLike } from './exception.js';
8+
9+
type NetworkErrorOptions = {
10+
cause: unknown;
11+
details: {
12+
kind: 'NETWORK_ERROR';
13+
url: string;
14+
};
15+
};
16+
17+
type HttpErrorOptions = {
18+
details: {
19+
kind: 'HTTP_ERROR';
20+
status: number;
21+
statusText: string;
22+
url: string;
23+
};
24+
};
25+
26+
export const { FetchException } = new ExceptionBuilder()
27+
.setOptionsType<HttpErrorOptions | NetworkErrorOptions>()
28+
.setParams({
29+
message: (details) => `HTTP request to '${details.url}' failed`,
30+
name: 'FetchException'
31+
})
32+
.build();
33+
34+
export function safeFetch(url: string, init: RequestInit): ResultAsync<Response, typeof FetchException.Instance> {
35+
return asyncResultify(async () => {
36+
try {
37+
const response = await fetch(url, init);
38+
if (response.ok) {
39+
return ok(response);
40+
}
41+
return FetchException.asErr({
42+
details: {
43+
kind: 'HTTP_ERROR',
44+
status: response.status,
45+
statusText: response.statusText,
46+
url
47+
}
48+
});
49+
} catch (err) {
50+
return FetchException.asErr({
51+
cause: err,
52+
details: {
53+
kind: 'NETWORK_ERROR',
54+
url
55+
}
56+
});
57+
}
58+
});
59+
}
60+
61+
export function waitForServer(
62+
url: string,
63+
{ interval = 1, timeout = 10 }: { interval?: number; timeout?: number } = {}
64+
): ResultAsync<void, ExceptionLike> {
65+
return asyncResultify(async () => {
66+
let secondsElapsed = 0;
67+
while (secondsElapsed < timeout) {
68+
const fetchResult = await safeFetch(url, { method: 'GET' });
69+
if (fetchResult.isOk()) {
70+
return ok();
71+
}
72+
const { error } = fetchResult;
73+
if (error.details.kind === 'HTTP_ERROR') {
74+
return RuntimeException.asErr(`Request to '${url}' returned status code ${error.details.status}`, {
75+
cause: fetchResult.error
76+
});
77+
}
78+
await sleep(interval);
79+
secondsElapsed += interval;
80+
}
81+
return RuntimeException.asErr(`Failed to connect to '${url}' after timeout of ${timeout}s`);
82+
});
83+
}

0 commit comments

Comments
 (0)