🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@slango/ristretto

Package Overview
Dependencies
Maintainers
1
Versions
54
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@slango/ristretto - npm Package Compare versions

Comparing version
1.0.52
to
1.0.53
+8
-5
package.json
{
"name": "@slango/ristretto",
"version": "1.0.52",
"version": "1.0.53",
"private": false,

@@ -12,2 +12,5 @@ "description": "Opinionated rest client",

"type": "module",
"files": [
"dist"
],
"main": "./dist/index.js",

@@ -26,3 +29,3 @@ "types": "./dist/index.d.ts",

"dependencies": {
"@slango/tessera": "1.0.29"
"@slango/tessera": "1.0.30"
},

@@ -37,5 +40,5 @@ "devDependencies": {

"vitest": "4.1.10",
"@slango.configs/eslint": "1.2.32",
"@slango.configs/vitest": "2.0.41",
"@slango.configs/typescript": "2.0.9"
"@slango.configs/eslint": "1.2.33",
"@slango.configs/typescript": "2.0.10",
"@slango.configs/vitest": "2.0.42"
},

@@ -42,0 +45,0 @@ "scripts": {

# @slango/ristretto
## 1.0.52
### Patch Changes
- 7d321c6: Dependencies bump
- Updated dependencies [7d321c6]
- @slango/tessera@1.0.29
## 1.0.51
### Patch Changes
- 83f12c6: Dependencies bump
- @slango/tessera@1.0.28
## 1.0.50
### Patch Changes
- 463d9ce: Dependencies bump
- Updated dependencies [463d9ce]
- @slango/tessera@1.0.28
## 1.0.49
### Patch Changes
- 6d64ea9: Dependencies bump
## 1.0.48
### Patch Changes
- 02b8cd1: Dependencies bump
- Updated dependencies [02b8cd1]
- @slango/tessera@1.0.27
## 1.0.47
### Patch Changes
- f59dae8: Dependencies bump
- @slango/tessera@1.0.26
## 1.0.46
### Patch Changes
- 75b71f7: Dependencies bump
- Updated dependencies [75b71f7]
- @slango/tessera@1.0.26
## 1.0.45
### Patch Changes
- 7b9def8: Dependencies bump
- Updated dependencies [7b9def8]
- @slango/tessera@1.0.25
## 1.0.44
### Patch Changes
- dfd3015: Dependencies bump
- @slango/tessera@1.0.24
## 1.0.43
### Patch Changes
- 1052e1d: Dependencies bump
## 1.0.42
### Patch Changes
- 59effbe: Dependencies bump
- Updated dependencies [59effbe]
- @slango/tessera@1.0.24
## 1.0.41
### Patch Changes
- 3e79aa6: Dependencies bump
- Updated dependencies [3e79aa6]
- @slango/tessera@1.0.23
## 1.0.40
### Patch Changes
- 70230d2: Dependencies bump
- Updated dependencies [70230d2]
- @slango/tessera@1.0.22
## 1.0.39
### Patch Changes
- f4e8645: Dependencies bump
- Updated dependencies [f4e8645]
- @slango/tessera@1.0.21
## 1.0.38
### Patch Changes
- e80d5ac: Dependencies bump
- Updated dependencies [e80d5ac]
- @slango/tessera@1.0.20
## 1.0.37
### Patch Changes
- a692cf9: Dependencies bump
- Updated dependencies [a692cf9]
- @slango/tessera@1.0.19
## 1.0.36
### Patch Changes
- dca0a26: Dependencies bump
- Updated dependencies [dca0a26]
- @slango/tessera@1.0.18
## 1.0.35
### Patch Changes
- df2e3c2: Dependencies bump
- Updated dependencies [df2e3c2]
- @slango/tessera@1.0.17
## 1.0.34
### Patch Changes
- 6ab8613: Dependencies bump
- Updated dependencies [6ab8613]
- @slango/tessera@1.0.16
## 1.0.33
### Patch Changes
- 07ec4aa: Dependencies bump
- Updated dependencies [07ec4aa]
- @slango/tessera@1.0.15
## 1.0.32
### Patch Changes
- 57f6c46: Dependencies bump
- Updated dependencies [57f6c46]
- @slango/tessera@1.0.14
## 1.0.31
### Patch Changes
- 4ba0ccb: Dependencies bump
- Updated dependencies [4ba0ccb]
- @slango/tessera@1.0.13
## 1.0.30
### Patch Changes
- 09ac7a7: Dependencies bump
- Updated dependencies [09ac7a7]
- @slango/tessera@1.0.12
## 1.0.29
### Patch Changes
- ad32208: Dependencies bump
- Updated dependencies [ad32208]
- @slango/tessera@1.0.11
## 1.0.28
### Patch Changes
- 35aaf02: Dependencies bump
- @slango/tessera@1.0.10
## 1.0.27
### Patch Changes
- fafa8e1: Dependencies bump
- Updated dependencies [fafa8e1]
- @slango/tessera@1.0.10
## 1.0.26
### Patch Changes
- ec2131e: Dependencies bump
- @slango/tessera@1.0.9
## 1.0.25
### Patch Changes
- ebe4053: Dependencies bump
- @slango/tessera@1.0.9
## 1.0.24
### Patch Changes
- 4010cfc: Dependencies bump
- @slango/tessera@1.0.9
## 1.0.23
### Patch Changes
- 8ee771f: Dependencies bump
- @slango/tessera@1.0.9
## 1.0.22
### Patch Changes
- 1731369: Dependencies bump
- @slango/tessera@1.0.9
## 1.0.21
### Patch Changes
- 3ceebbb: Dependencies bump
- Updated dependencies [3ceebbb]
- @slango/tessera@1.0.9
## 1.0.20
### Patch Changes
- 2ab1c4d: Dependencies bump
- @slango/tessera@1.0.8
## 1.0.19
### Patch Changes
- 9f290cf: Dependencies bump
- @slango/tessera@1.0.8
## 1.0.18
### Patch Changes
- 4e2e552: Dependencies bump
- Updated dependencies [4e2e552]
- @slango/tessera@1.0.8
## 1.0.17
### Patch Changes
- b6cdd05: Dependencies bump
- Updated dependencies [b6cdd05]
- @slango/tessera@1.0.7
## 1.0.16
### Patch Changes
- 7ceb0df: Dependencies bump
- @slango/tessera@1.0.6
## 1.0.15
### Patch Changes
- 86530f1: Dependencies bump
- Updated dependencies [86530f1]
- @slango/tessera@1.0.6
## 1.0.14
### Patch Changes
- 8519268: Dependencies bump
- Updated dependencies [8519268]
- @slango/tessera@1.0.5
## 1.0.13
### Patch Changes
- 5bcda82: Dependencies bump
- Updated dependencies [5bcda82]
- @slango/tessera@1.0.4
## 1.0.12
### Patch Changes
- cc43aa2: Dependencies bump
- @slango/tessera@1.0.3
## 1.0.11
### Patch Changes
- a661329: Dependencies bump
## 1.0.10
### Patch Changes
- 72ffa91: Dependnecies bump and eslint presets normalization
- @slango/tessera@1.0.3
## 1.0.9
### Patch Changes
- fb024e5: Dependencies bump
- Updated dependencies [fb024e5]
- @slango/tessera@1.0.3
## 1.0.8
### Patch Changes
- 7d6305d: Dependencies bump
- Updated dependencies [7d6305d]
- @slango/tessera@1.0.2
## 1.0.7
### Patch Changes
- 60f7e37: Dependencies bump
- @slango/tessera@1.0.1
## 1.0.6
### Patch Changes
- 87e2b79: Dependencies bump
- Updated dependencies [87e2b79]
- @slango/tessera@1.0.1
## 1.0.5
### Patch Changes
- 7912c01: Dependencies bump
## 1.0.4
### Patch Changes
- 0f0e49c: Dependencies bump
## 1.0.3
### Patch Changes
- c73ab09: Dependencies bump
- @slango/tessera@1.0.0
## 1.0.2
### Patch Changes
- 4016ce6: withBaseUrl helper
## 1.0.1
### Patch Changes
- Updated dependencies [9d73d40]
- @slango/tessera@1.0.0
## 1.0.0
### Major Changes
- 759bc27: First version (renamed from @slango/rest-client)
export { default } from '@slango.configs/eslint/typescript-isomorphic.js';
export { default } from '@slango.configs/lint-staged/typescript.js';
import { AbortablePromise, abortableRequest } from '../utils/abortableRequest.js';
import { HttpMethod } from '../utils/httpMethod.js';
import { Request as BaseRequest, RequestOptionsWithoutUrl, RequestURL } from '../utils/request.js';
export type { AbortablePromise } from '../utils/abortableRequest.js';
export { withBearerToken } from '../utils/auth.js';
export type { BaseURL } from '../utils/baseUrl.js';
export { withBaseUrl } from '../utils/baseUrl.js';
export { withFormDataBody, withJsonBody } from '../utils/body.js';
export { HttpError } from '../utils/HttpError.js';
export { withQueryParams } from '../utils/queryParams.js';
export type { RequestOptionsWithoutUrl, RequestURL } from '../utils/request.js';
export { withNotFoundAsNull } from '../utils/status.js';
export type Request<Response> = BaseRequest<Response, AbortablePromise<Response>>;
const abortableRequestFactory =
(method: HttpMethod) =>
<Response>(url: RequestURL) =>
(options?: RequestOptionsWithoutUrl): AbortablePromise<Response> =>
abortableRequest<Response>(method, { ...options, url });
export const get = abortableRequestFactory(HttpMethod.GET);
export const del = abortableRequestFactory(HttpMethod.DELETE);
export const post = abortableRequestFactory(HttpMethod.POST);
export const put = abortableRequestFactory(HttpMethod.PUT);
export const patch = abortableRequestFactory(HttpMethod.PATCH);
export const withTransformer =
<Input, Output>(transformFn: (input: Input) => Output) =>
(req: Request<Input>): Request<Output> =>
(options?: RequestOptionsWithoutUrl) => {
const abortablePromise = req(options);
const wrappedPromise = abortablePromise.then((result: Input) =>
transformFn ? transformFn(result) : result,
) as AbortablePromise<Output>;
if ('abort' in abortablePromise) {
// Assign the abort function if it's an AbortablePromise
wrappedPromise.abort = abortablePromise.abort;
}
return wrappedPromise;
};
import { HttpMethod } from './utils/httpMethod.js';
import {
Request as BaseRequest,
request,
RequestOptionsWithoutUrl,
RequestURL,
} from './utils/request.js';
export { withBearerToken } from './utils/auth.js';
export type { BaseURL } from './utils/baseUrl.js';
export { withBaseUrl } from './utils/baseUrl.js';
export { withFormDataBody, withJsonBody } from './utils/body.js';
export { HttpError } from './utils/HttpError.js';
export { withQueryParams } from './utils/queryParams.js';
export type { RequestOptionsWithoutUrl, RequestURL } from './utils/request.js';
export { withNotFoundAsNull } from './utils/status.js';
export type Request<Response> = BaseRequest<Response, Promise<Response>>;
const requestFactory =
(method: HttpMethod) =>
<Response>(url: RequestURL): Request<Response> =>
(options?: RequestOptionsWithoutUrl): Promise<Response> =>
request<Response>(method, { ...options, url });
export const get = requestFactory(HttpMethod.GET);
export const del = requestFactory(HttpMethod.DELETE);
export const post = requestFactory(HttpMethod.POST);
export const put = requestFactory(HttpMethod.PUT);
export const patch = requestFactory(HttpMethod.PATCH);
import { Promisable } from 'type-fest';
import { RequestOptions } from '../utils/request.js';
export type PreRequestMiddlewareContext = {
request: RequestOptions;
};
export type PostResponseMiddlewareContext = PreRequestMiddlewareContext & {
response: Response;
};
export type MiddlewareNext = (error?: Error) => Promisable<void>;
export type Middleware<Context> = (ctx: Context, next: MiddlewareNext) => Promisable<void>;
export {
addPostResponseMiddleware as usePostResponse,
addPreRequestMiddleware as usePreRequest,
} from './runner.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
MiddlewareNext,
PostResponseMiddlewareContext,
PreRequestMiddlewareContext,
} from './index.js';
import {
addPostResponseMiddleware,
addPreRequestMiddleware,
clearPostResponseMiddlewares,
clearPreRequestMiddlewares,
removePostResponseMiddleware,
removePreRequestMiddleware,
runPostResponse,
runPreRequest,
} from './runner.js';
const createPreRequestContext = (): PreRequestMiddlewareContext => ({
request: { url: 'https://example.com', method: 'GET', headers: {} },
});
const createPostResponseContext = (): PostResponseMiddlewareContext => ({
request: { url: 'https://example.com', method: 'GET', headers: {} },
response: new Response(null, { status: 200 }),
});
describe('runner.ts', () => {
beforeEach(() => {
clearPostResponseMiddlewares();
clearPreRequestMiddlewares();
});
it('should add and remove pre-request middleware', async () => {
const middleware1 = vi.fn((ctx, next: MiddlewareNext) => next());
const middleware2 = vi.fn();
addPreRequestMiddleware(middleware1);
addPreRequestMiddleware(middleware2);
await runPreRequest(createPreRequestContext());
expect(middleware1).toHaveBeenCalledTimes(1);
expect(middleware2).toHaveBeenCalledTimes(1);
removePreRequestMiddleware(middleware2);
await runPreRequest(createPreRequestContext());
expect(middleware1).toHaveBeenCalledTimes(2);
expect(middleware2).toHaveBeenCalledTimes(1);
});
it('should add and remove post-request middleware', async () => {
const middleware1 = vi.fn((ctx, next: MiddlewareNext) => next());
const middleware2 = vi.fn();
addPostResponseMiddleware(middleware1);
addPostResponseMiddleware(middleware2);
await runPostResponse(createPostResponseContext());
expect(middleware1).toHaveBeenCalledTimes(1);
expect(middleware2).toHaveBeenCalledTimes(1);
removePostResponseMiddleware(middleware2);
await runPostResponse(createPostResponseContext());
expect(middleware1).toHaveBeenCalledTimes(2);
expect(middleware2).toHaveBeenCalledTimes(1);
});
it('should run pre-request middlewares in order and modify context', async () => {
const middleware1 = vi.fn(async (ctx: PreRequestMiddlewareContext, next: MiddlewareNext) => {
(ctx.request.headers! as Record<string, string>)['X-Test'] = 'Middleware1';
(ctx.request.headers! as Record<string, string>)['X-Override'] = 'Middleware1';
await next();
});
const middleware2 = vi.fn(async (ctx: PreRequestMiddlewareContext, next: MiddlewareNext) => {
(ctx.request.headers! as Record<string, string>)['X-Chain'] = 'Middleware2';
(ctx.request.headers! as Record<string, string>)['X-Override'] = 'Middleware2';
await next();
});
addPreRequestMiddleware(middleware1);
addPreRequestMiddleware(middleware2);
const context = createPreRequestContext();
await runPreRequest(context);
expect(middleware1).toHaveBeenCalledOnce();
expect(middleware2).toHaveBeenCalledOnce();
expect(context.request.headers).toEqual({
'X-Test': 'Middleware1',
'X-Chain': 'Middleware2',
'X-Override': 'Middleware2',
});
});
it('should run post-response middlewares in order and modify context', async () => {
const middleware1 = vi.fn(async (ctx: PostResponseMiddlewareContext, next: MiddlewareNext) => {
ctx.response.headers.set('X-Test', 'Middleware1');
ctx.response.headers.set('X-Override', 'Middleware1');
await next();
});
const middleware2 = vi.fn(async (ctx: PostResponseMiddlewareContext, next: MiddlewareNext) => {
ctx.response.headers.set('X-Chain', 'Middleware2');
ctx.response.headers.set('X-Override', 'Middleware2');
await next();
});
addPostResponseMiddleware(middleware1);
addPostResponseMiddleware(middleware2);
const context = createPostResponseContext();
await runPostResponse(context);
expect(middleware1).toHaveBeenCalledOnce();
expect(middleware2).toHaveBeenCalledOnce();
expect(context.response.headers.get('X-Test')).toBe('Middleware1');
expect(context.response.headers.get('X-Chain')).toBe('Middleware2');
expect(context.response.headers.get('X-Override')).toBe('Middleware2');
});
it('should handle errors in pre-request middlewares and stop pipeline', async () => {
const middleware1 = vi.fn(async (ctx: PreRequestMiddlewareContext, next: MiddlewareNext) => {
await next();
});
const errorMiddleware = vi.fn(() => {
throw new Error('Test error');
});
const middleware2 = vi.fn(async (ctx: PreRequestMiddlewareContext, next: MiddlewareNext) => {
await next();
});
addPreRequestMiddleware(middleware1);
addPreRequestMiddleware(errorMiddleware);
addPreRequestMiddleware(middleware2);
const context = createPreRequestContext();
await expect(runPreRequest(context)).rejects.toThrow('Test error');
expect(middleware1).toHaveBeenCalledOnce();
expect(errorMiddleware).toHaveBeenCalledOnce();
expect(middleware2).not.toHaveBeenCalled();
removePreRequestMiddleware(middleware1);
removePreRequestMiddleware(errorMiddleware);
removePreRequestMiddleware(middleware2);
});
it('should handle errors in post-response middlewares and stop pipeline', async () => {
const middleware1 = vi.fn(async (ctx: PostResponseMiddlewareContext, next: MiddlewareNext) => {
await next();
});
const errorMiddleware = vi.fn(() => {
throw new Error('Test error');
});
const middleware2 = vi.fn(async (ctx: PostResponseMiddlewareContext, next: MiddlewareNext) => {
await next();
});
addPostResponseMiddleware(middleware1);
addPostResponseMiddleware(errorMiddleware);
addPostResponseMiddleware(middleware2);
const context = createPostResponseContext();
await expect(runPostResponse(context)).rejects.toThrow('Test error');
expect(middleware1).toHaveBeenCalledOnce();
expect(errorMiddleware).toHaveBeenCalledOnce();
expect(middleware2).not.toHaveBeenCalled();
removePostResponseMiddleware(middleware1);
removePostResponseMiddleware(errorMiddleware);
removePostResponseMiddleware(middleware2);
});
});
import {
Middleware,
MiddlewareNext,
PostResponseMiddlewareContext,
PreRequestMiddlewareContext,
} from './index.js';
export type PostResponseMiddleWare = Middleware<PostResponseMiddlewareContext>;
export type PreRequestMiddleWare = Middleware<PreRequestMiddlewareContext>;
const preRequestMiddlewares: PreRequestMiddleWare[] = [];
const postResponseMiddlewares: PostResponseMiddleWare[] = [];
export const addPreRequestMiddleware = (middleware: PreRequestMiddleWare) => {
preRequestMiddlewares.push(middleware);
};
export const addPostResponseMiddleware = (middleware: PostResponseMiddleWare) => {
postResponseMiddlewares.push(middleware);
};
export const removePreRequestMiddleware = (middleware: PreRequestMiddleWare) => {
const index = preRequestMiddlewares.findIndex((m) => m === middleware);
if (index !== -1) preRequestMiddlewares.splice(index, 1);
};
export const removePostResponseMiddleware = (middleware: PostResponseMiddleWare) => {
const index = postResponseMiddlewares.findIndex((m) => m === middleware);
if (index !== -1) postResponseMiddlewares.splice(index, 1);
};
export const clearPreRequestMiddlewares = () => {
preRequestMiddlewares.length = 0;
};
export const clearPostResponseMiddlewares = () => {
postResponseMiddlewares.length = 0;
};
const runMiddlewares = <Context>(middlewares: Middleware<Context>[]) => {
return async (context: Context): Promise<void> => {
let index = 0;
const next: MiddlewareNext = async (error?: Error) => {
if (error) {
throw error;
}
if (index < middlewares.length) {
const currentMiddleware = middlewares[index];
index++;
try {
await currentMiddleware(context, next);
} catch (err) {
await next(err as Error);
}
}
};
await next();
};
};
export const runPreRequest = runMiddlewares(preRequestMiddlewares);
export const runPostResponse = runMiddlewares(postResponseMiddlewares);
import { afterEach, describe, expect, it, vi } from 'vitest';
import { abortableRequest } from './abortableRequest.js';
import { HttpMethod } from './httpMethod.js';
import { request, RequestOptions } from './request.js';
vi.mock('./request', () => ({
request: vi.fn(),
}));
const requestMock = vi.mocked(request);
afterEach(() => {
vi.clearAllMocks();
});
describe('abortableRequest', () => {
it('should create a request with the given method and options', async () => {
const method = HttpMethod.GET;
const options: RequestOptions = {
headers: { 'Content-Type': 'application/json' },
url: '/api/test',
};
const mockResponse = Promise.resolve('response');
requestMock.mockReturnValue(mockResponse);
const result = abortableRequest(method, options);
expect(request).toHaveBeenCalledWith(method, {
...options,
signal: expect.any(AbortSignal) as AbortSignal,
});
await expect(result).resolves.toBe('response');
});
it('should abort the request when abort is called', async () => {
const method = HttpMethod.POST;
const options: RequestOptions = { url: '/api/test' };
const mockedResponse = new Promise((_, reject) => {
setTimeout(() => reject(new DOMException('Aborted', 'AbortError')));
});
requestMock.mockReturnValueOnce(mockedResponse);
const abortable = abortableRequest(method, options);
abortable.abort();
await expect(abortable).rejects.toThrowError('Aborted');
});
});
import { HttpMethod } from './httpMethod.js';
import { request, RequestOptions } from './request.js';
export interface AbortablePromise<Response> extends Promise<Response> {
abort: () => void;
}
export const abortableRequest = <Response>(
method: HttpMethod,
options: RequestOptions,
): AbortablePromise<Response> => {
const controller = new AbortController();
const signal = controller.signal;
const r = request(method, {
signal,
...options,
}) as AbortablePromise<Response>;
r.abort = () => controller.abort();
return r;
};
import { afterEach, describe, expect, it, vi } from 'vitest';
import { get, post, put } from '../index.js';
import { withBearerToken } from './auth.js';
import { request } from './request.js';
vi.mock('./request.js', () => ({
request: vi.fn(),
}));
const requestMock = vi.mocked(request);
const getRequest = get('/api/data/get');
const postRequest = post('/api/data/post');
const putRequest = put('/api/data/put');
afterEach(() => {
vi.clearAllMocks();
});
describe('withBearerToken', () => {
it('should add the Authorization header with the provided bearer token', async () => {
const bearerToken = 'test-token';
await withBearerToken(bearerToken)(getRequest)();
const requestHeaders = requestMock.mock.calls[0][1].headers!;
expect(requestHeaders).toEqual({
Authorization: `Bearer ${bearerToken}`,
});
});
it('should merge the Authorization header with existing headers', async () => {
const bearerToken = 'test-token';
await withBearerToken(bearerToken)(postRequest)({
headers: { 'X-Test': 'Value' },
});
const requestHeaders = requestMock.mock.calls[0][1].headers!;
expect(requestHeaders).toEqual({
Authorization: `Bearer ${bearerToken}`,
'X-Test': 'Value',
});
});
it('should override any existing Authorization header with the provided bearer token', async () => {
const bearerToken = 'new-token';
await withBearerToken(bearerToken)(putRequest)({
headers: {
Authorization: `Bearer Test Test`,
},
});
const requestHeaders = requestMock.mock.calls[0][1].headers!;
expect(requestHeaders).toEqual({
Authorization: `Bearer ${bearerToken}`,
});
});
});
import { AbortablePromise } from './abortableRequest.js';
import { Request, RequestOptionsWithoutUrl } from './request.js';
export const withBearerToken =
(bearerToken: string) =>
<Response, PromiseType extends AbortablePromise<Response> | Promise<Response>>(
req: Request<Response, PromiseType>,
) =>
(options: Omit<RequestOptionsWithoutUrl, 'body'> = {}) =>
req({
...options,
headers: {
...options.headers,
Authorization: `Bearer ${bearerToken}`,
},
});
type AbsoluteURLString = `${Lowercase<'http' | 'https'>}://${string}`;
export type BaseURL = AbsoluteURLString | URL;
export const getDefaultBaseUrl = (): string => {
const baseUrl =
typeof window === 'undefined' ? process.env.BASE_URL || '' : window.location.origin;
if (!baseUrl) {
throw new Error('BASE_URL is not defined');
}
return baseUrl;
};
// Helper to preset a base URL on requests.
export { withBaseUrl } from './withBaseUrl.js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { patch, post, put } from '../index.js';
import { JsonObjectWithFile, withFormDataBody, withJsonBody } from './body.js';
import { HttpMethod } from './httpMethod.js';
import { request } from './request.js';
vi.mock('./request.js', () => ({
request: vi.fn(),
}));
const requestMock = vi.mocked(request);
const postRequest = post('/api/data/post');
const putRequest = put('/api/data/put');
const patchRequest = patch('/api/data/patch');
afterEach(() => {
vi.clearAllMocks();
});
const formDataToObject = (formData: FormData) => {
const obj: JsonObjectWithFile = {};
for (const [key, value] of formData.entries()) {
if (obj[key]) {
obj[key] = Array.isArray(obj[key]) ? [...obj[key], value] : [obj[key], value];
} else {
obj[key] = value;
}
}
return obj;
};
describe('withJsonBody', () => {
it('should set JSON body and content-type header', async () => {
const jsonBody = { key: 'value' };
await withJsonBody(jsonBody)(postRequest)();
const requestBody = requestMock.mock.calls[0][1].body as string;
expect(requestBody).toBe(JSON.stringify(jsonBody));
});
it('should merge existing headers', async () => {
const jsonBody = { key: 'value' };
await withJsonBody(jsonBody)(putRequest)({ headers: { Authorization: 'Bearer token' } });
expect(requestMock).toHaveBeenCalledWith(HttpMethod.PUT, {
body: JSON.stringify(jsonBody),
headers: {
Authorization: 'Bearer token',
'content-type': 'application/json',
},
url: '/api/data/put',
});
});
});
describe('withFormDataBody', () => {
it('should set FormData body with simple key-value pairs', async () => {
const body = { key: 'value' };
await withFormDataBody(body)(patchRequest)();
const requestBody = requestMock.mock.calls[0][1].body as FormData;
const formData = new FormData();
formData.append('key', 'value');
expect(formDataToObject(requestBody)).toEqual(formDataToObject(formData));
});
it('should handle nested objects in FormData', async () => {
const body = { user: { age: 25, name: 'Alice' } };
await withFormDataBody(body)(postRequest)();
const requestBody = requestMock.mock.calls[0][1].body as FormData;
const formData = new FormData();
formData.append('user[name]', 'Alice');
formData.append('user[age]', '25');
expect(formDataToObject(requestBody)).toEqual(formDataToObject(formData));
});
it('should handle arrays in FormData', async () => {
const body = { tags: ['tag1', 'tag2'] };
await withFormDataBody(body)(putRequest)();
const requestBody = requestMock.mock.calls[0][1].body as FormData;
const formData = new FormData();
formData.append('tags[0]', 'tag1');
formData.append('tags[1]', 'tag2');
expect(formDataToObject(requestBody)).toEqual(formDataToObject(formData));
});
it('should handle File and Blob objects in FormData', async () => {
const file = new File(['file contents'], 'test.txt');
const body = { file };
await withFormDataBody(body)(patchRequest)();
const requestBody = requestMock.mock.calls[0][1].body as FormData;
const formData = new FormData();
formData.append('file', file);
expect(formDataToObject(requestBody)).toEqual(formDataToObject(formData));
});
it('should merge existing headers', async () => {
const jsonBody = { key: 'value' };
await withFormDataBody(jsonBody)(putRequest)({ headers: { Authorization: 'Bearer token' } });
const requestBody = requestMock.mock.calls[0][1].body as FormData;
const formData = new FormData();
formData.append('key', 'value');
expect(requestMock).toHaveBeenCalledWith(HttpMethod.PUT, {
body: expect.any(FormData) as FormData,
headers: {
Authorization: 'Bearer token',
},
url: '/api/data/put',
});
expect(formDataToObject(requestBody)).toEqual(formDataToObject(formData));
});
});
import { JsonPrimitive as BaseJsonPrimitive, JsonObject, JsonValue } from 'type-fest';
import { AbortablePromise } from './abortableRequest.js';
import { Request, RequestOptionsWithoutUrl } from './request.js';
// undefined is not allowed in JsonObjectWithFile (because it is not allowed in Json)
export type JsonObjectWithFile = {
[Key in string]: JsonValueWithFile;
} & {
[Key in string]?: JsonValueWithFile;
};
type JsonArrayWithFile = JsonValueWithFile[] | readonly JsonValueWithFile[];
type JsonPrimitiveWithFile = BaseJsonPrimitive | Blob | File;
type JsonValueWithFile = JsonArrayWithFile | JsonObjectWithFile | JsonPrimitiveWithFile;
const isJsonObject = (value: unknown): value is JsonObject =>
typeof value === 'object' && !Array.isArray(value);
const jsonToFormData = (
json: JsonObjectWithFile,
formData = new FormData(),
parentKey = '',
): FormData => {
const appendValue = (key: string, value: unknown) => {
if (value instanceof File || value instanceof Blob) {
formData.append(key, value);
} else if (isJsonObject(value)) {
// Recursive call for nested objects
jsonToFormData(value, formData, key);
} else {
formData.append(key, value as Blob | string);
}
};
Object.entries(json).forEach(([key, value]) => {
const fullKey = parentKey ? `${parentKey}[${key}]` : key;
if (Array.isArray(value)) {
value.forEach((item, index) => appendValue(`${fullKey}[${index}]`, item));
} else {
appendValue(fullKey, value);
}
});
return formData;
};
export type Body = BodyInit | JsonValueWithFile;
export const withJsonBody =
<Body extends JsonValue>(fixedBody: Body) =>
<Response, PromiseType extends AbortablePromise<Response> | Promise<Response>>(
req: Request<Response, PromiseType>,
) =>
(options: Omit<RequestOptionsWithoutUrl, 'body'> = {}) =>
req({
...options,
body: JSON.stringify(fixedBody),
headers: {
...options.headers,
'content-type': 'application/json',
},
});
export const withFormDataBody =
<Body extends JsonObjectWithFile>(fixedBody: Body) =>
<Response, PromiseType extends AbortablePromise<Response> | Promise<Response>>(
req: Request<Response, PromiseType>,
) =>
(options: Omit<RequestOptionsWithoutUrl, 'body'> = {}) =>
req({
...options,
body: jsonToFormData(fixedBody),
});
export class HttpError extends Error {
public readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'HttpError';
this.status = status;
}
}
export enum HttpMethod {
CONNECT = 'CONNECT',
DELETE = 'DELETE',
GET = 'GET',
HEAD = 'HEAD',
OPTIONS = 'OPTIONS',
PATCH = 'PATCH',
POST = 'POST',
PUT = 'PUT',
TRACE = 'TRACE',
}
import { afterEach, describe, expect, it, vi } from 'vitest';
import { get, patch, post, put } from '../index.js';
import { HttpMethod } from './httpMethod.js';
import { QueryParams, withQueryParams } from './queryParams.js';
import { request } from './request.js';
vi.mock('./request.js', () => ({
request: vi.fn(),
}));
const requestMock = vi.mocked(request);
const getRequest = get('/api/data/get');
const postRequest = post('/api/data/post');
const putRequest = put('/api/data/put');
const patchRequest = patch('/api/data/patch');
const compareSearchParams = (reference: URLSearchParams, value: URLSearchParams) => {
const referenceArray = Array.from(reference.entries());
const valueArray = Array.from(value.entries());
expect(referenceArray.length).toEqual(valueArray.length);
expect(valueArray).toEqual(expect.arrayContaining(referenceArray));
};
afterEach(() => {
vi.clearAllMocks();
});
describe('withQueryParams', () => {
it('should add single query parameters to the request URL', async () => {
const query: QueryParams = { baz: 42, foo: 'bar' };
await withQueryParams(query)(getRequest)();
const requestSearchParams = requestMock.mock.calls[0][1].searchParams!;
compareSearchParams(requestSearchParams, new URLSearchParams('foo=bar&baz=42'));
});
it('should handle array values in query parameters', async () => {
const query: QueryParams = { foo: ['bar', 'baz'] };
await withQueryParams(query)(postRequest)();
const requestSearchParams = requestMock.mock.calls[0][1].searchParams!;
compareSearchParams(requestSearchParams, new URLSearchParams('foo=bar&foo=baz'));
});
it('should ignore null and undefined values in query parameters', async () => {
const query: QueryParams = { baz: null, foo: 'bar', qux: undefined };
await withQueryParams(query)(putRequest)();
const requestSearchParams = requestMock.mock.calls[0][1].searchParams!;
compareSearchParams(requestSearchParams, new URLSearchParams('foo=bar'));
});
it('should handle boolean values in query parameters', async () => {
const query: QueryParams = { bar: false, foo: true };
await withQueryParams(query)(patchRequest)();
const requestSearchParams = requestMock.mock.calls[0][1].searchParams!;
compareSearchParams(requestSearchParams, new URLSearchParams('foo=true&bar=false'));
});
it('should merge query parameters with existing options', async () => {
const query: QueryParams = { foo: 'bar' };
await withQueryParams(query)(getRequest)({ headers: { Authorization: 'Bearer token' } });
const requestSearchParams = requestMock.mock.calls[0][1].searchParams!;
compareSearchParams(requestSearchParams, new URLSearchParams('foo=bar'));
expect(requestMock).toHaveBeenCalledWith(HttpMethod.GET, {
headers: { Authorization: 'Bearer token' },
searchParams: expect.any(URLSearchParams) as URLSearchParams,
url: '/api/data/get',
});
});
});
import { AbortablePromise } from './abortableRequest.js';
import { Request } from './request.js';
export type QueryParams = Record<string, QueryParamValue | QueryParamValue[]>;
type QueryParamValue = boolean | null | number | string | undefined;
export const queryParamsToURLSearchParams = (query: QueryParams): URLSearchParams => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => params.append(key, String(v)));
} else if (value !== null && value !== undefined) {
params.append(key, String(value));
}
});
return params;
};
export const withQueryParams =
<Query extends QueryParams>(query: Query) =>
<Response, PromiseType extends AbortablePromise<Response> | Promise<Response>>(
req: Request<Response, PromiseType>,
): Request<Response, PromiseType> =>
(options = {}) => {
return req({
...options,
searchParams: queryParamsToURLSearchParams(query),
});
};
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
MiddlewareNext,
PostResponseMiddlewareContext,
PreRequestMiddlewareContext,
usePostResponse,
usePreRequest,
} from '../middleware/index.js';
import { HttpError } from './HttpError.js';
import { HttpMethod } from './httpMethod.js';
import { request, RequestOptions } from './request.js';
describe('request', () => {
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
afterEach(() => {
mockFetch.mockClear();
});
const mockedResponseFactory = <T extends Record<string, unknown>>(data: T) => ({
body: JSON.stringify(data),
json: () => Promise.resolve(data),
ok: true,
});
it('should make a successful GET request and return JSON response', async () => {
const mockResponseData = { message: 'Success' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const options: RequestOptions = {
method: HttpMethod.GET,
url: '/api/data',
};
const result = await request<typeof mockResponseData>(HttpMethod.GET, options);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(`${window.location.origin}/api/data`, {
headers: undefined,
method: HttpMethod.GET,
});
expect(result).toEqual(mockResponseData);
});
it('should append query parameters to the URL', async () => {
const mockResponseData = { message: 'Success with query params' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const options: RequestOptions = {
method: HttpMethod.GET,
searchParams: new URLSearchParams({ page: '1', search: 'test' }),
url: '/api/data',
};
const result = await request<typeof mockResponseData>(HttpMethod.GET, options);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(
`${window.location.origin}/api/data?page=1&search=test`,
{
headers: undefined,
method: HttpMethod.GET,
},
);
expect(result).toEqual(mockResponseData);
});
it('should throw an error if response is not ok', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
const options: RequestOptions = {
method: HttpMethod.GET,
url: '/api/notfound',
};
await expect(request(HttpMethod.GET, options)).rejects.toThrow(new HttpError(404, 'Not Found'));
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(`${window.location.origin}/api/notfound`, {
headers: undefined,
method: HttpMethod.GET,
});
});
it('should include custom headers in the request', async () => {
const mockResponseData = { message: 'Success with headers' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const options: RequestOptions = {
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
method: HttpMethod.GET,
url: '/api/headers',
};
const result = await request<typeof mockResponseData>(HttpMethod.GET, options);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(`${window.location.origin}/api/headers`, {
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
method: HttpMethod.GET,
});
expect(result).toEqual(mockResponseData);
});
it('should send JSON body in POST request', async () => {
const mockResponseData = { message: 'Success with JSON body' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const options: RequestOptions = {
body: JSON.stringify({ key: 'value' }),
headers: {
'Content-Type': 'application/json',
},
method: HttpMethod.POST,
url: '/api/post',
};
const result = await request<typeof mockResponseData>(HttpMethod.POST, options);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(`${window.location.origin}/api/post`, {
body: JSON.stringify({ key: 'value' }),
headers: {
'Content-Type': 'application/json',
},
method: HttpMethod.POST,
});
expect(result).toEqual(mockResponseData);
});
it('should call middleware before and after the request', async () => {
const mockResponseData = { message: 'Middleware test' };
mockFetch.mockResolvedValueOnce({
...mockedResponseFactory({ ...mockResponseData }),
status: 200,
});
const options: RequestOptions = {
method: HttpMethod.GET,
url: '/api/middleware',
headers: {
'X-Test-Header': 'TestValue',
},
};
const preRequestMiddleware = vi.fn(
async (ctx: PreRequestMiddlewareContext, next: MiddlewareNext) => {
expect(ctx.request.method).toBe(HttpMethod.GET);
expect((ctx.request.headers! as Record<string, string>)['X-Test-Header']).toBe('TestValue');
await next();
},
);
const postResponseMiddleware = vi.fn(
async (ctx: PostResponseMiddlewareContext, next: MiddlewareNext) => {
expect(ctx.response.ok).toBe(true);
expect(ctx.response.status).toBe(200);
expect(ctx.response.body).toBe(JSON.stringify(mockResponseData));
await next();
},
);
usePreRequest(preRequestMiddleware);
usePostResponse(postResponseMiddleware);
await request<typeof mockResponseData>(HttpMethod.GET, options);
expect(preRequestMiddleware).toHaveBeenCalledOnce();
expect(postResponseMiddleware).toHaveBeenCalledOnce();
});
});
import { runPostResponse, runPreRequest } from '../middleware/runner.js';
import { AbortablePromise } from './abortableRequest.js';
import { BaseURL, getDefaultBaseUrl } from './baseUrl.js';
import { HttpError } from './HttpError.js';
import { HttpMethod } from './httpMethod.js';
type PathOnly = `../${string}` | `./${string}` | `/${string}`;
export type RequestURL = BaseURL | PathOnly;
export type Request<
Response,
PromiseType extends AbortablePromise<Response> | Promise<Response>,
> = (options?: RequestOptionsWithoutUrl) => PromiseType;
export type RequestOptions = RequestInit & {
searchParams?: URLSearchParams;
url: RequestURL;
baseUrl?: BaseURL;
};
export type RequestOptionsWithoutUrl = Omit<RequestOptions, 'url'>;
export const request = async <Response>(
method: HttpMethod,
options: RequestOptions,
): Promise<Response> => {
const fetchOptions = {
method,
...options,
};
const middlewareContext = { request: fetchOptions };
await runPreRequest(middlewareContext);
const { searchParams, url, baseUrl, ...requestOptions } = fetchOptions;
const fullUrl = new URL(url, baseUrl || getDefaultBaseUrl());
if (searchParams) {
searchParams.forEach((value, key) => {
fullUrl.searchParams.append(key, value);
});
}
const response = await fetch(fullUrl.toString(), requestOptions);
await runPostResponse({ ...middlewareContext, response });
if (!response.ok) {
throw new HttpError(response.status, response.statusText);
}
if (response.status === 204 || !response.body) {
return undefined as Response;
}
return (await response.json()) as Response;
};
import { afterEach, describe, expect, it, vi } from 'vitest';
import { get } from '../index.js';
import { HttpError } from './HttpError.js';
import { HttpMethod } from './httpMethod.js';
import { request, RequestOptionsWithoutUrl } from './request.js';
import { withNotFoundAsNull } from './status.js';
vi.mock('./request.js', () => ({
request: vi.fn(),
}));
const requestMock = vi.mocked(request);
const getRequest = get('/api/data/get');
afterEach(() => {
vi.clearAllMocks();
});
describe('withNotFoundAsNull', () => {
it('should return the response when the request succeeds', async () => {
const mockResponse = { data: 'valid response' };
requestMock.mockResolvedValueOnce(mockResponse);
const fetchData = withNotFoundAsNull(getRequest);
const result = await fetchData();
expect(result).toEqual(mockResponse);
expect(requestMock).toHaveBeenCalledWith(HttpMethod.GET, { url: '/api/data/get' });
});
it('should return null when the request fails with 404', async () => {
requestMock.mockRejectedValueOnce(new HttpError(404, 'Not Found'));
const fetchData = withNotFoundAsNull(getRequest);
const result = await fetchData();
expect(result).toBeNull();
expect(requestMock).toHaveBeenCalledWith(HttpMethod.GET, { url: '/api/data/get' });
});
it('should throw an error when the request fails with an error other than 404', async () => {
requestMock.mockRejectedValueOnce(new HttpError(500, 'Internal Server Error'));
const fetchData = withNotFoundAsNull(getRequest);
await expect(fetchData()).rejects.toThrow(new HttpError(500, 'Internal Server Error'));
expect(requestMock).toHaveBeenCalledWith(HttpMethod.GET, { url: '/api/data/get' });
});
it('should forward request options when calling the request function', async () => {
const mockResponse = { data: 'valid response' };
requestMock.mockResolvedValueOnce(mockResponse);
const fetchData = withNotFoundAsNull(getRequest);
const options: RequestOptionsWithoutUrl = { headers: { Authorization: 'Bearer token' } };
const result = await fetchData(options);
expect(result).toEqual(mockResponse);
expect(requestMock).toHaveBeenCalledWith(HttpMethod.GET, {
url: '/api/data/get',
headers: { Authorization: 'Bearer token' },
});
});
});
import { HttpError } from './HttpError.js';
import { RequestOptionsWithoutUrl } from './request.js';
export const withNotFoundAsNull =
<Response>(requestFn: (options?: RequestOptionsWithoutUrl) => Promise<Response>) =>
async (options?: RequestOptionsWithoutUrl): Promise<null | Response> => {
try {
return await requestFn(options);
} catch (error) {
if (error instanceof HttpError && error.status == 404) {
return null;
}
throw error;
}
};
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpMethod } from './httpMethod.js';
import { request, RequestOptionsWithoutUrl } from './request.js';
import { withBaseUrl } from './withBaseUrl.js';
describe('withBaseUrl', () => {
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
afterEach(() => {
mockFetch.mockClear();
});
const mockedResponseFactory = <T extends Record<string, unknown>>(data: T) => ({
body: JSON.stringify(data),
json: () => Promise.resolve(data),
ok: true,
});
const baseRequest = (options?: RequestOptionsWithoutUrl) =>
request<{ message: string }>(HttpMethod.GET, { ...options, url: '/data' });
it('should apply default base URL when none provided in options', async () => {
const req = withBaseUrl('https://api.example.com')(baseRequest);
const mockResponseData = { message: 'Success' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const result = await req();
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
headers: undefined,
method: HttpMethod.GET,
});
expect(result).toEqual(mockResponseData);
});
it('should allow overriding base URL in options', async () => {
const req = withBaseUrl('https://api.example.com')(baseRequest);
const mockResponseData = { message: 'Override' };
mockFetch.mockResolvedValueOnce(mockedResponseFactory(mockResponseData));
const result = await req({ baseUrl: 'https://override.example.com' });
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith('https://override.example.com/data', {
headers: undefined,
method: HttpMethod.GET,
});
expect(result).toEqual(mockResponseData);
});
});
import type { BaseURL } from './baseUrl.js';
import { AbortablePromise } from './abortableRequest.js';
import { Request, RequestOptionsWithoutUrl } from './request.js';
export const withBaseUrl =
(baseUrl: BaseURL) =>
<Response, PromiseType extends AbortablePromise<Response> | Promise<Response>>(
req: Request<Response, PromiseType>,
): Request<Response, PromiseType> =>
(options: RequestOptionsWithoutUrl = {}) =>
req({ baseUrl, ...options });
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src"
},
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
}
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@slango.configs/typescript/default.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}
export { default } from '@slango.configs/vitest/browser';