Skip to content

Commit b9e933c

Browse files
committed
Move all body decoding to the server side in remote client setups
This is a major structural change, although it shouldn't result in any visible changes in behaviour in most cases. Internally, this now decodes all message bodies on the server side by default, so that client-side decoding can be disabled entirely shortly (avoiding some awkward WASM dependencies). This does create some changes in the plugin model - while still experimental, this is probably a breaking change there.
1 parent 7748c0f commit b9e933c

18 files changed

+416
-74
lines changed

src/admin/admin-plugin-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export type PluginStartParams<Plugin> = Plugin extends AdminPlugin<infer StartPa
1919
? StartParams
2020
: never;
2121

22+
export type PluginStartDefaults<Plugins extends { [key: string]: AdminPlugin<any, any> }> = {
23+
[key in keyof Plugins]?: Partial<PluginStartParams<Plugins[key]>>
24+
};
25+
2226
export type PluginClientResponse<Plugin> = Plugin extends AdminPlugin<any, infer ClientResponse>
2327
? ClientResponse
2428
: never;

src/admin/admin-server.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ import { objectAllPromise } from '../util/promise';
2323
import { DEFAULT_ADMIN_SERVER_PORT } from '../types';
2424

2525
import { RuleParameters } from '../rules/rule-parameters';
26-
import { AdminPlugin, PluginConstructorMap, PluginStartParamsMap } from './admin-plugin-types';
26+
import { AdminPlugin, PluginConstructorMap, PluginStartDefaults, PluginStartParamsMap } from './admin-plugin-types';
2727
import { parseAnyAst } from './graphql-utils';
28-
import { MockttpAdminPlugin } from './mockttp-admin-plugin';
2928

