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
104 changes: 104 additions & 0 deletions src/createSetupApi.ts
@@ -0,0 +1,104 @@
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 { 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> {
private 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>
}[],
initialHandlers: RequestHandler[],
) {
this.interceptor = new BatchInterceptor({
name: 'setup-api',
kettanaito marked this conversation as resolved.
Show resolved Hide resolved
interceptors: interceptors.map((Interceptor) => new Interceptor()),
})
// Clone
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<
Record<string | symbol, any>
> {
return {
on: (evt: any, listener: any) => {
return this.publicEmitter.on(evt, listener)
},
removeListener: (evt: any, listener: any) => {
return this.publicEmitter.removeListener(evt, listener)
},
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
145 changes: 137 additions & 8 deletions src/node/setupServer.ts
@@ -1,15 +1,144 @@
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'
import { devUtils } from '../utils/internal/devUtils'
/**
* 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 = 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,
// )

export type ServerLifecycleEventsMap = LifeCycleEventsMap<IsomorphicResponse>

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

export class SetupServerApi extends SetupApi<ServerLifecycleEventsMap> {
private resolvedOptions: RequiredDeep<SharedOptions>

constructor(handlers: RequestHandler[]) {
super([ClientRequestInterceptor, XMLHttpRequestInterceptor], handlers)

this.resolvedOptions = {} as RequiredDeep<SharedOptions>

// TODO: Re-think this
this.init()
}

public async init(): Promise<void> {
const _self = this

this.interceptor.on('request', async function setupServerListener(request) {
Toxiapo marked this conversation as resolved.
Show resolved Hide resolved
const mockedRequest = new MockedRequest(request.url, {
...request,
body: await request.arrayBuffer(),
})

const response = await handleRequest<
MockedInterceptedResponse & { delay?: number }
>(
mockedRequest,
_self.currentHandlers,
_self.resolvedOptions,
_self.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') {
_self.emitter.emit('response:mocked', response, request.id)
} else {
_self.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()
}
}

export const setupServer = (...handlers: RequestHandler[]) => {
handlers.forEach((handler) => {
Toxiapo marked this conversation as resolved.
Show resolved Hide resolved
if (Array.isArray(handler))
throw new Error(
devUtils.formatMessage(
'Failed to call "setupServer" given an Array of request handlers (setupServer([a, b])), expected to receive each handler individually: setupServer(a, b).',
),
)
})
return new SetupServerApi(handlers)
}