Skip to content

Commit 297a78a

Browse files
committed
feat: upgrade bodyTransform middleware
1 parent 9dae389 commit 297a78a

File tree

3 files changed

+174
-42
lines changed

3 files changed

+174
-42
lines changed

README.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ global:
7878
- failRandomly:
7979
rate: 0.1
8080
status: 503
81+
- bodyTransform:
82+
request: "(body, ctx) => { body.foo = 'bar'; return body; }"
83+
response: "(body, ctx) => { body.transformed = true; return body; }"
8184
routes:
8285
"GET /users/:id":
8386
- failRandomly:
@@ -120,7 +123,7 @@ Chaos Proxy uses Koa Router for path matching, supporting named parameters (e.g.
120123
- `rateLimit({ limit, windowMs, key })` — rate limiting (by IP, header, or custom)
121124
- `cors({ origin, methods, headers })` — enable and configure CORS headers. All options are strings.
122125
`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.)
123-
- `bodyTransform({ transform })` — parse and mutate request body with a custom function.
126+
- `bodyTransform({ request?, response? })` — parse and mutate request and/or response body with custom functions.
124127

125128
### Rate Limiting
126129

@@ -191,28 +194,28 @@ This configuration throttles responses to an average of 1 KB/s, sending data in
191194

192195
### Body Transform
193196

194-
The `bodyTransform` middleware parses the incoming request body (JSON, form, or text), then replaces it with the result of your custom `transform` function. This lets you inspect, modify, or completely replace the body before it is proxied or processed by other middlewares. It uses `koa-bodyparser` under the hood.
197+
The `bodyTransform` middleware allows you to parse and mutate both the request and response bodies 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). Backward compatibility with the old `transform` key is removed—use the new object shape only.
195198

196199
How it works:
197200
- Parses the request body and makes it available as `ctx.request.body`.
198-
- Calls your `transform` function with the parsed body and Koa context.
199-
- Sets the return value as the new `ctx.request.body`.
200-
- Subsequent middlewares and proxy logic use the mutated body.
201+
- If a `request` transform is provided, it is called with the parsed body and Koa context, and its return value replaces `ctx.request.body`.
202+
- After downstream middleware and proxying, if a `response` transform is provided, it is called with the response body (`ctx.body`) and Koa context, and its return value replaces `ctx.body`.
203+
- Both transforms can be used independently or together.
201204

202-
**Example:**
205+
**Example (YAML):**
203206
```yaml
204207
global:
205208
- bodyTransform:
206-
transform: "(body, ctx) => { body.foo = 'bar'; return body; }"
209+
request: "(body, ctx) => { body.foo = 'bar'; return body; }"
210+
response: "(body, ctx) => { body.transformed = true; return body; }"
207211
```
208212

209-
This configuration adds a `foo: 'bar'` property to every JSON request body before it is proxied to the target server.
213+
This configuration adds a `foo: 'bar'` property to every JSON request body and a `transformed: true` property to every JSON response body.
210214

211215
**Note:**
212-
For maximum flexibility, the `transform` option in `bodyTransform` can be specified as a JavaScript function string 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.
216+
For maximum flexibility, the `request` and `response` options in `bodyTransform` 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.
213217

214-
215-
If you call `startServer` programmatically, you can also pass a real function instead of a string:
218+
If you call `startServer` programmatically, you can also pass real functions instead of strings:
216219

217220
```ts
218221
import { startServer, bodyTransform } from 'chaos-proxy';
@@ -222,9 +225,13 @@ startServer({
222225
port: 5000,
223226
global: [
224227
bodyTransform({
225-
transform: (body, ctx) => {
228+
request: (body, ctx) => {
226229
body.foo = 'bar';
227230
return body;
231+
},
232+
response: (body, ctx) => {
233+
body.transformed = true;
234+
return body;
228235
}
229236
})
230237
]

src/middlewares/bodyTransform.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,64 @@ import bodyParser from 'koa-bodyparser';
22
import type { Middleware, Context } from 'koa';
33

44
export interface BodyTransformOptions {
5-
transform: (body: unknown, ctx: Context) => unknown;
5+
request?: { transform: (body: unknown, ctx: Context) => unknown } | string;
6+
response?: { transform: (body: unknown, ctx: Context) => unknown } | string;
67
}
78

8-
export function bodyTransform(opts: BodyTransformOptions | string): Middleware {
9-
let transformFn: (body: unknown, ctx: Context) => unknown;
10-
if (typeof opts === 'string') {
9+
function isTransformObject(opt: unknown): opt is { transform: (body: unknown, ctx: Context) => unknown } {
10+
return (
11+
typeof opt === 'object' &&
12+
opt !== null &&
13+
'transform' in opt &&
14+
typeof (opt as { transform: unknown }).transform === 'function'
15+
);
16+
}
17+
18+
function parseTransform(opt: { transform: (body: unknown, ctx: Context) => unknown } | string | undefined, which: string): ((body: unknown, ctx: Context) => unknown) | undefined {
19+
if (!opt) return undefined;
20+
if (typeof opt === 'string') {
1121
try {
12-
if (opts.trim().startsWith('(')) {
13-
transformFn = eval(opts);
22+
if (opt.trim().startsWith('(')) {
23+
// Function string, e.g. (body, ctx) => ...
24+
return eval(opt);
1425
} else {
15-
transformFn = new Function('body', 'ctx', opts) as (body: unknown, ctx: Context) => unknown;
26+
// Function body string, e.g. 'return {...body, foo: "bar"}'
27+
return new Function('body', 'ctx', opt) as (body: unknown, ctx: Context) => unknown;
1628
}
1729
} catch (e) {
18-
throw new Error('Failed to evaluate bodyTransform function string: ' + (e as Error).message);
30+
throw new Error(`Failed to evaluate bodyTransform ${which} function string: ${(e as Error).message}`);
1931
}
32+
} else if (isTransformObject(opt)) {
33+
return opt.transform;
2034
} else {
21-
transformFn = opts.transform;
35+
throw new Error(`Invalid bodyTransform ${which} option: must be a function or string`);
36+
}
37+
}
38+
39+
export function bodyTransform(opts: BodyTransformOptions): Middleware {
40+
if (typeof opts !== 'object' || (!opts.request && !opts.response)) {
41+
throw new Error('bodyTransform expects an object with request and/or response keys');
2242
}
2343

44+
const requestTransform = parseTransform(opts.request, 'request');
45+
const responseTransform = parseTransform(opts.response, 'response');
2446
const parser = bodyParser();
47+
2548
return async (ctx: Context, next: () => Promise<void>) => {
26-
await parser(ctx, async () => {
27-
if (typeof transformFn === 'function' && ctx.request.body !== undefined) {
28-
ctx.request.body = transformFn(ctx.request.body, ctx);
29-
}
49+
// Transform request body if needed
50+
if (requestTransform) {
51+
await parser(ctx, async () => {
52+
if (ctx.request.body !== undefined) {
53+
ctx.request.body = requestTransform(ctx.request.body, ctx);
54+
}
55+
await next();
56+
});
57+
} else {
3058
await next();
31-
});
59+
}
60+
// Transform response body if needed
61+
if (responseTransform && ctx.body !== undefined) {
62+
ctx.body = responseTransform(ctx.body, ctx);
63+
}
3264
};
3365
}

test/middlewares/bodyTransform.test.ts

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,53 +16,146 @@ function createMockCtx(body: unknown, contentType = 'application/json'): Context
1616
}
1717

1818
describe('bodyTransform middleware', () => {
19-
it('mutates JSON body', async () => {
19+
it('mutates JSON request body', async () => {
2020
const mw = bodyTransform({
21-
transform: (body) => {
22-
if (typeof body === 'object' && body !== null) {
23-
(body as Record<string, unknown>).mutated = true;
24-
}
25-
return body;
21+
request: {
22+
transform: (body: unknown) => {
23+
if (typeof body === 'object' && body !== null) {
24+
(body as Record<string, unknown>).mutated = true;
25+
}
26+
return body;
27+
},
2628
},
2729
});
2830
const ctx = createMockCtx({ foo: 'bar' });
2931
await mw(ctx, async () => {});
3032
expect(ctx.request.body).toEqual({ foo: 'bar', mutated: true });
3133
});
3234

33-
it('handles non-object bodies', async () => {
35+
it('handles non-object request bodies', async () => {
3436
const mw = bodyTransform({
35-
transform: () => 'changed',
37+
request: {
38+
transform: () => 'changed',
39+
},
3640
});
3741
const ctx = createMockCtx('original', 'text/plain');
3842
await mw(ctx, async () => {});
3943
expect(ctx.request.body).toBe('changed');
4044
});
4145

42-
it('returns undefined if transform returns undefined', async () => {
46+
it('returns undefined if request transform returns undefined', async () => {
4347
const mw = bodyTransform({
44-
transform: () => undefined,
48+
request: {
49+
transform: () => undefined,
50+
},
4551
});
4652
const ctx = createMockCtx({ foo: 'bar' });
4753
await mw(ctx, async () => {});
4854
expect(ctx.request.body).toBeUndefined();
4955
});
5056

51-
it('accepts a string arrow function for transform', async () => {
52-
const mw = bodyTransform('(body, ctx) => { body.added = 123; return body; }');
57+
it('mutates response body', async () => {
58+
const mw = bodyTransform({
59+
response: {
60+
transform: (body: unknown) => {
61+
if (typeof body === 'object' && body !== null) {
62+
(body as Record<string, unknown>).mutated = true;
63+
}
64+
return body;
65+
},
66+
},
67+
});
68+
const ctx = createMockCtx(undefined);
69+
ctx.body = { foo: 'bar' };
70+
await mw(ctx, async () => {});
71+
expect(ctx.body).toEqual({ foo: 'bar', mutated: true });
72+
});
73+
74+
it('handles non-object response bodies', async () => {
75+
const mw = bodyTransform({
76+
response: {
77+
transform: () => 'changed',
78+
},
79+
});
80+
const ctx = createMockCtx(undefined);
81+
ctx.body = 'original';
82+
await mw(ctx, async () => {});
83+
expect(ctx.body).toBe('changed');
84+
});
85+
86+
it('returns undefined if response transform returns undefined', async () => {
87+
const mw = bodyTransform({
88+
response: {
89+
transform: () => undefined,
90+
},
91+
});
92+
const ctx = createMockCtx(undefined);
93+
ctx.body = { foo: 'bar' };
94+
await mw(ctx, async () => {});
95+
expect(ctx.body).toBeUndefined();
96+
});
97+
98+
it('can use both request and response transforms', async () => {
99+
const mw = bodyTransform({
100+
request: {
101+
transform: (body: unknown) => {
102+
if (typeof body === 'object' && body !== null) {
103+
(body as Record<string, unknown>).req = true;
104+
}
105+
return body;
106+
},
107+
},
108+
response: {
109+
transform: (body: unknown) => {
110+
if (typeof body === 'object' && body !== null) {
111+
(body as Record<string, unknown>).res = true;
112+
}
113+
return body;
114+
},
115+
},
116+
});
117+
const ctx = createMockCtx({ foo: 'bar' });
118+
ctx.body = { bar: 'baz' };
119+
await mw(ctx, async () => {});
120+
expect(ctx.request.body).toEqual({ foo: 'bar', req: true });
121+
expect(ctx.body).toEqual({ bar: 'baz', res: true });
122+
});
123+
124+
it('accepts a string arrow function for request transform', async () => {
125+
const mw = bodyTransform({ request: '(body, ctx) => { body.added = 123; return body; }' });
53126
const ctx = createMockCtx({ test: true });
54127
await mw(ctx, async () => {});
55128
expect(ctx.request.body).toEqual({ test: true, added: 123 });
56129
});
57130

58-
it('accepts a string function body for transform', async () => {
59-
const mw = bodyTransform('body.foo = "baz"; return body;');
131+
it('accepts a string function body for request transform', async () => {
132+
const mw = bodyTransform({ request: 'body.foo = "baz"; return body;' });
60133
const ctx = createMockCtx({ foo: 'bar' });
61134
await mw(ctx, async () => {});
62135
expect(ctx.request.body).toEqual({ foo: 'baz' });
63136
});
64137

65-
it('throws for invalid function string', async () => {
66-
expect(() => bodyTransform('not valid js')).toThrow();
138+
it('accepts a string arrow function for response transform', async () => {
139+
const mw = bodyTransform({ response: '(body, ctx) => { body.added = 456; return body; }' });
140+
const ctx = createMockCtx(undefined);
141+
ctx.body = { test: true };
142+
await mw(ctx, async () => {});
143+
expect(ctx.body).toEqual({ test: true, added: 456 });
144+
});
145+
146+
it('accepts a string function body for response transform', async () => {
147+
const mw = bodyTransform({ response: 'body.foo = "baz"; return body;' });
148+
const ctx = createMockCtx(undefined);
149+
ctx.body = { foo: 'bar' };
150+
await mw(ctx, async () => {});
151+
expect(ctx.body).toEqual({ foo: 'baz' });
152+
});
153+
154+
it('throws for invalid function string in request', async () => {
155+
expect(() => bodyTransform({ request: 'not valid js' })).toThrow();
156+
});
157+
158+
it('throws for invalid function string in response', async () => {
159+
expect(() => bodyTransform({ response: 'not valid js' })).toThrow();
67160
});
68161
});

0 commit comments

Comments
 (0)