3029
export interface AdminServerOptions<Plugins extends { [key: string]: AdminPlugin<any, any> }> {
3130
/**
@@ -53,7 +52,7 @@ export interface AdminServerOptions<Plugins extends { [key: string]: AdminPlugin
5352
* Override the default parameters for sessions started from this admin server. These values will be
5453
* used for each setting that is not explicitly specified by the client when creating a mock session.
5554
*/
56-
pluginDefaults?: Partial<PluginStartParamsMap<Plugins>>;
55+
pluginDefaults?: PluginStartDefaults<Plugins>;
5756

5857
/**
5958
* Some rule options can't easily be specified in remote clients, since they need to access
@@ -176,7 +175,7 @@ export class AdminServer<Plugins extends { [key: string]: AdminPlugin<any, any>
176175

177176
this.app.use(bodyParser.json({ limit: '50mb' }));
178177

179-
const defaultPluginStartParams: Partial<PluginStartParamsMap<Plugins>> = options.pluginDefaults ?? {};
178+
const defaultPluginStartParams: PluginStartDefaults<Plugins> = options.pluginDefaults ?? {};
180179

181180
this.app.post('/start', async (req, res) => {
182181
try {

src/admin/mockttp-admin-model.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,29 @@ import { Duplex } from "stream";
33

44
import { PubSub } from "graphql-subscriptions";
55
import type { IResolvers } from "@graphql-tools/utils";
6+
import { ErrorLike, UnreachableCheck } from "@httptoolkit/util";
67

8+
import type { Headers } from '../types';
79
import type { MockttpServer } from "../server/mockttp-server";
810
import type { ServerMockedEndpoint } from "../server/mocked-endpoint";
911
import type {
1012
MockedEndpoint,
1113
MockedEndpointData,
1214
CompletedRequest,
1315
CompletedResponse,
14-
ClientError
16+
ClientError,
17+
CompletedBody
1518
} from "../types";
1619
import type { Serialized } from "../serialization/serialization";
1720
import type { RequestRuleData } from "../rules/requests/request-rule";
1821
import type { WebSocketRuleData } from "../rules/websockets/websocket-rule";
1922

20-
import { deserializeRuleData, deserializeWebSocketRuleData } from "../rules/rule-deserialization";
23+
import {
24+
deserializeRuleData,
25+
deserializeWebSocketRuleData,
26+
MockttpDeserializationOptions
27+
} from "../rules/rule-deserialization";
28+
import { decodeBodyBuffer } from "../util/request-utils";
2129
import { SubscribableEvent } from "../main";
2230

2331
const graphqlSubscriptionPairs = Object.entries({
@@ -49,12 +57,51 @@ async function buildMockedEndpointData(endpoint: ServerMockedEndpoint): Promise<
4957
};
5058
}
5159

60+
const decodeAndSerializeBody = async (body: CompletedBody, headers: Headers): Promise<
61+
| false // Not required
62+
| { decoded: Buffer, decodingError?: undefined } // Success
63+
| { decodingError: string, decoded?: undefined } // Failure
64+
> => {
65+
try {
66+
const decoded = await decodeBodyBuffer(body.buffer, headers);
67+
if (decoded === body.buffer) return false; // No decoding required - no-op.
68+
else return { decoded }; // Successful decoding result
69+
} catch (e) {
70+
return { // Failed decoding - we just return the error message.
71+
decodingError: (e as ErrorLike)?.message ?? 'Failed to decode message body'
72+
};
73+
}
74+
};
75+
5276
export function buildAdminServerModel(
5377
mockServer: MockttpServer,
5478
stream: Duplex,
55-
ruleParameters: { [key: string]: any }
79+
ruleParams: { [key: string]: any },
80+
options: {
81+
messageBodyDecoding?: 'server-side' | 'none';
82+
} = {}
5683
): IResolvers {
5784
const pubsub = new PubSub();
85+
const messageBodyDecoding = options.messageBodyDecoding || 'server-side';
86+
87+
const ruleDeserializationOptions: MockttpDeserializationOptions = {
88+
bodySerializer: messageBodyDecoding === 'server-side'
89+
? async (body, headers) => {
90+
const encoded = body.buffer.toString('base64');
91+
const result = await decodeAndSerializeBody(body, headers);
92+
if (result === false) { // No decoding required - no-op.
93+
return { encoded };
94+
} else if (result.decodingError !== undefined) { // Failed decoding - we just return the error message.
95+
return { encoded, decodingError: result.decodingError };
96+
} else if (result.decoded) { // Success - we return both formats to the client
97+
return { encoded, decoded: result.decoded.toString('base64') };
98+
} else {
99+
throw new UnreachableCheck(result);
100+
}
101+
}
102+
: (body) => body.buffer.toString('base64'), // 'None' = just send encoded body (as base64).
103+
ruleParams
104+
};
58105

59106
for (let [gqlName, eventName] of graphqlSubscriptionPairs) {
60107
mockServer.on(eventName as any, (evt) => {
@@ -91,30 +138,30 @@ export function buildAdminServerModel(
91138

92139
Mutation: {
93140
addRule: async (__: any, { input }: { input: Serialized<RequestRuleData> }) => {
94-
return mockServer.addRequestRule(deserializeRuleData(input, stream, ruleParameters));
141+
return mockServer.addRequestRule(deserializeRuleData(input, stream, ruleDeserializationOptions));
95142
},
96143
addRules: async (__: any, { input }: { input: Array<Serialized<RequestRuleData>> }) => {
97144
return mockServer.addRequestRules(...input.map((rule) =>
98-
deserializeRuleData(rule, stream, ruleParameters)
145+
deserializeRuleData(rule, stream, ruleDeserializationOptions)
99146
));
100147
},
101148
setRules: async (__: any, { input }: { input: Array<Serialized<RequestRuleData>> }) => {
102149
return mockServer.setRequestRules(...input.map((rule) =>
103-
deserializeRuleData(rule, stream, ruleParameters)
150+
deserializeRuleData(rule, stream, ruleDeserializationOptions)
104151
));
105152
},
106153

107154
addWebSocketRule: async (__: any, { input }: { input: Serialized<WebSocketRuleData> }) => {
108-
return mockServer.addWebSocketRule(deserializeWebSocketRuleData(input, stream, ruleParameters));
155+
return mockServer.addWebSocketRule(deserializeWebSocketRuleData(input, stream, ruleDeserializationOptions));
109156
},
110157
addWebSocketRules: async (__: any, { input }: { input: Array<Serialized<WebSocketRuleData>> }) => {
111158
return mockServer.addWebSocketRules(...input.map((rule) =>
112-
deserializeWebSocketRuleData(rule, stream, ruleParameters)
159+
deserializeWebSocketRuleData(rule, stream, ruleDeserializationOptions)
113160
));
114161
},
115162
setWebSocketRules: async (__: any, { input }: { input: Array<Serialized<WebSocketRuleData>> }) => {
116163
return mockServer.setWebSocketRules(...input.map((rule) =>
117-
deserializeWebSocketRuleData(rule, stream, ruleParameters)
164+
deserializeWebSocketRuleData(rule, stream, ruleDeserializationOptions)
118165
));
119166
}
120167
},
@@ -124,12 +171,20 @@ export function buildAdminServerModel(
124171
Request: {
125172
body: (request: CompletedRequest) => {
126173
return request.body.buffer;
174+
},
175+
decodedBody: async (request: CompletedRequest) => {
176+
return (await decodeAndSerializeBody(request.body, request.headers))
177+
|| {}; // No decoding required
127178
}
128179
},
129180

130181
Response: {
131182
body: (response: CompletedResponse) => {
132183
return response.body.buffer;
184+
},
185+
decodedBody: async (response: CompletedResponse) => {
186+
return (await decodeAndSerializeBody(response.body, response.headers))
187+
|| {}; // No decoding required
133188
}
134189
},
135190

src/admin/mockttp-admin-plugin.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MockttpSchema } from './mockttp-schema';
1010

1111
export interface MockttpPluginOptions {
1212
options?: Partial<MockttpOptions>;
13+
messageBodyDecoding: 'server-side' | 'none';
1314
port?: number | PortRange;
1415
}
1516

@@ -24,9 +25,13 @@ export class MockttpAdminPlugin implements AdminPlugin<
2425
> {
2526

2627
private mockServer!: MockttpServer;
28+
private messageBodyDecoding!: 'server-side' | 'none';
2729

28-
async start({ port, options }: MockttpPluginOptions) {
30+
async start({ port, options, messageBodyDecoding }: MockttpPluginOptions) {
2931
this.mockServer = new MockttpServer(options);
32+
this.messageBodyDecoding = messageBodyDecoding ||
33+
'none'; // Backward compat - clients that don't set this option expect 'none'.
34+
3035
await this.mockServer.start(port);
3136

3237
return {
@@ -54,6 +59,8 @@ export class MockttpAdminPlugin implements AdminPlugin<
5459
schema = MockttpSchema;
5560

5661
buildResolvers(stream: Duplex, ruleParameters: { [key: string]: any }) {
57-
return buildAdminServerModel(this.mockServer, stream, ruleParameters)
62+
return buildAdminServerModel(this.mockServer, stream, ruleParameters, {
63+
messageBodyDecoding: this.messageBodyDecoding
64+
})
5865
};
5966
}

src/admin/mockttp-schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export const MockttpSchema = gql`
176176
rawHeaders: Json!
177177
178178
body: Buffer!
179+
decodedBody: DecodingResult!
180+
179181
rawTrailers: Json!
180182
}
181183
@@ -212,7 +214,10 @@ export const MockttpSchema = gql`
212214
213215
headers: Json!
214216
rawHeaders: Json!
217+
215218
body: Buffer!
219+
decodedBody: DecodingResult!
220+
216221
rawTrailers: Json!
217222
}
218223
@@ -241,4 +246,9 @@ export const MockttpSchema = gql`
241246
hostname: String!
242247
port: Int!
243248
}
249+
250+
type DecodingResult {
251+
decoded: Buffer
252+
decodingError: String
253+
}
244254
`;

src/client/mockttp-admin-request-builder.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import gql from 'graphql-tag';
44

55
import { MockedEndpoint, MockedEndpointData } from "../types";
66

7-
import { buildBodyReader } from '../util/request-utils';
8-
import { objectHeadersToRaw, rawHeadersToObject } from '../util/header-utils';
7+
import { rawHeadersToObject } from '../util/header-utils';
98

109
import { AdminQuery } from './admin-query';
1110
import { SchemaIntrospector } from './schema-introspection';
@@ -17,6 +16,7 @@ import { SubscribableEvent } from '../mockttp';
1716
import { MockedEndpointClient } from "./mocked-endpoint-client";
1817
import { AdminClient } from './admin-client';
1918
import { serializeRuleData } from '../rules/rule-serialization';
19+
import { deserializeBodyReader } from '../serialization/body-serialization';
2020

2121
function normalizeHttpMessage(message: any, event?: SubscribableEvent) {
2222
if (message.timingEvents) {
@@ -43,9 +43,18 @@ function normalizeHttpMessage(message: any, event?: SubscribableEvent) {
4343
}
4444

4545
if (message.body !== undefined) {
46-
// Body is serialized as the raw encoded buffer in base64
47-
message.body = buildBodyReader(Buffer.from(message.body, 'base64'), message.headers);
46+
// This will be unset if a) no decoding is required (so message.body is already decoded implicitly),
47+
// b) if messageBodyDecoding is set to 'none', or c) if the server is <v4 and doesn't do decoding.
48+
let { decoded, decodingError } = message.decodedBody || {};
49+
50+
message.body = deserializeBodyReader(
51+
message.body,
52+
decoded,
53+
decodingError,
54+
message.headers
55+
);
4856
}
57+
delete message.decodedBody;
4958

5059
// For backwards compat, all except errors should have tags if they're missing
5160
if (!message.tags) message.tags = [];
@@ -77,9 +86,14 @@ function normalizeWebSocketMessage(message: any) {
7786
*/
7887
export class MockttpAdminRequestBuilder {
7988

89+
private messageBodyDecoding: 'server-side' | 'none';
90+
8091
constructor(
81-
private schema: SchemaIntrospector
82-
) {}
92+
private schema: SchemaIntrospector,
93+
options: { messageBodyDecoding: 'server-side' | 'none' } = { messageBodyDecoding: 'server-side' }
94+
) {
95+
this.messageBodyDecoding = options.messageBodyDecoding;
96+
}
8397

8498
buildAddRequestRulesQuery(
8599
rules: Array<RequestRuleData>,
@@ -259,6 +273,10 @@ export class MockttpAdminRequestBuilder {
259273
260274
rawHeaders
261275
body
276+
${this.schema.typeHasField('Request', 'decodedBody') && this.messageBodyDecoding === 'server-side'
277+
? 'decodedBody { decoded, decodingError }'
278+
: ''
279+
}
262280
${this.schema.asOptionalField('Request', 'rawTrailers')}
263281
264282
timingEvents
@@ -274,6 +292,10 @@ export class MockttpAdminRequestBuilder {
274292
275293
rawHeaders
276294
body
295+
${this.schema.typeHasField('Response', 'decodedBody') && this.messageBodyDecoding === 'server-side'
296+
? 'decodedBody { decoded, decodingError }'
297+
: ''
298+
}
277299
${this.schema.asOptionalField('Response', 'rawTrailers')}
278300
279301
timingEvents
@@ -298,6 +320,10 @@ export class MockttpAdminRequestBuilder {
298320
299321
rawHeaders
300322
body
323+
${this.schema.typeHasField('Request', 'decodedBody') && this.messageBodyDecoding === 'server-side'
324+
? 'decodedBody { decoded, decodingError }'
325+
: ''
326+
}
301327
${this.schema.asOptionalField('Request', 'rawTrailers')}
302328
303329
timingEvents
@@ -313,6 +339,10 @@ export class MockttpAdminRequestBuilder {
313339
314340
rawHeaders
315341
body
342+
${this.schema.typeHasField('Response', 'decodedBody') && this.messageBodyDecoding === 'server-side'
343+
? 'decodedBody { decoded, decodingError }'
344+
: ''
345+
}
316346
${this.schema.asOptionalField('Response', 'rawTrailers')}
317347
318348
timingEvents
@@ -460,6 +490,11 @@ export class MockttpAdminRequestBuilder {
460490
rawHeaders
461491
462492
body
493+
${this.schema.typeHasField('Response', 'decodedBody') && this.messageBodyDecoding === 'server-side'
494+
? 'decodedBody { decoded, decodingError }'
495+
: ''
496+
}
497+
463498
${this.schema.asOptionalField('Response', 'rawTrailers')}
464499
}
465500
}
@@ -563,7 +598,11 @@ export class MockttpAdminRequestBuilder {
563598
564599
rawHeaders
565600
566-
body,
601+
body
602+
${this.schema.typeHasField('Request', 'decodedBody') && this.messageBodyDecoding === 'server-side'
603+
? 'decodedBody { decoded, decodingError }'
604+
: ''
605+
}
567606
timingEvents
568607
httpVersion
569608
}

0 commit comments

Comments
 (0)