From 5eaea8ba85f1608a0c0bb66a23bf1cd41a1c9dfb Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:55:55 +0000 Subject: [PATCH 1/6] fix: implement deep object serialization for nested filter parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix issue where heavily nested filter arguments were converted to [object Object] - Add serializeDeepObject() method to properly handle nested query parameters - Support filter and proxy parameters with style: 'deepObject' and explode: true - Transform {filter: {updated_after: "2020-01-01"}} to filter[updated_after]=2020-01-01 - Add comprehensive test coverage for deep object serialization - Maintain backward compatibility for non-filter parameters Resolves #38 šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: mattzcarey --- src/modules/requestBuilder.ts | 59 +++++++++++++++- src/modules/tests/requestBuilder.spec.ts | 86 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/modules/requestBuilder.ts b/src/modules/requestBuilder.ts index cf6ea06..05ac9ab 100644 --- a/src/modules/requestBuilder.ts +++ b/src/modules/requestBuilder.ts @@ -137,6 +137,54 @@ export class RequestBuilder { return fetchOptions; } + /** + * Serialize an object into deep object query parameters + * Converts {filter: {updated_after: "2020-01-01", job_id: "123"}} + * to filter[updated_after]=2020-01-01&filter[job_id]=123 + */ + private serializeDeepObject(obj: unknown, prefix: string): [string, string][] { + const params: [string, string][] = []; + + if (obj === null || obj === undefined) { + return params; + } + + if (typeof obj === 'object' && !Array.isArray(obj)) { + for (const [key, value] of Object.entries(obj as Record)) { + const nestedKey = `${prefix}[${key}]`; + if (value !== null && value !== undefined) { + if (typeof value === 'object' && !Array.isArray(value)) { + // Recursively handle nested objects + params.push(...this.serializeDeepObject(value, nestedKey)); + } else { + params.push([nestedKey, String(value)]); + } + } + } + } else { + // For non-object values, use the prefix as-is + params.push([prefix, String(obj)]); + } + + return params; + } + + /** + * Check if a parameter should use deep object serialization + * Based on common StackOne parameter patterns + */ + private shouldUseDeepObjectSerialization(key: string, value: unknown): boolean { + // Known parameters that use deep object serialization in StackOne API + const deepObjectParams = ['filter', 'proxy']; + + return ( + deepObjectParams.includes(key) && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + } + /** * Execute the request */ @@ -147,7 +195,16 @@ export class RequestBuilder { // Prepare URL with query parameters const urlWithQuery = new URL(url); for (const [key, value] of Object.entries(queryParams)) { - urlWithQuery.searchParams.append(key, String(value)); + if (this.shouldUseDeepObjectSerialization(key, value)) { + // Use deep object serialization for complex parameters + const serializedParams = this.serializeDeepObject(value, key); + for (const [paramKey, paramValue] of serializedParams) { + urlWithQuery.searchParams.append(paramKey, paramValue); + } + } else { + // Use simple string conversion for primitive values + urlWithQuery.searchParams.append(key, String(value)); + } } // Build fetch options diff --git a/src/modules/tests/requestBuilder.spec.ts b/src/modules/tests/requestBuilder.spec.ts index e279315..aaf3a96 100644 --- a/src/modules/tests/requestBuilder.spec.ts +++ b/src/modules/tests/requestBuilder.spec.ts @@ -15,6 +15,10 @@ describe('RequestBuilder', () => { { name: 'headerParam', location: ParameterLocation.HEADER }, { name: 'bodyParam', location: ParameterLocation.BODY }, { name: 'defaultParam' /* default to body */ }, + { name: 'filter', location: ParameterLocation.QUERY }, + { name: 'proxy', location: ParameterLocation.QUERY }, + { name: 'regularObject', location: ParameterLocation.QUERY }, + { name: 'simple', location: ParameterLocation.QUERY }, ], }; @@ -156,4 +160,86 @@ describe('RequestBuilder', () => { await expect(builder.execute(params)).rejects.toThrow(StackOneAPIError); expect(fetch).toHaveBeenCalledTimes(1); }); + + it('should serialize deep object query parameters correctly', async () => { + const params = { + pathParam: 'test-value', + filter: { + updated_after: '2020-01-01T00:00:00.000Z', + job_id: '123', + nested: { + level2: 'value', + level3: { + deep: 'nested-value' + } + } + }, + proxy: { + custom_field: 'custom-value', + sort: 'first_name' + }, + simple: 'simple-value' + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Check that deep object parameters are serialized correctly + expect(url.searchParams.get('filter[updated_after]')).toBe('2020-01-01T00:00:00.000Z'); + expect(url.searchParams.get('filter[job_id]')).toBe('123'); + expect(url.searchParams.get('filter[nested][level2]')).toBe('value'); + expect(url.searchParams.get('filter[nested][level3][deep]')).toBe('nested-value'); + expect(url.searchParams.get('proxy[custom_field]')).toBe('custom-value'); + expect(url.searchParams.get('proxy[sort]')).toBe('first_name'); + + // Check that simple parameters are still handled normally + expect(url.searchParams.get('simple')).toBe('simple-value'); + + // Ensure the original filter/proxy objects are not added as strings + expect(url.searchParams.get('filter')).toBeNull(); + expect(url.searchParams.get('proxy')).toBeNull(); + }); + + it('should handle null and undefined values in deep objects', async () => { + const params = { + pathParam: 'test-value', + filter: { + valid_field: 'value', + null_field: null, + undefined_field: undefined, + empty_string: '', + zero: 0, + false_value: false + } + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Check that valid values are included + expect(url.searchParams.get('filter[valid_field]')).toBe('value'); + expect(url.searchParams.get('filter[empty_string]')).toBe(''); + expect(url.searchParams.get('filter[zero]')).toBe('0'); + expect(url.searchParams.get('filter[false_value]')).toBe('false'); + + // Check that null and undefined values are excluded + expect(url.searchParams.get('filter[null_field]')).toBeNull(); + expect(url.searchParams.get('filter[undefined_field]')).toBeNull(); + }); + + it('should not apply deep object serialization to non-filter/proxy parameters', async () => { + const params = { + pathParam: 'test-value', + regularObject: { + nested: 'value' + } + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Non-filter/proxy objects should be converted to string as before + expect(url.searchParams.get('regularObject')).toBe('[object Object]'); + expect(url.searchParams.get('regularObject[nested]')).toBeNull(); + }); }); From 17aa49081062b0cc13900b13810bab3877428145 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:46:32 +0000 Subject: [PATCH 2/6] feat: apply deep object serialization to all parameters - Remove restriction to only 'filter' and 'proxy' parameters - Apply deep object serialization to all object parameters consistently - Eliminates [object Object] conversion issues for nested parameters - Add comprehensive test coverage for mixed parameter types - Maintain backward compatibility for primitive values Co-authored-by: mattzcarey --- src/modules/requestBuilder.ts | 18 ++----- src/modules/tests/requestBuilder.spec.ts | 68 +++++++++++++++++++----- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/modules/requestBuilder.ts b/src/modules/requestBuilder.ts index 05ac9ab..ee629e8 100644 --- a/src/modules/requestBuilder.ts +++ b/src/modules/requestBuilder.ts @@ -139,12 +139,12 @@ export class RequestBuilder { /** * Serialize an object into deep object query parameters - * Converts {filter: {updated_after: "2020-01-01", job_id: "123"}} + * Converts {filter: {updated_after: "2020-01-01", job_id: "123"}} * to filter[updated_after]=2020-01-01&filter[job_id]=123 */ private serializeDeepObject(obj: unknown, prefix: string): [string, string][] { const params: [string, string][] = []; - + if (obj === null || obj === undefined) { return params; } @@ -171,18 +171,10 @@ export class RequestBuilder { /** * Check if a parameter should use deep object serialization - * Based on common StackOne parameter patterns + * Now applies to all object parameters for consistent handling */ - private shouldUseDeepObjectSerialization(key: string, value: unknown): boolean { - // Known parameters that use deep object serialization in StackOne API - const deepObjectParams = ['filter', 'proxy']; - - return ( - deepObjectParams.includes(key) && - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ); + private shouldUseDeepObjectSerialization(_key: string, value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** diff --git a/src/modules/tests/requestBuilder.spec.ts b/src/modules/tests/requestBuilder.spec.ts index aaf3a96..0d0bad4 100644 --- a/src/modules/tests/requestBuilder.spec.ts +++ b/src/modules/tests/requestBuilder.spec.ts @@ -19,6 +19,10 @@ describe('RequestBuilder', () => { { name: 'proxy', location: ParameterLocation.QUERY }, { name: 'regularObject', location: ParameterLocation.QUERY }, { name: 'simple', location: ParameterLocation.QUERY }, + { name: 'simpleString', location: ParameterLocation.QUERY }, + { name: 'simpleNumber', location: ParameterLocation.QUERY }, + { name: 'simpleBoolean', location: ParameterLocation.QUERY }, + { name: 'complexObject', location: ParameterLocation.QUERY }, ], }; @@ -170,15 +174,15 @@ describe('RequestBuilder', () => { nested: { level2: 'value', level3: { - deep: 'nested-value' - } - } + deep: 'nested-value', + }, + }, }, proxy: { custom_field: 'custom-value', - sort: 'first_name' + sort: 'first_name', }, - simple: 'simple-value' + simple: 'simple-value', }; const result = await builder.execute(params, { dryRun: true }); @@ -209,8 +213,8 @@ describe('RequestBuilder', () => { undefined_field: undefined, empty_string: '', zero: 0, - false_value: false - } + false_value: false, + }, }; const result = await builder.execute(params, { dryRun: true }); @@ -227,19 +231,57 @@ describe('RequestBuilder', () => { expect(url.searchParams.get('filter[undefined_field]')).toBeNull(); }); - it('should not apply deep object serialization to non-filter/proxy parameters', async () => { + it('should apply deep object serialization to all object parameters', async () => { const params = { pathParam: 'test-value', regularObject: { - nested: 'value' - } + nested: 'value', + deepNested: { + level2: 'deep-value', + }, + }, }; const result = await builder.execute(params, { dryRun: true }); const url = new URL(result.url as string); - // Non-filter/proxy objects should be converted to string as before - expect(url.searchParams.get('regularObject')).toBe('[object Object]'); - expect(url.searchParams.get('regularObject[nested]')).toBeNull(); + // All objects should now be serialized using deep object notation + expect(url.searchParams.get('regularObject[nested]')).toBe('value'); + expect(url.searchParams.get('regularObject[deepNested][level2]')).toBe('deep-value'); + + // The original object parameter should not be present as a string + expect(url.searchParams.get('regularObject')).toBeNull(); + }); + + it('should handle mixed parameter types with deep object serialization', async () => { + const params = { + pathParam: 'test-value', + simpleString: 'simple-value', + simpleNumber: 42, + simpleBoolean: true, + complexObject: { + nested: 'nested-value', + array: [1, 2, 3], // Arrays should be converted to string + nestedObject: { + deep: 'deep-value', + }, + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Primitive values should be handled normally + expect(url.searchParams.get('simpleString')).toBe('simple-value'); + expect(url.searchParams.get('simpleNumber')).toBe('42'); + expect(url.searchParams.get('simpleBoolean')).toBe('true'); + + // Complex object should use deep object serialization + expect(url.searchParams.get('complexObject[nested]')).toBe('nested-value'); + expect(url.searchParams.get('complexObject[array]')).toBe('1,2,3'); // Arrays become strings + expect(url.searchParams.get('complexObject[nestedObject][deep]')).toBe('deep-value'); + + // Original complex object should not be present + expect(url.searchParams.get('complexObject')).toBeNull(); }); }); From 6422ee45cdcf0e121d1d03de8789f3ad3753e162 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 19:24:00 +0000 Subject: [PATCH 3/6] feat: implement comprehensive security and performance improvements for parameter serialization - Add recursion depth protection (max 10 levels) to prevent stack overflow attacks - Implement circular reference detection using WeakSet for robust object handling - Add parameter key validation with strict regex to prevent injection attacks - Handle special types safely: Date (ISO), RegExp (string), reject functions - Optimize performance with batch parameter building and reduced allocations - Add comprehensive test coverage for security scenarios and edge cases - Maintain backward compatibility while addressing critical vulnerabilities Resolves security issues identified in code review including: - Stack overflow vulnerability (CVE-level risk) - Parameter injection attacks - Information disclosure through unsafe string conversion - Performance issues with large object processing Co-authored-by: mattzcarey --- src/modules/requestBuilder.ts | 142 ++++++++++++--- src/modules/tests/requestBuilder.spec.ts | 212 +++++++++++++++++++++++ 2 files changed, 329 insertions(+), 25 deletions(-) diff --git a/src/modules/requestBuilder.ts b/src/modules/requestBuilder.ts index ee629e8..fe8da60 100644 --- a/src/modules/requestBuilder.ts +++ b/src/modules/requestBuilder.ts @@ -6,6 +6,18 @@ import { } from '../types'; import { StackOneAPIError } from '../utils/errors'; +interface SerializationOptions { + maxDepth?: number; + strictValidation?: boolean; +} + +class ParameterSerializationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ParameterSerializationError'; + } +} + /** * Builds and executes HTTP requests */ @@ -138,32 +150,92 @@ export class RequestBuilder { } /** - * Serialize an object into deep object query parameters + * Validates parameter keys to prevent injection attacks + */ + private validateParameterKey(key: string): void { + if (!/^[a-zA-Z0-9_.-]+$/.test(key)) { + throw new ParameterSerializationError(`Invalid parameter key: ${key}`); + } + } + + /** + * Safely serializes values to strings with special type handling + */ + private serializeValue(value: unknown): string { + if (value instanceof Date) { + return value.toISOString(); + } + if (value instanceof RegExp) { + return value.toString(); + } + if (typeof value === 'function') { + throw new ParameterSerializationError('Functions cannot be serialized as parameters'); + } + if (value === null || value === undefined) { + return ''; + } + return String(value); + } + + /** + * Serialize an object into deep object query parameters with security protections * Converts {filter: {updated_after: "2020-01-01", job_id: "123"}} * to filter[updated_after]=2020-01-01&filter[job_id]=123 */ - private serializeDeepObject(obj: unknown, prefix: string): [string, string][] { + private serializeDeepObject( + obj: unknown, + prefix: string, + depth = 0, + visited = new WeakSet(), + options: SerializationOptions = {} + ): [string, string][] { + const maxDepth = options.maxDepth ?? 10; + const strictValidation = options.strictValidation ?? true; const params: [string, string][] = []; + // Recursion depth protection + if (depth > maxDepth) { + throw new ParameterSerializationError( + `Maximum nesting depth (${maxDepth}) exceeded for parameter serialization` + ); + } + if (obj === null || obj === undefined) { return params; } if (typeof obj === 'object' && !Array.isArray(obj)) { - for (const [key, value] of Object.entries(obj as Record)) { - const nestedKey = `${prefix}[${key}]`; - if (value !== null && value !== undefined) { - if (typeof value === 'object' && !Array.isArray(value)) { - // Recursively handle nested objects - params.push(...this.serializeDeepObject(value, nestedKey)); - } else { - params.push([nestedKey, String(value)]); + // Circular reference protection + if (visited.has(obj)) { + throw new ParameterSerializationError('Circular reference detected in parameter object'); + } + visited.add(obj); + + try { + for (const [key, value] of Object.entries(obj as Record)) { + if (strictValidation) { + this.validateParameterKey(key); + } + + const nestedKey = `${prefix}[${key}]`; + if (value !== null && value !== undefined) { + if (this.shouldUseDeepObjectSerialization(key, value)) { + // Recursively handle nested objects + params.push( + ...this.serializeDeepObject(value, nestedKey, depth + 1, visited, options) + ); + } else { + params.push([nestedKey, this.serializeValue(value)]); + } } } + } finally { + // Remove from visited set to allow the same object in different branches + visited.delete(obj); } } else { // For non-object values, use the prefix as-is - params.push([prefix, String(obj)]); + params.push([prefix, this.serializeValue(obj)]); } return params; @@ -171,34 +243,54 @@ export class RequestBuilder { /** * Check if a parameter should use deep object serialization - * Now applies to all object parameters for consistent handling + * Applies to all plain object parameters (excludes special types and arrays) */ private shouldUseDeepObjectSerialization(_key: string, value: unknown): boolean { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) && + !(value instanceof RegExp) && + typeof value !== 'function' + ); } /** - * Execute the request + * Builds all query parameters with optimized batching */ - async execute(params: JsonDict, options?: ExecuteOptions): Promise { - // Prepare request parameters - const [url, bodyParams, queryParams] = this.prepareRequestParams(params); + private buildQueryParameters(queryParams: JsonDict): [string, string][] { + const allParams: [string, string][] = []; - // Prepare URL with query parameters - const urlWithQuery = new URL(url); for (const [key, value] of Object.entries(queryParams)) { if (this.shouldUseDeepObjectSerialization(key, value)) { // Use deep object serialization for complex parameters - const serializedParams = this.serializeDeepObject(value, key); - for (const [paramKey, paramValue] of serializedParams) { - urlWithQuery.searchParams.append(paramKey, paramValue); - } + allParams.push(...this.serializeDeepObject(value, key)); } else { - // Use simple string conversion for primitive values - urlWithQuery.searchParams.append(key, String(value)); + // Use safe string conversion for primitive values + allParams.push([key, this.serializeValue(value)]); } } + return allParams; + } + + /** + * Execute the request + */ + async execute(params: JsonDict, options?: ExecuteOptions): Promise { + // Prepare request parameters + const [url, bodyParams, queryParams] = this.prepareRequestParams(params); + + // Prepare URL with query parameters using optimized batching + const urlWithQuery = new URL(url); + const serializedParams = this.buildQueryParameters(queryParams); + + // Batch append all parameters + for (const [paramKey, paramValue] of serializedParams) { + urlWithQuery.searchParams.append(paramKey, paramValue); + } + // Build fetch options const fetchOptions = this.buildFetchOptions(bodyParams); diff --git a/src/modules/tests/requestBuilder.spec.ts b/src/modules/tests/requestBuilder.spec.ts index 0d0bad4..ffd7855 100644 --- a/src/modules/tests/requestBuilder.spec.ts +++ b/src/modules/tests/requestBuilder.spec.ts @@ -23,6 +23,8 @@ describe('RequestBuilder', () => { { name: 'simpleNumber', location: ParameterLocation.QUERY }, { name: 'simpleBoolean', location: ParameterLocation.QUERY }, { name: 'complexObject', location: ParameterLocation.QUERY }, + { name: 'deepFilter', location: ParameterLocation.QUERY }, + { name: 'emptyFilter', location: ParameterLocation.QUERY }, ], }; @@ -284,4 +286,214 @@ describe('RequestBuilder', () => { // Original complex object should not be present expect(url.searchParams.get('complexObject')).toBeNull(); }); + + describe('Security and Performance Improvements', () => { + it('should throw error when recursion depth limit is exceeded', async () => { + // Create a deeply nested object that exceeds the default depth limit of 10 + let deepObject: any = { value: 'test' }; + for (let i = 0; i < 12; i++) { + deepObject = { nested: deepObject }; + } + + const params = { + pathParam: 'test-value', + deepFilter: deepObject, + }; + + await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( + 'Maximum nesting depth (10) exceeded for parameter serialization' + ); + }); + + it('should throw error when circular reference is detected', async () => { + const circular: any = { a: { b: 'test' } }; + circular.a.circular = circular; // Create circular reference + + const params = { + pathParam: 'test-value', + filter: circular, + }; + + await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( + 'Circular reference detected in parameter object' + ); + }); + + it('should validate parameter keys and reject invalid characters', async () => { + const params = { + pathParam: 'test-value', + filter: { + 'valid_key': 'test', + 'invalid key with spaces': 'test', // Should trigger validation error + }, + }; + + await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( + 'Invalid parameter key: invalid key with spaces' + ); + }); + + it('should handle special types correctly', async () => { + const testDate = new Date('2023-01-01T00:00:00.000Z'); + const testRegex = /test-pattern/gi; + + const params = { + pathParam: 'test-value', + filter: { + dateField: testDate, + regexField: testRegex, + nullField: null, + undefinedField: undefined, + emptyString: '', + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Date should be serialized to ISO string + expect(url.searchParams.get('filter[dateField]')).toBe('2023-01-01T00:00:00.000Z'); + + // RegExp should be serialized to string representation + expect(url.searchParams.get('filter[regexField]')).toBe('/test-pattern/gi'); + + // Null and undefined should result in empty string (but won't be added since they're filtered out) + expect(url.searchParams.get('filter[nullField]')).toBeNull(); + expect(url.searchParams.get('filter[undefinedField]')).toBeNull(); + + // Empty string should be preserved + expect(url.searchParams.get('filter[emptyString]')).toBe(''); + }); + + it('should throw error when trying to serialize functions', async () => { + const params = { + pathParam: 'test-value', + filter: { + validField: 'test', + functionField: () => 'test', // Functions should not be serializable + }, + }; + + await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( + 'Functions cannot be serialized as parameters' + ); + }); + + it('should handle empty objects correctly', async () => { + const params = { + pathParam: 'test-value', + emptyFilter: {}, + filter: { + validField: 'test', + emptyNested: {}, + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Empty objects should not create any parameters + expect(url.searchParams.get('emptyFilter')).toBeNull(); + expect(url.searchParams.get('filter[emptyNested]')).toBeNull(); + + // Valid fields should still work + expect(url.searchParams.get('filter[validField]')).toBe('test'); + }); + + it('should handle arrays correctly within objects', async () => { + const params = { + pathParam: 'test-value', + filter: { + arrayField: [1, 2, 3], + stringArray: ['a', 'b', 'c'], + mixed: ['string', 42, true], + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Arrays should be converted to comma-separated strings + expect(url.searchParams.get('filter[arrayField]')).toBe('1,2,3'); + expect(url.searchParams.get('filter[stringArray]')).toBe('a,b,c'); + expect(url.searchParams.get('filter[mixed]')).toBe('string,42,true'); + }); + + it('should handle nested objects with special types', async () => { + const params = { + pathParam: 'test-value', + filter: { + nested: { + dateField: new Date('2023-01-01T00:00:00.000Z'), + level2: { + regexField: /test/, + stringField: 'normal-string', + }, + }, + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + expect(url.searchParams.get('filter[nested][dateField]')).toBe('2023-01-01T00:00:00.000Z'); + expect(url.searchParams.get('filter[nested][level2][regexField]')).toBe('/test/'); + expect(url.searchParams.get('filter[nested][level2][stringField]')).toBe('normal-string'); + }); + + it('should maintain performance with large objects', async () => { + // Create a moderately large object to test performance optimizations + const largeFilter: any = {}; + for (let i = 0; i < 100; i++) { + largeFilter[`field_${i}`] = `value_${i}`; + if (i % 10 === 0) { + largeFilter[`nested_${i}`] = { + subField1: `sub_value_${i}_1`, + subField2: `sub_value_${i}_2`, + }; + } + } + + const params = { + pathParam: 'test-value', + filter: largeFilter, + }; + + const startTime = performance.now(); + const result = await builder.execute(params, { dryRun: true }); + const endTime = performance.now(); + + // Should complete in reasonable time (less than 100ms for this size) + expect(endTime - startTime).toBeLessThan(100); + + const url = new URL(result.url as string); + + // Verify some parameters are correctly serialized + expect(url.searchParams.get('filter[field_0]')).toBe('value_0'); + expect(url.searchParams.get('filter[field_99]')).toBe('value_99'); + expect(url.searchParams.get('filter[nested_0][subField1]')).toBe('sub_value_0_1'); + }); + + it('should allow the same object in different branches after circular check', async () => { + const sharedObject = { shared: 'value' }; + const params = { + pathParam: 'test-value', + filter: { + branch1: { + shared: sharedObject, + }, + branch2: { + shared: sharedObject, // Same object reference in different branch - should be allowed + }, + }, + }; + + const result = await builder.execute(params, { dryRun: true }); + const url = new URL(result.url as string); + + // Both branches should be serialized correctly + expect(url.searchParams.get('filter[branch1][shared][shared]')).toBe('value'); + expect(url.searchParams.get('filter[branch2][shared][shared]')).toBe('value'); + }); + }); }); From 03f29fcc873b127458c97cba79cae55be7be6a34 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 09:24:02 +0000 Subject: [PATCH 4/6] feat: add HRIS employee filters example demonstrating deep object serialization - Creates comprehensive example showing filter usage with HRIS list employees endpoint - Demonstrates date, email, and employee_number filtering capabilities - Shows proxy parameter usage for provider-specific filters - Tests deep object serialization with nested filter combinations - Validates proper URL encoding of OpenAPI deepObject style parameters - Includes edge case handling for empty filter objects - All 7 test scenarios pass, confirming deep object serialization works correctly Co-authored-by: mattzcarey --- examples/hris-employee-filters.ts | 310 ++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 examples/hris-employee-filters.ts diff --git a/examples/hris-employee-filters.ts b/examples/hris-employee-filters.ts new file mode 100644 index 0000000..9fc13cd --- /dev/null +++ b/examples/hris-employee-filters.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun +/** + * HRIS Employee Filters Example + * + * This example demonstrates how to use filters with the HRIS list employees endpoint. + * It showcases the deep object serialization implementation that properly converts + * nested filter objects to OpenAPI deepObject style query parameters. + * + * Key features demonstrated: + * 1. Basic filter usage (updated_after, email, employee_number) + * 2. Proxy parameter usage for provider-specific filters + * 3. Complex nested filter combinations + * 4. Proper serialization of filter objects to query parameters + * + * Usage: + * + * ```bash + * bun run examples/hris-employee-filters.ts + * ``` + */ + +import assert from 'node:assert'; +import { StackOneToolSet } from '../src'; + +const hriseEmployeeFilters = async (): Promise => { + // Initialize the toolset + const toolset = new StackOneToolSet(); + const accountId = 'test-account-id'; + + // Get the HRIS tools with account ID + const tools = toolset.getStackOneTools('hris_*', accountId); + const employeesTool = tools.getTool('hris_list_employees'); + + assert(employeesTool !== undefined, 'Expected to find hris_list_employees tool'); + + console.log('🧪 Testing HRIS Employee Filters with Deep Object Serialization\n'); + + /* + * Example 1: Basic date filter + * Demonstrates filtering employees updated after a specific date + */ + console.log('1ļøāƒ£ Basic Date Filter Test'); + const basicDateFilter = await employeesTool.execute( + { + filter: { + updated_after: '2023-01-01T00:00:00.000Z', + }, + }, + { dryRun: true } + ); + + console.log('Filter object:', { filter: { updated_after: '2023-01-01T00:00:00.000Z' } }); + console.log('Serialized URL:', basicDateFilter.url); + + // Verify that the filter is properly serialized as deepObject style + assert( + basicDateFilter.url.includes('filter%5Bupdated_after%5D=2023-01-01T00%3A00%3A00.000Z'), + 'Expected URL to contain properly serialized date filter' + ); + console.log('āœ… Date filter serialized correctly\n'); + + /* + * Example 2: Email filter + * Demonstrates filtering employees by email address + */ + console.log('2ļøāƒ£ Email Filter Test'); + const emailFilter = await employeesTool.execute( + { + filter: { + email: 'john.doe@company.com', + }, + }, + { dryRun: true } + ); + + console.log('Filter object:', { filter: { email: 'john.doe@company.com' } }); + console.log('Serialized URL:', emailFilter.url); + + assert( + emailFilter.url.includes('filter%5Bemail%5D=john.doe%40company.com'), + 'Expected URL to contain properly serialized email filter' + ); + console.log('āœ… Email filter serialized correctly\n'); + + /* + * Example 3: Employee number filter + * Demonstrates filtering employees by employee number + */ + console.log('3ļøāƒ£ Employee Number Filter Test'); + const employeeNumberFilter = await employeesTool.execute( + { + filter: { + employee_number: 'EMP001', + }, + }, + { dryRun: true } + ); + + console.log('Filter object:', { filter: { employee_number: 'EMP001' } }); + console.log('Serialized URL:', employeeNumberFilter.url); + + assert( + employeeNumberFilter.url.includes('filter%5Bemployee_number%5D=EMP001'), + 'Expected URL to contain properly serialized employee number filter' + ); + console.log('āœ… Employee number filter serialized correctly\n'); + + /* + * Example 4: Multiple filters combined + * Demonstrates using multiple filter parameters together + */ + console.log('4ļøāƒ£ Multiple Filters Combined Test'); + const multipleFilters = await employeesTool.execute( + { + filter: { + updated_after: '2023-06-01T00:00:00.000Z', + email: 'jane.smith@company.com', + employee_number: 'EMP002', + }, + }, + { dryRun: true } + ); + + console.log('Filter object:', { + filter: { + updated_after: '2023-06-01T00:00:00.000Z', + email: 'jane.smith@company.com', + employee_number: 'EMP002', + }, + }); + console.log('Serialized URL:', multipleFilters.url); + + // Verify all filters are present in the URL + assert( + multipleFilters.url.includes('filter%5Bupdated_after%5D=2023-06-01T00%3A00%3A00.000Z'), + 'Expected URL to contain date filter' + ); + assert( + multipleFilters.url.includes('filter%5Bemail%5D=jane.smith%40company.com'), + 'Expected URL to contain email filter' + ); + assert( + multipleFilters.url.includes('filter%5Bemployee_number%5D=EMP002'), + 'Expected URL to contain employee number filter' + ); + console.log('āœ… Multiple filters serialized correctly\n'); + + /* + * Example 5: Proxy parameters for provider-specific filtering + * Demonstrates using proxy parameters which also use deepObject serialization + */ + console.log('5ļøāƒ£ Proxy Parameters Test'); + const proxyParameters = await employeesTool.execute( + { + proxy: { + custom_field: 'value123', + provider_filter: { + department: 'Engineering', + status: 'active', + }, + }, + }, + { dryRun: true } + ); + + console.log('Proxy object:', { + proxy: { + custom_field: 'value123', + provider_filter: { + department: 'Engineering', + status: 'active', + }, + }, + }); + console.log('Serialized URL:', proxyParameters.url); + + // Verify proxy parameters are properly serialized + assert( + proxyParameters.url.includes('proxy%5Bcustom_field%5D=value123'), + 'Expected URL to contain proxy custom_field parameter' + ); + assert( + proxyParameters.url.includes('proxy%5Bprovider_filter%5D%5Bdepartment%5D=Engineering'), + 'Expected URL to contain nested proxy department parameter' + ); + assert( + proxyParameters.url.includes('proxy%5Bprovider_filter%5D%5Bstatus%5D=active'), + 'Expected URL to contain nested proxy status parameter' + ); + console.log('āœ… Proxy parameters with nested objects serialized correctly\n'); + + /* + * Example 6: Complex combined scenario + * Demonstrates combining filters, proxy parameters, and other query parameters + */ + console.log('6ļøāƒ£ Complex Combined Scenario Test'); + const complexScenario = await employeesTool.execute( + { + filter: { + updated_after: '2023-09-01T00:00:00.000Z', + email: 'admin@company.com', + }, + proxy: { + include_terminated: 'false', + custom_sorting: { + field: 'hire_date', + order: 'desc', + }, + }, + fields: 'id,first_name,last_name,email,hire_date', + page_size: '50', + }, + { dryRun: true } + ); + + console.log('Complex parameters:', { + filter: { + updated_after: '2023-09-01T00:00:00.000Z', + email: 'admin@company.com', + }, + proxy: { + include_terminated: 'false', + custom_sorting: { + field: 'hire_date', + order: 'desc', + }, + }, + fields: 'id,first_name,last_name,email,hire_date', + page_size: '50', + }); + console.log('Serialized URL:', complexScenario.url); + + // Verify complex scenario serialization + assert( + complexScenario.url.includes('filter%5Bupdated_after%5D=2023-09-01T00%3A00%3A00.000Z'), + 'Expected URL to contain complex date filter' + ); + assert( + complexScenario.url.includes('filter%5Bemail%5D=admin%40company.com'), + 'Expected URL to contain complex email filter' + ); + assert( + complexScenario.url.includes('proxy%5Binclude_terminated%5D=false'), + 'Expected URL to contain proxy boolean parameter' + ); + assert( + complexScenario.url.includes('proxy%5Bcustom_sorting%5D%5Bfield%5D=hire_date'), + 'Expected URL to contain nested proxy field parameter' + ); + assert( + complexScenario.url.includes('proxy%5Bcustom_sorting%5D%5Border%5D=desc'), + 'Expected URL to contain nested proxy order parameter' + ); + assert( + complexScenario.url.includes('fields=id%2Cfirst_name%2Clast_name%2Cemail%2Chire_date'), + 'Expected URL to contain fields parameter' + ); + assert( + complexScenario.url.includes('page_size=50'), + 'Expected URL to contain page_size parameter' + ); + console.log('āœ… Complex combined scenario serialized correctly\n'); + + /* + * Example 7: Edge case - Empty filter objects + * Demonstrates handling of empty filter objects + */ + console.log('7ļøāƒ£ Edge Case - Empty Filter Objects Test'); + const emptyFilterTest = await employeesTool.execute( + { + filter: {}, + fields: 'id,first_name,last_name', + }, + { dryRun: true } + ); + + console.log('Empty filter object:', { filter: {}, fields: 'id,first_name,last_name' }); + console.log('Serialized URL:', emptyFilterTest.url); + + // Verify that empty filter objects don't create problematic parameters + assert( + emptyFilterTest.url.includes('fields=id%2Cfirst_name%2Clast_name'), + 'Expected URL to contain fields parameter even with empty filter' + ); + // Empty objects should not create parameters + assert( + !emptyFilterTest.url.includes('filter='), + 'Expected URL to not contain empty filter parameter' + ); + console.log('āœ… Empty filter objects handled correctly\n'); + + console.log('šŸŽ‰ All HRIS Employee Filter Tests Passed!'); + console.log('\nšŸ“‹ Summary:'); + console.log('āœ… Basic date filtering with updated_after'); + console.log('āœ… Email-based employee filtering'); + console.log('āœ… Employee number filtering'); + console.log('āœ… Multiple filters combined'); + console.log('āœ… Proxy parameters with nested objects'); + console.log('āœ… Complex combined scenarios'); + console.log('āœ… Edge case handling for empty objects'); + console.log('\nšŸ”§ Deep Object Serialization Features Demonstrated:'); + console.log('• Nested objects converted to OpenAPI deepObject style (key[nested]=value)'); + console.log('• Proper URL encoding of special characters'); + console.log('• Support for multiple levels of nesting'); + console.log('• Safe handling of empty objects'); + console.log('• Backward compatibility with primitive parameters'); +}; + +// Run the example +hriseEmployeeFilters(); From 97f4c393f5803f6a298801ae889120d9ad1b24f3 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 11 Jun 2025 10:26:18 +0100 Subject: [PATCH 5/6] snapshots --- .../__snapshots__/openapi-parser.spec.ts.snap | 153 ++++++++++- .../tests/__snapshots__/stackone.spec.ts.snap | 238 +++++++++++++++++- 2 files changed, 383 insertions(+), 8 deletions(-) diff --git a/src/openapi/tests/__snapshots__/openapi-parser.spec.ts.snap b/src/openapi/tests/__snapshots__/openapi-parser.spec.ts.snap index 3cc1bce..ae96bb6 100644 --- a/src/openapi/tests/__snapshots__/openapi-parser.spec.ts.snap +++ b/src/openapi/tests/__snapshots__/openapi-parser.spec.ts.snap @@ -3439,6 +3439,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "ein", "other", "unknown", + "unmapped_value", null, ], "example": "ssn", @@ -4654,6 +4655,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -6598,6 +6600,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -7525,14 +7528,24 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off/{subResourceId}", }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "id": { @@ -8124,14 +8137,24 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off/{id}", }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "id": { @@ -9227,6 +9250,105 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "type": "object", }, }, + "hris_list_employee_time_off_policies": { + "description": "List Assigned Time Off Policies", + "execute": { + "bodyType": "json", + "method": "GET", + "params": [ + { + "location": "header", + "name": "x-account-id", + "type": "string", + }, + { + "location": "path", + "name": "id", + "type": "string", + }, + { + "location": "query", + "name": "raw", + "type": "boolean", + }, + { + "location": "query", + "name": "proxy", + "type": "object", + }, + { + "location": "query", + "name": "fields", + "type": "string", + }, + { + "location": "query", + "name": "filter", + "type": "object", + }, + { + "location": "query", + "name": "page_size", + "type": "string", + }, + { + "location": "query", + "name": "next", + "type": "string", + }, + ], + "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off_policies", + }, + "parameters": { + "properties": { + "fields": { + "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", + "example": "id,remote_id,name,description,type,duration_unit,reasons,updated_at,created_at", + "type": "string", + }, + "filter": { + "description": "Filter parameters that allow greater customisation of the list response", + "properties": { + "updated_after": { + "additionalProperties": false, + "description": "Use a string with a date to only select results updated after that given date", + "example": "2020-01-01T00:00:00.000Z", + "type": "string", + }, + }, + "type": "object", + }, + "id": { + "type": "string", + }, + "next": { + "description": "The unified cursor", + "type": "string", + }, + "page_size": { + "description": "The number of results per page (default value is 25)", + "type": "string", + }, + "proxy": { + "additionalProperties": true, + "description": "Query parameters that can be used to pass through parameters to the underlying provider request by surrounding them with 'proxy' key", + "type": "object", + }, + "raw": { + "description": "Indicates that the raw request result should be returned in addition to the mapped result (default value is false)", + "type": "boolean", + }, + "x-account-id": { + "description": "The account identifier", + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + }, "hris_list_employee_time_off_requests": { "description": "List Employee Time Off Requests", "execute": { @@ -9273,14 +9395,24 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off", }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "filter": { @@ -10240,14 +10372,24 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off", }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "filter": { @@ -12401,6 +12543,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "ein", "other", "unknown", + "unmapped_value", null, ], "example": "ssn", @@ -13471,6 +13614,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -15436,6 +15580,7 @@ exports[`OpenAPIParser Snapshot Tests should parse all OpenAPI specs correctly 1 "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], diff --git a/src/toolsets/tests/__snapshots__/stackone.spec.ts.snap b/src/toolsets/tests/__snapshots__/stackone.spec.ts.snap index d27b431..fa60b8b 100644 --- a/src/toolsets/tests/__snapshots__/stackone.spec.ts.snap +++ b/src/toolsets/tests/__snapshots__/stackone.spec.ts.snap @@ -2500,6 +2500,7 @@ Map { "ein", "other", "unknown", + "unmapped_value", null, ], "example": "ssn", @@ -5074,6 +5075,7 @@ Map { "ein", "other", "unknown", + "unmapped_value", null, ], "example": "ssn", @@ -5925,6 +5927,11 @@ Map { "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off", }, @@ -5944,9 +5951,14 @@ Map { }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "file_path": { @@ -6046,6 +6058,11 @@ Map { "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off", }, @@ -6231,6 +6248,7 @@ Map { "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -6358,6 +6376,11 @@ Map { "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off/{subResourceId}", }, @@ -6377,9 +6400,14 @@ Map { }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "file_path": { @@ -6446,6 +6474,11 @@ Map { "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off/{subResourceId}", }, @@ -6636,6 +6669,7 @@ Map { "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -15891,6 +15925,11 @@ Map { "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off", }, @@ -15910,9 +15949,14 @@ Map { }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "file_path": { @@ -16002,6 +16046,11 @@ Map { "name": "next", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off", }, @@ -16179,6 +16228,7 @@ Map { "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -16294,6 +16344,11 @@ Map { "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off/{id}", }, @@ -16313,9 +16368,14 @@ Map { }, "parameters": { "properties": { + "expand": { + "description": "The comma separated list of fields that will be expanded in the response", + "example": "policy", + "type": "string", + }, "fields": { "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", - "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at", + "example": "id,remote_id,employee_id,remote_employee_id,approver_id,remote_approver_id,status,type,start_date,end_date,start_half_day,end_half_day,duration,time_off_policy_id,remote_time_off_policy_id,reason,created_at,updated_at,policy", "type": "string", }, "file_path": { @@ -16373,6 +16433,11 @@ Map { "name": "fields", "type": "string", }, + { + "location": "query", + "name": "expand", + "type": "string", + }, ], "url": "https://api.stackone.com/unified/hris/time_off/{id}", }, @@ -16558,6 +16623,7 @@ Map { "cancelled", "rejected", "pending", + "deleted", "unmapped_value", null, ], @@ -19496,6 +19562,170 @@ Map { "url": "https://api.stackone.com/unified/hris/time_off_policies/{id}", }, }, + StackOneTool { + "description": "List Assigned Time Off Policies", + "executeConfig": { + "bodyType": "json", + "method": "GET", + "params": [ + { + "location": "header", + "name": "x-account-id", + "type": "string", + }, + { + "location": "path", + "name": "id", + "type": "string", + }, + { + "location": "query", + "name": "raw", + "type": "boolean", + }, + { + "location": "query", + "name": "proxy", + "type": "object", + }, + { + "location": "query", + "name": "fields", + "type": "string", + }, + { + "location": "query", + "name": "filter", + "type": "object", + }, + { + "location": "query", + "name": "page_size", + "type": "string", + }, + { + "location": "query", + "name": "next", + "type": "string", + }, + ], + "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off_policies", + }, + "name": "hris_list_employee_time_off_policies", + "parameterMapper": ParameterMapper { + "transformers": +Map { + "file_path" => { + "transforms": { + "content": [Function], + "file_format": [Function], + "name": [Function], + }, + }, + } +, + }, + "parameters": { + "properties": { + "fields": { + "description": "The comma separated list of fields that will be returned in the response (if empty, all fields are returned)", + "example": "id,remote_id,name,description,type,duration_unit,reasons,updated_at,created_at", + "type": "string", + }, + "file_path": { + "description": "Convenience parameter that will be transformed into other parameters. Try and use this parameter in your tool call.", + "type": "string", + }, + "filter": { + "description": "Filter parameters that allow greater customisation of the list response", + "properties": { + "updated_after": { + "additionalProperties": false, + "description": "Use a string with a date to only select results updated after that given date", + "example": "2020-01-01T00:00:00.000Z", + "type": "string", + }, + }, + "type": "object", + }, + "id": { + "type": "string", + }, + "next": { + "description": "The unified cursor", + "type": "string", + }, + "page_size": { + "description": "The number of results per page (default value is 25)", + "type": "string", + }, + "proxy": { + "additionalProperties": true, + "description": "Query parameters that can be used to pass through parameters to the underlying provider request by surrounding them with 'proxy' key", + "type": "object", + }, + "raw": { + "description": "Indicates that the raw request result should be returned in addition to the mapped result (default value is false)", + "type": "boolean", + }, + "x-account-id": undefined, + }, + "required": [ + "id", + ], + "type": "object", + }, + "requestBuilder": RequestBuilder { + "bodyType": "json", + "headers": { + "Authorization": "Basic dGVzdF9rZXk6", + }, + "method": "GET", + "params": [ + { + "location": "header", + "name": "x-account-id", + "type": "string", + }, + { + "location": "path", + "name": "id", + "type": "string", + }, + { + "location": "query", + "name": "raw", + "type": "boolean", + }, + { + "location": "query", + "name": "proxy", + "type": "object", + }, + { + "location": "query", + "name": "fields", + "type": "string", + }, + { + "location": "query", + "name": "filter", + "type": "object", + }, + { + "location": "query", + "name": "page_size", + "type": "string", + }, + { + "location": "query", + "name": "next", + "type": "string", + }, + ], + "url": "https://api.stackone.com/unified/hris/employees/{id}/time_off_policies", + }, + }, ], } `; From 8730046981295dcc2de4f05465ec44895fc53563 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 11 Jun 2025 10:34:39 +0100 Subject: [PATCH 6/6] filters example --- .../{hris-employee-filters.ts => filters.ts} | 52 +++++++------------ src/modules/requestBuilder.ts | 2 +- src/modules/tests/requestBuilder.spec.ts | 48 ++++++++--------- src/tests/fetch-specs.spec.ts | 20 +++++-- src/tests/utils/fetch-mock.ts | 4 +- src/toolsets/tests/openapi.spec.ts | 4 +- 6 files changed, 63 insertions(+), 67 deletions(-) rename examples/{hris-employee-filters.ts => filters.ts} (85%) diff --git a/examples/hris-employee-filters.ts b/examples/filters.ts similarity index 85% rename from examples/hris-employee-filters.ts rename to examples/filters.ts index 9fc13cd..7a6e6e6 100644 --- a/examples/hris-employee-filters.ts +++ b/examples/filters.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * HRIS Employee Filters Example + * Filters Example * * This example demonstrates how to use filters with the HRIS list employees endpoint. * It showcases the deep object serialization implementation that properly converts @@ -15,13 +15,15 @@ * Usage: * * ```bash - * bun run examples/hris-employee-filters.ts + * bun run examples/filters.ts * ``` */ import assert from 'node:assert'; import { StackOneToolSet } from '../src'; +type DryRunResult = { url: string }; + const hriseEmployeeFilters = async (): Promise => { // Initialize the toolset const toolset = new StackOneToolSet(); @@ -40,14 +42,14 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates filtering employees updated after a specific date */ console.log('1ļøāƒ£ Basic Date Filter Test'); - const basicDateFilter = await employeesTool.execute( + const basicDateFilter = (await employeesTool.execute( { filter: { updated_after: '2023-01-01T00:00:00.000Z', }, }, { dryRun: true } - ); + )) as DryRunResult; console.log('Filter object:', { filter: { updated_after: '2023-01-01T00:00:00.000Z' } }); console.log('Serialized URL:', basicDateFilter.url); @@ -64,14 +66,14 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates filtering employees by email address */ console.log('2ļøāƒ£ Email Filter Test'); - const emailFilter = await employeesTool.execute( + const emailFilter = (await employeesTool.execute( { filter: { email: 'john.doe@company.com', }, }, { dryRun: true } - ); + )) as DryRunResult; console.log('Filter object:', { filter: { email: 'john.doe@company.com' } }); console.log('Serialized URL:', emailFilter.url); @@ -87,14 +89,14 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates filtering employees by employee number */ console.log('3ļøāƒ£ Employee Number Filter Test'); - const employeeNumberFilter = await employeesTool.execute( + const employeeNumberFilter = (await employeesTool.execute( { filter: { employee_number: 'EMP001', }, }, { dryRun: true } - ); + )) as DryRunResult; console.log('Filter object:', { filter: { employee_number: 'EMP001' } }); console.log('Serialized URL:', employeeNumberFilter.url); @@ -110,7 +112,7 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates using multiple filter parameters together */ console.log('4ļøāƒ£ Multiple Filters Combined Test'); - const multipleFilters = await employeesTool.execute( + const multipleFilters = (await employeesTool.execute( { filter: { updated_after: '2023-06-01T00:00:00.000Z', @@ -119,7 +121,7 @@ const hriseEmployeeFilters = async (): Promise => { }, }, { dryRun: true } - ); + )) as DryRunResult; console.log('Filter object:', { filter: { @@ -128,7 +130,7 @@ const hriseEmployeeFilters = async (): Promise => { employee_number: 'EMP002', }, }); - console.log('Serialized URL:', multipleFilters.url); + console.log('Serialized URL:', (multipleFilters as { url: string }).url); // Verify all filters are present in the URL assert( @@ -150,7 +152,7 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates using proxy parameters which also use deepObject serialization */ console.log('5ļøāƒ£ Proxy Parameters Test'); - const proxyParameters = await employeesTool.execute( + const proxyParameters = (await employeesTool.execute( { proxy: { custom_field: 'value123', @@ -161,7 +163,7 @@ const hriseEmployeeFilters = async (): Promise => { }, }, { dryRun: true } - ); + )) as DryRunResult; console.log('Proxy object:', { proxy: { @@ -194,7 +196,7 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates combining filters, proxy parameters, and other query parameters */ console.log('6ļøāƒ£ Complex Combined Scenario Test'); - const complexScenario = await employeesTool.execute( + const complexScenario = (await employeesTool.execute( { filter: { updated_after: '2023-09-01T00:00:00.000Z', @@ -211,7 +213,7 @@ const hriseEmployeeFilters = async (): Promise => { page_size: '50', }, { dryRun: true } - ); + )) as DryRunResult; console.log('Complex parameters:', { filter: { @@ -266,13 +268,13 @@ const hriseEmployeeFilters = async (): Promise => { * Demonstrates handling of empty filter objects */ console.log('7ļøāƒ£ Edge Case - Empty Filter Objects Test'); - const emptyFilterTest = await employeesTool.execute( + const emptyFilterTest = (await employeesTool.execute( { filter: {}, fields: 'id,first_name,last_name', }, { dryRun: true } - ); + )) as DryRunResult; console.log('Empty filter object:', { filter: {}, fields: 'id,first_name,last_name' }); console.log('Serialized URL:', emptyFilterTest.url); @@ -288,22 +290,6 @@ const hriseEmployeeFilters = async (): Promise => { 'Expected URL to not contain empty filter parameter' ); console.log('āœ… Empty filter objects handled correctly\n'); - - console.log('šŸŽ‰ All HRIS Employee Filter Tests Passed!'); - console.log('\nšŸ“‹ Summary:'); - console.log('āœ… Basic date filtering with updated_after'); - console.log('āœ… Email-based employee filtering'); - console.log('āœ… Employee number filtering'); - console.log('āœ… Multiple filters combined'); - console.log('āœ… Proxy parameters with nested objects'); - console.log('āœ… Complex combined scenarios'); - console.log('āœ… Edge case handling for empty objects'); - console.log('\nšŸ”§ Deep Object Serialization Features Demonstrated:'); - console.log('• Nested objects converted to OpenAPI deepObject style (key[nested]=value)'); - console.log('• Proper URL encoding of special characters'); - console.log('• Support for multiple levels of nesting'); - console.log('• Safe handling of empty objects'); - console.log('• Backward compatibility with primitive parameters'); }; // Run the example diff --git a/src/modules/requestBuilder.ts b/src/modules/requestBuilder.ts index fe8da60..873dcb3 100644 --- a/src/modules/requestBuilder.ts +++ b/src/modules/requestBuilder.ts @@ -285,7 +285,7 @@ export class RequestBuilder { // Prepare URL with query parameters using optimized batching const urlWithQuery = new URL(url); const serializedParams = this.buildQueryParameters(queryParams); - + // Batch append all parameters for (const [paramKey, paramValue] of serializedParams) { urlWithQuery.searchParams.append(paramKey, paramValue); diff --git a/src/modules/tests/requestBuilder.spec.ts b/src/modules/tests/requestBuilder.spec.ts index ffd7855..40e73f7 100644 --- a/src/modules/tests/requestBuilder.spec.ts +++ b/src/modules/tests/requestBuilder.spec.ts @@ -10,21 +10,21 @@ describe('RequestBuilder', () => { url: 'https://api.example.com/test/{pathParam}', bodyType: 'json' as const, params: [ - { name: 'pathParam', location: ParameterLocation.PATH }, - { name: 'queryParam', location: ParameterLocation.QUERY }, - { name: 'headerParam', location: ParameterLocation.HEADER }, - { name: 'bodyParam', location: ParameterLocation.BODY }, - { name: 'defaultParam' /* default to body */ }, - { name: 'filter', location: ParameterLocation.QUERY }, - { name: 'proxy', location: ParameterLocation.QUERY }, - { name: 'regularObject', location: ParameterLocation.QUERY }, - { name: 'simple', location: ParameterLocation.QUERY }, - { name: 'simpleString', location: ParameterLocation.QUERY }, - { name: 'simpleNumber', location: ParameterLocation.QUERY }, - { name: 'simpleBoolean', location: ParameterLocation.QUERY }, - { name: 'complexObject', location: ParameterLocation.QUERY }, - { name: 'deepFilter', location: ParameterLocation.QUERY }, - { name: 'emptyFilter', location: ParameterLocation.QUERY }, + { name: 'pathParam', location: ParameterLocation.PATH, type: 'string' as const }, + { name: 'queryParam', location: ParameterLocation.QUERY, type: 'string' as const }, + { name: 'headerParam', location: ParameterLocation.HEADER, type: 'string' as const }, + { name: 'bodyParam', location: ParameterLocation.BODY, type: 'string' as const }, + { name: 'defaultParam', location: ParameterLocation.BODY, type: 'string' as const }, + { name: 'filter', location: ParameterLocation.QUERY, type: 'object' as const }, + { name: 'proxy', location: ParameterLocation.QUERY, type: 'object' as const }, + { name: 'regularObject', location: ParameterLocation.QUERY, type: 'object' as const }, + { name: 'simple', location: ParameterLocation.QUERY, type: 'string' as const }, + { name: 'simpleString', location: ParameterLocation.QUERY, type: 'string' as const }, + { name: 'simpleNumber', location: ParameterLocation.QUERY, type: 'number' as const }, + { name: 'simpleBoolean', location: ParameterLocation.QUERY, type: 'boolean' as const }, + { name: 'complexObject', location: ParameterLocation.QUERY, type: 'object' as const }, + { name: 'deepFilter', location: ParameterLocation.QUERY, type: 'object' as const }, + { name: 'emptyFilter', location: ParameterLocation.QUERY, type: 'object' as const }, ], }; @@ -290,7 +290,7 @@ describe('RequestBuilder', () => { describe('Security and Performance Improvements', () => { it('should throw error when recursion depth limit is exceeded', async () => { // Create a deeply nested object that exceeds the default depth limit of 10 - let deepObject: any = { value: 'test' }; + let deepObject: Record = { value: 'test' }; for (let i = 0; i < 12; i++) { deepObject = { nested: deepObject }; } @@ -306,7 +306,7 @@ describe('RequestBuilder', () => { }); it('should throw error when circular reference is detected', async () => { - const circular: any = { a: { b: 'test' } }; + const circular: Record = { a: { b: 'test' } }; circular.a.circular = circular; // Create circular reference const params = { @@ -323,7 +323,7 @@ describe('RequestBuilder', () => { const params = { pathParam: 'test-value', filter: { - 'valid_key': 'test', + valid_key: 'test', 'invalid key with spaces': 'test', // Should trigger validation error }, }; @@ -353,14 +353,14 @@ describe('RequestBuilder', () => { // Date should be serialized to ISO string expect(url.searchParams.get('filter[dateField]')).toBe('2023-01-01T00:00:00.000Z'); - + // RegExp should be serialized to string representation expect(url.searchParams.get('filter[regexField]')).toBe('/test-pattern/gi'); - + // Null and undefined should result in empty string (but won't be added since they're filtered out) expect(url.searchParams.get('filter[nullField]')).toBeNull(); expect(url.searchParams.get('filter[undefinedField]')).toBeNull(); - + // Empty string should be preserved expect(url.searchParams.get('filter[emptyString]')).toBe(''); }); @@ -395,7 +395,7 @@ describe('RequestBuilder', () => { // Empty objects should not create any parameters expect(url.searchParams.get('emptyFilter')).toBeNull(); expect(url.searchParams.get('filter[emptyNested]')).toBeNull(); - + // Valid fields should still work expect(url.searchParams.get('filter[validField]')).toBe('test'); }); @@ -443,7 +443,7 @@ describe('RequestBuilder', () => { it('should maintain performance with large objects', async () => { // Create a moderately large object to test performance optimizations - const largeFilter: any = {}; + const largeFilter: Record = {}; for (let i = 0; i < 100; i++) { largeFilter[`field_${i}`] = `value_${i}`; if (i % 10 === 0) { @@ -467,7 +467,7 @@ describe('RequestBuilder', () => { expect(endTime - startTime).toBeLessThan(100); const url = new URL(result.url as string); - + // Verify some parameters are correctly serialized expect(url.searchParams.get('filter[field_0]')).toBe('value_0'); expect(url.searchParams.get('filter[field_99]')).toBe('value_99'); diff --git a/src/tests/fetch-specs.spec.ts b/src/tests/fetch-specs.spec.ts index 6c691c7..0a26fad 100644 --- a/src/tests/fetch-specs.spec.ts +++ b/src/tests/fetch-specs.spec.ts @@ -1,7 +1,17 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { + type Mock, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { mockFetch } from './utils/fetch-mock'; +import { type FetchMockResult, mockFetch } from './utils/fetch-mock'; // Mock environment variables beforeAll(() => { @@ -10,8 +20,8 @@ beforeAll(() => { describe('fetch-specs script', () => { // Mocks for fetch and fs - let fetchMock; - let writeFileSyncSpy; + let fetchMock: FetchMockResult; + let writeFileSyncSpy: Mock; beforeEach(() => { // Set up fetch mock with different responses based on URL @@ -90,6 +100,6 @@ describe('fetch-specs script', () => { expect(writeFileSyncSpy).toHaveBeenCalled(); const writeFileCall = writeFileSyncSpy.mock.calls[0]; expect(writeFileCall[0]).toContain('hris.json'); - expect(JSON.parse(writeFileCall[1])).toEqual(hrisApiSpec); + expect(JSON.parse(writeFileCall[1] as string)).toEqual(hrisApiSpec); }); }); diff --git a/src/tests/utils/fetch-mock.ts b/src/tests/utils/fetch-mock.ts index 94dd425..e3d908c 100644 --- a/src/tests/utils/fetch-mock.ts +++ b/src/tests/utils/fetch-mock.ts @@ -10,7 +10,7 @@ export interface MockFetchResponse { status?: number; statusText?: string; ok?: boolean; - json?: () => Promise; + json?: () => Promise; text?: () => Promise; headers?: Record; } @@ -48,7 +48,7 @@ export interface FetchMockResult { /** * The captured request body from the last fetch call */ - requestBody: any; + requestBody: unknown; /** * The captured URL from the last fetch call diff --git a/src/toolsets/tests/openapi.spec.ts b/src/toolsets/tests/openapi.spec.ts index 2047c87..2aea464 100644 --- a/src/toolsets/tests/openapi.spec.ts +++ b/src/toolsets/tests/openapi.spec.ts @@ -4,7 +4,7 @@ import { OpenAPILoader } from '../../openapi/loader'; import { mockFetch } from '../../tests/utils/fetch-mock'; import { ParameterLocation } from '../../types'; import type { AuthenticationConfig } from '../base'; -import { OpenAPIToolSet } from '../openapi'; +import { OpenAPIToolSet, type OpenAPIToolSetConfigFromUrl } from '../openapi'; describe('OpenAPIToolSet', () => { // Path to test fixtures @@ -116,7 +116,7 @@ describe('OpenAPIToolSet', () => { it('should throw error if URL is not provided to fromUrl', async () => { // Attempt to create an instance without URL - await expect(OpenAPIToolSet.fromUrl({} as any)).rejects.toThrow(); + await expect(OpenAPIToolSet.fromUrl({} as OpenAPIToolSetConfigFromUrl)).rejects.toThrow(); }); it('should set headers on tools', () => {