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: implement a RequestController class #595

Merged
merged 19 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outvariant had issues with polymorphic errors and multiple positionals. Fixed it and updated the dependency.

"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
Loading