Skip to content

Commit cbaf050

Browse files
authored
Added simple har builder (#2)
* Added simple har builder * Updating readme * Capturing body information * Removing debugger call
1 parent 2e44dd5 commit cbaf050

File tree

9 files changed

+475
-126
lines changed

9 files changed

+475
-126
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ To setup:
1515
`prettier` is used with `husky` and `lint-staged` to lint on commit, which should be installed automatically by `npm`.
1616

1717
Use `yarn test` to run the test suite.
18+
19+
Can also use `yarn test-with-debugger` to run the builtin node debugger by placing a `debugger;` anywhere in the code base.

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
module.exports = {
33
preset: "ts-jest",
44
testEnvironment: "node",
5+
testPathIgnorePatterns: ["dist"],
56
};

package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
"author": "",
77
"license": "MIT",
88
"devDependencies": {
9+
"@types/content-type": "^1.1.5",
10+
"@types/cookie": "^0.5.1",
911
"@types/express": "^4.17.13",
1012
"@types/har-format": "^1.2.8",
1113
"@types/jest": "^28.1.6",
1214
"@types/node": "^18.6.0",
15+
"@types/set-cookie-parser": "^2.4.2",
1316
"@types/supertest": "^2.0.12",
17+
"express": "^4.16.0",
1418
"husky": "^8.0.1",
1519
"jest": "^28.1.3",
1620
"lint-staged": "^13.0.3",
@@ -21,14 +25,20 @@
2125
"typescript": "^4.7.4"
2226
},
2327
"peerDepedencies": {
24-
"express": "^4.18.1"
28+
"express": "^4.16.0"
2529
},
2630
"scripts": {
2731
"build": "yarn tsc",
2832
"test": "yarn jest",
33+
"test-with-debugger": "node inspect ./node_modules/.bin/jest --runInBand",
2934
"prepare": "yarn husky install"
3035
},
31-
"dependencies": {},
36+
"dependencies": {
37+
"content-type": "^1.0.4",
38+
"cookie": "^0.5.0",
39+
"raw-body": "^2.5.1",
40+
"set-cookie-parser": "^2.5.1"
41+
},
3242
"engines": {
3343
"node": ">=16.0.0"
3444
},

src/format/har.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import url from "url";
2+
import http from "http";
3+
4+
import cookie from "cookie";
5+
import setCookie from "set-cookie-parser";
6+
import {
7+
Har as HarType,
8+
Cookie,
9+
Entry,
10+
Request as HarRequest,
11+
Response as HarResponse,
12+
Header,
13+
QueryString,
14+
PostData,
15+
Content,
16+
} from "har-format";
17+
18+
// Cannot get TS to point to node's types, it's defaulting to types from the fetchApi
19+
type Request = any;
20+
type Response = any;
21+
22+
export type AdditionalData = {
23+
reqBodyString?: string;
24+
reqBodyStringComment?: string;
25+
responseText?: string;
26+
responseSize?: number;
27+
};
28+
29+
export class Har {
30+
har: HarType;
31+
32+
public constructor() {
33+
this.har = {
34+
log: {
35+
version: "1.2",
36+
creator: {
37+
name: "Speakeasy Typescript SDK",
38+
version: "0.0.1", //TODO(kevinc): Use actual version
39+
},
40+
browser: {
41+
name: "",
42+
version: "",
43+
},
44+
pages: [],
45+
entries: [],
46+
comment: "",
47+
},
48+
};
49+
}
50+
51+
public populate(req: Request, res: Response, additionalData: AdditionalData) {
52+
this.har.log.entries.push(this.buildEntry(req, res, additionalData));
53+
}
54+
55+
public static buildHar(
56+
req: Request,
57+
res: Response,
58+
additionalData: AdditionalData = {}
59+
): Har {
60+
const har = new Har();
61+
har.populate(req, res, additionalData);
62+
return har;
63+
}
64+
65+
public toString(): string {
66+
return JSON.stringify(this.har);
67+
}
68+
69+
private buildEntry(
70+
req: Request,
71+
res: Response,
72+
additionalData: AdditionalData
73+
): Entry {
74+
return {
75+
pageref: "page_0",
76+
startedDateTime: new Date().toISOString(), //TODO(kevinc): Get the actual start date time from the header
77+
time: 0, //TODO(kevinc): Compute the actual elapsed time
78+
request: this.buildRequest(req, additionalData),
79+
response: this.buildResponse(res, additionalData),
80+
cache: {},
81+
timings: {
82+
send: 0,
83+
receive: 0,
84+
wait: 0,
85+
},
86+
};
87+
}
88+
89+
private buildRequest(
90+
req: Request,
91+
additionalData: AdditionalData
92+
): HarRequest {
93+
const result: HarRequest = {
94+
method: req.method,
95+
url: req.url,
96+
httpVersion: req.httpVersion,
97+
cookies: this.buildRequestCookie(req),
98+
headers: this.buildRequestHeaders(req.headers),
99+
queryString: this.buildQueryString(req.url),
100+
headersSize: this.buildRequestHeadersSize(req),
101+
bodySize: -1,
102+
};
103+
const postData = this.buildRequestPostData(req, additionalData);
104+
if (postData != null) {
105+
result.postData = postData;
106+
result.bodySize = postData.text?.length ?? -1;
107+
}
108+
109+
return result;
110+
}
111+
112+
private buildResponse(
113+
res: Response,
114+
additionalData: AdditionalData
115+
): HarResponse {
116+
const content = this.buildResponseContent(res, additionalData);
117+
const result: HarResponse = {
118+
status: res.statusCode,
119+
statusText: res.statusMessage,
120+
httpVersion: "", // TODO(kevinc): Response HTTP version
121+
cookies: this.buildResponseCookie(res),
122+
headers: this.buildResponseHeaders(res.getHeaders()),
123+
content: content,
124+
redirectURL: res.getHeader("location") ?? "",
125+
headersSize: this.buildResponseHeadersSize(res.req, res),
126+
bodySize: content.size,
127+
};
128+
129+
return result;
130+
}
131+
132+
private buildRequestCookie(req: Request): Cookie[] {
133+
const cookies = req.header("cookie");
134+
const entries: Cookie[] = [];
135+
if (cookies == null) {
136+
return [];
137+
}
138+
Object.entries(cookie.parse(cookies)).forEach(([cookieName, cookieVal]) => {
139+
entries.push({
140+
name: cookieName,
141+
value: cookieVal,
142+
});
143+
});
144+
return entries;
145+
}
146+
147+
private buildRequestPostData(
148+
req: Request,
149+
additionalData: AdditionalData
150+
): PostData | undefined {
151+
if (additionalData.reqBodyString == undefined) {
152+
return undefined;
153+
}
154+
return {
155+
// We don't parse the body params
156+
mimeType: req.getHeader("content-type"),
157+
text: additionalData.reqBodyString,
158+
};
159+
}
160+
161+
private buildResponseCookie(res: Response): Cookie[] {
162+
return setCookie.parse(res).map((cookie) => ({
163+
name: cookie.name,
164+
value: cookie.value,
165+
path: cookie.path,
166+
domain: cookie.domain,
167+
expires:
168+
cookie.expires == null ? undefined : cookie.expires.toUTCString(),
169+
httpOnly: cookie.httpOnly,
170+
secure: cookie.secure,
171+
}));
172+
}
173+
174+
private buildRequestHeaders(headers: Object): Header[] {
175+
return Object.entries(headers).map(([name, value]) => ({
176+
name,
177+
value,
178+
}));
179+
}
180+
181+
private buildRequestHeadersSize(req: Request): number {
182+
// Building string to get actual byte size
183+
let rawHeaders = "";
184+
rawHeaders += `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
185+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
186+
rawHeaders += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
187+
}
188+
rawHeaders += "\r\n";
189+
return Buffer.byteLength(rawHeaders, "utf8");
190+
}
191+
192+
private buildResponseHeadersSize(req: Request, res: Response): number {
193+
// Building string to get actual byte size
194+
let rawHeaders = "";
195+
rawHeaders += `HTTP/${req.httpVersion} ${res.statusCode} ${res.statusMessage}\r\n`;
196+
Object.entries(res.getHeaders()).forEach(([header, value]) => {
197+
rawHeaders += `${header}: ${value}`;
198+
});
199+
rawHeaders += "\r\n";
200+
return Buffer.byteLength(rawHeaders, "utf8");
201+
}
202+
203+
private buildResponseHeaders(headers: Object): Header[] {
204+
const responseHeaders: Header[] = [];
205+
Object.entries(headers).forEach(([name, value]) => {
206+
if (Array.isArray(value)) {
207+
responseHeaders.concat(
208+
value.map((val) => ({
209+
name: name,
210+
value: val,
211+
}))
212+
);
213+
} else {
214+
responseHeaders.push({
215+
name,
216+
value,
217+
});
218+
}
219+
});
220+
return responseHeaders;
221+
}
222+
223+
private buildResponseContent(
224+
res: Response,
225+
additionalData: AdditionalData
226+
): Content {
227+
const content: Content = {
228+
size: -1,
229+
mimeType: res.getHeaders()["content-type"] ?? "",
230+
};
231+
if (additionalData.responseText != null) {
232+
content.text = additionalData.responseText;
233+
}
234+
if (additionalData.responseSize != null) {
235+
content.size = additionalData.responseSize;
236+
}
237+
return content;
238+
}
239+
240+
private buildQueryString(urlStr: string): QueryString[] {
241+
const queryObj = url.parse(urlStr, true).query;
242+
const queryString: QueryString[] = [];
243+
Object.entries(queryObj).forEach(([name, value]) => {
244+
if (value === undefined) {
245+
queryString.push({
246+
name: name,
247+
value: "",
248+
});
249+
} else if (Array.isArray(value)) {
250+
queryString.concat(
251+
value.map((val) => ({
252+
name: name,
253+
value: val,
254+
}))
255+
);
256+
} else {
257+
queryString.push({
258+
name,
259+
value,
260+
});
261+
}
262+
});
263+
return queryString;
264+
}
265+
}

src/middleware/express/middleware.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { Duplex } from "stream";
2+
13
import { Request, Response } from "express";
4+
import getRawBody from "raw-body";
5+
import { parse as parseContentType } from "content-type";
6+
27
import { getInstance } from "../../speakeasy";
38
import { sendApiCall } from "../../transport";
49
import { outputError } from "../../error";
10+
import { Har, AdditionalData } from "../../format/har";
511

612
export function expressMiddleware() {
713
return function (req: Request, res: Response, next: () => void) {
@@ -11,8 +17,64 @@ export function expressMiddleware() {
1117
return next();
1218
}
1319

20+
// Capturing request body
21+
let rawBody: null | string = null;
22+
let rawBodyParserErr: null | Error = null;
23+
const rawBodyOptions: {
24+
encoding: string;
25+
} = {
26+
encoding: "utf-8",
27+
};
28+
try {
29+
rawBodyOptions.encoding = parseContentType(req).parameters.charset;
30+
} catch (err) {} // We don't decode if we can't parse the charset
31+
getRawBody(req, rawBodyOptions, (err, result) => {
32+
if (err) {
33+
rawBodyParserErr = err;
34+
return;
35+
// We skip logging the body if there's an error
36+
}
37+
rawBody = result;
38+
});
39+
40+
// Capturing response body by overwriting the default write and end methods
41+
const oldWrite = res.write;
42+
const oldEnd = res.end;
43+
44+
const responseChunks: any[] = [];
45+
46+
res.write = (chunk, ...args) => {
47+
responseChunks.push(chunk);
48+
//@ts-ignore TS cannot type args correctly
49+
return oldWrite.apply(res, [chunk, ...args]);
50+
};
51+
52+
//@ts-ignore TS cannot type args correctly
53+
res.end = (...args) => {
54+
// #end has 3 signatures, the first argument is a chunk only if there are more than 1 argument
55+
if (args.length > 1) {
56+
responseChunks.push(args[0]);
57+
}
58+
//@ts-ignore TS cannot type args correctly
59+
return oldEnd.apply(res, args);
60+
};
61+
1462
res.on("finish", () => {
15-
sendApiCall();
63+
const additionalData: AdditionalData = {};
64+
if (rawBody != null) {
65+
additionalData.reqBodyString = rawBody;
66+
}
67+
if (rawBodyParserErr != null) {
68+
additionalData.reqBodyStringComment = `Failed to capture body string: ${rawBodyParserErr.message}`;
69+
}
70+
if (responseChunks.length > 0) {
71+
const responseBuffer = Buffer.concat(responseChunks);
72+
additionalData.responseText = responseBuffer.toString("utf8");
73+
additionalData.responseSize = responseBuffer.byteLength;
74+
}
75+
76+
const har = Har.buildHar(req, res, additionalData);
77+
sendApiCall(har);
1678
});
1779
next();
1880
};

0 commit comments

Comments
 (0)