UNPKG

mockttp-mvs

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

509 lines (455 loc) 19 kB
import _ = require('lodash'); import * as stream from 'stream'; import gql from 'graphql-tag'; import { MockedEndpoint, MockedEndpointData } from "../types"; import { buildBodyReader } from '../util/request-utils'; import { objectHeadersToRaw, rawHeadersToObject } from '../util/header-utils'; import { AdminQuery } from './admin-query'; import { SchemaIntrospector } from './schema-introspection'; import type { RequestRuleData } from "../rules/requests/request-rule"; import type { WebSocketRuleData } from '../rules/websockets/websocket-rule'; import { SubscribableEvent } from '../mockttp'; import { MockedEndpointClient } from "./mocked-endpoint-client"; import { AdminClient } from './admin-client'; import { serializeRuleData } from '../rules/rule-serialization'; function normalizeHttpMessage(message: any, event?: SubscribableEvent) { if (message.timingEvents) { // Timing events are serialized as raw JSON message.timingEvents = JSON.parse(message.timingEvents); } else if (event !== 'tls-client-error' && event !== 'client-error') { // For backwards compat, all except errors should have timing events if they're missing message.timingEvents = {}; } if (message.rawHeaders) { message.rawHeaders = JSON.parse(message.rawHeaders); // We use raw headers where possible to derive headers, instead of using any pre-derived // header data, for maximum accuracy (and to avoid any need to query for both). message.headers = rawHeadersToObject(message.rawHeaders); } else if (message.headers) { // Backward compat for older servers: message.headers = JSON.parse(message.headers); message.rawHeaders = objectHeadersToRaw(message.headers); } if (message.body !== undefined) { // Body is serialized as the raw encoded buffer in base64 message.body = buildBodyReader(Buffer.from(message.body, 'base64'), message.headers); } // For backwards compat, all except errors should have tags if they're missing if (!message.tags) message.tags = []; if (event?.startsWith('tls-')) { // TLS passthrough & error events should have raw JSON socket metadata: if (message.tlsMetadata) { message.tlsMetadata = JSON.parse(message.tlsMetadata); } else { // For old servers, just use empty metadata: message.tlsMetadata = {}; } } } function normalizeWebSocketMessage(message: any) { // Timing events are serialized as raw JSON message.timingEvents = JSON.parse(message.timingEvents); // Content is serialized as the raw encoded buffer in base64 message.content = Buffer.from(message.content, 'base64'); } /** * This is part of Mockttp's experimental 'pluggable admin' API. This may change * unpredictably, even in minor releases. * * @internal */ export class MockttpAdminRequestBuilder { constructor( private schema: SchemaIntrospector ) {} buildAddRequestRulesQuery( rules: Array<RequestRuleData>, reset: boolean, adminStream: stream.Duplex ): AdminQuery< { endpoints: Array<{ id: string, explanation?: string }> }, MockedEndpoint[] > { const requestName = (reset ? 'Set' : 'Add') + 'Rules'; const mutationName = (reset ? 'set' : 'add') + 'Rules'; const serializedRules = rules.map((rule) => { const serializedRule = serializeRuleData(rule, adminStream) if (!this.schema.typeHasInputField('MockRule', 'id')) { delete serializedRule.id; } return serializedRule; }); return { query: gql` mutation ${requestName}($newRules: [MockRule!]!) { endpoints: ${mutationName}(input: $newRules) { id, ${this.schema.asOptionalField('MockedEndpoint', 'explanation')} } } `, variables: { newRules: serializedRules }, transformResponse: (response, { adminClient }) => { return response.endpoints.map(({ id, explanation }) => new MockedEndpointClient( id, explanation, this.getEndpointDataGetter(adminClient, id) ) ) } }; } buildAddWebSocketRulesQuery( rules: Array<WebSocketRuleData>, reset: boolean, adminStream: stream.Duplex ): AdminQuery< { endpoints: Array<{ id: string, explanation?: string }> }, MockedEndpoint[] > { // Seperate and simpler than buildAddRequestRulesQuery, because it doesn't have to // deal with backward compatibility. const requestName = (reset ? 'Set' : 'Add') + 'WebSocketRules'; const mutationName = (reset ? 'set' : 'add') + 'WebSocketRules'; const serializedRules = rules.map((rule) => serializeRuleData(rule, adminStream)); return { query: gql` mutation ${requestName}($newRules: [WebSocketMockRule!]!) { endpoints: ${mutationName}(input: $newRules) { id, explanation } } `, variables: { newRules: serializedRules }, transformResponse: (response, { adminClient }) => { return response.endpoints.map(({ id, explanation }) => new MockedEndpointClient( id, explanation, this.getEndpointDataGetter(adminClient, id) ) ); } }; }; buildMockedEndpointsQuery(): AdminQuery< { mockedEndpoints: MockedEndpointData[] }, MockedEndpoint[] > { return { query: gql` query GetAllEndpointData { mockedEndpoints { id, ${this.schema.asOptionalField('MockedEndpoint', 'explanation')} } } `, transformResponse: (response, { adminClient }) => { const mockedEndpoints = response.mockedEndpoints; return mockedEndpoints.map(({ id, explanation }) => new MockedEndpointClient( id, explanation, this.getEndpointDataGetter(adminClient, id) ) ); } }; } public buildPendingEndpointsQuery(): AdminQuery< { pendingEndpoints: MockedEndpointData[] }, MockedEndpoint[] > { return { query: gql` query GetPendingEndpointData { pendingEndpoints { id, explanation } } `, transformResponse: (response, { adminClient }) => { const pendingEndpoints = response.pendingEndpoints; return pendingEndpoints.map(({ id, explanation }) => new MockedEndpointClient( id, explanation, this.getEndpointDataGetter(adminClient, id) ) ); } }; } public buildSubscriptionRequest<T>(event: SubscribableEvent): AdminQuery<unknown, T> | undefined { // Note the asOptionalField checks - these are a quick hack for backward compatibility, // introspecting the server schema to avoid requesting fields that don't exist on old servers. const query = { 'request-initiated': gql`subscription OnRequestInitiated { requestInitiated { id, protocol, method, url, path, ${this.schema.asOptionalField('InitiatedRequest', 'remoteIpAddress')}, ${this.schema.asOptionalField('InitiatedRequest', 'remotePort')}, hostname, ${this.schema.typeHasField('InitiatedRequest', 'rawHeaders') ? 'rawHeaders' : 'headers' } timingEvents, httpVersion, ${this.schema.asOptionalField('InitiatedRequest', 'tags')} } }`, request: gql`subscription OnRequest { requestReceived { id, ${this.schema.asOptionalField('Request', 'matchedRuleId')} protocol, method, url, path, ${this.schema.asOptionalField('Request', 'remoteIpAddress')}, ${this.schema.asOptionalField('Request', 'remotePort')}, hostname, ${this.schema.typeHasField('Request', 'rawHeaders') ? 'rawHeaders' : 'headers' } body, ${this.schema.asOptionalField('Request', 'timingEvents')} ${this.schema.asOptionalField('Request', 'httpVersion')} ${this.schema.asOptionalField('Request', 'tags')} } }`, response: gql`subscription OnResponse { responseCompleted { id, statusCode, statusMessage, ${this.schema.typeHasField('Response', 'rawHeaders') ? 'rawHeaders' : 'headers' } body, ${this.schema.asOptionalField('Response', 'timingEvents')} ${this.schema.asOptionalField('Response', 'tags')} } }`, 'websocket-request': gql`subscription OnWebSocketRequest { webSocketRequest { id, matchedRuleId, protocol, method, url, path, remoteIpAddress, remotePort, hostname, rawHeaders, body, timingEvents, httpVersion, tags } }`, 'websocket-accepted': gql`subscription OnWebSocketAccepted { webSocketAccepted { id, statusCode, statusMessage, rawHeaders, body, timingEvents, tags } }`, 'websocket-message-received': gql`subscription OnWebSocketMessageReceived { webSocketMessageReceived { streamId, direction, content, isBinary, eventTimestamp, timingEvents, tags } }`, 'websocket-message-sent': gql`subscription OnWebSocketMessageSent { webSocketMessageSent { streamId, direction, content, isBinary, eventTimestamp, timingEvents, tags } }`, 'websocket-close': gql`subscription OnWebSocketClose { webSocketClose { streamId, closeCode, closeReason, timingEvents, tags } }`, abort: gql`subscription OnAbort { requestAborted { id, protocol, method, url, path, hostname, ${this.schema.typeHasField('Request', 'rawHeaders') ? 'rawHeaders' : 'headers' } ${this.schema.asOptionalField('Request', 'timingEvents')} ${this.schema.asOptionalField('Request', 'tags')} ${this.schema.asOptionalField('AbortedRequest', 'error')} } }`, 'tls-passthrough-opened': gql`subscription OnTlsPassthroughOpened { tlsPassthroughOpened { id upstreamPort hostname remoteIpAddress remotePort tags timingEvents ${this.schema.asOptionalField('TlsPassthroughEvent', 'tlsMetadata')} } }`, 'tls-passthrough-closed': gql`subscription OnTlsPassthroughClosed { tlsPassthroughClosed { id upstreamPort hostname remoteIpAddress remotePort tags timingEvents ${this.schema.asOptionalField('TlsPassthroughEvent', 'tlsMetadata')} } }`, 'tls-client-error': gql`subscription OnTlsClientError { failedTlsRequest { failureCause hostname remoteIpAddress ${this.schema.asOptionalField(['TlsHandshakeFailure', 'TlsRequest'], 'remotePort')} ${this.schema.asOptionalField(['TlsHandshakeFailure', 'TlsRequest'], 'tags')} ${this.schema.asOptionalField(['TlsHandshakeFailure', 'TlsRequest'], 'timingEvents')} ${this.schema.asOptionalField(['TlsHandshakeFailure', 'TlsRequest'], 'tlsMetadata')} } }`, 'client-error': gql`subscription OnClientError { failedClientRequest { errorCode request { id timingEvents tags protocol httpVersion method url path ${this.schema.typeHasField('ClientErrorRequest', 'rawHeaders') ? 'rawHeaders' : 'headers' } ${this.schema.asOptionalField('ClientErrorRequest', 'remoteIpAddress')}, ${this.schema.asOptionalField('ClientErrorRequest', 'remotePort')}, } response { id timingEvents tags statusCode statusMessage ${this.schema.typeHasField('Response', 'rawHeaders') ? 'rawHeaders' : 'headers' } body } } }` }[event]; if (!query) return; // Unrecognized event, we can't subscribe to this. return { query, transformResponse: (data: any): T => { if (event === 'client-error') { data.request = _.mapValues(data.request, (v) => // Normalize missing values to undefined to match the local result v === null ? undefined : v ); normalizeHttpMessage(data.request, event); if (data.response) { normalizeHttpMessage(data.response, event); } else { data.response = 'aborted'; } } else if (event === 'websocket-message-received' || event === 'websocket-message-sent') { normalizeWebSocketMessage(data); } else if (event === 'abort') { normalizeHttpMessage(data, event); data.error = data.error ? JSON.parse(data.error) : undefined; } else { normalizeHttpMessage(data, event); } return data; } }; } private getEndpointDataGetter = (adminClient: AdminClient<{}>, ruleId: string) => async (): Promise<MockedEndpointData | null> => { let result = await adminClient.sendQuery<{ mockedEndpoint: MockedEndpointData | null }>({ query: gql` query GetEndpointData($id: ID!) { mockedEndpoint(id: $id) { seenRequests { protocol, method, url, path, hostname ${this.schema.typeHasField('Request', 'rawHeaders') ? 'rawHeaders' : 'headers' } body, ${this.schema.asOptionalField('Request', 'timingEvents')} ${this.schema.asOptionalField('Request', 'httpVersion')} } ${this.schema.asOptionalField('MockedEndpoint', 'isPending')} } } `, variables: { id: ruleId } }); const mockedEndpoint = result.mockedEndpoint; if (!mockedEndpoint) return null; mockedEndpoint.seenRequests.forEach(req => normalizeHttpMessage(req)); return mockedEndpoint; } }