mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
174 lines (148 loc) • 6.83 kB
text/typescript
import { Buffer } from 'buffer';
import * as _ from 'lodash';
import { OngoingRequest, CompletedRequest, OngoingResponse, Explainable, RulePriority } from "../../types";
import { buildBodyReader, buildInitiatedRequest, waitForCompletedRequest } from '../../util/request-utils';
import { MaybePromise } from '@httptoolkit/util';
import * as matchers from "../matchers";
import { type RequestStepDefinition } from "./request-step-definitions";
import { StepLookup, RequestStepImpl } from "./request-step-impls";
import * as completionCheckers from "../completion-checkers";
import { validateMockRuleData } from '../rule-serialization';
// The internal representation of a mocked endpoint
export interface RequestRule extends Explainable {
id: string;
requests: Promise<CompletedRequest>[];
// We don't extend the main interfaces for these, because MockRules are not Serializable
matches(request: OngoingRequest): MaybePromise<boolean>;
handle(request: OngoingRequest, response: OngoingResponse, options: {
record: boolean,
debug: boolean,
emitEventCallback?: (type: string, event: unknown) => void
}): Promise<void>;
isComplete(): boolean | null;
}
export interface RequestRuleData {
id?: string;
priority?: number; // Higher is higher, by default 0 is fallback, 1 is normal, must be positive
matchers: matchers.RequestMatcher[];
steps: Array<RequestStepDefinition>;
completionChecker?: completionCheckers.RuleCompletionChecker;
}
export class RequestRule implements RequestRule {
private matchers: matchers.RequestMatcher[];
private steps: Array<RequestStepImpl>;
private completionChecker?: completionCheckers.RuleCompletionChecker;
public id: string;
public readonly priority: number;
public requests: Promise<CompletedRequest>[] = [];
public requestCount = 0;
constructor(data: RequestRuleData) {
validateMockRuleData(data);
this.id = data.id || crypto.randomUUID();
this.priority = data.priority ?? RulePriority.DEFAULT;
this.matchers = data.matchers;
this.completionChecker = data.completionChecker;
this.steps = data.steps.map((stepDefinition, i) => {
const step = Object.assign(
Object.create(StepLookup[stepDefinition.type].prototype),
stepDefinition
) as RequestStepImpl;
if (StepLookup[step.type].isFinal && i !== data.steps.length - 1) {
throw new Error(
`Cannot create a rule with a final step before the last position ("${
step.explain()
}" in position ${i + 1} of ${data.steps.length})`
);
}
return step;
});
}
matches(request: OngoingRequest) {
return matchers.matchesAll(request, this.matchers);
}
handle(req: OngoingRequest, res: OngoingResponse, options: {
record?: boolean,
debug: boolean,
emitEventCallback?: (type: string, event: unknown) => void
}): Promise<void> {
let stepsPromise = (async () => {
for (let step of this.steps) {
const result = await step.handle(req, res, {
emitEventCallback: options.emitEventCallback,
debug: options.debug
});
if (!result || result.continue === false) break;
}
})();
// Requests are added to rule.requests as soon as they start being handled,
// as promises, which resolve only when the response & request body is complete.
if (options.record) {
this.requests.push(
Promise.race([
// When the steps all resolve, the request is completed:
stepsPromise,
// If the response is closed before the step completes (due to aborts, step
// timeouts, whatever) then that also counts as the request being completed:
new Promise((resolve) => res.on('close', resolve))
])
.catch(() => {}) // Ignore step errors here - we're only tracking the request
.then(() => waitForCompletedRequest(req))
.catch((): CompletedRequest => {
// If for some reason the request is not completed, we still want to record it.
// TODO: Update the body to return the data that has been received so far.
const initiatedRequest = buildInitiatedRequest(req);
return {
...initiatedRequest,
body: buildBodyReader(Buffer.from([]), req.headers),
rawTrailers: [],
trailers: {}
};
})
);
}
// Even if traffic recording is disabled, the number of matched
// requests is still tracked
this.requestCount += 1;
return stepsPromise as Promise<any>;
}
isComplete(): boolean | null {
if (this.completionChecker) {
// If we have a specific rule, use that
return this.completionChecker.isComplete(this.requestCount);
} else if (this.requestCount === 0) {
// Otherwise, by default we're definitely incomplete if we've seen no requests
return false;
} else {
// And we're _maybe_ complete if we've seen at least one request. In reality, we're incomplete
// but we should be used anyway if we're at any point we're the last matching rule for a request.
return null;
}
}
explain(withoutExactCompletion = false): string {
let explanation = `Match requests ${matchers.explainMatchers(this.matchers)}, ` +
`and then ${explainSteps(this.steps)}`;
if (this.completionChecker) {
explanation += `, ${this.completionChecker.explain(
withoutExactCompletion ? undefined : this.requestCount
)}.`;
} else {
explanation += '.';
}
return explanation;
}
dispose() {
this.steps.forEach(s => s.dispose());
this.matchers.forEach(m => m.dispose());
if (this.completionChecker) this.completionChecker.dispose();
}
}
export function explainSteps(steps: RequestStepDefinition[]) {
if (steps.length === 1) return steps[0].explain();
if (steps.length === 2) {
return `${steps[0].explain()} then ${steps[1].explain()}`;
}
// With 3+, we need to oxford comma separate explanations to make them readable
return steps.slice(0, -1)
.map((s) => s.explain())
.join(', ') + ', and ' + steps.slice(-1)[0].explain();
}