Skip to content

Commit eea0656

Browse files
committed
Improve SDK robustness: signing path filtering, backoff jitter, input validation
1 parent fe46a08 commit eea0656

7 files changed

Lines changed: 52 additions & 19 deletions

File tree

src/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export const DEFAULT_RETRY_CONFIG: Readonly<RetryConfig> = {
1919
methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
2020
};
2121

22-
export const DEFAULT_USER_AGENT = 'foxnose-sdk-js/0.1.0';
22+
export const SDK_VERSION = '0.1.0';
23+
24+
export const DEFAULT_USER_AGENT = `foxnose-sdk-js/${SDK_VERSION}`;
2325

2426
/**
2527
* General transport-level configuration shared by all clients.

src/flux/client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,21 @@ export class FluxClient {
5353
return suffix ? `${base}${suffix}` : base;
5454
}
5555

56-
async listResources(folderPath: string, params?: Record<string, any>): Promise<any> {
56+
async listResources<T = any>(folderPath: string, params?: Record<string, any>): Promise<T> {
5757
const path = this.buildPath(folderPath);
5858
return this.transport.request('GET', path, { params });
5959
}
6060

61-
async getResource(
61+
async getResource<T = any>(
6262
folderPath: string,
6363
resourceKey: string,
6464
params?: Record<string, any>,
65-
): Promise<any> {
65+
): Promise<T> {
6666
const path = this.buildPath(folderPath, `/${resourceKey}`);
6767
return this.transport.request('GET', path, { params });
6868
}
6969

70-
async search(folderPath: string, body: Record<string, any>): Promise<any> {
70+
async search<T = any>(folderPath: string, body: Record<string, any>): Promise<T> {
7171
const path = this.buildPath(folderPath, '/_search');
7272
return this.transport.request('POST', path, { jsonBody: body });
7373
}

src/http.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export class HttpTransport {
9595
const requestData: RequestData = {
9696
method: method.toUpperCase(),
9797
url,
98-
path: path + (options?.params ? `?${new URLSearchParams(options.params as Record<string, string>).toString()}` : ''),
98+
path: this.buildSigningPath(path, options?.params),
9999
body,
100100
};
101101

@@ -113,6 +113,20 @@ export class HttpTransport {
113113
return { url, init, body };
114114
}
115115

116+
private buildSigningPath(path: string, params?: Record<string, any>): string {
117+
if (!params || Object.keys(params).length === 0) {
118+
return path;
119+
}
120+
const searchParams = new URLSearchParams();
121+
for (const [key, value] of Object.entries(params)) {
122+
if (value !== undefined && value !== null) {
123+
searchParams.append(key, String(value));
124+
}
125+
}
126+
const qs = searchParams.toString();
127+
return qs ? `${path}?${qs}` : path;
128+
}
129+
116130
private shouldRetry(method: string, statusCode: number): boolean {
117131
if (!this.retry.methods.includes(method.toUpperCase())) {
118132
return false;
@@ -128,7 +142,8 @@ export class HttpTransport {
128142
}
129143
return 0;
130144
}
131-
return this.retry.backoffFactor * Math.pow(2, Math.max(attempt - 1, 0)) * 1000;
145+
const base = this.retry.backoffFactor * Math.pow(2, Math.max(attempt - 1, 0)) * 1000;
146+
return base * (0.5 + Math.random() * 0.5);
132147
}
133148

134149
private async maybeDecodeResponse(response: Response, parseJson: boolean): Promise<any> {

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type { AuthStrategy, RequestData, TokenProvider } from './auth/index.js';
1010

1111
// Config
1212
export type { FoxnoseConfig, RetryConfig } from './config.js';
13-
export { createConfig, DEFAULT_RETRY_CONFIG, DEFAULT_USER_AGENT } from './config.js';
13+
export { createConfig, DEFAULT_RETRY_CONFIG, DEFAULT_USER_AGENT, SDK_VERSION } from './config.js';
1414

1515
// Errors
1616
export {
@@ -75,6 +75,11 @@ export type {
7575
PlanDetails,
7676
OrganizationPlanStatus,
7777
OrganizationUsage,
78+
UnitsUsage,
79+
StorageUsage,
80+
UsageMetric,
81+
UsageBreakdown,
82+
CurrentUsage,
7883
BatchUpsertItem,
7984
BatchItemError,
8085
BatchUpsertResult,
@@ -96,4 +101,4 @@ export type {
96101

97102
export { resolveKey } from './management/models.js';
98103

99-
export const VERSION = '0.1.0';
104+
export { SDK_VERSION as VERSION } from './config.js';

src/management/paths.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ export function managementPaths(environmentKey: string) {
1515
`/organizations/${orgKey}/projects/${projectKey}/environments/${envKey}`,
1616

1717
// Folder paths
18-
foldersRoot: () => `/v1/${environmentKey}/folders`,
1918
foldersTreeRoot: () => `/v1/${environmentKey}/folders/tree`,
2019
foldersTreeItem: () => `/v1/${environmentKey}/folders/tree/folder`,
21-
folderRoot: (folderKey: string) => `/v1/${environmentKey}/folders/${folderKey}`,
2220
folderVersionsBase: (folderKey: string) =>
2321
`/v1/${environmentKey}/folders/${folderKey}/model/versions`,
2422
folderSchemaTree: (folderKey: string, versionKey: string) =>

tests/http.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,27 @@ describe('HttpTransport', () => {
345345
expect(url).not.toContain('c=');
346346
});
347347

348+
it('signing path filters null/undefined params same as URL', async () => {
349+
let capturedPath = '';
350+
const auth = {
351+
buildHeaders: (req: any) => {
352+
capturedPath = req.path;
353+
return {};
354+
},
355+
};
356+
const fetchMock = mockFetch([{ status: 200, body: {} }]);
357+
globalThis.fetch = fetchMock;
358+
359+
const transport = new HttpTransport({ config: baseConfig, auth });
360+
await transport.request('GET', '/test', {
361+
params: { a: 'yes', b: null, c: undefined },
362+
});
363+
364+
expect(capturedPath).toBe('/test?a=yes');
365+
expect(capturedPath).not.toContain('null');
366+
expect(capturedPath).not.toContain('undefined');
367+
});
368+
348369
it('handles non-JSON error response body', async () => {
349370
globalThis.fetch = vi.fn(async () =>
350371
new Response('Plain text error', { status: 500 }),

tests/management/paths.test.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,10 @@ describe('managementPaths', () => {
2828
);
2929
});
3030

31-
it('foldersRoot', () => {
32-
expect(paths.foldersRoot()).toBe('/v1/env-abc/folders');
33-
});
34-
3531
it('foldersTreeRoot', () => {
3632
expect(paths.foldersTreeRoot()).toBe('/v1/env-abc/folders/tree');
3733
});
3834

39-
it('folderRoot', () => {
40-
expect(paths.folderRoot('f1')).toBe('/v1/env-abc/folders/f1');
41-
});
42-
4335
it('folderVersionsBase', () => {
4436
expect(paths.folderVersionsBase('f1')).toBe('/v1/env-abc/folders/f1/model/versions');
4537
});

0 commit comments

Comments
 (0)