Skip to content

Commit

Permalink
FormidableLabs#64 First pass at matching().respond()
Browse files Browse the repository at this point in the history
  • Loading branch information
ianwsperber committed Mar 29, 2020
1 parent b9d2907 commit dcb0413
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 47 deletions.
53 changes: 52 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,84 @@
import { ISerializedHttp, RequestSerializer } from './http-serializer';
import { ResponseForRequest } from './filtering/collection';
import { match, Matcher } from './filtering/matcher';
import {
ISerializedHttp,
ISerializedRequest,
ISerializedResponse,
RequestSerializer,
} from './http-serializer';
import { RecordMode as Mode } from './recording';

export interface IInFlightRequest {
startTime: number;
requestSerializer: RequestSerializer;
}

export interface IResponseForMatchingRequest {
response: ResponseForRequest;
matcher: Matcher;
}

/**
* Store the current execution context for YesNo by tracking requests & mocks.
*/
export default class Context {
public mode: Mode = Mode.Spy;

/**
* Completed serialized request-response objects. Used for:
* A. Assertions
* B. Saved to disk if in record mode
*/
public interceptedRequestsCompleted: ISerializedHttp[] = [];

/**
* Serialized records loaded from disk.
*/
public loadedMocks: ISerializedHttp[] = [];

/**
* Proxied requests which have not yet responded. When completed
* the value is set to "null" but the index is preserved.
*/
public inFlightRequests: Array<IInFlightRequest | null> = [];

public responsesForMatchingRequests: IResponseForMatchingRequest[] = [];

public clear() {
this.interceptedRequestsCompleted = [];
this.inFlightRequests = [];
this.loadedMocks = [];
this.responsesForMatchingRequests = [];
}

public getMatchingMocks(matcher: Matcher): ISerializedHttp[] {
return this.loadedMocks.filter(match(matcher));
}

public getMatchingIntercepted(matcher: Matcher): ISerializedHttp[] {
return this.interceptedRequestsCompleted.filter(match(matcher));
}

public hasResponsesDefinedForMatchers(): boolean {
return !!this.responsesForMatchingRequests.length;
}

public addResponseForMatchingRequests(matchingResponse: IResponseForMatchingRequest): void {
this.responsesForMatchingRequests.push(matchingResponse);
}

public getResponseDefinedMatching(request: ISerializedRequest): ISerializedResponse | undefined {
let firstMatchingResponse: ISerializedResponse | undefined;

for (const { matcher, response } of this.responsesForMatchingRequests) {
const doesMatch = match(matcher)({ request });

if (doesMatch) {
firstMatchingResponse = typeof response === 'function' ? response(request) : response;
break;
}
}

return firstMatchingResponse;
}
}
20 changes: 16 additions & 4 deletions src/filtering/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ISerializedRequest,
ISerializedResponse,
} from '../http-serializer';
import { ISerializedHttpPartialDeepMatch, match, MatchFn } from './matcher';
import { ISerializedHttpPartialDeepMatch, Matcher, MatchFn } from './matcher';
import { redact, Redactor } from './redact';

