Skip to content

Commit bc70b24

Browse files
committed
feat: add headerTransform middleware
1 parent 297a78a commit bc70b24

File tree

4 files changed

+210
-5
lines changed

4 files changed

+210
-5
lines changed

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Chaos Proxy uses Koa Router for path matching, supporting named parameters (e.g.
124124
- `cors({ origin, methods, headers })` — enable and configure CORS headers. All options are strings.
125125
`throttle({ rate, chunkSize, burst, key })` — throttles bandwidth per request to a specified rate (bytes per second), with optional burst capacity and chunk size. The key option allows per-client throttling. (Implemented natively, not using koa-throttle.)
126126
- `bodyTransform({ request?, response? })` — parse and mutate request and/or response body with custom functions.
127+
- `headerTransform({ request?, response? })` — parse and mutate request and/or response headers with custom functions.
127128

128129
### Rate Limiting
129130

@@ -238,6 +239,51 @@ startServer({
238239
});
239240
```
240241

242+
### Header Transform
243+
244+
The `headerTransform` middleware allows you to parse and mutate both the request and response headers using custom transformation functions. You can specify a `request` and/or `response` transform, each as either a JavaScript function string (for YAML config) or a real function (for programmatic usage).
245+
246+
How it works:
247+
- If a `request` transform is provided, it is called with a copy of the request headers and Koa context, and its return value replaces `ctx.request.headers`.
248+
- After downstream middleware and proxying, if a `response` transform is provided, it is called with a copy of the response headers and Koa context, and its return value replaces `ctx.response.headers`.
249+
- Both transforms can be used independently or together.
250+
251+
**Example (YAML):**
252+
```yaml
253+
global:
254+
- headerTransform:
255+
request: "(headers, ctx) => { headers['x-added'] = 'foo'; return headers; }"
256+
response: "(headers, ctx) => { headers['x-powered-by'] = 'chaos'; return headers; }"
257+
```
258+
259+
This configuration adds an `x-added: foo` header to every request and an `x-powered-by: chaos` header to every response.
260+
261+
**Note:**
262+
For maximum flexibility, the `request` and `response` options in `headerTransform` can be specified as JavaScript function strings in your YAML config. This allows you to define custom transformation logic directly in the config file. Be aware that evaluating JS from config can introduce security and syntax risks. Use with care and only in trusted environments.
263+
264+
If you call `startServer` programmatically, you can also pass real functions instead of strings:
265+
266+
```ts
267+
import { startServer, headerTransform } from 'chaos-proxy';
268+
269+
startServer({
270+
target: 'http://localhost:4000',
271+
port: 5000,
272+
global: [
273+
headerTransform({
274+
request: (headers, ctx) => {
275+
headers['x-added'] = 'foo';
276+
return headers;
277+
},
278+
response: (headers, ctx) => {
279+
headers['x-powered-by'] = 'chaos';
280+
return headers;
281+
}
282+
})
283+
]
284+
});
285+
```
286+
241287
---
242288

243289
## Extensibility

src/middlewares/headerTransform.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Context } from 'koa';
2+
3+
export type HeaderTransformFn = (headers: Record<string, string | string[] | undefined>, ctx: Context) => Record<string, string | string[] | undefined>;
4+
5+
export interface HeaderTransformConfig {
6+
request?: string | { transform: HeaderTransformFn };
7+
response?: string | { transform: HeaderTransformFn };
8+
}
9+
10+
function parseTransform(fnOrString: string | { transform: HeaderTransformFn } | undefined): HeaderTransformFn | undefined {
11+
if (!fnOrString) return undefined;
12+
if (typeof fnOrString === 'string') {
13+
// Try to parse as arrow function or function body
14+
try {
15+
if (fnOrString.trim().startsWith('(') || fnOrString.includes('=>')) {
16+
return eval(fnOrString);
17+
} else {
18+
return new Function('headers', 'ctx', fnOrString) as HeaderTransformFn;
19+
}
20+
} catch (e) {
21+
throw new Error('Invalid headerTransform function string: ' + e);
22+
}
23+
}
24+
if (typeof fnOrString === 'object' && typeof fnOrString.transform === 'function') {
25+
return fnOrString.transform;
26+
}
27+
throw new Error('Invalid headerTransform config');
28+
}
29+
30+
export function headerTransform(config: HeaderTransformConfig) {
31+
const requestTransform = parseTransform(config.request);
32+
const responseTransform = parseTransform(config.response);
33+
34+
return async function headerTransformMiddleware(ctx: Context, next: () => Promise<void>) {
35+
// Request headers
36+
if (requestTransform) {
37+
// Only pass string or string[] values to the transform
38+
const reqHeaders: Record<string, string | string[] | undefined> = {};
39+
for (const [k, v] of Object.entries(ctx.request.headers)) {
40+
if (typeof v === 'string' || Array.isArray(v)) {
41+
reqHeaders[k] = v;
42+
} else if (typeof v === 'number') {
43+
reqHeaders[k] = String(v);
44+
} else if (v != null) {
45+
reqHeaders[k] = String(v);
46+
}
47+
}
48+
const newHeaders = requestTransform(reqHeaders, ctx);
49+
ctx.request.headers = { ...newHeaders };
50+
}
51+
await next();
52+
// Response headers
53+
if (responseTransform) {
54+
// Only pass string or string[] values to the transform
55+
const resHeaders: Record<string, string | string[] | undefined> = {};
56+
for (const [k, v] of Object.entries(ctx.response.headers)) {
57+
if (typeof v === 'string' || Array.isArray(v)) {
58+
resHeaders[k] = v;
59+
} else if (typeof v === 'number') {
60+
resHeaders[k] = String(v);
61+
} else if (v != null) {
62+
resHeaders[k] = String(v);
63+
}
64+
}
65+
const newHeaders = responseTransform(resHeaders, ctx);
66+
ctx.response.headers = { ...newHeaders };
67+
}
68+
};
69+
}

src/registry/builtin.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { cors } from '../middlewares/cors';
21
import { registerMiddleware } from './middleware';
3-
// ...existing code...
4-
import { latency } from '../middlewares/latency';
5-
import { latencyRange } from '../middlewares/latencyRange';
6-
import { failRandomly } from '../middlewares/failRandomly';
2+
import { cors } from '../middlewares/cors';
73
import { dropConnection } from '../middlewares/dropConnection';
4+
import { headerTransform } from '../middlewares/headerTransform';
85
import { fail } from '../middlewares/fail';
6+
import { failRandomly } from '../middlewares/failRandomly';
7+
import { latency } from '../middlewares/latency';
8+
import { latencyRange } from '../middlewares/latencyRange';
99
import { rateLimit } from '../middlewares/rateLimit';
1010
import type { RateLimitOptions } from '../middlewares/rateLimit';
1111
import { throttle } from '../middlewares/throttle';
@@ -27,4 +27,5 @@ export function registerBuiltins() {
2727
);
2828
registerMiddleware('rateLimit', (opts) => rateLimit(opts as unknown as RateLimitOptions));
2929
registerMiddleware('throttle', (opts) => throttle(opts as unknown as ThrottleOptions));
30+
registerMiddleware('headerTransform', (opts) => headerTransform(opts));
3031
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { headerTransform } from '../../src/middlewares/headerTransform';
3+
import type { Context } from 'koa';
4+
5+
function createMockCtx(headers: Record<string, string | string[] | undefined> = {}, responseHeaders: Record<string, string | string[] | undefined> = {}): Context {
6+
return {
7+
request: {
8+
headers: { ...headers },
9+
},
10+
response: {
11+
headers: { ...responseHeaders },
12+
},
13+
set: () => {},
14+
method: 'GET',
15+
} as unknown as Context;
16+
}
17+
18+
describe('headerTransform middleware', () => {
19+
it('mutates request headers', async () => {
20+
const mw = headerTransform({
21+
request: {
22+
transform: (headers) => {
23+
headers['x-mutated'] = 'yes';
24+
return headers;
25+
},
26+
},
27+
});
28+
const ctx = createMockCtx({ foo: 'bar' });
29+
await mw(ctx, async () => {});
30+
expect(ctx.request.headers['x-mutated']).toBe('yes');
31+
});
32+
33+
it('mutates response headers', async () => {
34+
const mw = headerTransform({
35+
response: {
36+
transform: (headers) => {
37+
headers['x-res'] = 'done';
38+
return headers;
39+
},
40+
},
41+
});
42+
const ctx = createMockCtx({}, { foo: 'bar' });
43+
await mw(ctx, async () => {});
44+
expect(ctx.response.headers['x-res']).toBe('done');
45+
});
46+
47+
it('can use both request and response transforms', async () => {
48+
const mw = headerTransform({
49+
request: {
50+
transform: (headers) => {
51+
headers['x-req'] = '1';
52+
return headers;
53+
},
54+
},
55+
response: {
56+
transform: (headers) => {
57+
headers['x-res'] = '2';
58+
return headers;
59+
},
60+
},
61+
});
62+
const ctx = createMockCtx({ foo: 'bar' }, { bar: 'baz' });
63+
await mw(ctx, async () => {});
64+
expect(ctx.request.headers['x-req']).toBe('1');
65+
expect(ctx.response.headers['x-res']).toBe('2');
66+
});
67+
68+
it('accepts a string arrow function for request transform', async () => {
69+
const mw = headerTransform({ request: '(headers, ctx) => { headers["x-added"] = "abc"; return headers; }' });
70+
const ctx = createMockCtx({ foo: 'bar' });
71+
await mw(ctx, async () => {});
72+
expect(ctx.request.headers['x-added']).toBe('abc');
73+
});
74+
75+
it('accepts a string function body for response transform', async () => {
76+
const mw = headerTransform({ response: 'headers["x-func"] = "body"; return headers;' });
77+
const ctx = createMockCtx({}, { foo: 'bar' });
78+
await mw(ctx, async () => {});
79+
expect(ctx.response.headers['x-func']).toBe('body');
80+
});
81+
82+
it('throws for invalid function string in request', async () => {
83+
expect(() => headerTransform({ request: 'not valid js' })).toThrow();
84+
});
85+
86+
it('throws for invalid function string in response', async () => {
87+
expect(() => headerTransform({ response: 'not valid js' })).toThrow();
88+
});
89+
});

0 commit comments

Comments
 (0)