Skip to content

add x-request-id default header #43

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

Merged
merged 5 commits into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand All @@ -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
Expand All @@ -36,6 +45,7 @@ export class HttpRequestUtil {
...opts,
headers: {
"User-Agent": "heroku-applink-node-sdk/1.0",
"X-Request-Id": uuidGenerator.generate(),
...(opts?.headers ?? {}),
},
};
Expand Down
90 changes: 89 additions & 1 deletion test/utils/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 () => {
Expand Down