export interface IFiltered {
Expand All @@ -20,9 +20,13 @@ export interface IFiltered {

export interface IFilteredHttpCollectionParams {
context: Context;
matcher?: ISerializedHttpPartialDeepMatch | MatchFn;
matcher?: Matcher;
}

export type ResponseForRequest =
| ISerializedResponse
| ((request: ISerializedRequest) => ISerializedResponse);

/**
* Represents a collection of HTTP requests which match the provided filter.
*
Expand All @@ -41,14 +45,22 @@ export default class FilteredHttpCollection implements IFiltered {
* Return all intercepted requests matching the current filter
*/
public intercepted(): ISerializedHttp[] {
return this.ctx.interceptedRequestsCompleted.filter(match(this.matcher));
return this.ctx.getMatchingIntercepted(this.matcher);
}

/**
* Return all intercepted mocks matching the current filter
*/
public mocks(): ISerializedHttp[] {
return this.ctx.loadedMocks.filter(match(this.matcher));
return this.ctx.getMatchingMocks(this.matcher);
}

/**
* Provide a mock response for all matching requests
* @param response Serialized response or a callback to define the response per request
*/
public respond(response: ResponseForRequest): void {
this.ctx.addResponseForMatchingRequests({ response, matcher: this.matcher });
}

/**
Expand Down
30 changes: 22 additions & 8 deletions src/filtering/matcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import _ from 'lodash';
import {
formatUrl,
ISerializedHttp,
ISeralizedRequestResponse,
ISerializedRequest,
ISerializedResponse,
} from '../http-serializer';
Expand All @@ -15,7 +15,18 @@ export interface ISerializedHttpPartialDeepMatch {
response?: ResponseQuery;
}

export type MatchFn = (serialized: ISerializedHttp) => boolean;
export interface ISeralizedRequestResponseToMatch {
request: ISerializedRequest;
response?: ISerializedResponse;
}

export type MatchFn = (serialized: ISeralizedRequestResponse) => boolean;

export type UnsafeMatchFn = (serialized: ISeralizedRequestResponseToMatch) => boolean;

export type Matcher = ISerializedHttpPartialDeepMatch | MatchFn;

export const EMPTY_RESPONSE = { body: {}, headers: {}, statusCode: 0 };

/**
* Curried function to determine whether a query matches an intercepted request.
Expand All @@ -24,9 +35,7 @@ export type MatchFn = (serialized: ISerializedHttp) => boolean;
*
* RegEx values are tested for match.
*/
export function match(
fnOrPartialMatch: ISerializedHttpPartialDeepMatch | MatchFn,
): (intercepted: ISerializedHttp) => boolean {
export function match(fnOrPartialMatch: ISerializedHttpPartialDeepMatch | MatchFn): UnsafeMatchFn {
const equalityOrRegExpDeep = (reqResValue: any, queryValue: any): boolean => {
if (queryValue instanceof RegExp) {
return queryValue.test(reqResValue);
Expand All @@ -38,9 +47,9 @@ export function match(
}
};

const matcher = _.isFunction(fnOrPartialMatch)
const matcher: MatchFn = _.isFunction(fnOrPartialMatch)
? fnOrPartialMatch
: (serialized: ISerializedHttp): boolean => {
: (serialized: ISeralizedRequestResponse): boolean => {
const query = fnOrPartialMatch as ISerializedHttpPartialDeepMatch;
let isMatch = true;

Expand All @@ -63,5 +72,10 @@ export function match(
return isMatch;
};

return matcher;
return (serialized: ISeralizedRequestResponseToMatch) =>
matcher({
request: serialized.request,
// Response will be empty if matching against requests
response: serialized.response || EMPTY_RESPONSE,
});
}
10 changes: 9 additions & 1 deletion src/http-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const SerializedHttp = t.intersection([
]);

/**
* HTTP request/response serialized in a consistent format to be stored on disk in a mocks file
* HTTP request/response serialized in a consistent format
*/
export interface ISerializedHttp extends t.TypeOf<typeof SerializedHttp> {}

Expand All @@ -69,6 +69,14 @@ export interface ISerializedResponse extends t.TypeOf<typeof SerializedResponse>
*/
export interface ISerializedRequest extends t.TypeOf<typeof SerializedRequest> {}

/**
* HTTP request & response
*/
export interface ISeralizedRequestResponse {
request: ISerializedRequest;
response: ISerializedResponse;
}

export interface IHeaders extends t.TypeOf<typeof Headers> {}

// Some properties are not present in the TS definition
Expand Down
48 changes: 30 additions & 18 deletions src/mock-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import * as readable from 'readable-stream';
import Context from './context';
import { YesNoError } from './errors';
import * as comparator from './filtering/comparator';
import { ISerializedHttp, ISerializedRequest } from './http-serializer';
import {
ISeralizedRequestResponse,
ISerializedHttp,
ISerializedRequest,
ISerializedResponse,
} from './http-serializer';
import { IInterceptEvent } from './interceptor';
import { RecordMode as Mode } from './recording';

const debug: IDebugger = require('debug')('yesno:mock-response');

Expand All @@ -19,30 +25,36 @@ export default class MockResponse {
this.event = event;
}

public async send(): Promise<Pick<ISerializedHttp, 'request' | 'response'>> {
public async send(): Promise<ISeralizedRequestResponse | undefined> {
const {
comparatorFn,
interceptedRequest,
interceptedResponse,
requestSerializer,
requestNumber,
} = this.event;
let response: ISerializedResponse | undefined;

debug(`[#${requestNumber}] Mock response`);
const mock = this.getMockForIntecept(this.event);

await (readable as any).pipeline(interceptedRequest, requestSerializer);
const request = requestSerializer.serialize();
response = this.ctx.getResponseDefinedMatching(request);

if (!response && this.ctx.mode === Mode.Mock) {
const mock = this.getMockForIntecept(this.event);

const serializedRequest = requestSerializer.serialize();
// Assertion must happen before promise -
// mitm does not support promise rejections on "request" event
this.assertMockMatches({ mock, serializedRequest: request, requestNumber, comparatorFn });

// Assertion must happen before promise -
// mitm does not support promise rejections on "request" event
this.assertMockMatches({ mock, serializedRequest, requestNumber, comparatorFn });
this.writeMockResponse(mock, interceptedResponse);
response = mock.response;
}

return {
request: serializedRequest,
response: mock.response,
};
if (response) {
this.writeMockResponse(response, interceptedResponse);
return { request, response };
}
}

private getMockForIntecept({ requestNumber }: IInterceptEvent): ISerializedHttp {
Expand Down Expand Up @@ -77,22 +89,22 @@ export default class MockResponse {
}

private writeMockResponse(
mock: ISerializedHttp,
response: ISerializedResponse,
interceptedResponse: IInterceptEvent['interceptedResponse'],
): void {
const bodyString = _.isPlainObject(mock.response.body)
? JSON.stringify(mock.response.body)
: (mock.response.body as string);
const bodyString = _.isPlainObject(response.body)
? JSON.stringify(response.body)
: (response.body as string);

const responseHeaders = { ...mock.response.headers };
const responseHeaders = { ...response.headers };
if (
responseHeaders['content-length'] &&
parseInt(responseHeaders['content-length'] as string, 10) !== Buffer.byteLength(bodyString)
) {
responseHeaders['content-length'] = Buffer.byteLength(bodyString);
}

interceptedResponse.writeHead(mock.response.statusCode, responseHeaders);
interceptedResponse.writeHead(response.statusCode, responseHeaders);
interceptedResponse.write(bodyString);
interceptedResponse.end();
}
Expand Down
37 changes: 22 additions & 15 deletions src/yesno.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IDebugger } from 'debug';
import * as _ from 'lodash';
import { EOL } from 'os';

import { YESNO_RECORDING_MODE_ENV_VAR } from './consts';
import Context, { IInFlightRequest } from './context';
import { YesNoError } from './errors';
Expand Down Expand Up @@ -38,7 +39,6 @@ export interface IRecordableTest {
* Client API for YesNo
*/
export class YesNo implements IFiltered {
private mode: Mode = Mode.Spy;
private readonly interceptor: Interceptor;
private readonly ctx: Context;

Expand Down Expand Up @@ -249,7 +249,7 @@ export class YesNo implements IFiltered {
* Determine the current mode
*/
private isMode(mode: Mode): boolean {
return this.mode === mode;
return this.ctx.mode === mode;
}

private createInterceptor() {
Expand All @@ -267,21 +267,28 @@ export class YesNo implements IFiltered {
startTime: Date.now(),
};

if (this.isMode(Mode.Mock)) {
if (!this.ctx.hasResponsesDefinedForMatchers() && !this.isMode(Mode.Mock)) {
return;
}

try {
const mockResponse = new MockResponse(event, this.ctx);
try {
const { request, response } = await mockResponse.send();
this.recordRequestResponse(request, response, event.requestNumber);
} catch (e) {
if (!(e instanceof YesNoError)) {
debug(`[#${event.requestNumber}] Mock response failed unexpectedly`, e);
e.message = `YesNo: Mock response failed: ${e.message}`;
} else {
debug(`[#${event.requestNumber}] Mock response failed`, e.message);
}
const sent = await mockResponse.send();

event.clientRequest.emit('error', e);
if (sent) {
this.recordRequestResponse(sent.request, sent.response, event.requestNumber);
} else if (this.isMode(Mode.Mock)) {
throw new Error('Unexpectedly failed to send mock respond');
}
} catch (e) {
if (!(e instanceof YesNoError)) {
debug(`[#${event.requestNumber}] Mock response failed unexpectedly`, e);
e.message = `YesNo: Mock response failed: ${e.message}`;
} else {
debug(`[#${event.requestNumber}] Mock response failed`, e.message);
}

event.clientRequest.emit('error', e);
}
}

Expand All @@ -294,7 +301,7 @@ export class YesNo implements IFiltered {
}

private setMode(mode: Mode) {
this.mode = mode;
this.ctx.mode = mode;

if (this.interceptor) {
this.interceptor.proxy(!this.isMode(Mode.Mock));
Expand Down

0 comments on commit dcb0413

Please sign in to comment.