Skip to content

Commit 18f7011

Browse files
committed
feat: Add createMiddleware helper for cleaner middleware creation
- Implement createMiddleware helper that provides cleaner syntax - Separates next handler and request parameters for easier access - Supports all middleware patterns: conditional logic, short-circuiting, response transformation - Add comprehensive test coverage for all use cases - Maintains full compatibility with existing middleware patterns Example usage: const customMiddleware = createMiddleware(async (next, input, init) => { const headers = new Headers(init?.headers); headers.set('X-Custom', 'value'); return next(input, { ...init, headers }); });
1 parent a4c5203 commit 18f7011

File tree

2 files changed

+273
-1
lines changed

2 files changed

+273
-1
lines changed

src/shared/fetchWrapper.test.ts

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { withOAuth, withLogging, applyMiddleware, withWrappers } from './fetchWrapper.js';
1+
import { withOAuth, withLogging, applyMiddleware, withWrappers, createMiddleware } from './fetchWrapper.js';
22
import { OAuthClientProvider } from '../client/auth.js';
33
import { FetchLike } from './transport.js';
44

@@ -898,3 +898,211 @@ describe('Integration Tests', () => {
898898
});
899899
});
900900
});
901+
902+
describe('createMiddleware', () => {
903+
let mockFetch: jest.MockedFunction<FetchLike>;
904+
905+
beforeEach(() => {
906+
jest.clearAllMocks();
907+
mockFetch = jest.fn();
908+
});
909+
910+
it('should create middleware with cleaner syntax', async () => {
911+
const response = new Response('success', { status: 200 });
912+
mockFetch.mockResolvedValue(response);
913+
914+
const customMiddleware = createMiddleware(async (next, input, init) => {
915+
const headers = new Headers(init?.headers);
916+
headers.set('X-Custom-Header', 'custom-value');
917+
return next(input, { ...init, headers });
918+
});
919+
920+
const enhancedFetch = customMiddleware(mockFetch);
921+
await enhancedFetch('https://api.example.com/data');
922+
923+
expect(mockFetch).toHaveBeenCalledWith(
924+
'https://api.example.com/data',
925+
expect.objectContaining({
926+
headers: expect.any(Headers),
927+
})
928+
);
929+
930+
const callArgs = mockFetch.mock.calls[0];
931+
const headers = callArgs[1]?.headers as Headers;
932+
expect(headers.get('X-Custom-Header')).toBe('custom-value');
933+
});
934+
935+
it('should support conditional middleware logic', async () => {
936+
const apiResponse = new Response('api response', { status: 200 });
937+
const publicResponse = new Response('public response', { status: 200 });
938+
mockFetch
939+
.mockResolvedValueOnce(apiResponse)
940+
.mockResolvedValueOnce(publicResponse);
941+
942+
const conditionalMiddleware = createMiddleware(async (next, input, init) => {
943+
const url = typeof input === 'string' ? input : input.toString();
944+
945+
if (url.includes('/api/')) {
946+
const headers = new Headers(init?.headers);
947+
headers.set('X-API-Version', 'v2');
948+
return next(input, { ...init, headers });
949+
}
950+
951+
return next(input, init);
952+
});
953+
954+
const enhancedFetch = conditionalMiddleware(mockFetch);
955+
956+
// Test API route
957+
await enhancedFetch('https://example.com/api/users');
958+
let callArgs = mockFetch.mock.calls[0];
959+
let headers = callArgs[1]?.headers as Headers;
960+
expect(headers.get('X-API-Version')).toBe('v2');
961+
962+
// Test non-API route
963+
await enhancedFetch('https://example.com/public/page');
964+
callArgs = mockFetch.mock.calls[1];
965+
const maybeHeaders = callArgs[1]?.headers as Headers | undefined;
966+
expect(maybeHeaders?.get('X-API-Version')).toBeUndefined();
967+
});
968+
969+
it('should support short-circuit responses', async () => {
970+
const customMiddleware = createMiddleware(async (next, input, init) => {
971+
const url = typeof input === 'string' ? input : input.toString();
972+
973+
// Short-circuit for specific URL
974+
if (url.includes('/cached')) {
975+
return new Response('cached data', { status: 200 });
976+
}
977+
978+
return next(input, init);
979+
});
980+
981+
const enhancedFetch = customMiddleware(mockFetch);
982+
983+
// Test cached route (should not call mockFetch)
984+
const cachedResponse = await enhancedFetch('https://example.com/cached/data');
985+
expect(await cachedResponse.text()).toBe('cached data');
986+
expect(mockFetch).not.toHaveBeenCalled();
987+
988+
// Test normal route
989+
mockFetch.mockResolvedValue(new Response('fresh data', { status: 200 }));
990+
const freshResponse = await enhancedFetch('https://example.com/fresh/data');
991+
expect(mockFetch).toHaveBeenCalledTimes(1);
992+
});
993+
994+
it('should handle response transformation', async () => {
995+
const originalResponse = new Response('{"data": "original"}', {
996+
status: 200,
997+
headers: { 'Content-Type': 'application/json' }
998+
});
999+
mockFetch.mockResolvedValue(originalResponse);
1000+
1001+
const transformMiddleware = createMiddleware(async (next, input, init) => {
1002+
const response = await next(input, init);
1003+
1004+
if (response.headers.get('content-type')?.includes('application/json')) {
1005+
const data = await response.json();
1006+
const transformed = { ...data, timestamp: 123456789 };
1007+
1008+
return new Response(JSON.stringify(transformed), {
1009+
status: response.status,
1010+
statusText: response.statusText,
1011+
headers: response.headers
1012+
});
1013+
}
1014+
1015+
return response;
1016+
});
1017+
1018+
const enhancedFetch = transformMiddleware(mockFetch);
1019+
const response = await enhancedFetch('https://api.example.com/data');
1020+
const result = await response.json();
1021+
1022+
expect(result).toEqual({
1023+
data: 'original',
1024+
timestamp: 123456789
1025+
});
1026+
});
1027+
1028+
it('should support error handling and recovery', async () => {
1029+
let attemptCount = 0;
1030+
mockFetch.mockImplementation(async () => {
1031+
attemptCount++;
1032+
if (attemptCount === 1) {
1033+
throw new Error('Network error');
1034+
}
1035+
return new Response('success', { status: 200 });
1036+
});
1037+
1038+
const retryMiddleware = createMiddleware(async (next, input, init) => {
1039+
try {
1040+
return await next(input, init);
1041+
} catch (error) {
1042+
// Retry once on network error
1043+
console.log('Retrying request after error:', error);
1044+
return await next(input, init);
1045+
}
1046+
});
1047+
1048+
const enhancedFetch = retryMiddleware(mockFetch);
1049+
const response = await enhancedFetch('https://api.example.com/data');
1050+
1051+
expect(await response.text()).toBe('success');
1052+
expect(mockFetch).toHaveBeenCalledTimes(2);
1053+
});
1054+
1055+
it('should compose well with other middleware', async () => {
1056+
const response = new Response('success', { status: 200 });
1057+
mockFetch.mockResolvedValue(response);
1058+
1059+
// Create custom middleware using createMiddleware
1060+
const customAuth = createMiddleware(async (next, input, init) => {
1061+
const headers = new Headers(init?.headers);
1062+
headers.set('Authorization', 'Custom token');
1063+
return next(input, { ...init, headers });
1064+
});
1065+
1066+
const customLogging = createMiddleware(async (next, input, init) => {
1067+
const url = typeof input === 'string' ? input : input.toString();
1068+
console.log(`Request to: ${url}`);
1069+
const response = await next(input, init);
1070+
console.log(`Response status: ${response.status}`);
1071+
return response;
1072+
});
1073+
1074+
// Compose with existing middleware
1075+
const enhancedFetch = applyMiddleware(
1076+
customAuth,
1077+
customLogging,
1078+
withLogging({ statusLevel: 400 })
1079+
)(mockFetch);
1080+
1081+
await enhancedFetch('https://api.example.com/data');
1082+
1083+
const callArgs = mockFetch.mock.calls[0];
1084+
const headers = callArgs[1]?.headers as Headers;
1085+
expect(headers.get('Authorization')).toBe('Custom token');
1086+
});
1087+
1088+
it('should have access to both input types (string and URL)', async () => {
1089+
const response = new Response('success', { status: 200 });
1090+
mockFetch.mockResolvedValue(response);
1091+
1092+
let capturedInputType: string | undefined;
1093+
const inspectMiddleware = createMiddleware(async (next, input, init) => {
1094+
capturedInputType = typeof input === 'string' ? 'string' : 'URL';
1095+
return next(input, init);
1096+
});
1097+
1098+
const enhancedFetch = inspectMiddleware(mockFetch);
1099+
1100+
// Test with string input
1101+
await enhancedFetch('https://api.example.com/data');
1102+
expect(capturedInputType).toBe('string');
1103+
1104+
// Test with URL input
1105+
await enhancedFetch(new URL('https://api.example.com/data'));
1106+
expect(capturedInputType).toBe('URL');
1107+
});
1108+
});

