@slango/ristretto
Advanced tools
+8
-5
| { | ||
| "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": { |
-394
| # @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; | ||
| }; |
-35
| 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'; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1
-66.67%2
-50%50249
-47.82%59
-32.18%349
-74.19%+ Added
- Removed
Updated