mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
224 lines (201 loc) • 9.52 kB
text/typescript
import * as _ from "lodash";
import { Duplex } from 'stream';
import { PubSub } from "graphql-subscriptions";
import type { IResolvers } from "@graphql-tools/utils";
import { ErrorLike, UnreachableCheck } from "@httptoolkit/util";
import type { Headers } from '../types';
import type { MockttpServer } from "../server/mockttp-server";
import type { ServerMockedEndpoint } from "../server/mocked-endpoint";
import type {
MockedEndpoint,
MockedEndpointData,
CompletedRequest,
CompletedResponse,
ClientError,
CompletedBody
} from "../types";
import type { Serialized } from "../serialization/serialization";
import type { RequestRuleData } from "../rules/requests/request-rule";
import type { WebSocketRuleData } from "../rules/websockets/websocket-rule";
import {
deserializeRuleData,
deserializeWebSocketRuleData,
MockttpDeserializationOptions
} from "../rules/rule-deserialization";
import { decodeBodyBuffer } from "../util/request-utils";
import { SubscribableEvent } from "../main";
const graphqlSubscriptionPairs = Object.entries({
'requestInitiated': 'request-initiated',
'requestBodyData': 'request-body-data',
'requestReceived': 'request',
'responseInitiated': 'response-initiated',
'responseBodyData': 'response-body-data',
'responseCompleted': 'response',
'webSocketRequest': 'websocket-request',
'webSocketAccepted': 'websocket-accepted',
'webSocketMessageReceived': 'websocket-message-received',
'webSocketMessageSent': 'websocket-message-sent',
'webSocketClose': 'websocket-close',
'requestAborted': 'abort',
'tlsPassthroughOpened': 'tls-passthrough-opened',
'tlsPassthroughClosed': 'tls-passthrough-closed',
'failedTlsRequest': 'tls-client-error',
'failedClientRequest': 'client-error',
'rawPassthroughOpened': 'raw-passthrough-opened',
'rawPassthroughClosed': 'raw-passthrough-closed',
'rawPassthroughData': 'raw-passthrough-data',
'ruleEvent': 'rule-event'
} satisfies { [key: string]: SubscribableEvent });
async function buildMockedEndpointData(endpoint: ServerMockedEndpoint): Promise<MockedEndpointData> {
return {
id: endpoint.id,
explanation: endpoint.toString(true),
seenRequests: await endpoint.getSeenRequests(),
isPending: await endpoint.isPending()
};
}
const decodeAndSerializeBody = async (body: CompletedBody, headers: Headers): Promise<
| false // Not required
| { decoded: Buffer, decodingError?: undefined } // Success
| { decodingError: string, decoded?: undefined } // Failure
> => {
try {
const decoded = await decodeBodyBuffer(body.buffer, headers);
if (decoded === body.buffer) return false; // No decoding required - no-op.
else return { decoded }; // Successful decoding result
} catch (e) {
return { // Failed decoding - we just return the error message.
decodingError: (e as ErrorLike)?.message ?? 'Failed to decode message body'
};
}
};
const serverSideRuleBodySerializer = async (body: CompletedBody, headers: Headers) => {
const encoded = body.buffer.toString('base64');
const result = await decodeAndSerializeBody(body, headers);
if (result === false) { // No decoding required - no-op.
return { encoded };
} else if (result.decodingError !== undefined) { // Failed decoding - we just return the error message.
return { encoded, decodingError: result.decodingError };
} else if (result.decoded) { // Success - we return both formats to the client
return { encoded, decoded: result.decoded.toString('base64') };
} else {
throw new UnreachableCheck(result);
}
}
// messageBodyDecoding === 'None' => Just send encoded body as base64
const noopRuleBodySerializer = (body: CompletedBody) => body.buffer.toString('base64')
export function buildAdminServerModel(
mockServer: MockttpServer,
stream: Duplex,
ruleParams: { [key: string]: any },
options: {
messageBodyDecoding?: 'server-side' | 'none';
} = {}
): IResolvers {
const pubsub = new PubSub();
const messageBodyDecoding = options.messageBodyDecoding || 'server-side';
const ruleDeserializationOptions: MockttpDeserializationOptions = {
bodySerializer: messageBodyDecoding === 'server-side'
? serverSideRuleBodySerializer
: noopRuleBodySerializer,
ruleParams
};
// Build a set of event publishing callbacks (but don't subscribe them yet - we only
// want to subscribe on demand, to allow the server to opt-out unused event processing).
const eventListeners = graphqlSubscriptionPairs.reduce((acc, [, eventName]) => {
acc[eventName] = (evt: any) => {
pubsub.publish(eventName, { [eventName]: evt });
};
return acc;
}, {} as { [eventName: string]: (...args: any[]) => void });
const subscriptionResolvers = Object.fromEntries(graphqlSubscriptionPairs.map(([gqlName, eventName]) => ([
gqlName, {
subscribe: () => {
// Subscribe to the underlying server event, if we haven't already. Needs to actively check
// currently listeners because reset() clears all listeners, so they may disappear any time.
if (mockServer.listenerCount(eventName, eventListeners[eventName]) === 0) {
mockServer.on(eventName as any, (evt) => {
pubsub.publish(eventName, { [gqlName]: evt });
});
}
return pubsub.asyncIterator(eventName);
}
}
])));
return {
Query: {
mockedEndpoints: async (): Promise<MockedEndpointData[]> => {
return Promise.all((await mockServer.getMockedEndpoints()).map(buildMockedEndpointData));
},
pendingEndpoints: async (): Promise<MockedEndpointData[]> => {
return Promise.all((await mockServer.getPendingEndpoints()).map(buildMockedEndpointData));
},
mockedEndpoint: async (__: any, { id }: { id: string }): Promise<MockedEndpointData | null> => {
let endpoint = _.find(await mockServer.getMockedEndpoints(), (endpoint: MockedEndpoint) => {
return endpoint.id === id;
});
if (!endpoint) return null;
return buildMockedEndpointData(endpoint);
}
},
Mutation: {
addRule: async (__: any, { input }: { input: Serialized<RequestRuleData> }) => {
return mockServer.addRequestRule(deserializeRuleData(input, stream, ruleDeserializationOptions));
},
addRules: async (__: any, { input }: { input: Array<Serialized<RequestRuleData>> }) => {
return mockServer.addRequestRules(...input.map((rule) =>
deserializeRuleData(rule, stream, ruleDeserializationOptions)
));
},
setRules: async (__: any, { input }: { input: Array<Serialized<RequestRuleData>> }) => {
return mockServer.setRequestRules(...input.map((rule) =>
deserializeRuleData(rule, stream, ruleDeserializationOptions)
));
},
addWebSocketRule: async (__: any, { input }: { input: Serialized<WebSocketRuleData> }) => {
return mockServer.addWebSocketRule(deserializeWebSocketRuleData(input, stream, ruleDeserializationOptions));
},
addWebSocketRules: async (__: any, { input }: { input: Array<Serialized<WebSocketRuleData>> }) => {
return mockServer.addWebSocketRules(...input.map((rule) =>
deserializeWebSocketRuleData(rule, stream, ruleDeserializationOptions)
));
},
setWebSocketRules: async (__: any, { input }: { input: Array<Serialized<WebSocketRuleData>> }) => {
return mockServer.setWebSocketRules(...input.map((rule) =>
deserializeWebSocketRuleData(rule, stream, ruleDeserializationOptions)
));
}
},
Subscription: subscriptionResolvers,
Request: {
body: (request: CompletedRequest) => {
return request.body.buffer;
},
decodedBody: async (request: CompletedRequest) => {
if (messageBodyDecoding === 'none') {
throw new Error('Decoded body requested, but messageBodyDecoding is set to "none"');
}
return (await decodeAndSerializeBody(request.body, request.headers))
|| {}; // No decoding required
}
},
Response: {
body: (response: CompletedResponse) => {
return response.body.buffer;
},
decodedBody: async (response: CompletedResponse) => {
if (messageBodyDecoding === 'none') {
throw new Error('Decoded body requested, but messageBodyDecoding is set to "none"');
}
return (await decodeAndSerializeBody(response.body, response.headers))
|| {}; // No decoding required
}
},
ClientError: {
response: (error: ClientError) => {
if (error.response === 'aborted') return undefined;
else return error.response;
}
}
};
}