src/shared/fetchWrapper.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,67 @@ export const applyMiddleware = (...middleware: FetchMiddleware[]): FetchMiddlewa
269269
* @deprecated Use applyMiddleware instead
270270
*/
271271
export const withWrappers = applyMiddleware;
272+
273+
/**
274+
* Helper function to create custom fetch middleware with cleaner syntax.
275+
* Provides the next handler and request details as separate parameters for easier access.
276+
*
277+
* @example
278+
* ```typescript
279+
* // Create custom authentication middleware
280+
* const customAuthMiddleware = createMiddleware(async (next, input, init) => {
281+
* const headers = new Headers(init?.headers);
282+
* headers.set('X-Custom-Auth', 'my-token');
283+
*
284+
* const response = await next(input, { ...init, headers });
285+
*
286+
* if (response.status === 401) {
287+
* console.log('Authentication failed');
288+
* }
289+
*
290+
* return response;
291+
* });
292+
*
293+
* // Create conditional middleware
294+
* const conditionalMiddleware = createMiddleware(async (next, input, init) => {
295+
* const url = typeof input === 'string' ? input : input.toString();
296+
*
297+
* // Only add headers for API routes
298+
* if (url.includes('/api/')) {
299+
* const headers = new Headers(init?.headers);
300+
* headers.set('X-API-Version', 'v2');
301+
* return next(input, { ...init, headers });
302+
* }
303+
*
304+
* // Pass through for non-API routes
305+
* return next(input, init);
306+
* });
307+
*
308+
* // Create caching middleware
309+
* const cacheMiddleware = createMiddleware(async (next, input, init) => {
310+
* const cacheKey = typeof input === 'string' ? input : input.toString();
311+
*
312+
* // Check cache first
313+
* const cached = await getFromCache(cacheKey);
314+
* if (cached) {
315+
* return new Response(cached, { status: 200 });
316+
* }
317+
*
318+
* // Make request and cache result
319+
* const response = await next(input, init);
320+
* if (response.ok) {
321+
* await saveToCache(cacheKey, await response.clone().text());
322+
* }
323+
*
324+
* return response;
325+
* });
326+
* ```
327+
*
328+
* @param handler - Function that receives the next handler and request parameters
329+
* @returns A fetch middleware function
330+
*/
331+
export const createMiddleware = (
332+
handler: (next: FetchLike, input: string | URL, init?: RequestInit) => Promise<Response>
333+
): FetchMiddleware => {
334+
return (next) => (input, init) => handler(next, input as string | URL, init);
335+
};

0 commit comments

Comments
 (0)