diff --git a/src/handlers/GraphQLHandler.ts b/src/handlers/GraphQLHandler.ts index e556364d3..9c2121e9e 100644 --- a/src/handlers/GraphQLHandler.ts +++ b/src/handlers/GraphQLHandler.ts @@ -1,15 +1,13 @@ import { DocumentNode, OperationTypeNode } from 'graphql' import { SerializedResponse } from '../setupWorker/glossary' -import { set } from '../context/set' -import { status } from '../context/status' -import { delay } from '../context/delay' -import { fetch } from '../context/fetch' import { data } from '../context/data' import { extensions } from '../context/extensions' import { errors } from '../context/errors' import { GraphQLPayloadContext } from '../typeUtils' import { cookie } from '../context/cookie' import { + defaultContext, + DefaultContext, MockedRequest, RequestHandler, RequestHandlerDefaultInfo, @@ -36,22 +34,16 @@ export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string // GraphQL related context should contain utility functions // useful for GraphQL. Functions like `xml()` bear no value // in the GraphQL universe. -export type GraphQLContext> = { - set: typeof set - status: typeof status - delay: typeof delay - fetch: typeof fetch - data: GraphQLPayloadContext - extensions: GraphQLPayloadContext - errors: typeof errors - cookie: typeof cookie -} +export type GraphQLContext> = + DefaultContext & { + data: GraphQLPayloadContext + extensions: GraphQLPayloadContext + errors: typeof errors + cookie: typeof cookie + } export const graphqlContext: GraphQLContext = { - set, - status, - delay, - fetch, + ...defaultContext, data, extensions, errors, diff --git a/src/handlers/RequestHandler.ts b/src/handlers/RequestHandler.ts index 41e3ee574..a241eb202 100644 --- a/src/handlers/RequestHandler.ts +++ b/src/handlers/RequestHandler.ts @@ -1,5 +1,10 @@ import { Headers } from 'headers-polyfill' -import { MockedResponse, response, ResponseComposition } from '../response' +import { + MaybePromise, + MockedResponse, + response, + ResponseComposition, +} from '../response' import { getCallFrame } from '../utils/internal/getCallFrame' import { isIterable } from '../utils/internal/isIterable' import { status } from '../context/status' @@ -9,7 +14,14 @@ import { fetch } from '../context/fetch' import { ResponseResolutionContext } from '../utils/getResponse' import { SerializedResponse } from '../setupWorker/glossary' -export const defaultContext = { +export type DefaultContext = { + status: typeof status + set: typeof set + delay: typeof delay + fetch: typeof fetch +} + +export const defaultContext: DefaultContext = { status, set, delay, @@ -47,6 +59,7 @@ export interface MockedRequest { referrerPolicy: Request['referrerPolicy'] body: Body bodyUsed: Request['bodyUsed'] + passthrough: typeof passthrough } export interface RequestHandlerDefaultInfo { @@ -64,9 +77,9 @@ export type ResponseResolverReturnType = | undefined | void -export type MaybeAsyncResponseResolverReturnType = - | ResponseResolverReturnType - | Promise> +export type MaybeAsyncResponseResolverReturnType = MaybePromise< + ResponseResolverReturnType +> export type AsyncResponseResolverReturnType = | MaybeAsyncResponseResolverReturnType @@ -272,3 +285,22 @@ export abstract class RequestHandler< } } } + +/** + * Bypass this intercepted request. + * This will make a call to the actual endpoint requested. + */ +export function passthrough(): MockedResponse { + // Constructing a dummy "101 Continue" mocked response + // to keep the return type of the resolver consistent. + return { + status: 101, + statusText: 'Continue', + headers: new Headers(), + body: null, + // Setting "passthrough" to true will signal the response pipeline + // to perform this intercepted request as-is. + passthrough: true, + once: false, + } +} diff --git a/src/handlers/RestHandler.ts b/src/handlers/RestHandler.ts index 2ef802e98..d9bf2c026 100644 --- a/src/handlers/RestHandler.ts +++ b/src/handlers/RestHandler.ts @@ -1,14 +1,4 @@ -import { - body, - cookie, - delay, - fetch, - json, - set, - status, - text, - xml, -} from '../context' +import { body, cookie, json, text, xml } from '../context' import { SerializedResponse } from '../setupWorker/glossary' import { ResponseResolutionContext } from '../utils/getResponse' import { devUtils } from '../utils/internal/devUtils' @@ -27,6 +17,8 @@ import { getPublicUrlFromRequest } from '../utils/request/getPublicUrlFromReques import { cleanUrl, getSearchParams } from '../utils/url/cleanUrl' import { DefaultRequestBody, + defaultContext, + DefaultContext, MockedRequest, RequestHandler, RequestHandlerDefaultInfo, @@ -52,28 +44,21 @@ export enum RESTMethods { // Declaring a context interface infers // JSDoc description of the referenced utils. -export type RestContext = { - set: typeof set - status: typeof status +export type RestContext = DefaultContext & { cookie: typeof cookie text: typeof text body: typeof body json: typeof json xml: typeof xml - delay: typeof delay - fetch: typeof fetch } export const restContext: RestContext = { - set, - status, + ...defaultContext, cookie, body, text, json, xml, - delay, - fetch, } export type RequestQuery = { diff --git a/src/response.ts b/src/response.ts index 2371e1479..b6e4a7534 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,6 +2,8 @@ import { Headers } from 'headers-polyfill' import { compose } from './utils/internal/compose' import { NetworkError } from './utils/NetworkError' +export type MaybePromise = ValueType | Promise + /** * Internal representation of a mocked response instance. */ @@ -11,6 +13,7 @@ export interface MockedResponse { statusText: string headers: Headers once: boolean + passthrough: boolean delay?: number } @@ -19,11 +22,11 @@ export type ResponseTransformer< TransformerBodyType = any, > = ( res: MockedResponse, -) => MockedResponse | Promise> +) => MaybePromise> export type ResponseFunction = ( ...transformers: ResponseTransformer[] -) => MockedResponse | Promise> +) => MaybePromise> export type ResponseComposition = ResponseFunction & { /** @@ -40,6 +43,7 @@ export const defaultResponse: Omit = { body: null, delay: 0, once: false, + passthrough: false, } export type ResponseCompositionOptions = { diff --git a/src/utils/handleRequest.test.ts b/src/utils/handleRequest.test.ts index 1f21d1763..04e345416 100644 --- a/src/utils/handleRequest.test.ts +++ b/src/utils/handleRequest.test.ts @@ -5,7 +5,7 @@ import { createMockedRequest } from '../../test/support/utils' import { SharedOptions } from '../sharedOptions' import { RequestHandler } from '../handlers/RequestHandler' import { rest } from '../rest' -import { handleRequest } from './handleRequest' +import { handleRequest, HandleRequestOptions } from './handleRequest' import { response } from '../response' import { context } from '..' import { RequiredDeep } from '../typeUtils' @@ -24,8 +24,8 @@ function getEmittedEvents() { const options: RequiredDeep = { onUnhandledRequest: jest.fn(), } -const callbacks = { - onBypassResponse: jest.fn(), +const callbacks: Partial, any>> = { + onPassthroughResponse: jest.fn(), onMockedResponse: jest.fn(), onMockedResponseSent: jest.fn(), } @@ -67,7 +67,7 @@ test('returns undefined for a request with the "x-msw-bypass" header equal to "t ['request:end', request], ]) expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onBypassResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) expect(callbacks.onMockedResponse).not.toHaveBeenCalled() expect(callbacks.onMockedResponseSent).not.toHaveBeenCalled() }) @@ -123,7 +123,7 @@ test('reports request as unhandled when it has no matching request handlers', as warning: expect.any(Function), error: expect.any(Function), }) - expect(callbacks.onBypassResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) expect(callbacks.onMockedResponse).not.toHaveBeenCalled() expect(callbacks.onMockedResponseSent).not.toHaveBeenCalled() }) @@ -153,7 +153,7 @@ test('returns undefined and warns on a request handler that returns no response' ['request:end', request], ]) expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onBypassResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) expect(callbacks.onMockedResponse).not.toHaveBeenCalled() expect(callbacks.onMockedResponseSent).not.toHaveBeenCalled() @@ -198,7 +198,7 @@ test('returns the mocked response for a request with a matching request handler' ['request:match', request], ['request:end', request], ]) - expect(callbacks.onBypassResponse).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() expect(callbacks.onMockedResponse).toHaveBeenNthCalledWith( 1, mockedResponse, @@ -243,7 +243,7 @@ test('returns a transformed response if the "transformResponse" option is provid ['request:match', request], ['request:end', request], ]) - expect(callbacks.onBypassResponse).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() expect(transformResponse).toHaveBeenNthCalledWith(1, mockedResponse) expect(callbacks.onMockedResponse).toHaveBeenNthCalledWith( 1, @@ -256,3 +256,32 @@ test('returns a transformed response if the "transformResponse" option is provid lookupResult, ) }) + +it('returns undefined without warning on a passthrough request', async () => { + const request = createMockedRequest({ + url: new URL('http://localhost/user'), + }) + const handlers: RequestHandler[] = [ + rest.get('/user', (req) => { + return req.passthrough() + }), + ] + + const result = await handleRequest( + request, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toBeUndefined() + expect(getEmittedEvents()).toEqual([ + ['request:start', request], + ['request:end', request], + ]) + expect(options.onUnhandledRequest).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onMockedResponse).not.toHaveBeenCalled() + expect(callbacks.onMockedResponseSent).not.toHaveBeenCalled() +}) diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 6a125a45a..f8dd163b8 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -24,9 +24,9 @@ export interface HandleRequestOptions { transformResponse?(response: MockedResponse): ResponseType /** - * Invoked whenever returning a bypassed (as-is) response. + * Invoked whenever a request is performed as-is. */ - onBypassResponse?(request: MockedRequest): void + onPassthroughResponse?(request: MockedRequest): void /** * Invoked when the mocked response is ready to be sent. @@ -60,7 +60,7 @@ export async function handleRequest< // Perform bypassed requests (i.e. issued via "ctx.fetch") as-is. if (request.headers.get('x-msw-bypass') === 'true') { emitter.emit('request:end', request) - handleRequestOptions?.onBypassResponse?.(request) + handleRequestOptions?.onPassthroughResponse?.(request) return } @@ -78,7 +78,7 @@ export async function handleRequest< onUnhandledRequest(request, handlers, options.onUnhandledRequest) emitter.emit('request:unhandled', request) emitter.emit('request:end', request) - handleRequestOptions?.onBypassResponse?.(request) + handleRequestOptions?.onPassthroughResponse?.(request) return } @@ -98,7 +98,15 @@ Expected response resolver to return a mocked response Object, but got %s. The o ) emitter.emit('request:end', request) - handleRequestOptions?.onBypassResponse?.(request) + handleRequestOptions?.onPassthroughResponse?.(request) + return + } + + // When the developer explicitly returned "req.passthrough()" do not warn them. + // Perform the request as-is. + if (response.passthrough) { + emitter.emit('request:end', request) + handleRequestOptions?.onPassthroughResponse?.(request) return } diff --git a/src/utils/logging/prepareRequest.test.ts b/src/utils/logging/prepareRequest.test.ts index 413098b91..08818332f 100644 --- a/src/utils/logging/prepareRequest.test.ts +++ b/src/utils/logging/prepareRequest.test.ts @@ -1,4 +1,5 @@ import { Headers } from 'headers-polyfill' +import { passthrough } from '../../handlers/RequestHandler' import { prepareRequest } from './prepareRequest' test('converts request headers into an object', () => { @@ -22,6 +23,7 @@ test('converts request headers into an object', () => { body: 'text-body', bodyUsed: false, cookies: {}, + passthrough, }) // Converts `Headers` instance into inspectable object diff --git a/src/utils/request/parseIsomorphicRequest.test.ts b/src/utils/request/parseIsomorphicRequest.test.ts index 33c0fa877..f0e210a32 100644 --- a/src/utils/request/parseIsomorphicRequest.test.ts +++ b/src/utils/request/parseIsomorphicRequest.test.ts @@ -4,7 +4,7 @@ import { Headers } from 'headers-polyfill/lib' import { parseIsomorphicRequest } from './parseIsomorphicRequest' import { createIsomorphicRequest } from '../../../test/support/utils' -import { MockedRequest } from '../../handlers/RequestHandler' +import { MockedRequest, passthrough } from '../../handlers/RequestHandler' test('parses an isomorphic request', () => { const request = parseIsomorphicRequest( @@ -46,6 +46,7 @@ test('parses an isomorphic request', () => { body: { id: 'user-1', }, + passthrough, }) }) diff --git a/src/utils/request/parseIsomorphicRequest.ts b/src/utils/request/parseIsomorphicRequest.ts index 1ccaca70a..d088082ab 100644 --- a/src/utils/request/parseIsomorphicRequest.ts +++ b/src/utils/request/parseIsomorphicRequest.ts @@ -1,5 +1,5 @@ import { IsomorphicRequest } from '@mswjs/interceptors' -import { MockedRequest } from '../../handlers/RequestHandler' +import { MockedRequest, passthrough } from '../../handlers/RequestHandler' import { parseBody } from './parseBody' import { setRequestCookies } from './setRequestCookies' @@ -26,6 +26,7 @@ export function parseIsomorphicRequest( integrity: '', destination: 'document', bodyUsed: false, + passthrough, } // Attach all the cookies from the virtual cookie store. diff --git a/src/utils/request/parseWorkerRequest.ts b/src/utils/request/parseWorkerRequest.ts index 9bd2e43f5..8dbc3abe5 100644 --- a/src/utils/request/parseWorkerRequest.ts +++ b/src/utils/request/parseWorkerRequest.ts @@ -1,4 +1,5 @@ import { Headers } from 'headers-polyfill' +import { passthrough } from '../../handlers/RequestHandler' import { RestRequest } from '../../handlers/RestHandler' import { ServiceWorkerIncomingRequest } from '../../setupWorker/glossary' import { setRequestCookies } from './setRequestCookies' @@ -30,6 +31,7 @@ export function parseWorkerRequest( body: pruneGetRequestBody(rawRequest), bodyUsed: rawRequest.bodyUsed, headers: new Headers(rawRequest.headers), + passthrough, } // Set document cookies on the request. diff --git a/src/utils/worker/createRequestListener.ts b/src/utils/worker/createRequestListener.ts index 638050bf6..f72aa2104 100644 --- a/src/utils/worker/createRequestListener.ts +++ b/src/utils/worker/createRequestListener.ts @@ -41,7 +41,7 @@ export const createRequestListener = ( headers: response.headers.all(), } }, - onBypassResponse() { + onPassthroughResponse() { return channel.send({ type: 'MOCK_NOT_FOUND', }) diff --git a/test/msw-api/req/passthrough.mocks.ts b/test/msw-api/req/passthrough.mocks.ts new file mode 100644 index 000000000..2f0d9cdc6 --- /dev/null +++ b/test/msw-api/req/passthrough.mocks.ts @@ -0,0 +1,15 @@ +import { setupWorker, rest } from 'msw' + +const worker = setupWorker( + rest.post('/', (req) => { + return req.passthrough() + }), +) + +worker.start() + +// @ts-ignore +window.msw = { + worker, + rest, +} diff --git a/test/msw-api/req/passthrough.node.test.ts b/test/msw-api/req/passthrough.node.test.ts new file mode 100644 index 000000000..9bed65782 --- /dev/null +++ b/test/msw-api/req/passthrough.node.test.ts @@ -0,0 +1,98 @@ +/** + * @jest-environment node + */ +import fetch from 'node-fetch' +import { rest } from 'msw' +import { setupServer } from 'msw/node' +import { ServerApi, createServer } from '@open-draft/test-server' + +let httpServer: ServerApi +const server = setupServer() + +interface ResponseBody { + name: string +} + +beforeAll(async () => { + httpServer = await createServer((app) => { + app.post('/user', (req, res) => { + res.json({ name: 'John' }) + }) + }) + + server.listen() + + jest.spyOn(console, 'warn').mockImplementation() +}) + +afterEach(() => { + server.resetHandlers() + jest.resetAllMocks() +}) + +afterAll(async () => { + server.close() + jest.restoreAllMocks() + await httpServer.close() +}) + +it('performs request as-is when returning "req.passthrough" call in the resolver', async () => { + const endpointUrl = httpServer.http.makeUrl('/user') + server.use( + rest.post(endpointUrl, (req) => { + return req.passthrough() + }), + ) + + const res = await fetch(endpointUrl, { method: 'POST' }) + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + expect(console.warn).not.toHaveBeenCalled() +}) + +it('does not allow fall-through when returning "req.passthrough" call in the resolver', async () => { + const endpointUrl = httpServer.http.makeUrl('/user') + server.use( + rest.post(endpointUrl, (req) => { + return req.passthrough() + }), + rest.post(endpointUrl, (req, res, ctx) => { + return res(ctx.json({ name: 'Kate' })) + }), + ) + + const res = await fetch(endpointUrl, { method: 'POST' }) + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + expect(console.warn).not.toHaveBeenCalled() +}) + +it('prints a warning and performs a request as-is if nothing was returned from the resolver', async () => { + const endpointUrl = httpServer.http.makeUrl('/user') + server.use( + rest.post(endpointUrl, () => { + return + }), + ) + + const res = await fetch(endpointUrl, { method: 'POST' }) + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + + const warning = (console.warn as any as jest.SpyInstance).mock.calls[0][0] + + expect(warning).toContain( + '[MSW] Expected response resolver to return a mocked response Object, but got undefined. The original response is going to be used instead.', + ) + expect(warning).toContain(`POST ${endpointUrl}`) + expect(console.warn).toHaveBeenCalledTimes(1) +}) diff --git a/test/msw-api/req/passthrough.test.ts b/test/msw-api/req/passthrough.test.ts new file mode 100644 index 000000000..2a3d34a9a --- /dev/null +++ b/test/msw-api/req/passthrough.test.ts @@ -0,0 +1,120 @@ +/** + * @jest-environment jsdom + */ +import * as path from 'path' +import { pageWith } from 'page-with' +import { rest, SetupWorkerApi } from 'msw' +import { createServer, ServerApi } from '@open-draft/test-server' + +declare namespace window { + export const msw: { + worker: SetupWorkerApi + rest: typeof rest + } +} + +interface ResponseBody { + name: string +} + +function prepareRuntime() { + return pageWith({ + example: path.resolve(__dirname, 'passthrough.mocks.ts'), + }) +} + +let httpServer: ServerApi + +beforeAll(async () => { + httpServer = await createServer((app) => { + app.post('/user', (req, res) => { + res.json({ name: 'John' }) + }) + }) +}) + +afterAll(async () => { + await httpServer.close() +}) + +it('performs request as-is when returning "req.passthrough" call in the resolver', async () => { + const runtime = await prepareRuntime() + const endpointUrl = httpServer.http.makeUrl('/user') + + await runtime.page.evaluate((endpointUrl) => { + const { worker, rest } = window.msw + worker.use( + rest.post(endpointUrl, (req) => { + return req.passthrough() + }), + ) + }, endpointUrl) + + const res = await runtime.request(endpointUrl, { method: 'POST' }) + const headers = await res.allHeaders() + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + expect(headers).toHaveProperty('x-powered-by', 'Express') + expect(runtime.consoleSpy.get('warning')).toBeUndefined() +}) + +it('does not allow fall-through when returning "req.passthrough" call in the resolver', async () => { + const runtime = await prepareRuntime() + const endpointUrl = httpServer.http.makeUrl('/user') + + await runtime.page.evaluate((endpointUrl) => { + const { worker, rest } = window.msw + worker.use( + rest.post(endpointUrl, (req) => { + return req.passthrough() + }), + rest.post(endpointUrl, (req, res, ctx) => { + return res(ctx.json({ name: 'Kate' })) + }), + ) + }, endpointUrl) + + const res = await runtime.request(endpointUrl, { method: 'POST' }) + const headers = await res.allHeaders() + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + expect(headers).toHaveProperty('x-powered-by', 'Express') + expect(runtime.consoleSpy.get('warning')).toBeUndefined() +}) + +it('prints a warning and performs a request as-is if nothing was returned from the resolver', async () => { + const runtime = await prepareRuntime() + const endpointUrl = httpServer.http.makeUrl('/user') + + await runtime.page.evaluate((endpointUrl) => { + const { worker, rest } = window.msw + worker.use( + rest.post(endpointUrl, () => { + return + }), + ) + }, endpointUrl) + + const res = await runtime.request(endpointUrl, { method: 'POST' }) + const headers = await res.allHeaders() + const json = await res.json() + + expect(json).toEqual({ + name: 'John', + }) + expect(headers).toHaveProperty('x-powered-by', 'Express') + + expect(runtime.consoleSpy.get('warning')).toEqual( + expect.arrayContaining([ + expect.stringContaining( + '[MSW] Expected response resolver to return a mocked response Object, but got undefined. The original response is going to be used instead.', + ), + ]), + ) +}) diff --git a/test/support/utils.ts b/test/support/utils.ts index 2bddc0afa..55b4706fc 100644 --- a/test/support/utils.ts +++ b/test/support/utils.ts @@ -4,6 +4,7 @@ import { MockedRequest } from './../../src' import { uuidv4 } from '../../src/utils/internal/uuidv4' import { ChildProcess } from 'child_process' import { IsomorphicRequest } from '@mswjs/interceptors' +import { passthrough } from '../../src/handlers/RequestHandler' export function sleep(duration: number) { return new Promise((resolve) => { @@ -37,6 +38,7 @@ export function createMockedRequest( integrity: '', keepalive: true, cookies: {}, + passthrough, ...init, } }