Skip to content

Commit

Permalink
feat: implement a RequestController class (#595)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Jul 11, 2024
1 parent 1c4aa13 commit 73dd07a
Show file tree
Hide file tree
Showing 94 changed files with 1,229 additions and 877 deletions.
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ All HTTP request interceptors emit a "request" event. In the listener to this ev
> There are many ways to describe a request in Node.js but this library coerces different request definitions to a single specification-compliant `Request` instance to make the handling consistent.
```js
interceptor.on('request', ({ request, requestId }) => {
interceptor.on('request', ({ request, requestId, controller }) => {
console.log(request.method, request.url)
})
```
Expand Down Expand Up @@ -251,11 +251,11 @@ interceptor.on('request', ({ request }) => {

Although this library can be used purely for request introspection purposes, you can also affect request resolution by responding to any intercepted request within the "request" event.

Use the `request.respondWith()` method to respond to a request with a mocked response:
Access the `controller` object from the request event listener arguments and call its `controller.respondWith()` method, providing it with a mocked `Response` instance:

```js
interceptor.on('request', ({ request, requestId }) => {
request.respondWith(
interceptor.on('request', ({ request, controller }) => {
controller.respondWith(
new Response(
JSON.stringify({
firstName: 'John',
Expand Down Expand Up @@ -284,12 +284,40 @@ Requests must be responded to within the same tick as the request listener. This
```js
// Respond to all requests with a 500 response
// delayed by 500ms.
interceptor.on('request', async ({ request, requestId }) => {
interceptor.on('request', async ({ controller }) => {
await sleep(500)
request.respondWith(new Response(null, { status: 500 }))
controller.respondWith(new Response(null, { status: 500 }))
})
```

### Mocking response errors

You can provide an instance of `Response.error()` to error the pending request.

```js
interceptor.on('request', ({ request, controller }) => {
controller.respondWith(Response.error())
})
```

This will automatically translate to the appropriate request error based on the request client that issued the request. **Use this method to produce a generic network error**.

> Note that the standard `Response.error()` API does not accept an error message.
## Mocking errors

Use the `controller.errorWith()` method to error the request.

```js
interceptor.on('request', ({ request, controller }) => {
controller.errorWith(new Error('reason'))
})
```

Unlike responding with `Response.error()`, you can provide an exact error reason to use to `.errorWith()`. **Use this method to error the request**.

> Note that it is up to the request client to respect your custom error. Some clients, like `ClientRequest` will use the provided error message, while others, like `fetch`, will produce a generic `TypeError: failed to fetch` responses. Interceptors will try to preserve the original error in the `cause` property of such generic errors.
## Observing responses

You can use the "response" event to transparently observe any incoming responses in your Node.js process.
Expand All @@ -303,7 +331,7 @@ interceptor.on(
)
```

> Note that the `isMockedResponse` property will only be set to `true` if you resolved this request in the "request" event listener using the `request.respondWith()` method and providing a mocked `Response` instance.
> Note that the `isMockedResponse` property will only be set to `true` if you resolved this request in the "request" event listener using the `controller.respondWith()` method and providing a mocked `Response` instance.
## Error handling

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
"@open-draft/logger": "^0.3.0",
"@open-draft/until": "^2.0.0",
"is-node-process": "^1.2.0",
"outvariant": "^1.2.1",
"outvariant": "^1.4.3",
"strict-event-emitter": "^0.5.1"
},
"resolutions": {
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/InterceptorError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class InterceptorError extends Error {
constructor(message?: string) {
super(message)
this.name = 'InterceptorError'
Object.setPrototypeOf(this, InterceptorError.prototype)
}
}
119 changes: 62 additions & 57 deletions src/RemoteHttpInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { Interceptor } from './Interceptor'
import { BatchInterceptor } from './BatchInterceptor'
import { ClientRequestInterceptor } from './interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest'
import { toInteractiveRequest } from './utils/toInteractiveRequest'
import { emitAsync } from './utils/emitAsync'
import { handleRequest } from './utils/handleRequest'
import { RequestController } from './RequestController'

export interface SerializedRequest {
id: string
Expand Down Expand Up @@ -46,7 +46,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor<

let handleParentMessage: NodeJS.MessageListener

this.on('request', async ({ request, requestId }) => {
this.on('request', async ({ request, requestId, controller }) => {
// Send the stringified intercepted request to
// the parent process where the remote resolver is established.
const serializedRequest = JSON.stringify({
Expand All @@ -64,6 +64,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
'sent serialized request to the child:',
serializedRequest
)

process.send?.(`request:${serializedRequest}`)

const responsePromise = new Promise<void>((resolve) => {
Expand All @@ -90,7 +91,12 @@ export class RemoteHttpInterceptor extends BatchInterceptor<
headers: responseInit.headers,
})

request.respondWith(mockedResponse)
/**
* @todo Support "errorWith" as well.
* This response handling from the child is incomplete.
*/

controller.respondWith(mockedResponse)
return resolve()
}
}
Expand Down Expand Up @@ -158,69 +164,68 @@ export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
serializedRequest,
requestReviver
) as RevivedRequest

logger.info('parsed intercepted request', requestJson)

const capturedRequest = new Request(requestJson.url, {
const request = new Request(requestJson.url, {
method: requestJson.method,
headers: new Headers(requestJson.headers),
credentials: requestJson.credentials,
body: requestJson.body,
})

const { interactiveRequest, requestController } =
toInteractiveRequest(capturedRequest)

this.emitter.once('request', () => {
if (requestController.responsePromise.state === 'pending') {
requestController.respondWith(undefined)
}
})

await emitAsync(this.emitter, 'request', {
request: interactiveRequest,
const controller = new RequestController(request)
await handleRequest({
request,
requestId: requestJson.id,
controller,
emitter: this.emitter,
onResponse: async (response) => {
this.logger.info('received mocked response!', { response })

const responseClone = response.clone()
const responseText = await responseClone.text()

// // Send the mocked response to the child process.
const serializedResponse = JSON.stringify({
status: response.status,
statusText: response.statusText,
headers: Array.from(response.headers.entries()),
body: responseText,
} as SerializedResponse)

this.process.send(
`response:${requestJson.id}:${serializedResponse}`,
(error) => {
if (error) {
return
}

// Emit an optimistic "response" event at this point,
// not to rely on the back-and-forth signaling for the sake of the event.
this.emitter.emit('response', {
request,
requestId: requestJson.id,
response: responseClone,
isMockedResponse: true,
})
}
)

logger.info(
'sent serialized mocked response to the parent:',
serializedResponse
)
},
onRequestError: (response) => {
this.logger.info('received a network error!', { response })
throw new Error('Not implemented')
},
onError: (error) => {
this.logger.info('request has errored!', { error })
throw new Error('Not implemented')
},
})

const mockedResponse = await requestController.responsePromise

if (!mockedResponse) {
return
}

logger.info('event.respondWith called with:', mockedResponse)
const responseClone = mockedResponse.clone()
const responseText = await mockedResponse.text()

// Send the mocked response to the child process.
const serializedResponse = JSON.stringify({
status: mockedResponse.status,
statusText: mockedResponse.statusText,
headers: Array.from(mockedResponse.headers.entries()),
body: responseText,
} as SerializedResponse)

this.process.send(
`response:${requestJson.id}:${serializedResponse}`,
(error) => {
if (error) {
return
}

// Emit an optimistic "response" event at this point,
// not to rely on the back-and-forth signaling for the sake of the event.
this.emitter.emit('response', {
response: responseClone,
isMockedResponse: true,
request: capturedRequest,
requestId: requestJson.id,
})
}
)

logger.info(
'sent serialized mocked response to the parent:',
serializedResponse
)
}

this.subscriptions.push(() => {
Expand Down
49 changes: 49 additions & 0 deletions src/RequestController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { it, expect } from 'vitest'
import { kResponsePromise, RequestController } from './RequestController'

it('creates a pending response promise on construction', () => {
const controller = new RequestController(new Request('http://localhost'))
expect(controller[kResponsePromise]).toBeInstanceOf(Promise)
expect(controller[kResponsePromise].state).toBe('pending')
})

it('resolves the response promise with the response provided to "respondWith"', async () => {
const controller = new RequestController(new Request('http://localhost'))
controller.respondWith(new Response('hello world'))

const response = (await controller[kResponsePromise]) as Response

expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(200)
expect(await response.text()).toBe('hello world')
})

it('resolves the response promise with the error provided to "errorWith"', async () => {
const controller = new RequestController(new Request('http://localhost'))
const error = new Error('Oops!')
controller.errorWith(error)

await expect(controller[kResponsePromise]).resolves.toEqual(error)
})

it('throws when calling "respondWith" multiple times', () => {
const controller = new RequestController(new Request('http://localhost'))
controller.respondWith(new Response('hello world'))

expect(() => {
controller.respondWith(new Response('second response'))
}).toThrow(
'Failed to respond to the "GET http://localhost/" request: the "request" event has already been handled.'
)
})

it('throws when calling "errorWith" multiple times', () => {
const controller = new RequestController(new Request('http://localhost'))
controller.errorWith(new Error('Oops!'))

expect(() => {
controller.errorWith(new Error('second error'))
}).toThrow(
'Failed to error the "GET http://localhost/" request: the "request" event has already been handled.'
)
})
Loading

0 comments on commit 73dd07a

Please sign in to comment.