diff --git a/CHANGELOG.md b/CHANGELOG.md index 0215cde..9816361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Update `HttpRequestUtil.request` documentation - Update license year +- Add `X-Request-Id` header diff --git a/src/utils/request.ts b/src/utils/request.ts index 8ec180e..261a9b4 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -5,6 +5,8 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import { randomUUID } from "node:crypto"; + /** Error thrown by the SDK when receiving non-2xx responses on HTTP requests. */ export class HTTPResponseError extends Error { response: any; @@ -14,6 +16,13 @@ export class HTTPResponseError extends Error { } } +/** + * UUID generator utility wrapping node:crypto's randomUUID for stubbing in tests + */ +export const uuidGenerator = { + generate: () => randomUUID(), +}; + /** * Handles HTTP requests. */ @@ -23,7 +32,7 @@ export class HttpRequestUtil { * * @param url - The URL to make the request to * @param opts - Fetch request options (method, headers, body, etc.). Headers will be merged - * with default headers `{ "User-Agent: heroku-applink-node-sdk/1.0" }` + * with default User-Agent and X-Request-ID headers. * @param json - Whether to parse the response as JSON (default: true). If false, * returns the raw Response object @@ -36,6 +45,7 @@ export class HttpRequestUtil { ...opts, headers: { "User-Agent": "heroku-applink-node-sdk/1.0", + "X-Request-Id": uuidGenerator.generate(), ...(opts?.headers ?? {}), }, }; diff --git a/test/utils/request.test.ts b/test/utils/request.test.ts index 11e3be9..9d94f02 100644 --- a/test/utils/request.test.ts +++ b/test/utils/request.test.ts @@ -7,15 +7,21 @@ import { expect } from "chai"; import sinon from "sinon"; -import { HttpRequestUtil, HTTPResponseError } from "../../src/utils/request"; +import { + HttpRequestUtil, + HTTPResponseError, + uuidGenerator, +} from "../../src/utils/request"; describe("HttpRequestUtil", () => { let httpRequestUtil: HttpRequestUtil; let fetchStub: sinon.SinonStub; + let uuidGeneratorStub: sinon.SinonStub; beforeEach(() => { httpRequestUtil = new HttpRequestUtil(); fetchStub = sinon.stub(global, "fetch"); + uuidGeneratorStub = sinon.stub(uuidGenerator, "generate"); }); afterEach(() => { @@ -91,7 +97,54 @@ describe("HttpRequestUtil", () => { ); }); + it("should include default request-id header", async () => { + const mockUUID = "test-uuid-1234-5678-9abc"; + uuidGeneratorStub.returns(mockUUID); + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: sinon.stub().resolves({}), + }; + fetchStub.resolves(mockResponse); + + await httpRequestUtil.request("https://api.example.com/test", {}); + + const [, options] = fetchStub.getCall(0).args; + expect(options.headers["X-Request-Id"]).to.equal(mockUUID); + expect(uuidGeneratorStub.calledOnce).to.be.true; + }); + + it("should generate unique request-id for each request", async () => { + const mockUUID1 = "test-uuid-1111-1111-1111"; + const mockUUID2 = "test-uuid-2222-2222-2222"; + uuidGeneratorStub.onFirstCall().returns(mockUUID1); + uuidGeneratorStub.onSecondCall().returns(mockUUID2); + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: sinon.stub().resolves({}), + }; + fetchStub.resolves(mockResponse); + + await httpRequestUtil.request("https://api.example.com/test1", {}); + await httpRequestUtil.request("https://api.example.com/test2", {}); + + const [, options1] = fetchStub.getCall(0).args; + const [, options2] = fetchStub.getCall(1).args; + + expect(options1.headers["X-Request-Id"]).to.equal(mockUUID1); + expect(options2.headers["X-Request-Id"]).to.equal(mockUUID2); + expect(uuidGeneratorStub.calledTwice).to.be.true; + }); + it("should merge custom headers with default headers", async () => { + const mockUUID = "test-uuid-merge-test"; + uuidGeneratorStub.returns(mockUUID); + const mockResponse = { ok: true, status: 200, @@ -115,11 +168,15 @@ describe("HttpRequestUtil", () => { expect(options.headers["User-Agent"]).to.equal( "heroku-applink-node-sdk/1.0" ); + expect(options.headers["X-Request-Id"]).to.equal(mockUUID); expect(options.headers["Content-Type"]).to.equal("application/json"); expect(options.headers["Authorization"]).to.equal("Bearer token123"); }); it("should allow custom headers to override User-Agent", async () => { + const mockUUID = "test-uuid-override-test"; + uuidGeneratorStub.returns(mockUUID); + const mockResponse = { ok: true, status: 200, @@ -138,6 +195,37 @@ describe("HttpRequestUtil", () => { const [, options] = fetchStub.getCall(0).args; expect(options.headers["User-Agent"]).to.equal("custom-agent/1.0"); + expect(options.headers["X-Request-Id"]).to.equal(mockUUID); + }); + + it("should allow custom headers to override request-id", async () => { + // UUID generator should still be called since it's called before merging custom headers + uuidGeneratorStub.returns("generated-uuid-that-gets-overridden"); + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: sinon.stub().resolves({}), + }; + fetchStub.resolves(mockResponse); + + const customRequestId = "custom-request-id-123"; + const customOpts = { + headers: { + "X-Request-Id": customRequestId, + }, + }; + + await httpRequestUtil.request("https://api.example.com/test", customOpts); + + const [, options] = fetchStub.getCall(0).args; + expect(options.headers["X-Request-Id"]).to.equal(customRequestId); + expect(options.headers["User-Agent"]).to.equal( + "heroku-applink-node-sdk/1.0" + ); + // UUID generator should still be called since it's called before merging custom headers + expect(uuidGeneratorStub.calledOnce).to.be.true; }); it("should throw HTTPResponseError for 4xx status codes", async () => {