From 9e2569675be3065bc35c433ea380b33088e134a4 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Wed, 27 Aug 2025 14:22:01 -0400 Subject: [PATCH 1/2] feat(api): expose raw response headers on all responses via non-enumerable rawHeaders; keep existing camelCased headers intact - add tests for rawHeaders on responses - update response models to include rawHeaders - attach rawHeaders in API client without breaking equality --- CHANGELOG.md | 3 ++ examples/messages/README.md | 9 +++++ src/apiClient.ts | 21 +++++++++--- src/models/response.ts | 30 ++++++++++++++++ tests/apiClient.spec.ts | 68 +++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2eb23b..76f2183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated Jest configuration to properly handle ESM modules from node-fetch v3 - Removed incompatible AbortSignal import from node-fetch externals (now uses native Node.js AbortSignal) +### Added +- Expose raw response headers on all responses via non-enumerable `rawHeaders` while keeping existing `headers` camelCased + ## [7.11.0] - 2025-06-23 ### Added diff --git a/examples/messages/README.md b/examples/messages/README.md index 50b53925..3dde23cf 100644 --- a/examples/messages/README.md +++ b/examples/messages/README.md @@ -21,6 +21,15 @@ The file is structured with: ### Basic Messages (`messages.ts`) Shows basic message operations including reading, sending, and drafting messages. +### Accessing Rate Limit Headers +All SDK responses now expose a non-enumerable `rawHeaders` with dashed lowercase keys so you can read rate limit information: + +```ts +const res = await nylas.messages.list({ identifier: process.env.NYLAS_GRANT_ID!, queryParams: { limit: 1 } }); +const limit = res.rawHeaders?.['x-rate-limit-limit']; +const remaining = res.rawHeaders?.['x-rate-limit-remaining']; +``` + ## Quick Start ### 1. Set up environment diff --git a/src/apiClient.ts b/src/apiClient.ts index 17bc7b17..ddc85f40 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -288,11 +288,22 @@ export default class APIClient { const text = await response.text(); try { - const responseJSON = JSON.parse(text); - // Inject the flow ID and headers into the response - responseJSON.flowId = flowId; - responseJSON.headers = headers; - return objKeysToCamelCase(responseJSON, ['metadata']); + const parsed = JSON.parse(text); + const payload = objKeysToCamelCase( + { + ...parsed, + flowId, + // deprecated: headers will be removed in a future release. This is for backwards compatibility. + headers, + }, + ['metadata'] + ); + // Attach rawHeaders as a non-enumerable property to avoid breaking deep equality + Object.defineProperty(payload, 'rawHeaders', { + value: headers, + enumerable: false, + }); + return payload; } catch (e) { throw new Error(`Could not parse response from the server: ${text}`); } diff --git a/src/models/response.ts b/src/models/response.ts index 5c320bdf..9679d4c8 100644 --- a/src/models/response.ts +++ b/src/models/response.ts @@ -3,6 +3,19 @@ */ export interface NylasBaseResponse { requestId: string; + /** + * The flow ID + * Provide this to Nylas support to help trace requests and responses + */ + flowId?: string; + /** + * The response headers with camelCased keys (backwards compatible) + */ + headers?: Record; + /** + * The raw response headers with original dashed lowercase keys + */ + rawHeaders?: Record; } /** @@ -26,6 +39,10 @@ export interface NylasResponse { * The response headers */ headers?: Record; + /** + * The raw response headers with original dashed lowercase keys + */ + rawHeaders?: Record; } /** @@ -44,6 +61,19 @@ export interface NylasListResponse { * The cursor to use to get the next page of data. */ nextCursor?: string; + /** + * The flow ID + * Provide this to Nylas support to help trace requests and responses + */ + flowId?: string; + /** + * The response headers with camelCased keys (backwards compatible) + */ + headers?: Record; + /** + * The raw response headers with original dashed lowercase keys + */ + rawHeaders?: Record; } /** diff --git a/tests/apiClient.spec.ts b/tests/apiClient.spec.ts index 17a09acb..ed379ad3 100644 --- a/tests/apiClient.spec.ts +++ b/tests/apiClient.spec.ts @@ -236,6 +236,40 @@ describe('APIClient', () => { mockHeaders['x-request-id'] ); }); + + it('should include rawHeaders with dashed lowercase keys', async () => { + const mockFlowId = 'test-flow-raw-123'; + const mockHeaders = { + 'x-request-id': 'req-raw-123', + 'x-nylas-api-version': 'v3', + 'x-rate-limit-limit': '100', + 'x-rate-limit-remaining': '99', + }; + + const payload = { + id: 456, + name: 'raw-test', + }; + + const mockResp = mockResponse(JSON.stringify(payload)); + mockResp.headers.set('x-fastly-id', mockFlowId); + Object.entries(mockHeaders).forEach(([key, value]) => { + mockResp.headers.set(key, value); + }); + + const result = await client.requestWithResponse(mockResp); + + expect((result as any).rawHeaders).toBeDefined(); + expect((result as any).rawHeaders['x-fastly-id']).toBe(mockFlowId); + expect((result as any).rawHeaders['x-request-id']).toBe( + mockHeaders['x-request-id'] + ); + expect((result as any).rawHeaders['x-nylas-api-version']).toBe( + mockHeaders['x-nylas-api-version'] + ); + expect((result as any).rawHeaders['x-rate-limit-limit']).toBe('100'); + expect((result as any).rawHeaders['x-rate-limit-remaining']).toBe('99'); + }); }); describe('request', () => { @@ -278,6 +312,40 @@ describe('APIClient', () => { expect((response as any).headers['xFastlyId']).toBe(mockFlowId); }); + it('should include rawHeaders on standard responses', async () => { + const mockFlowId = 'test-flow-raw-abc'; + const mockHeaders = { + 'x-request-id': 'req-raw-abc', + 'x-nylas-api-version': 'v3', + 'x-rate-limit-limit': '200', + }; + + const payload = { + id: 789, + name: 'raw', + }; + + const mockResp = mockResponse(JSON.stringify(payload)); + mockResp.headers.set('x-fastly-id', mockFlowId); + Object.entries(mockHeaders).forEach(([key, value]) => { + mockResp.headers.set(key, value); + }); + + fetchMock.mockImplementationOnce(() => Promise.resolve(mockResp)); + + const response = await client.request({ path: '/test', method: 'GET' }); + + expect((response as any).rawHeaders).toBeDefined(); + expect((response as any).rawHeaders['x-fastly-id']).toBe(mockFlowId); + expect((response as any).rawHeaders['x-request-id']).toBe( + mockHeaders['x-request-id'] + ); + expect((response as any).rawHeaders['x-nylas-api-version']).toBe( + mockHeaders['x-nylas-api-version'] + ); + expect((response as any).rawHeaders['x-rate-limit-limit']).toBe('200'); + }); + it('should throw an error if the response is undefined', async () => { fetchMock.mockImplementationOnce(() => Promise.resolve(undefined as any) From da3d7c1de1e5afe229311df618094bbd358119a8 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Wed, 27 Aug 2025 22:12:09 -0400 Subject: [PATCH 2/2] Add @deprecated tag --- src/models/response.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/models/response.ts b/src/models/response.ts index 9679d4c8..f72261e9 100644 --- a/src/models/response.ts +++ b/src/models/response.ts @@ -10,6 +10,7 @@ export interface NylasBaseResponse { flowId?: string; /** * The response headers with camelCased keys (backwards compatible) + * @deprecated Use rawHeaders instead */ headers?: Record; /** @@ -37,6 +38,7 @@ export interface NylasResponse { flowId?: string; /** * The response headers + * @deprecated Use rawHeaders instead */ headers?: Record; /** @@ -68,6 +70,7 @@ export interface NylasListResponse { flowId?: string; /** * The response headers with camelCased keys (backwards compatible) + * @deprecated Use rawHeaders instead */ headers?: Record; /**