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
111 changes: 111 additions & 0 deletions src/createSetupApi.ts
@@ -0,0 +1,111 @@
import {
BatchInterceptor,
HttpRequestEventMap,
Interceptor,
} from '@mswjs/interceptors'
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<TLifecycleEventsMap extends EventMapType> {
protected readonly initialHandlers: RequestHandler[]
protected readonly interceptor: BatchInterceptor<
Interceptor<HttpRequestEventMap>[],
HttpRequestEventMap
>
protected readonly emitter = new StrictEventEmitter<TLifecycleEventsMap>()
protected readonly publicEmitter =
new StrictEventEmitter<TLifecycleEventsMap>()
protected currentHandlers: RequestHandler[]

public readonly events: LifeCycleEventEmitter<Record<string | symbol, any>>

constructor(
interceptors: {
new (): Interceptor<HttpRequestEventMap>
}[],
readonly interceptorName: string,
initialHandlers: RequestHandler[],
) {
initialHandlers.forEach((handler) => {
if (Array.isArray(handler))
throw new Error(
devUtils.formatMessage(
`Failed to call "${this.constructor.name}" given an Array of request handlers (${this.constructor.name}([a, b])), expected to receive each handler individually: ${this.constructor.name}(a, b).`,
),
)
})

this.interceptor = new BatchInterceptor({
name: interceptorName,
interceptors: interceptors.map((Interceptor) => new Interceptor()),
})
this.initialHandlers = [...initialHandlers]
this.currentHandlers = [...initialHandlers]
pipeEvents(this.emitter, this.publicEmitter)
this.events = this.registerEvents()
}

protected apply(): void {
this.interceptor.apply()
}

protected dispose(): void {
this.emitter.removeAllListeners()
this.publicEmitter.removeAllListeners()
this.interceptor.dispose()
}

public use(...runtimeHandlers: RequestHandler[]): void {
this.currentHandlers.unshift(...runtimeHandlers)
}

public restoreHandlers(): void {
this.currentHandlers.forEach((handler) => {
handler.markAsSkipped(false)
})
}

public resetHandlers(...nextHandlers: RequestHandler[]) {
this.currentHandlers =
nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers]
}

public listHandlers(): ReadonlyArray<
RequestHandler<
RequestHandlerDefaultInfo,
MockedRequest<DefaultBodyType>,
any,
MockedRequest<DefaultBodyType>
>
> {
return toReadonlyArray(this.currentHandlers)
}

private registerEvents(): LifeCycleEventEmitter<TLifecycleEventsMap> {
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 './createSetupApi'

export {
response,
defaultResponse,
Expand Down
2 changes: 1 addition & 1 deletion src/node/index.ts
@@ -1,2 +1,2 @@
export { setupServer } from './setupServer'
export { setupServer, ServerLifecycleEventsMap } from './setupServer'
export type { SetupServerApi } from './glossary'
136 changes: 129 additions & 7 deletions src/node/setupServer.ts
@@ -1,15 +1,137 @@
import { ClientRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest'
import { createSetupServer } from './createSetupServer'
import { SetupApi } from '../createSetupApi'
import { RequestHandler } from '../handlers/RequestHandler'
import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions'
import { RequiredDeep } from '../typeUtils'
import { mergeRight } from '../utils/internal/mergeRight'
import { bold } from 'chalk'
import { MockedRequest } from '../utils/request/MockedRequest'
import { handleRequest } from '../utils/handleRequest'
import {
IsomorphicResponse,
MockedResponse as MockedInterceptedResponse,
} from '@mswjs/interceptors'

export type ServerLifecycleEventsMap = LifeCycleEventsMap<IsomorphicResponse>

const DEFAULT_LISTEN_OPTIONS: RequiredDeep<SharedOptions> = {
onUnhandledRequest: 'warn',
}

/**
* Concrete class to implement the SetupApi for the node server environment, uses both the ClientRequestInterceptor
* and XMLHttpRequestInterceptor.
*/
export class SetupServerApi extends SetupApi<ServerLifecycleEventsMap> {
private resolvedOptions: RequiredDeep<SharedOptions>

constructor(handlers: RequestHandler[]) {
super(
[ClientRequestInterceptor, XMLHttpRequestInterceptor],
'server-setup',
handlers,
)

Toxiapo marked this conversation as resolved.
Show resolved Hide resolved
this.resolvedOptions = {} as RequiredDeep<SharedOptions>

this.init()
}

/**
* Subscribe to all requests that are using the interceptor object
*/
public async init(): Promise<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: Record<string, any> = {}): void {
this.resolvedOptions = mergeRight(
DEFAULT_LISTEN_OPTIONS,
options,
) as RequiredDeep<SharedOptions>
super.apply()
}

public printHandlers() {
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()
}
}

/**
* Sets up a requests interception in Node.js with the given request handlers.
* @param {RequestHandler[]} requestHandlers List of request handlers.
* @see {@link https://mswjs.io/docs/api/setup-server `setupServer`}
*/
export const setupServer = createSetupServer(
// List each interceptor separately instead of using the "node" preset
// so that MSW wouldn't bundle the unnecessary classes (i.e. "SocketPolyfill").
ClientRequestInterceptor,
XMLHttpRequestInterceptor,
)
kettanaito marked this conversation as resolved.
Show resolved Hide resolved
export const setupServer = (...handlers: RequestHandler[]) => {
return new SetupServerApi(handlers)
}