Skip to content

Commit 00c8a2b

Browse files
committed
refactor(fetch): implement immutable builder and full-cycle middleware
This commit introduces a major architectural refactoring of the package to improve flexibility, safety, and developer experience. Key changes include: - **Immutable FetchBuilder**: The is now immutable. Methods like or return a new cloned instance instead of mutating the existing one, preventing side effects and making the builder safer to reuse. - **Full-Cycle Middleware**: A new middleware system based on the pattern has been implemented. This allows middleware to inspect and modify both the request and the response, enabling features like logging, caching, and authentication. - **Lazy Response Parsing**: The now receives a function to parse the JSON body () instead of an already-resolved JSON object. This gives developers full control over the raw object, allowing for conditional parsing and better error handling. - **Intelligent Body Handling**: The request body serialization logic has been improved to automatically set the header for JSON objects and pass other body types through unmodified.
1 parent a063133 commit 00c8a2b

File tree

21 files changed

+2356
-1749
lines changed

21 files changed

+2356
-1749
lines changed

README.md

Lines changed: 332 additions & 116 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"bench": "pnpm --filter=\"benchmark\" start run",
1717
"start": "turbo run start",
1818
"clean": "turbo run clean",
19-
"test": "vitest",
19+
"test": "vitest --run",
2020
"test:watch": "vitest --watch -u",
2121
"test:coverage": "vitest run --coverage",
2222
"test:ci": "pnpm test:coverage && pnpm prettier && pnpm ts:typecheck && pnpm build",
@@ -56,7 +56,8 @@
5656
"turbo": "^2.3.3",
5757
"typescript": "^5.7.3",
5858
"vite-tsconfig-paths": "^5.1.4",
59-
"vitest": "^3.0.0"
59+
"vitest": "^3.0.0",
60+
"@metal-box/type": "^0.2.0"
6061
},
6162
"engines": {
6263
"node": ">=20.0.0"

packages/fetch/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,5 @@
1818
"scripts": {
1919
"build": "tsup src/index.ts --format=cjs,esm --dts",
2020
"build:fast": "tsup src/index.ts --format=cjs,esm"
21-
},
22-
"devDependencies": {
23-
"@metal-box/type": "^0.2.0"
2421
}
2522
}
Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { MetalSchemaShape, t } from '@metal-box/type'
2-
import * as f from '../../index'
2+
import { type GetRouterConfig, f } from '../..'
33
import { BASE_URL } from './constant'
44
import { Model } from './model'
55

