Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add "SetupApi" base class (#1445)
* feat: wip setupApi * feat: concret class for setupWorker * chore: fix types and clean up * chore: rely on parent class reset/restore methods * fix(SetupApi): abstract interceptors to child classes * chore: import "chalk" for esm compatibility * fix(setupWorker): call parent dispose * fix(setupWorker): annotate "start" options * fix(setupWorker): mark "startOptions" as non-nullable * chore: adjust invalid handlers signature message Co-authored-by: Artem Zakharchenko <kettanaito@gmail.com>
- Loading branch information
1 parent
670dda7
commit 85ba844
Showing
13 changed files
with
476 additions
and
409 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { invariant } from 'outvariant' | ||
import { EventMapType, StrictEventEmitter } from 'strict-event-emitter' | ||
import { | ||
DefaultBodyType, | ||
RequestHandler, | ||
RequestHandlerDefaultInfo, | ||
} from './handlers/RequestHandler' | ||
import { LifeCycleEventEmitter } from './sharedOptions' | ||
import { devUtils } from './utils/internal/devUtils' | ||
import { pipeEvents } from './utils/internal/pipeEvents' | ||
import { toReadonlyArray } from './utils/internal/toReadonlyArray' | ||
import { MockedRequest } from './utils/request/MockedRequest' | ||
|
||
/** | ||
* Generic class for the mock API setup. | ||
*/ | ||
export abstract class SetupApi<EventsMap extends EventMapType> { | ||
protected initialHandlers: ReadonlyArray<RequestHandler> | ||
protected currentHandlers: Array<RequestHandler> | ||
protected readonly emitter: StrictEventEmitter<EventsMap> | ||
protected readonly publicEmitter: StrictEventEmitter<EventsMap> | ||
|
||
public readonly events: LifeCycleEventEmitter<EventsMap> | ||
|
||
constructor(initialHandlers: Array<RequestHandler>) { | ||
this.validateHandlers(initialHandlers) | ||
|
||
this.initialHandlers = toReadonlyArray(initialHandlers) | ||
this.currentHandlers = [...initialHandlers] | ||
|
||
this.emitter = new StrictEventEmitter<EventsMap>() | ||
this.publicEmitter = new StrictEventEmitter<EventsMap>() | ||
pipeEvents(this.emitter, this.publicEmitter) | ||
|
||
this.events = this.createLifeCycleEvents() | ||
} | ||
|
||
private validateHandlers(handlers: ReadonlyArray<RequestHandler>): void { | ||
// Guard against incorrect call signature of the setup API. | ||
for (const handler of handlers) { | ||
invariant( | ||
!Array.isArray(handler), | ||
devUtils.formatMessage( | ||
'Failed to construct "%s" given an Array of request handlers. Make sure you spread the request handlers when calling the respective setup function.', | ||
), | ||
this.constructor.name, | ||
) | ||
} | ||
} | ||
|
||
protected dispose(): void { | ||
this.emitter.removeAllListeners() | ||
this.publicEmitter.removeAllListeners() | ||
} | ||
|
||
public use(...runtimeHandlers: Array<RequestHandler>): void { | ||
this.currentHandlers.unshift(...runtimeHandlers) | ||
} | ||
|
||
public restoreHandlers(): void { | ||
this.currentHandlers.forEach((handler) => { | ||
handler.markAsSkipped(false) | ||
}) | ||
} | ||
|
||
public resetHandlers(...nextHandlers: Array<RequestHandler>): void { | ||
this.currentHandlers = | ||
nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] | ||
} | ||
|
||
public listHandlers(): ReadonlyArray< | ||
RequestHandler< | ||
RequestHandlerDefaultInfo, | ||
MockedRequest<DefaultBodyType>, | ||
any, | ||
MockedRequest<DefaultBodyType> | ||
> | ||
> { | ||
return toReadonlyArray(this.currentHandlers) | ||
} | ||
|
||
private createLifeCycleEvents(): LifeCycleEventEmitter<EventsMap> { | ||
return { | ||
on: (...args) => { | ||
return this.publicEmitter.on(...args) | ||
}, | ||
removeListener: (...args) => { | ||
return this.publicEmitter.removeListener(...args) | ||
}, | ||
removeAllListeners: (...args: any) => { | ||
return this.publicEmitter.removeAllListeners(...args) | ||
}, | ||
} | ||
} | ||
|
||
abstract printHandlers(): void | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,16 @@ | ||
import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' | ||
import { createSetupServer } from '../node/createSetupServer' | ||
import { RequestHandler } from '../handlers/RequestHandler' | ||
import { SetupServerApi } from '../node/SetupServerApi' | ||
|
||
// Provision request interception via patching the `XMLHttpRequest` class only | ||
// in React Native. There is no `http`/`https` modules in that environment. | ||
export const setupServer = createSetupServer(XMLHttpRequestInterceptor) | ||
/** | ||
* Sets up a requests interception in React Native with the given request handlers. | ||
* @param {RequestHandler[]} handlers List of request handlers. | ||
* @see {@link https://mswjs.io/docs/api/setup-server `setupServer`} | ||
*/ | ||
export function setupServer( | ||
...handlers: Array<RequestHandler> | ||
): SetupServerApi { | ||
// Provision request interception via patching the `XMLHttpRequest` class only | ||
// in React Native. There is no `http`/`https` modules in that environment. | ||
return new SetupServerApi([XMLHttpRequestInterceptor], handlers) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import chalk from 'chalk' | ||
import { invariant } from 'outvariant' | ||
import { | ||
BatchInterceptor, | ||
HttpRequestEventMap, | ||
Interceptor, | ||
InterceptorReadyState, | ||
IsomorphicResponse, | ||
MockedResponse as MockedInterceptedResponse, | ||
} from '@mswjs/interceptors' | ||
import { SetupApi } from '../SetupApi' | ||
import { RequestHandler } from '../handlers/RequestHandler' | ||
import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' | ||
import { RequiredDeep } from '../typeUtils' | ||
import { mergeRight } from '../utils/internal/mergeRight' | ||
import { MockedRequest } from '../utils/request/MockedRequest' | ||
import { handleRequest } from '../utils/handleRequest' | ||
import { devUtils } from '../utils/internal/devUtils' | ||
|
||
/** | ||
* @see https://github.com/mswjs/msw/pull/1399 | ||
*/ | ||
const { bold } = chalk | ||
|
||
export type ServerLifecycleEventsMap = LifeCycleEventsMap<IsomorphicResponse> | ||
|
||
const DEFAULT_LISTEN_OPTIONS: RequiredDeep<SharedOptions> = { | ||
onUnhandledRequest: 'warn', | ||
} | ||
|
||
export class SetupServerApi extends SetupApi<ServerLifecycleEventsMap> { | ||
protected readonly interceptor: BatchInterceptor< | ||
Array<Interceptor<HttpRequestEventMap>>, | ||
HttpRequestEventMap | ||
> | ||
private resolvedOptions: RequiredDeep<SharedOptions> | ||
|
||
constructor( | ||
interceptors: Array<{ | ||
new (): Interceptor<HttpRequestEventMap> | ||
}>, | ||
handlers: Array<RequestHandler>, | ||
) { | ||
super(handlers) | ||
|
||
this.interceptor = new BatchInterceptor({ | ||
name: 'setup-server', | ||
interceptors: interceptors.map((Interceptor) => new Interceptor()), | ||
}) | ||
this.resolvedOptions = {} as RequiredDeep<SharedOptions> | ||
|
||
this.init() | ||
} | ||
|
||
/** | ||
* Subscribe to all requests that are using the interceptor object | ||
*/ | ||
private init(): void { | ||
this.interceptor.on('request', async (request) => { | ||
const mockedRequest = new MockedRequest(request.url, { | ||
...request, | ||
body: await request.arrayBuffer(), | ||
}) | ||
|
||
const response = await handleRequest< | ||
MockedInterceptedResponse & { delay?: number } | ||
>( | ||
mockedRequest, | ||
this.currentHandlers, | ||
this.resolvedOptions, | ||
this.emitter, | ||
{ | ||
transformResponse(response) { | ||
return { | ||
status: response.status, | ||
statusText: response.statusText, | ||
headers: response.headers.all(), | ||
body: response.body, | ||
delay: response.delay, | ||
} | ||
}, | ||
}, | ||
) | ||
|
||
if (response) { | ||
// Delay Node.js responses in the listener so that | ||
// the response lookup logic is not concerned with responding | ||
// in any way. The same delay is implemented in the worker. | ||
if (response.delay) { | ||
await new Promise((resolve) => { | ||
setTimeout(resolve, response.delay) | ||
}) | ||
} | ||
|
||
request.respondWith(response) | ||
} | ||
|
||
return | ||
}) | ||
|
||
this.interceptor.on('response', (request, response) => { | ||
if (!request.id) { | ||
return | ||
} | ||
|
||
if (response.headers.get('x-powered-by') === 'msw') { | ||
this.emitter.emit('response:mocked', response, request.id) | ||
} else { | ||
this.emitter.emit('response:bypass', response, request.id) | ||
} | ||
}) | ||
} | ||
|
||
public listen(options: Partial<SharedOptions> = {}): void { | ||
this.resolvedOptions = mergeRight( | ||
DEFAULT_LISTEN_OPTIONS, | ||
options, | ||
) as RequiredDeep<SharedOptions> | ||
|
||
// Apply the interceptor when starting the server. | ||
this.interceptor.apply() | ||
|
||
// Assert that the interceptor has been applied successfully. | ||
// Also guards us from forgetting to call "interceptor.apply()" | ||
// as a part of the "listen" method. | ||
invariant( | ||
[InterceptorReadyState.APPLYING, InterceptorReadyState.APPLIED].includes( | ||
this.interceptor.readyState, | ||
), | ||
devUtils.formatMessage( | ||
'Failed to start "setupServer": the interceptor failed to apply. This is likely an issue with the library and you should report it at "%s".', | ||
), | ||
'https://github.com/mswjs/msw/issues/new/choose', | ||
) | ||
} | ||
|
||
public printHandlers(): void { | ||
const handlers = this.listHandlers() | ||
|
||
handlers.forEach((handler) => { | ||
const { header, callFrame } = handler.info | ||
|
||
const pragma = handler.info.hasOwnProperty('operationType') | ||
? '[graphql]' | ||
: '[rest]' | ||
|
||
console.log(`\ | ||
${bold(`${pragma} ${header}`)} | ||
Declaration: ${callFrame} | ||
`) | ||
}) | ||
} | ||
|
||
public close(): void { | ||
super.dispose() | ||
this.interceptor.dispose() | ||
} | ||
} |
Oops, something went wrong.