Skip to content

Commit

Permalink
feat: add "SetupApi" base class (#1445)
Browse files Browse the repository at this point in the history
* 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
Toxiapo and kettanaito committed Nov 7, 2022
1 parent 670dda7 commit 85ba844
Show file tree
Hide file tree
Showing 13 changed files with 476 additions and 409 deletions.
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()
}
}

0 comments on commit 85ba844

Please sign in to comment.