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 recorder gunzip #2359

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Expand Up @@ -1296,6 +1296,22 @@ nock.recorder.rec({
})
```

### `gunzip` option

By default, nock won't decompress gzipped response bodies (you will see the body as an array of hex strings instead).
However, when recording/replaying it can be useful to work with the decompressed versions.

To have nock decompress the response bodies before it outputs the request, set `gunzip` to `true`.

```js
nock.recorder.rec({
gunzip: true,
})
```

Note: This option requires the `content-encoding` header on the response to be `gzip` to have an effect (trigger decompression).
No other `content-encoding` values are supported to enable decompression at this time (e.g. `deflate` is not supported).

### .removeInterceptor()

This allows removing a specific interceptor. This can be either an interceptor instance or options for a url. It's useful when there's a list of common interceptors shared between tests, where an individual test requires one of the shared interceptors to behave differently.
Expand Down
61 changes: 42 additions & 19 deletions lib/recorder.js
Expand Up @@ -3,6 +3,7 @@
const debug = require('debug')('nock.recorder')
const querystring = require('querystring')
const { inspect } = require('util')
const zlib = require('zlib')

const common = require('./common')
const { restoreOverriddenClientRequest } = require('./intercept')
Expand All @@ -20,25 +21,14 @@ function getMethod(options) {
return options.method || 'GET'
}

function getBodyFromChunks(chunks, headers) {
// If we have headers and there is content-encoding it means that the body
// shouldn't be merged but instead persisted as an array of hex strings so
// that the response chunks can be mocked one by one.
if (headers && common.isContentEncoded(headers)) {
return {
body: chunks.map(chunk => chunk.toString('hex')),
}
}

const mergedBuffer = Buffer.concat(chunks)

function bodyFromBuffer(buff) {
// The merged buffer can be one of three things:
// 1. A UTF-8-representable string buffer which represents a JSON object.
// 2. A UTF-8-representable buffer which doesn't represent a JSON object.
// 3. A non-UTF-8-representable buffer which then has to be recorded as a hex string.
const isUtf8Representable = common.isUtf8Representable(mergedBuffer)
const isUtf8Representable = common.isUtf8Representable(buff)
if (isUtf8Representable) {
const maybeStringifiedJson = mergedBuffer.toString('utf8')
const maybeStringifiedJson = buff.toString('utf8')
try {
return {
isUtf8Representable,
Expand All @@ -53,9 +43,36 @@ function getBodyFromChunks(chunks, headers) {
} else {
return {
isUtf8Representable,
body: mergedBuffer.toString('hex'),
body: buff.toString('hex'),
}
}
}

function getBodyFromChunks(chunks, headers, gunzip) {
if (headers && gunzip && common.contentEncoding(headers, 'gzip')) {
debug('"gunzip" option enabled and content encoded set to "gzip" - decompressing response body')

let uncompressedBuff
try {
uncompressedBuff = zlib.gunzipSync(Buffer.concat(chunks))
return bodyFromBuffer(uncompressedBuff)
} catch (err) {
debug(`'gunzip' enabled, but an error occurred while unzipping response (falling back to default recording): "${err.message}".`)
}
}

// If we have headers and there is content-encoding it means that the body
// shouldn't be merged but instead persisted as an array of hex strings so
// that the response chunks can be mocked one by one.
if (headers && common.isContentEncoded(headers)) {
return {
body: chunks.map(chunk => chunk.toString('hex')),
}
}

const mergedBuffer = Buffer.concat(chunks)

return bodyFromBuffer(mergedBuffer)
}

function generateRequestAndResponseObject({
Expand All @@ -65,10 +82,12 @@ function generateRequestAndResponseObject({
res,
dataChunks,
reqheaders,
gunzip,
}) {
const { body, isUtf8Representable } = getBodyFromChunks(
dataChunks,
res.headers
res.headers,
gunzip,
)
options.path = req.path

Expand All @@ -77,7 +96,7 @@ function generateRequestAndResponseObject({
method: getMethod(options),
path: options.path,
// Is it deliberate that `getBodyFromChunks()` is called a second time?
body: getBodyFromChunks(bodyChunks).body,
body: getBodyFromChunks(bodyChunks, null, gunzip).body,
status: res.statusCode,
response: body,
rawHeaders: res.rawHeaders,
Expand All @@ -95,9 +114,10 @@ function generateRequestAndResponse({
res,
dataChunks,
reqheaders,
gunzip,
}) {
const requestBody = getBodyFromChunks(bodyChunks).body
const responseBody = getBodyFromChunks(dataChunks, res.headers).body
const requestBody = getBodyFromChunks(bodyChunks, null, gunzip).body
const responseBody = getBodyFromChunks(dataChunks, res.headers, gunzip).body

// Remove any query params from options.path so they can be added in the query() function
let { path } = options
Expand Down Expand Up @@ -171,6 +191,7 @@ const defaultRecordOptions = {
logging: console.log, // eslint-disable-line no-console
output_objects: false,
use_separator: true,
gunzip: false,
}

function record(recOptions) {
Expand Down Expand Up @@ -203,6 +224,7 @@ function record(recOptions) {
logging,
output_objects: outputObjects,
use_separator: useSeparator,
gunzip,
} = recOptions

debug(thisRecordingId, 'restoring overridden requests before new overrides')
Expand Down Expand Up @@ -253,6 +275,7 @@ function record(recOptions) {
res,
dataChunks,
reqheaders,
gunzip,
})

debug('out:', out)
Expand Down
82 changes: 79 additions & 3 deletions tests/test_recorder.js
Expand Up @@ -2,14 +2,15 @@

const http = require('http')
const https = require('https')
const { URLSearchParams } = require('url')
const { URLSearchParams, parse } = require('url')
const zlib = require('zlib')
const sinon = require('sinon')
const { expect } = require('chai')
const nock = require('..')

const got = require('./got_client')
const servers = require('./servers')
const assertRejects = require("assert-rejects");

describe('Recorder', () => {
let globalCount
Expand Down Expand Up @@ -224,7 +225,7 @@ describe('Recorder', () => {
const exampleText = '<html><body>example</body></html>'

const { origin } = await servers.startHttpServer((request, response) => {
switch (require('url').parse(request.url).pathname) {
switch (parse(request.url).pathname) {
case '/':
response.writeHead(302, { Location: '/abc' })
break
Expand Down Expand Up @@ -260,6 +261,81 @@ describe('Recorder', () => {
nocks.forEach(nock => nock.done())
})

it('records gunzipped objects correctly', async () => {
const exampleText = '<html><body>gunzipped</body></html>'

const { origin } = await servers.startHttpServer((request, response) => {
switch (parse(request.url).pathname) {
case '/comp':
response.writeHead(200, { 'content-encoding': 'gzip' })
response.write(zlib.gzipSync(Buffer.from(exampleText)))
break
case '/noncomp':
response.write(exampleText)
break
}
response.end()
})

nock.restore()
nock.recorder.clear()
expect(nock.recorder.play()).to.be.empty()

nock.recorder.rec({
dont_print: true,
output_objects: true,
gunzip: true,
})

const compRes = await got(`${origin}/comp`)
const nonCompRes = await got(`${origin}/noncomp`)

nock.restore()
const recorded = nock.recorder.play()
nock.recorder.clear()
nock.activate()

expect(recorded).to.have.lengthOf(2)
const [recComp, recNonComp] = recorded
expect(recComp.response).to.equal(exampleText)
expect(recNonComp.response).to.equal(exampleText)

expect(compRes.body).to.equal(exampleText)
expect(nonCompRes.body).to.equal(exampleText)
})

it('falls back to default behaviour on invalid gzip body', async () => {
const invalidBody = 'not valid gzip'
const { origin } = await servers.startHttpServer((request, response) => {
response.writeHead(200, { 'content-encoding': 'gzip' })
response.write(invalidBody)
response.end()
})

nock.restore()
nock.recorder.clear()
expect(nock.recorder.play()).to.be.empty()

nock.recorder.rec({
dont_print: true,
output_objects: true,
gunzip: true,
})

await assertRejects(
got(origin),
/incorrect header check/
)

nock.restore()
const recorded = nock.recorder.play()
nock.recorder.clear()
nock.activate()
const expectedResponseBody = [Buffer.from(invalidBody, 'utf8').toString('hex')]
expect(recorded).to.have.lengthOf(1)
expect(recorded[0].response).to.eql(expectedResponseBody)
})

it('records and replays correctly with filteringRequestBody', async () => {
const responseBody = '<html><body>example</body></html>'
const { origin } = await servers.startHttpServer((request, response) => {
Expand Down Expand Up @@ -684,7 +760,7 @@ describe('Recorder', () => {
const exampleBody = '<html><body>example</body></html>'

const { origin } = await servers.startHttpServer((request, response) => {
switch (require('url').parse(request.url).pathname) {
switch (parse(request.url).pathname) {
case '/':
response.writeHead(302, { Location: '/abc' })
break
Expand Down
1 change: 1 addition & 0 deletions types/index.d.ts
Expand Up @@ -225,6 +225,7 @@ declare namespace nock {
enable_reqheaders_recording?: boolean
logging?: (content: string) => void
use_separator?: boolean
gunzip?: boolean
}

interface Definition {
Expand Down