Skip to content

Commit dd6d8dc

Browse files
authored
Merge pull request #3 from exogen/request-body
Add request body support
2 parents 6e74eea + 5e91fa3 commit dd6d8dc

3 files changed

Lines changed: 148 additions & 17 deletions

File tree

README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# node-fetch-har
22

3-
[![npm](https://img.shields.io/npm/v/node-fetch-har.svg)](https://www.npmjs.com/package/node-fetch-har)
4-
![Travis](https://img.shields.io/travis/exogen/node-fetch-har.svg)
5-
![Coveralls](https://img.shields.io/coveralls/github/exogen/node-fetch-har.svg)
3+
[![npm](https://img.shields.io/npm/v/node-fetch-har.svg)][npm]
4+
[![Travis](https://img.shields.io/travis/exogen/node-fetch-har.svg)][travis]
5+
[![Coveralls](https://img.shields.io/coveralls/github/exogen/node-fetch-har.svg)][coveralls]
66

77
A [Fetch API][fetch] wrapper that records [HAR logs][har] for server requests
88
made with [node-fetch][]. You can then expose this data to get visibility into
@@ -129,15 +129,30 @@ if (!process.browser) {
129129

130130
### Redirects
131131

132+
Due to redirects, it is possible for a single `fetch` call to result in multiple
133+
HTTP requests. As you might expect, multiple HAR entries will be recorded as
134+
well.
135+
132136
With the Fetch API’s `redirect` option in `follow` mode (the default), calls
133-
will transparently follow redirects; that is, you get the response from the
137+
will transparently follow redirects; that is, you get the response for the
134138
final, redirected request. Likewise, the `harEntry` property of the response
135139
will correspond with that final request.
136140

137141
To get the HAR entries for the redirects, use the `har` or `onHarEntry` options
138142
(described above). The redirects will be appended to the log and reported with
139-
`onHarEntry` along with the final entry. Note that this means that it’s possible
140-
for a single `fetch` call to result in multiple entries.
143+
`onHarEntry` in addition to the final entry, in the order that they were made.
144+
145+
### Request Body
146+
147+
If there is no `Content-Type` header specified in the request, then `postData`
148+
will not be populated since we would not be able to populate the required
149+
`mimeType` field.
150+
151+
Additionally, `params` will only be populated if the `Content-Type` is exactly
152+
`application/x-www-form-urlencoded`. If it is anything else (including
153+
`multipart/form-data`) then `text` will be populated instead.
154+
155+
There may be limited support for exotic request body encodings.
141156

142157
### Page Info
143158

@@ -183,7 +198,6 @@ $ yarn start
183198

184199
## TODO
185200

186-
- Support for request body info.
187201
- Support for compression info.
188202
- Better tests with multiple response types, encodings, etc.
189203

@@ -199,3 +213,6 @@ key timestamps and metadata like the HTTP version.
199213
[har]: http://www.softwareishard.com/blog/har-12-spec/
200214
[isomorphic-fetch]: https://github.com/matthew-andrews/isomorphic-fetch
201215
[isomorphic-unfetch]: https://github.com/developit/unfetch
216+
[npm]: https://www.npmjs.com/package/node-fetch-har
217+
[travis]: https://travis-ci.org/exogen/node-fetch-har
218+
[coveralls]: https://coveralls.io/github/exogen/node-fetch-har

index.js

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { URL } = require("url");
22
const http = require("http");
33
const https = require("https");
4+
const querystring = require("querystring");
45
const generateId = require("nanoid");
56
const cookie = require("cookie");
67
const setCookie = require("set-cookie-parser");
@@ -32,6 +33,7 @@ function handleRequest(harEntryMap, request, options) {
3233
const url = new URL(options.url || options.href); // Depends on Node version?
3334

3435
const entry = {
36+
_parent: parentEntry,
3537
_timestamps: {
3638
start: now,
3739
sent: now
@@ -54,14 +56,14 @@ function handleRequest(harEntryMap, request, options) {
5456
ssl: -1
5557
},
5658
request: {
57-
url: url.href,
5859
method: request.method,
60+
url: url.href,
61+
cookies: buildRequestCookies(headers),
62+
headers: buildHeaders(headers),
5963
queryString: [...url.searchParams].map(([name, value]) => ({
6064
name,
6165
value
6266
})),
63-
cookies: buildRequestCookies(headers),
64-
headers: buildHeaders(headers),
6567
headersSize: -1,
6668
bodySize: -1
6769
},
@@ -71,28 +73,87 @@ function handleRequest(harEntryMap, request, options) {
7173
}
7274
};
7375

74-
if (parentEntry) {
75-
entry._parent = parentEntry;
76-
}
76+
// Some versions of `node-fetch` will put `body` in the `options` received by
77+
// this function and others exclude it. Instead we have to capture writes to
78+
// the `ClientRequest` stream. There might be some official way to do this
79+
// with streams, but the events and piping I tried didn't work. FIXME?
80+
const _write = request.write;
81+
const _end = request.end;
82+
let requestBody;
83+
84+
const concatBody = chunk => {
85+
// Assume the writer will be consistent such that we wouldn't get Buffers in
86+
// some writes and strings in others.
87+
if (typeof chunk === "string") {
88+
if (requestBody == null) {
89+
requestBody = chunk;
90+
} else {
91+
requestBody += chunk;
92+
}
93+
} else if (Buffer.isBuffer(chunk)) {
94+
if (requestBody == null) {
95+
requestBody = chunk;
96+
} else {
97+
requestBody = Buffer.concat([requestBody, chunk]);
98+
}
99+
}
100+
};
101+
102+
request.write = function(...args) {
103+
concatBody(...args);
104+
return _write.call(this, ...args);
105+
};
106+
107+
request.end = function(...args) {
108+
concatBody(...args);
77109

78-
entry.request.url = url.href;
110+
if (requestBody != null) {
111+
// Works for both buffers and strings.
112+
entry.request.bodySize = requestBody.length;
113+
114+
let mimeType;
115+
for (const name in headers) {
116+
if (name.toLowerCase() === "content-type") {
117+
mimeType = headers[name][0];
118+
break;
119+
}
120+
}
121+
122+
if (mimeType) {
123+
const bodyString = requestBody.toString(); // FIXME: Assumes encoding?
124+
if (mimeType === "application/x-www-form-urlencoded") {
125+
entry.request.postData = {
126+
mimeType,
127+
params: buildParams(bodyString)
128+
};
129+
} else {
130+
entry.request.postData = { mimeType, text: bodyString };
131+
}
132+
}
133+
}
134+
135+
return _end.call(this, ...args);
136+
};
79137

80138
request.on("response", response => {
81139
entry._timestamps.firstByte = Date.now();
82140
harEntryMap.set(requestId, entry);
83141
const httpVersion = `HTTP/${response.httpVersion}`;
142+
143+
// Populate request info that isn't available until now.
84144
entry.request.httpVersion = httpVersion;
145+
85146
entry.response = {
86-
httpVersion,
87147
status: response.statusCode,
88148
statusText: response.statusMessage,
89-
redirectURL: response.headers.location || "",
90-
headers: buildHeaders(response.rawHeaders),
149+
httpVersion,
91150
cookies: buildResponseCookies(response.headers),
151+
headers: buildHeaders(response.rawHeaders),
92152
content: {
93153
size: -1,
94154
mimeType: response.headers["content-type"]
95155
},
156+
redirectURL: response.headers.location || "",
96157
headersSize: -1,
97158
bodySize: -1
98159
};
@@ -145,6 +206,22 @@ function buildRequestCookies(headers) {
145206
return cookies;
146207
}
147208

209+
function buildParams(paramString) {
210+
const params = [];
211+
const parsed = querystring.parse(paramString);
212+
for (const name in parsed) {
213+
const value = parsed[name];
214+
if (Array.isArray(value)) {
215+
value.forEach(item => {
216+
params.push({ name, value: item });
217+
});
218+
} else {
219+
params.push({ name, value });
220+
}
221+
}
222+
return params;
223+
}
224+
148225
function buildResponseCookies(headers) {
149226
const cookies = [];
150227
const setCookies = headers["set-cookie"];

test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,43 @@ fragment TypeRef on __Type {
380380
);
381381
expect(response.harEntry.request.method).toBe("POST");
382382
});
383+
384+
it("records request body info", async () => {
385+
const fetch = withHar(baseFetch);
386+
const response = await fetch("https://postman-echo.com/post", {
387+
method: "POST",
388+
headers: {
389+
"Content-Type": "text/plain"
390+
},
391+
body: "test one two!"
392+
});
393+
expect(response.harEntry.request.bodySize).toBe(13);
394+
expect(response.harEntry.request.postData).toEqual({
395+
mimeType: "text/plain",
396+
text: "test one two!"
397+
});
398+
});
399+
400+
it("records request body params", async () => {
401+
const fetch = withHar(baseFetch);
402+
const response = await fetch("https://postman-echo.com/post", {
403+
method: "POST",
404+
headers: {
405+
"Content-Type": "application/x-www-form-urlencoded"
406+
},
407+
body: "foo=1&bar=2&bar=three%20aka%203&baz=4"
408+
});
409+
expect(response.harEntry.request.bodySize).toBe(37);
410+
expect(response.harEntry.request.postData).toEqual({
411+
mimeType: "application/x-www-form-urlencoded",
412+
params: [
413+
{ name: "foo", value: "1" },
414+
{ name: "bar", value: "2" },
415+
{ name: "bar", value: "three aka 3" },
416+
{ name: "baz", value: "4" }
417+
]
418+
});
419+
});
383420
});
384421

385422
it("reports entries with the onHarEntry option", async () => {

0 commit comments

Comments
 (0)