Skip to content

Commit a1a3b1b

Browse files
authored
Merge pull request #8 from DouglasNeuroInformatics/dev
add new functions
2 parents 6f6b25e + a45e8e4 commit a1a3b1b

File tree

10 files changed

+253
-15
lines changed

10 files changed

+253
-15
lines changed

eslint.config.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { config } from '@douglasneuroinformatics/eslint-config';
22

3-
export default config(
4-
{},
5-
{
6-
rules: {
7-
'@typescript-eslint/explicit-function-return-type': 'error'
8-
}
3+
export default config({
4+
typescript: {
5+
enabled: true,
6+
explicitReturnTypes: true
97
}
10-
);
8+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"type-fest": "^4.34.1"
4141
},
4242
"devDependencies": {
43-
"@douglasneuroinformatics/eslint-config": "^5.3.1",
43+
"@douglasneuroinformatics/eslint-config": "^5.3.2",
4444
"@douglasneuroinformatics/prettier-config": "^0.0.1",
4545
"@douglasneuroinformatics/semantic-release": "^0.2.1",
4646
"@douglasneuroinformatics/tsconfig": "^1.0.2",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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/__tests__/result.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Err, err, Ok, ok } from 'neverthrow';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { asyncResultify } from '../result.js';
5+
6+
describe('asyncResultify', () => {
7+
it('should convert a successful Result to ResultAsync', async () => {
8+
const fn = () => Promise.resolve(ok(42));
9+
const result = (await asyncResultify(fn)) as Ok<number, never>;
10+
expect(result.isOk()).toBe(true);
11+
expect(result.value).toBe(42);
12+
});
13+
14+
it('should convert an error Result to ResultAsync', async () => {
15+
const error = new Error('test error');
16+
const fn = () => Promise.resolve(err(error));
17+
const result = (await asyncResultify(fn)) as Err<never, Error>;
18+
expect(result.isErr()).toBe(true);
19+
expect(result.error).toBe(error);
20+
});
21+
22+
it('should handle async operations correctly', async () => {
23+
const fn = async () => {
24+
await new Promise((resolve) => setTimeout(resolve, 10));
25+
return ok('delayed result');
26+
};
27+
const result = (await asyncResultify(fn)) as Ok<string, never>;
28+
expect(result.isOk()).toBe(true);
29+
expect(result.value).toBe('delayed result');
30+
});
31+
});

src/exception.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions */
12
/* eslint-disable @typescript-eslint/no-unsafe-return */
23
/* eslint-disable no-dupe-class-members */
34

@@ -55,7 +56,17 @@ type ExceptionType<
5556
TStaticProps = unknown
5657
> = ExceptionConstructor<TParams, TOptions> & ExceptionStatic<TParams, TOptions> & TStaticProps;
5758

58-
abstract class BaseException<TParams extends ExceptionParams, TOptions extends ExceptionOptions> extends Error {
59+
interface ExceptionLike extends Error, ExceptionOptions {
60+
name: string;
61+
toAsyncErr(): ResultAsync<never, this>;
62+
toErr(): Result<never, this>;
63+
toString(): string;
64+
}
65+
66+
abstract class BaseException<TParams extends ExceptionParams, TOptions extends ExceptionOptions>
67+
extends Error
68+
implements ExceptionLike
69+
{
5970
override cause: TOptions['cause'];
6071
details: TOptions['details'];
6172
abstract override name: TParams['name'];
@@ -206,6 +217,7 @@ export const { ValidationException } = new ExceptionBuilder()
206217
export type {
207218
ExceptionConstructor,
208219
ExceptionConstructorArgs,
220+
ExceptionLike,
209221
ExceptionName,
210222
ExceptionOptions,
211223
ExceptionParams,

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+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
export * from './array.js';
22
export * from './datetime.js';
33
export * from './exception.js';
4+
export * from './http.js';
45
export * from './json.js';
56
export * from './number.js';
67
export * from './object.js';
78
export * from './random.js';
89
export * from './range.js';
10+
export * from './result.js';
911
export * from './string.js';
1012
export * from './types.js';
1113
export * from './zod.js';

src/result.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Result, ResultAsync } from 'neverthrow';
2+
3+
export function asyncResultify<T, E>(fn: () => Promise<Result<T, E>>): ResultAsync<T, E> {
4+
return new ResultAsync(fn());
5+
}

vitest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default defineConfig({
1111
functions: 100,
1212
lines: 100,
1313
statements: 100
14-
}
14+
},
15+
skipFull: true
1516
},
1617
watch: false
1718
}

0 commit comments

Comments
 (0)