@@ -12,67 +12,69 @@ export const api = f.router(BASE_URL, {
1212
auth: {
1313
login: {
1414
GET: f
15-
.unit()
15+
.builder()
1616
.def_json()
1717
.def_default_referrer('about:client')
18-
.def_response(({ json }) =>
19-
ApiResponse(t.union(t.string, t.undefined)).parse(json)
18+
.def_response(async ({ json }) =>
19+
ApiResponse(t.union(t.string, t.undefined)).parse(
20+
await json()
21+
)
2022
),
2123
},
2224
},
2325
books: {
2426
GET: f
25-
.unit()
27+
.builder()
2628
.def_json()
2729
.def_default_referrer('about:client')
28-
.def_response(({ json }) =>
29-
ApiResponse(Model.bookList).parse(json)
30+
.def_response(async ({ json }) =>
31+
ApiResponse(Model.bookList).parse(await json())
3032
),
3133
POST: f
32-
.unit()
34+
.builder()
3335
.def_json()
3436
.def_default_referrer('about:client')
3537
.def_body(Model.bookRequest.parse)
36-
.def_response(({ json }) => {
37-
const parsed = ApiResponse(Model.book).parse(json)
38+
.def_response(async ({ json }) => {
39+
const parsed = ApiResponse(Model.book).parse(await json())
3840
return parsed
3941
}),
4042
$id: {
4143
GET: f
42-
.unit()
44+
.builder()
4345
.def_json()
4446
.def_default_referrer('about:client')
4547
.def_response(async ({ json }) => {
46-
return ApiResponse(Model.book).parse(json)
48+
return ApiResponse(Model.book).parse(await json())
4749
}),
4850
PUT: f
49-
.unit()
51+
.builder()
5052
.def_json()
5153
.def_default_referrer('about:client')
5254
.def_body(Model.bookRequest.parse)
53-
.def_response(({ json }) =>
54-
ApiResponse(Model.book).parse(json)
55+
.def_response(async ({ json }) =>
56+
ApiResponse(Model.book).parse(await json())
5557
),
5658
DELETE: f
57-
.unit()
59+
.builder()
5860
.def_json()
5961
.def_default_referrer('about:client')
60-
.def_response(({ json }) =>
61-
ApiResponse(Model.book).parse(json)
62+
.def_response(async ({ json }) =>
63+
ApiResponse(Model.book).parse(await json())
6264
),
6365
},
6466
},
6567
category: {
6668
$name: {
6769
GET: f
68-
.unit()
70+
.builder()
6971
.def_json()
7072
.def_default_referrer('about:client')
71-
.def_response(({ json }) => {
72-
return ApiResponse(Model.bookList).parse(json)
73+
.def_response(async ({ json }) => {
74+
return ApiResponse(Model.bookList).parse(await json())
7375
}),
7476
},
7577
},
7678
})
7779

78-
export type BookApi = f.GetRouterConfig<typeof api>
80+
export type BookApi = GetRouterConfig<typeof api>

packages/fetch/src/__tests__/__mocks__/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class BookServer {
132132
header: Headers,
133133
data: (authId: UUID) => T,
134134
skipAuth: boolean = false
135-
): HttpResponse {
135+
): HttpResponse<any> {
136136
if (skipAuth) {
137137
return HttpResponse.json(
138138
{

packages/fetch/src/__tests__/build.router.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { setupServer } from 'msw/node'
2-
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
2+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
33
import { api } from './__mocks__/client'
44
import {
55
type BookModel,
@@ -14,7 +14,6 @@ const apiServer = setupServer(...bookServer.routes)
1414

1515
beforeAll(() => apiServer.listen({ onUnhandledRequest: 'error' }))
1616
afterAll(() => apiServer.close())
17-
afterEach(() => apiServer.resetHandlers())
1817
// ---------------------------------------------------- //
1918

2019
describe(label.unit('router api request'), () => {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { t } from '@metal-box/type'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { f } from '..'
4+
import { FetchBuilder } from '../core/fetcher'
5+
import { FetchUnit } from '../core/fetcher/unit'
6+
7+
describe('FetchBuilder', () => {
8+
it('should create a new FetchBuilder instance', () => {
9+
const b = f.builder()
10+
expect(b).toBeInstanceOf(FetchBuilder)
11+
})
12+
13+
it('should set the method', () => {
14+
const b = f.builder().def_method('POST')
15+
expect(b.$store.method).toBe('POST')
16+
})
17+
18+
it('should set the base URL', () => {
19+
const url = 'https://api.test.com'
20+
const b = f.builder().def_url(url)
21+
expect(b.$store.defaultUrl).toBe(url)
22+
})
23+
24+
it('should set json mode', () => {
25+
const b = f.builder().def_json()
26+
expect(b.isJsonMode).toBe(true)
27+
})
28+
29+
it('should build a FetchUnit', () => {
30+
const unit = f.builder().build()
31+
expect(unit).toBeInstanceOf(FetchUnit)
32+
})
33+
34+
it('should define a body validator', () => {
35+
const bodySchema = t.object({ name: t.string })
36+
const b = f.builder().def_body(bodySchema.parse)
37+
const testBody = { name: 'test' }
38+
expect(b.bodyValidator(testBody)).toEqual(testBody)
39+
expect(() => b.bodyValidator({ name: 123 })).toThrow()
40+
})
41+
42+
it('should be immutable', () => {
43+
const originalBuilder = f
44+
.builder()
45+
.def_method('GET')
46+
.def_url('https://a.com')
47+
const modifiedBuilder = originalBuilder.def_method('POST')
48+
49+
expect(originalBuilder.$store.method).toBe('GET')
50+
expect(modifiedBuilder.$store.method).toBe('POST')
51+
expect(originalBuilder.$store.defaultUrl).toBe('https://a.com')
52+
expect(modifiedBuilder.$store.defaultUrl).toBe('https://a.com')
53+
})
54+
55+
it('should handle request handler modification', async () => {
56+
const fetchUnit = f
57+
.builder()
58+
.def_url('https://api.com')
59+
.def_request_handler(({ request }) => {
60+
if (request instanceof Request) {
61+
request.headers.set('X-Test', 'true')
62+
}
63+
return request
64+
})
65+
.build()
66+
67+
const mockFetch = vi
68+
.spyOn(global, 'fetch')
69+
.mockImplementation(async (req) => {
70+
if (req instanceof Request) {
71+
expect(req.headers.get('X-Test')).toBe('true')
72+
} else {
73+
throw new Error('Expected a Request object')
74+
}
75+
return new Response('OK')
76+
})
77+
78+
await fetchUnit.query()
79+
expect(mockFetch).toHaveBeenCalledOnce()
80+
mockFetch.mockRestore()
81+
})
82+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { f } from '..'
3+
4+
describe('Middleware Integration', () => {
5+
it('should run middleware and modify request headers', async () => {
6+
const TOKEN = 'token'
7+
const middleware = new f.Middleware()
8+
9+
middleware.use(async (req, next) => {
10+
req.headers.set('Authorization', TOKEN)
11+
return next(req)
12+
})
13+
14+
const fetchBuilder = f
15+
.builder()
16+
.def_method('GET')
17+
.def_url('https://api.com')
18+
.def_middleware(middleware.procedures[0]!)
19+
20+
const fetchUnit = fetchBuilder.build()
21+
22+
const mockFetch = vi
23+
.spyOn(global, 'fetch')
24+
.mockImplementation(async (req) => {
25+
if (req instanceof Request) {
26+
expect(req.headers.get('Authorization')).toBe(TOKEN)
27+
} else {
28+
throw new Error('Expected a Request object')
29+
}
30+
return new Response('OK', { status: 200 })
31+
})
32+
33+
await fetchUnit.query()
34+
35+
expect(mockFetch).toHaveBeenCalledOnce()
36+
mockFetch.mockRestore()
37+
})
38+
39+
it('should run middleware and modify the response', async () => {
40+
const middleware = new f.Middleware()
41+
42+
middleware.use(async (req, next) => {
43+
const response = await next(req)
44+
response.headers.set('X-Middleware-Handled', 'true')
45+
return response
46+
})
47+
48+
const fetchUnit = f
49+
.builder()
50+
.def_method('GET')
51+
.def_url('https://api.com')
52+
.def_middleware(middleware.procedures[0]!)
53+
.def_response(async ({ response }) => {
54+
expect(response.headers.get('X-Middleware-Handled')).toBe(
55+
'true'
56+
)
57+
return response.text()
58+
})
59+
.build()
60+
61+
const mockFetch = vi
62+
.spyOn(global, 'fetch')
63+
.mockImplementation(async () => new Response('OK'))
64+
65+
await fetchUnit.query()
66+
expect(mockFetch).toHaveBeenCalledOnce()
67+
mockFetch.mockRestore()
68+
})
69+
})
Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,36 @@
11
import { describe, expect, it } from 'vitest'
2-
import { Middleware } from '../utils/middleware'
2+
import { f } from '..'
33
import { label } from './utils/test.label'
44

55
describe(label.unit('middleware'), () => {
6-
it(label.case('should add middleware'), () => {
7-
const middleware = new Middleware<{ counter: number }, { b: string }>()
6+
it(label.case('should execute middleware in order'), async () => {
7+
const middleware = new f.Middleware()
8+
const executionOrder: number[] = []
89

9-
let counter = 0
10-
middleware.use([
11-
({ next }) => {
12-
counter++
10+
middleware.use(async (req, next) => {
11+
executionOrder.push(1)
12+
const response = await next(req)
13+
executionOrder.push(6)
14+
return response
15+
})
1316

14-
next({
15-
req: { counter },
16-
})
17-
},
18-
({ next }) => {
19-
counter++
17+
middleware.use(async (req, next) => {
18+
executionOrder.push(2)
19+
const response = await next(req)
20+
executionOrder.push(5)
21+
return response
22+
})
2023

21-
next({
22-
req: { counter },
23-
res: {
24-
b: 'love',
25-
},
26-
})
27-
},
28-
({ next }) => {
29-
counter++
24+
middleware.use(async (req, next) => {
25+
executionOrder.push(3)
26+
const response = await next(req)
27+
executionOrder.push(4)
28+
return response
29+
})
3030

31-
next({
32-
req: { counter },
33-
})
34-
},
35-
])
31+
const mockFetcher = async (req: Request) => new Response('ok')
32+
await middleware.execute(new Request('https://test.com'), mockFetcher)
3633

37-
middleware.execute({ counter }, { b: '2' })
38-
39-
expect(counter).toBe(3)
34+
expect(executionOrder).toEqual([1, 2, 3, 4, 5, 6])
4035
})
4136
})

0 commit comments

Comments
 (0)