diff --git a/lib/api_client/execute_request.js b/lib/api_client/execute_request.js index 3958ef91..ee27c373 100644 --- a/lib/api_client/execute_request.js +++ b/lib/api_client/execute_request.js @@ -76,9 +76,12 @@ function execute_request(method, params, auth, api_url, callback, options = {}) request_options.headers['Content-Length'] = Buffer.byteLength(query_params); } handle_response = function (res) { - const { hide_sensitive = false } = config(); + const { hide_sensitive = false, debug = false } = config(); const sanitizedOptions = { ...request_options }; + // Capture X-Request-Id from response headers for debugging + const requestId = res.headers['x-request-id']; + if (hide_sensitive === true) { if ("auth" in sanitizedOptions) { delete sanitizedOptions.auth; } if ("Authorization" in sanitizedOptions.headers) { delete sanitizedOptions.headers.Authorization; } @@ -108,6 +111,10 @@ function execute_request(method, params, auth, api_url, callback, options = {}) if (result.error) { result.error.http_code = res.statusCode; + // Include request_id in errors when debug mode is enabled + if (debug && requestId) { + result.error.request_id = requestId; + } } else { if (res.headers["x-featureratelimit-limit"]) { result.rate_limit_allowed = parseInt(res.headers["x-featureratelimit-limit"]); @@ -118,6 +125,10 @@ function execute_request(method, params, auth, api_url, callback, options = {}) if (res.headers["x-featureratelimit-remaining"]) { result.rate_limit_remaining = parseInt(res.headers["x-featureratelimit-remaining"]); } + // Include request_id in success responses when debug mode is enabled + if (debug && requestId) { + result.request_id = requestId; + } } if (result.error) { @@ -142,6 +153,10 @@ function execute_request(method, params, auth, api_url, callback, options = {}) query_params } }; + // Include request_id in network errors when debug mode is enabled + if (debug && requestId) { + err_obj.error.request_id = requestId; + } deferred.reject(err_obj.error); if (typeof callback === "function") { callback(err_obj); @@ -156,6 +171,10 @@ function execute_request(method, params, auth, api_url, callback, options = {}) query_params } }; + // Include request_id in unexpected status code errors when debug mode is enabled + if (debug && requestId) { + err_obj.error.request_id = requestId; + } deferred.reject(err_obj.error); if (typeof callback === "function") { callback(err_obj); diff --git a/test/unit/debug_mode_spec.js b/test/unit/debug_mode_spec.js new file mode 100644 index 00000000..1e6b1b50 --- /dev/null +++ b/test/unit/debug_mode_spec.js @@ -0,0 +1,195 @@ +const expect = require('expect.js'); +const sinon = require('sinon'); +const https = require('https'); +const { EventEmitter } = require('events'); +const cloudinary = require('../../lib/cloudinary'); +const createTestConfig = require('../testUtils/createTestConfig'); + +describe('debug mode', function () { + let requestStub; + let mockResponse; + + beforeEach(function () { + cloudinary.config(createTestConfig()); + + // Create a mock response that extends EventEmitter + mockResponse = new EventEmitter(); + mockResponse.statusCode = 200; + mockResponse.headers = { + 'x-request-id': 'test-request-id-12345678', + 'x-featureratelimit-limit': '500', + 'x-featureratelimit-reset': new Date().toUTCString(), + 'x-featureratelimit-remaining': '499' + }; + + // Stub the https.request method + requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) { + // Call the callback with our mock response + setTimeout(() => callback(mockResponse), 0); + + // Return a mock request object + const mockRequest = new EventEmitter(); + mockRequest.write = sinon.stub(); + mockRequest.end = function () { + // Simulate response data after end() is called + setTimeout(() => { + mockResponse.emit('data', JSON.stringify({ status: 'ok' })); + mockResponse.emit('end'); + }, 10); + }; + mockRequest.setTimeout = sinon.stub(); + + return mockRequest; + }); + }); + + afterEach(function () { + requestStub.restore(); + }); + + describe('when debug mode is enabled', function () { + beforeEach(function () { + cloudinary.config({ debug: true }); + }); + + it('should include request_id in successful responses', function (done) { + cloudinary.v2.api.ping() + .then((result) => { + expect(result.request_id).to.be('test-request-id-12345678'); + expect(result.status).to.be('ok'); + done(); + }) + .catch(done); + }); + + it('should include request_id in error responses', function (done) { + // Override the mock response to simulate an error + mockResponse.statusCode = 404; + requestStub.restore(); + + requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) { + setTimeout(() => callback(mockResponse), 0); + + const mockRequest = new EventEmitter(); + mockRequest.write = sinon.stub(); + mockRequest.end = function () { + setTimeout(() => { + mockResponse.emit('data', JSON.stringify({ + error: { + message: 'Resource not found' + } + })); + mockResponse.emit('end'); + }, 10); + }; + mockRequest.setTimeout = sinon.stub(); + + return mockRequest; + }); + + cloudinary.v2.api.resource('nonexistent').catch((error) => { + expect(error.error.request_id).to.be('test-request-id-12345678'); + expect(error.error.message).to.be('Resource not found'); + expect(error.error.http_code).to.be(404); + done(); + }); + }); + + it('should include request_id even when X-Request-Id header has different casing', function (done) { + // Test case insensitivity (Node.js lowercases headers) + mockResponse.headers = { + 'x-request-id': 'case-insensitive-id' + }; + + cloudinary.v2.api.ping() + .then((result) => { + expect(result.request_id).to.be('case-insensitive-id'); + done(); + }) + .catch(done); + }); + }); + + describe('when debug mode is disabled', function () { + beforeEach(function () { + cloudinary.config({ debug: false }); + }); + + it('should NOT include request_id in successful responses', function (done) { + cloudinary.v2.api.ping() + .then((result) => { + expect(result.request_id).to.be(undefined); + expect(result.status).to.be('ok'); + done(); + }) + .catch(done); + }); + + it('should NOT include request_id in error responses', function (done) { + mockResponse.statusCode = 404; + requestStub.restore(); + + requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) { + setTimeout(() => callback(mockResponse), 0); + + const mockRequest = new EventEmitter(); + mockRequest.write = sinon.stub(); + mockRequest.end = function () { + setTimeout(() => { + mockResponse.emit('data', JSON.stringify({ + error: { + message: 'Resource not found' + } + })); + mockResponse.emit('end'); + }, 10); + }; + mockRequest.setTimeout = sinon.stub(); + + return mockRequest; + }); + + cloudinary.v2.api.resource('nonexistent').catch((error) => { + expect(error.error.request_id).to.be(undefined); + expect(error.error.message).to.be('Resource not found'); + done(); + }); + }); + }); + + describe('when X-Request-Id header is missing', function () { + beforeEach(function () { + cloudinary.config({ debug: true }); + // Remove the x-request-id header + delete mockResponse.headers['x-request-id']; + }); + + it('should not break when request_id is missing from headers', function (done) { + cloudinary.v2.api.ping() + .then((result) => { + expect(result.request_id).to.be(undefined); + expect(result.status).to.be('ok'); + done(); + }) + .catch(done); + }); + }); + + describe('config option', function () { + it('should accept debug option in config', function () { + cloudinary.config({ debug: true }); + expect(cloudinary.config('debug')).to.be(true); + + cloudinary.config({ debug: false }); + expect(cloudinary.config('debug')).to.be(false); + }); + + it('should be undefined when not explicitly set', function () { + const testConfig = createTestConfig(); + delete testConfig.debug; // Ensure debug is not in the config + cloudinary.config(testConfig); + // When not set, it should be undefined (falsy) + expect(cloudinary.config('debug')).to.not.be.ok(); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 43dea0f8..e212d98d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -360,6 +360,7 @@ declare module 'cloudinary' { provisioning_api_key?: string; provisioning_api_secret?: string; oauth_token?: string; + debug?: boolean; [futureKey: string]: any; } @@ -633,6 +634,7 @@ declare module 'cloudinary' { rate_limit_allowed?: number; rate_limit_reset_at?: string; rate_limit_remaining?: number; + request_id?: string; } export interface UploadApiResponse { @@ -667,6 +669,7 @@ declare module 'cloudinary' { message: string; name: string; http_code: number; + request_id?: string; [futureKey: string]: any; }