diff --git a/.yarn/cache/nock-npm-13.4.0-200f928100-30c3751854.zip b/.yarn/cache/nock-npm-13.4.0-200f928100-30c3751854.zip new file mode 100644 index 00000000..41f8bda1 Binary files /dev/null and b/.yarn/cache/nock-npm-13.4.0-200f928100-30c3751854.zip differ diff --git a/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip b/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip new file mode 100644 index 00000000..2a4a26df Binary files /dev/null and b/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip differ diff --git a/package.json b/package.json index cd65f377..e99b7386 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ }, "workspaces": [ "test/express", + "test/httpClient", "test/jest", "test/mocha", "test/vitest", diff --git a/src/AppMap.d.ts b/src/AppMap.d.ts index 62d8390b..5e77d746 100644 --- a/src/AppMap.d.ts +++ b/src/AppMap.d.ts @@ -190,7 +190,7 @@ namespace AppMap { export type Event = CallEvent | ReturnEvent; export interface AppMap { - version: "1.12"; + version: string; metadata?: Metadata; classMap: ClassMap; events?: Event[]; diff --git a/src/hooks/http.ts b/src/hooks/http.ts index 18c5e060..94dfaead 100644 --- a/src/hooks/http.ts +++ b/src/hooks/http.ts @@ -62,7 +62,7 @@ function handleClientRequest(request: http.ClientRequest) { const startTime = getTime(); request.on("finish", () => { - const url = new URL(`${request.protocol}//${request.host}${request.path}`); + const url = extractRequestURL(request); // Setting port to the default port for the protocol makes it empty string. // See: https://nodejs.org/api/url.html#urlport url.port = request.socket?.remotePort + ""; @@ -78,6 +78,17 @@ function handleClientRequest(request: http.ClientRequest) { }); } +function extractRequestURL(request: ClientRequest): URL { + let { protocol, host } = request; + /* nock OverridenClientRequest stores protocol and host on options instead */ + if ("options" in request && request.options && typeof request.options === "object") { + protocol = getStringField(request.options, "protocol") ?? protocol; + host = getStringField(request.options, "host") ?? host; + } + + return new URL(`${protocol}//${host}${request.path}`); +} + function handleClientResponse( requestEvent: AppMap.HttpClientRequestEvent, startTime: number, @@ -136,6 +147,11 @@ function getNormalizedPath(req: http.IncomingMessage) { } } +function getStringField(obj: object, field: string): string | undefined { + const v = getField(obj, field); + if (v && typeof v === "string") return v; +} + function getField(obj: object, field: string): unknown { if (field in obj) return (obj as never)[field]; } @@ -156,10 +172,13 @@ function normalizeHeaders( ): Record | undefined { const result: Record = {}; - for (const [k, v] of Object.entries(headers)) + for (const [k, v] of Object.entries(headers)) { if (v === undefined) continue; - else if (v instanceof Array) result[k] = v.join("\n"); - else result[k] = String(v); + + const key = k.split("-").map(capitalize).join("-"); + if (v instanceof Array) result[key] = v.join("\n"); + else result[key] = String(v); + } return result; } @@ -176,3 +195,7 @@ function handleResponse( normalizeHeaders(response.getHeaders()), ); } + +function capitalize(str: string): string { + return str[0].toUpperCase() + str.slice(1).toLowerCase(); +} diff --git a/test/__snapshots__/express.test.ts.snap b/test/__snapshots__/express.test.ts.snap index 6802cefe..c649eae0 100644 --- a/test/__snapshots__/express.test.ts.snap +++ b/test/__snapshots__/express.test.ts.snap @@ -39,9 +39,9 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "content-type": "application/json", - "host": "localhost:27627", - "transfer-encoding": "chunked", + "Content-Type": "application/json", + "Host": "localhost:27627", + "Transfer-Encoding": "chunked", }, "normalized_path_info": "/api/:ident", "path_info": "/api/bar", @@ -109,7 +109,7 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "host": "localhost:27627", + "Host": "localhost:27627", }, "normalized_path_info": "/api/:ident", "path_info": "/api/foo", @@ -142,7 +142,7 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "host": "localhost:27627", + "Host": "localhost:27627", }, "path_info": "/", "protocol": "HTTP/1.1", @@ -191,10 +191,10 @@ exports[`mapping Express.js requests 1`] = ` "event": "return", "http_server_response": { "headers": { - "content-length": "12", - "content-type": "text/html; charset=utf-8", - "etag": "W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"", - "x-powered-by": "Express", + "Content-Length": "12", + "Content-Type": "text/html; charset=utf-8", + "Etag": "W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"", + "X-Powered-By": "Express", }, "status_code": 200, }, @@ -206,7 +206,7 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "host": "localhost:27627", + "Host": "localhost:27627", }, "path_info": "/nonexistent", "protocol": "HTTP/1.1", @@ -220,11 +220,11 @@ exports[`mapping Express.js requests 1`] = ` "event": "return", "http_server_response": { "headers": { - "content-length": "150", - "content-security-policy": "default-src 'none'", - "content-type": "text/html; charset=utf-8", - "x-content-type-options": "nosniff", - "x-powered-by": "Express", + "Content-Length": "150", + "Content-Security-Policy": "default-src 'none'", + "Content-Type": "text/html; charset=utf-8", + "X-Content-Type-Options": "nosniff", + "X-Powered-By": "Express", }, "status_code": 404, }, @@ -236,7 +236,7 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "host": "localhost:27627", + "Host": "localhost:27627", }, "path_info": "/api/foo", "protocol": "HTTP/1.1", @@ -316,10 +316,10 @@ exports[`mapping Express.js requests 1`] = ` "event": "return", "http_server_response": { "headers": { - "content-length": "42", - "content-type": "application/json; charset=utf-8", - "etag": "W/"2a-YHNH/nKM8UUG4pNEEtm91sktuKc"", - "x-powered-by": "Express", + "Content-Length": "42", + "Content-Type": "application/json; charset=utf-8", + "Etag": "W/"2a-YHNH/nKM8UUG4pNEEtm91sktuKc"", + "X-Powered-By": "Express", }, "status_code": 200, }, @@ -331,9 +331,9 @@ exports[`mapping Express.js requests 1`] = ` "event": "call", "http_server_request": { "headers": { - "content-type": "application/json", - "host": "localhost:27627", - "transfer-encoding": "chunked", + "Content-Type": "application/json", + "Host": "localhost:27627", + "Transfer-Encoding": "chunked", }, "path_info": "/api/bar", "protocol": "HTTP/1.1", @@ -467,10 +467,10 @@ exports[`mapping Express.js requests 1`] = ` "event": "return", "http_server_response": { "headers": { - "content-length": "99", - "content-type": "application/json; charset=utf-8", - "etag": "W/"63-avsgRi3I+MK54okDiWb1V4/K3j8"", - "x-powered-by": "Express", + "Content-Length": "99", + "Content-Type": "application/json; charset=utf-8", + "Etag": "W/"63-avsgRi3I+MK54okDiWb1V4/K3j8"", + "X-Powered-By": "Express", }, "status_code": 200, }, diff --git a/test/__snapshots__/httpClient.test.ts.snap b/test/__snapshots__/httpClient.test.ts.snap index 9b21579f..7f26d139 100644 --- a/test/__snapshots__/httpClient.test.ts.snap +++ b/test/__snapshots__/httpClient.test.ts.snap @@ -8,7 +8,7 @@ exports[`mapping http client requests 1`] = ` { "children": [ { - "location": "./index.ts:18", + "location": "./index.ts:19", "name": "makeRequests", "static": true, "type": "function", @@ -41,7 +41,7 @@ exports[`mapping http client requests 1`] = ` "defined_class": "", "event": "call", "id": 1, - "lineno": 18, + "lineno": 19, "method_id": "makeRequests", "parameters": [], "path": "./index.ts", @@ -64,8 +64,8 @@ exports[`mapping http client requests 1`] = ` "event": "call", "http_client_request": { "headers": { - "host": "localhost:27628", - "test-header": "This test header is added after ClientRequest creation", + "Host": "localhost:27628", + "Test-Header": "This test header is added after ClientRequest creation", }, "request_method": "GET", "url": "http://localhost:27628/endpoint/one", @@ -78,7 +78,7 @@ exports[`mapping http client requests 1`] = ` "event": "return", "http_client_response": { "headers": { - "transfer-encoding": "chunked", + "Transfer-Encoding": "chunked", }, "status_code": 200, }, @@ -90,8 +90,8 @@ exports[`mapping http client requests 1`] = ` "event": "call", "http_client_request": { "headers": { - "content-type": "application/json", - "host": "localhost:27628", + "Content-Type": "application/json", + "Host": "localhost:27628", }, "request_method": "POST", "url": "http://localhost:27628/endpoint/two", @@ -104,8 +104,8 @@ exports[`mapping http client requests 1`] = ` "event": "return", "http_client_response": { "headers": { - "content-type": "text/html", - "transfer-encoding": "chunked", + "Content-Type": "text/html", + "Transfer-Encoding": "chunked", }, "status_code": 404, }, @@ -117,7 +117,7 @@ exports[`mapping http client requests 1`] = ` "event": "call", "http_client_request": { "headers": { - "host": "localhost:27628", + "Host": "localhost:27628", }, "request_method": "GET", "url": "http://localhost:27628/endpoint/three", @@ -130,8 +130,8 @@ exports[`mapping http client requests 1`] = ` "event": "return", "http_client_response": { "headers": { - "content-type": "text/html", - "transfer-encoding": "chunked", + "Content-Type": "text/html", + "Transfer-Encoding": "chunked", }, "status_code": 404, }, @@ -141,7 +141,185 @@ exports[`mapping http client requests 1`] = ` }, ], "metadata": { - "app": "appmap-node", + "app": "http-client-appmap-node-test", + "client": { + "name": "appmap-node", + "url": "https://github.com/getappmap/appmap-node", + "version": "test node-appmap version", + }, + "language": { + "engine": "Node.js", + "name": "javascript", + "version": "test node version", + }, + "name": "test process recording", + "recorder": { + "name": "process", + "type": "process", + }, + }, + "version": "1.12", +} +`; + +exports[`mapping mocked http client requests 1`] = ` +{ + "classMap": [ + { + "children": [ + { + "children": [ + { + "location": "./index.ts:19", + "name": "makeRequests", + "static": true, + "type": "function", + }, + { + "location": "./index.ts:39", + "name": "mock", + "static": true, + "type": "function", + }, + ], + "name": "index", + "type": "class", + }, + ], + "name": "index", + "type": "package", + }, + ], + "eventUpdates": { + "4": { + "elapsed": 31.337, + "event": "return", + "id": 4, + "parent_id": 3, + "return_value": { + "class": "Promise", + "object_id": 1, + "value": "Promise { undefined }", + }, + "thread_id": 0, + }, + }, + "events": [ + { + "defined_class": "", + "event": "call", + "id": 1, + "lineno": 39, + "method_id": "mock", + "parameters": [], + "path": "./index.ts", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 2, + "parent_id": 1, + "thread_id": 0, + }, + { + "defined_class": "", + "event": "call", + "id": 3, + "lineno": 19, + "method_id": "makeRequests", + "parameters": [], + "path": "./index.ts", + "static": true, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "id": 4, + "parent_id": 3, + "return_value": { + "class": "Promise", + "object_id": 1, + "value": "Promise { }", + }, + "thread_id": 0, + }, + { + "event": "call", + "http_client_request": { + "headers": { + "Host": "localhost:27628", + "Test-Header": "This test header is added after ClientRequest creation", + }, + "url": "http://localhost:27628/endpoint/one", + }, + "id": 5, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "http_client_response": { + "headers": {}, + "status_code": 200, + }, + "id": 6, + "parent_id": 5, + "thread_id": 0, + }, + { + "event": "call", + "http_client_request": { + "headers": { + "Content-Type": "application/json", + "Host": "localhost:27628", + }, + "request_method": "POST", + "url": "http://localhost:27628/endpoint/two", + }, + "id": 7, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "http_client_response": { + "headers": {}, + "status_code": 200, + }, + "id": 8, + "parent_id": 7, + "thread_id": 0, + }, + { + "event": "call", + "http_client_request": { + "headers": { + "Host": "localhost:27628", + }, + "url": "http://localhost:27628/endpoint/three", + }, + "id": 9, + "thread_id": 0, + }, + { + "elapsed": 31.337, + "event": "return", + "http_client_response": { + "headers": { + "Content-Type": "text/html", + }, + "status_code": 404, + }, + "id": 10, + "parent_id": 9, + "thread_id": 0, + }, + ], + "metadata": { + "app": "http-client-appmap-node-test", "client": { "name": "appmap-node", "url": "https://github.com/getappmap/appmap-node", diff --git a/test/helpers.ts b/test/helpers.ts index dfb7b55a..416b16c4 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -43,7 +43,7 @@ export function integrationTest(name: string, fn?: jest.ProvidesCallback, timeou type AppMap = object & Record<"events", unknown>; -export function readAppmap(path?: string): AppMap { +export function readAppmap(path?: string): AppMap.AppMap { if (!path) { const files = globSync(resolve(target, "tmp/**/*.appmap.json")); expect(files.length).toBe(1); @@ -54,18 +54,20 @@ export function readAppmap(path?: string): AppMap { assert(typeof result === "object" && result && "events" in result); assert(result.events instanceof Array); result.events.forEach(fixEvent); - if ("classMap" in result && result.classMap instanceof Array) fixClassMap(result.classMap); + assert("classMap" in result && result.classMap instanceof Array); + assert("version" in result && typeof result.version === "string"); + fixClassMap(result.classMap); if ("metadata" in result && typeof result.metadata === "object" && result.metadata) fixMetadata(result.metadata as AppMap.Metadata); if ("eventUpdates" in result && typeof result.eventUpdates === "object" && result.eventUpdates) Object.values(result.eventUpdates).forEach(fixEvent); - return result; + return result as AppMap.AppMap; } -export function readAppmaps(): Record { +export function readAppmaps(): Record { const files = globSync(resolve(target, "tmp/**/*.appmap.json")); - const maps = files.map<[string, AppMap]>((path) => [fixPath(path), readAppmap(path)]); + const maps = files.map<[string, AppMap.AppMap]>((path) => [fixPath(path), readAppmap(path)]); return Object.fromEntries(maps); } @@ -83,10 +85,10 @@ function fixEvent(event: unknown) { "headers" in event.http_server_request && typeof event.http_server_request.headers === "object" && event.http_server_request.headers && - "connection" in event.http_server_request.headers + "Connection" in event.http_server_request.headers ) // the default of this varies between node versions - delete event.http_server_request.headers.connection; + delete event.http_server_request.headers.Connection; if ( "http_client_response" in event && @@ -96,12 +98,12 @@ function fixEvent(event: unknown) { typeof event.http_client_response.headers === "object" && event.http_client_response.headers ) { - if ("date" in event.http_client_response.headers) - delete event.http_client_response.headers.date; - if ("connection" in event.http_client_response.headers) - delete event.http_client_response.headers.connection; - if ("keep-alive" in event.http_client_response.headers) - delete event.http_client_response.headers["keep-alive"]; + if ("Date" in event.http_client_response.headers) + delete event.http_client_response.headers.Date; + if ("Connection" in event.http_client_response.headers) + delete event.http_client_response.headers.Connection; + if ("Keep-Alive" in event.http_client_response.headers) + delete event.http_client_response.headers["Keep-Alive"]; } if ("elapsed" in event && typeof event.elapsed === "number") event.elapsed = 31.337; } diff --git a/test/httpClient.test.ts b/test/httpClient.test.ts index 61e0c33c..2e3044e5 100644 --- a/test/httpClient.test.ts +++ b/test/httpClient.test.ts @@ -1,6 +1,6 @@ import http from "node:http"; -import { integrationTest, readAppmap, spawnAppmapNode } from "./helpers"; +import { integrationTest, readAppmap, runAppmapNode, spawnAppmapNode } from "./helpers"; import { SERVER_PORT, TEST_HEADER_VALUE } from "./httpClient"; integrationTest("mapping http client requests", async () => { @@ -24,3 +24,12 @@ integrationTest("mapping http client requests", async () => { expect(JSON.stringify(appMap.events)).toContain(TEST_HEADER_VALUE); expect(appMap).toMatchSnapshot(); }); + +integrationTest("mapping mocked http client requests", () => { + expect(runAppmapNode("yarn", "exec", "ts-node", "index.ts", "--mock").status).toBe(0); + const appMap = readAppmap(); + + // Make sure we capture the headers modified/added after ClientRequest creation. + expect(JSON.stringify(appMap.events)).toContain(TEST_HEADER_VALUE); + expect(appMap).toMatchSnapshot(); +}); diff --git a/test/httpClient/index.ts b/test/httpClient/index.ts index 0f8916ad..16568b1d 100644 --- a/test/httpClient/index.ts +++ b/test/httpClient/index.ts @@ -1,4 +1,5 @@ import http, { ClientRequest } from "node:http"; +import nock from "nock"; export const SERVER_PORT = 27628; export const TEST_HEADER_VALUE = "This test header is added after ClientRequest creation"; @@ -35,4 +36,15 @@ async function makeRequests() { await consume(r3); } +function mock() { + const n = nock(`http://localhost:${SERVER_PORT}`); + n.get("/endpoint/one").reply(200, "Hello World!"); + n.post("/endpoint/two?p1=v1&p2=v2").reply(200, "Hello World!"); + n.get("/endpoint/three").reply(404, undefined, { "Content-Type": "text/html" }); +} + +if (process.argv.includes("--mock")) { + mock(); +} + void makeRequests(); diff --git a/test/httpClient/package.json b/test/httpClient/package.json new file mode 100644 index 00000000..773ea4bd --- /dev/null +++ b/test/httpClient/package.json @@ -0,0 +1,8 @@ +{ + "name": "http-client-appmap-node-test", + "packageManager": "yarn@3.6.3", + "private": true, + "dependencies": { + "nock": "^13.4.0" + } +} diff --git a/yarn.lock b/yarn.lock index 2ace6435..a2224def 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4898,6 +4898,14 @@ __metadata: languageName: node linkType: hard +"http-client-appmap-node-test@workspace:test/httpClient": + version: 0.0.0-use.local + resolution: "http-client-appmap-node-test@workspace:test/httpClient" + dependencies: + nock: ^13.4.0 + languageName: unknown + linkType: soft + "http-errors@npm:2.0.0": version: 2.0.0 resolution: "http-errors@npm:2.0.0" @@ -6915,6 +6923,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:^13.4.0": + version: 13.4.0 + resolution: "nock@npm:13.4.0" + dependencies: + debug: ^4.1.0 + json-stringify-safe: ^5.0.1 + propagate: ^2.0.0 + checksum: 30c3751854f9c412df5f99e01eeaef25b2583d3cae80b8c46524acb39d8b7fa61043603472ad94a3adc4b7d1e0f3098e6bb06e787734cbfbde2751891115b311 + languageName: node + linkType: hard + "node-addon-api@npm:^4.2.0": version: 4.3.0 resolution: "node-addon-api@npm:4.3.0" @@ -8071,6 +8090,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: c4febaee2be0979e82fb6b3727878fd122a98d64a7fa3c9d09b0576751b88514a9e9275b1b92e76b364d488f508e223bd7e1dcdc616be4cdda876072fbc2a96c + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4"