Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add "SetupApi" base class #1445

Merged
merged 12 commits into from Nov 7, 2022
97 changes: 97 additions & 0 deletions src/SetupApi.ts
@@ -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
}
3 changes: 3 additions & 0 deletions src/index.ts
Expand Up @@ -2,6 +2,9 @@ import * as context from './context'
export { context }

export { setupWorker } from './setupWorker/setupWorker'

export { SetupApi } from './SetupApi'

export {
response,
defaultResponse,
Expand Down
18 changes: 14 additions & 4 deletions src/native/index.ts
@@ -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)
}
158 changes: 158 additions & 0 deletions src/node/SetupServerApi.ts
@@ -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()
}
}