@aloma.io/integration-sdk 3.8.57 → 3.8.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,13 +37,64 @@ interface OperationInfo {
37
37
  responses?: any;
38
38
  }
39
39
 
40
+ export interface OpenAPIToConnectorOptions {
41
+ nestedPaths?: boolean;
42
+ }
43
+
40
44
  export class OpenAPIToConnector {
41
45
  private spec: OpenAPIV3.Document;
42
46
  private connectorName: string;
47
+ private options: OpenAPIToConnectorOptions;
43
48
 
44
- constructor(spec: OpenAPIV3.Document, connectorName: string) {
49
+ constructor(spec: OpenAPIV3.Document, connectorName: string, options?: OpenAPIToConnectorOptions) {
45
50
  this.spec = spec;
46
51
  this.connectorName = connectorName;
52
+ this.options = options || {};
53
+ }
54
+
55
+ /**
56
+ * Derive a dotted method path from URL path and operationId.
57
+ * When nestedPaths is enabled, builds a namespace from the URL segments.
58
+ * When disabled (default), falls back to the existing flat method name.
59
+ */
60
+ deriveMethodPath(httpMethod: string, urlPath: string, operationId: string): string {
61
+ if (!this.options.nestedPaths) {
62
+ return this.generateMethodName({method: httpMethod, path: urlPath, operationId});
63
+ }
64
+
65
+ const VERSION_RE = /^v\d+$/;
66
+ const STRIP = new Set(['objects', 'items']);
67
+
68
+ // Build namespace parts from the URL path
69
+ const parts = urlPath
70
+ .replace(/\{[^}]+\}/g, '')
71
+ .split('/')
72
+ .filter(Boolean)
73
+ .filter((p) => !VERSION_RE.test(p))
74
+ .filter((p) => !STRIP.has(p));
75
+
76
+ // Extract the leaf action from operationId suffix
77
+ let suffix = operationId.includes('_')
78
+ ? operationId.split('_').pop()!
79
+ : httpMethod.toLowerCase();
80
+
81
+ // Bug 3 fix: If the suffix looks like a raw URL path (contains slashes),
82
+ // clean it by extracting meaningful segments rather than using it literally
83
+ if (suffix.includes('/')) {
84
+ const suffixParts = suffix
85
+ .replace(/\{[^}]+\}/g, '')
86
+ .split('/')
87
+ .filter(Boolean)
88
+ .filter((p) => !VERSION_RE.test(p))
89
+ .filter((p) => !STRIP.has(p));
90
+ // Use the last meaningful segment from the URL-like suffix, or fall back to HTTP method
91
+ suffix = suffixParts.length > 0 ? suffixParts[suffixParts.length - 1] : httpMethod.toLowerCase();
92
+ }
93
+
94
+ // Dedup: if suffix equals last path segment, don't repeat it
95
+ const last = parts[parts.length - 1];
96
+ if (suffix === last) return parts.join('.');
97
+ return [...parts, suffix].join('.');
47
98
  }
48
99
 
49
100
  /**
@@ -172,6 +223,29 @@ export class OpenAPIToConnector {
172
223
  return `${methodPrefix}_${pathSuffix}`;
173
224
  }
174
225
 
226
+ /**
227
+ * Disambiguate a colliding method name by prepending path context.
228
+ * E.g., "archive" from path "/contacts/batch/archive" becomes "batchArchive".
229
+ */
230
+ private disambiguateMethodName(baseName: string, operation: OperationInfo): string {
231
+ // Extract meaningful path segments excluding parameters and the base name itself
232
+ const pathParts = operation.path
233
+ .replace(/\{[^}]+\}/g, '')
234
+ .split('/')
235
+ .filter(Boolean)
236
+ .filter((p) => p.toLowerCase() !== baseName.toLowerCase());
237
+
238
+ // Use the last path segment before the method name as a distinguishing prefix
239
+ if (pathParts.length > 0) {
240
+ const prefix = pathParts[pathParts.length - 1];
241
+ const capitalizedBase = baseName.charAt(0).toUpperCase() + baseName.slice(1);
242
+ return `${prefix}${capitalizedBase}`;
243
+ }
244
+
245
+ // Fallback: prepend the HTTP method
246
+ return `${operation.method.toLowerCase()}${baseName.charAt(0).toUpperCase() + baseName.slice(1)}`;
247
+ }
248
+
175
249
  /**
176
250
  * Get the number of operations in the OpenAPI spec
177
251
  */
@@ -799,9 +873,10 @@ export class OpenAPIToConnector {
799
873
  }
800
874
  lines.push('');
801
875
  }
876
+
877
+ // Make the API call
878
+ lines.push(` return this.api.fetch(url, fetchOptions);`);
802
879
  }
803
- // Make the API call
804
- lines.push(` return this.api.fetch(url, fetchOptions);`);
805
880
 
806
881
  return lines.join('\n');
807
882
  }
@@ -823,7 +898,7 @@ export class OpenAPIToConnector {
823
898
 
824
899
  if (resourceSpec) {
825
900
  // Create a temporary generator for this resource's spec
826
- const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName);
901
+ const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName, this.options);
827
902
  const operations = resourceGenerator.extractOperations();
828
903
 
829
904
  for (const operation of operations) {
@@ -832,15 +907,30 @@ export class OpenAPIToConnector {
832
907
  const signature = resourceGenerator.generateMethodSignature(operation);
833
908
 
834
909
  // Generate the exposed method that delegates to the resource
835
- const exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
910
+ let exposedMethodName: string;
911
+ if (this.options.nestedPaths && operation.operationId) {
912
+ // Use dotted path for nested method names
913
+ exposedMethodName = resourceGenerator.deriveMethodPath(
914
+ operation.method,
915
+ operation.path,
916
+ operation.operationId
917
+ );
918
+ } else {
919
+ exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
920
+ }
836
921
 
837
922
  // Generate parameter call based on operation details
838
923
  const parameterCall = this.generateParameterCallForOperation(operation, signature);
839
924
 
925
+ // Use quoted method name if it contains dots
926
+ const methodDecl = exposedMethodName.includes('.')
927
+ ? `'${exposedMethodName}'`
928
+ : exposedMethodName;
929
+
840
930
  methods.push(` /**
841
931
  ${jsdoc}
842
932
  */
843
- async ${exposedMethodName}${signature} {
933
+ async ${methodDecl}${signature} {
844
934
  return this.${resourceName}.${methodName}(${parameterCall});
845
935
  }`);
846
936
  }
@@ -1305,9 +1395,24 @@ ${jsdoc}
1305
1395
 
1306
1396
  const resourceName = className.replace('Resource', '').toLowerCase();
1307
1397
 
1398
+ // Bug 1 fix: Track used method names and disambiguate collisions
1399
+ const usedNames = new Map<string, number>();
1308
1400
  const functions = operations
1309
1401
  .map((operation) => {
1310
- const methodName = this.generateMethodName(operation);
1402
+ let methodName = this.generateMethodName(operation);
1403
+
1404
+ // Disambiguate duplicate names by prepending path context
1405
+ if (usedNames.has(methodName)) {
1406
+ methodName = this.disambiguateMethodName(methodName, operation);
1407
+ // If still colliding after disambiguation, append a numeric suffix
1408
+ if (usedNames.has(methodName)) {
1409
+ const count = (usedNames.get(methodName) || 1) + 1;
1410
+ usedNames.set(methodName, count);
1411
+ methodName = `${methodName}${count}`;
1412
+ }
1413
+ }
1414
+ usedNames.set(methodName, (usedNames.get(methodName) || 0) + 1);
1415
+
1311
1416
  const jsdoc = this.generateDetailedJSDoc(operation);
1312
1417
  const signature = this.generateMethodSignature(operation);
1313
1418
  const implementation = this.generateResourceFunctionImplementation(operation);
@@ -1407,14 +1512,35 @@ ${exposedMethods}
1407
1512
  throw new Error('No operations found in OpenAPI specification');
1408
1513
  }
1409
1514
 
1515
+ // Bug 1 fix: Track used method names and disambiguate collisions
1516
+ const usedNames = new Map<string, number>();
1410
1517
  const methods = operations
1411
1518
  .map((operation) => {
1412
- const methodName = this.generateMethodName(operation);
1519
+ let methodName: string;
1520
+ if (this.options.nestedPaths && operation.operationId) {
1521
+ methodName = this.deriveMethodPath(operation.method, operation.path, operation.operationId);
1522
+ } else {
1523
+ methodName = this.generateMethodName(operation);
1524
+ }
1525
+
1526
+ // Disambiguate duplicate names
1527
+ if (usedNames.has(methodName)) {
1528
+ methodName = this.disambiguateMethodName(methodName, operation);
1529
+ if (usedNames.has(methodName)) {
1530
+ const count = (usedNames.get(methodName) || 1) + 1;
1531
+ usedNames.set(methodName, count);
1532
+ methodName = `${methodName}${count}`;
1533
+ }
1534
+ }
1535
+ usedNames.set(methodName, (usedNames.get(methodName) || 0) + 1);
1536
+
1413
1537
  const jsdoc = this.generateDetailedJSDoc(operation); // Use detailed JSDoc like multi-resource
1414
1538
  const signature = this.generateMethodSignature(operation);
1415
1539
  const implementation = this.generateControllerMethodImplementation(operation); // Use improved implementation
1416
1540
 
1417
- return ` /**\n${jsdoc}\n */\n async ${methodName}${signature} {\n${implementation}\n }`;
1541
+ // Use quoted method name if it contains dots
1542
+ const methodDecl = methodName.includes('.') ? `'${methodName}'` : methodName;
1543
+ return ` /**\n${jsdoc}\n */\n async ${methodDecl}${signature} {\n${implementation}\n }`;
1418
1544
  })
1419
1545
  .join('\n\n');
1420
1546
 
@@ -0,0 +1,184 @@
1
+ import { Dispatcher } from '../build/internal/dispatcher/index.mjs';
2
+
3
+ // Simple test framework matching project convention
4
+ const colors = {
5
+ green: '\x1b[32m',
6
+ red: '\x1b[31m',
7
+ yellow: '\x1b[33m',
8
+ reset: '\x1b[0m',
9
+ cyan: '\x1b[36m'
10
+ };
11
+
12
+ let passed = 0;
13
+ let failed = 0;
14
+
15
+ async function test(name, fn) {
16
+ try {
17
+ console.log(`${colors.cyan}Running: ${name}${colors.reset}`);
18
+ await fn();
19
+ console.log(`${colors.green}✓ PASS: ${name}${colors.reset}\n`);
20
+ passed++;
21
+ } catch (error) {
22
+ console.log(`${colors.red}✗ FAIL: ${name}${colors.reset}`);
23
+ console.log(`${colors.red} Error: ${error.message}${colors.reset}\n`);
24
+ failed++;
25
+ }
26
+ }
27
+
28
+ function assert(condition, message) {
29
+ if (!condition) throw new Error(message || 'Assertion failed');
30
+ }
31
+
32
+ function assertEqual(actual, expected, message) {
33
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
34
+ throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
35
+ }
36
+ }
37
+
38
+ // Helper: build a dispatcher with given resolvers and execute a query
39
+ async function buildAndExecute(resolvers, query, variables) {
40
+ const dispatcher = new Dispatcher();
41
+ dispatcher.types({ fields: {} });
42
+ dispatcher.resolvers(resolvers);
43
+ dispatcher.main(async () => {});
44
+ const built = dispatcher.build();
45
+ return built.execute({ query, variables });
46
+ }
47
+
48
+ // --- TESTS ---
49
+
50
+ // Test 1: Dispatcher correctly resolves pre-built nested structures (baseline)
51
+ await test('Dispatcher resolves pre-built nested structure (baseline)', async () => {
52
+ const handler = async (args) => ({ created: true, ...args });
53
+ const resolvers = { crm: { contacts: { create: handler } } };
54
+ const result = await buildAndExecute(resolvers, ['crm', 'contacts', 'create'], { name: 'test' });
55
+ assertEqual(result, { created: true, name: 'test' });
56
+ });
57
+
58
+ // Test 2: Dispatcher resolves flat keys (baseline)
59
+ await test('Dispatcher resolves flat key (baseline)', async () => {
60
+ const handler = async (args) => ({ flat: true, ...args });
61
+ const resolvers = { contactsCreate: handler };
62
+ const result = await buildAndExecute(resolvers, ['contactsCreate'], { id: 1 });
63
+ assertEqual(result, { flat: true, id: 1 });
64
+ });
65
+
66
+ // Test 3: CRITICAL — import buildResolvers from runtime-context and verify it
67
+ // handles dotted method names by creating nested structures.
68
+ // This is the RED test: buildResolvers doesn't exist yet.
69
+ await test('buildResolvers creates nested structure for dotted method names', async () => {
70
+ const { buildResolvers } = await import('../build/builder/runtime-context.mjs');
71
+
72
+ assert(typeof buildResolvers === 'function', 'buildResolvers should be exported from runtime-context');
73
+
74
+ const mockController = {
75
+ 'crm.contacts.create': async (args) => ({ method: 'crm.contacts.create', ...args }),
76
+ 'crm.contacts.list': async (args) => ({ method: 'crm.contacts.list', ...args }),
77
+ 'flatMethod': async (args) => ({ method: 'flatMethod', ...args }),
78
+ '__autocomplete': async () => ({}),
79
+ '__endpoint': async () => ({}),
80
+ '__default': async () => ({}),
81
+ };
82
+
83
+ const methods = ['crm.contacts.create', 'crm.contacts.list', 'flatMethod', '__autocomplete', '__endpoint', '__default'];
84
+ const resolvers = buildResolvers(methods, mockController);
85
+
86
+ // Nested structure for dotted names
87
+ assert(resolvers.crm, 'resolvers.crm should exist');
88
+ assert(resolvers.crm.contacts, 'resolvers.crm.contacts should exist');
89
+ assert(typeof resolvers.crm.contacts.create === 'function', 'crm.contacts.create should be a function');
90
+ assert(typeof resolvers.crm.contacts.list === 'function', 'crm.contacts.list should be a function');
91
+
92
+ // Flat structure for non-dotted names
93
+ assert(typeof resolvers.flatMethod === 'function', 'flatMethod should be a function');
94
+ assert(typeof resolvers.__autocomplete === 'function', '__autocomplete should be a function');
95
+ assert(typeof resolvers.__endpoint === 'function', '__endpoint should be a function');
96
+ assert(typeof resolvers.__default === 'function', '__default should be a function');
97
+ });
98
+
99
+ // Test 4: buildResolvers nested methods resolve correctly through dispatcher
100
+ await test('buildResolvers nested methods resolve through dispatcher end-to-end', async () => {
101
+ const { buildResolvers } = await import('../build/builder/runtime-context.mjs');
102
+
103
+ const mockController = {
104
+ 'crm.contacts.create': async (args) => ({ created: true, ...(args || {}) }),
105
+ 'crm.contacts.list': async () => ({ items: ['a', 'b'] }),
106
+ 'flatMethod': async (args) => ({ flat: true, ...(args || {}) }),
107
+ '__autocomplete': async () => ({}),
108
+ '__endpoint': async () => ({}),
109
+ '__default': async () => ({}),
110
+ };
111
+
112
+ const methods = ['crm.contacts.create', 'crm.contacts.list', 'flatMethod', '__autocomplete', '__endpoint', '__default'];
113
+ const resolvers = buildResolvers(methods, mockController);
114
+
115
+ // Execute through dispatcher
116
+ const nestedResult = await buildAndExecute(resolvers, ['crm', 'contacts', 'create'], { name: 'Acme' });
117
+ assertEqual(nestedResult, { created: true, name: 'Acme' });
118
+
119
+ const listResult = await buildAndExecute(resolvers, ['crm', 'contacts', 'list'], {});
120
+ assertEqual(listResult, { items: ['a', 'b'] });
121
+
122
+ const flatResult = await buildAndExecute(resolvers, ['flatMethod'], { x: 42 });
123
+ assertEqual(flatResult, { flat: true, x: 42 });
124
+ });
125
+
126
+ // Test 5: buildResolvers preserves existing flat registration for non-dotted names
127
+ await test('buildResolvers flat names are unchanged (backward compat)', async () => {
128
+ const { buildResolvers } = await import('../build/builder/runtime-context.mjs');
129
+
130
+ const mockController = {
131
+ 'simpleMethodA': async () => ({ a: true }),
132
+ 'simpleMethodB': async () => ({ b: true }),
133
+ '__autocomplete': async () => ({}),
134
+ '__endpoint': async () => ({}),
135
+ '__default': async () => ({}),
136
+ };
137
+
138
+ const methods = ['simpleMethodA', 'simpleMethodB', '__autocomplete', '__endpoint', '__default'];
139
+ const resolvers = buildResolvers(methods, mockController);
140
+
141
+ // All flat — no nested structures
142
+ assert(typeof resolvers.simpleMethodA === 'function', 'simpleMethodA should be a function');
143
+ assert(typeof resolvers.simpleMethodB === 'function', 'simpleMethodB should be a function');
144
+
145
+ const resultA = await buildAndExecute(resolvers, ['simpleMethodA'], {});
146
+ assertEqual(resultA, { a: true });
147
+
148
+ const resultB = await buildAndExecute(resolvers, ['simpleMethodB'], {});
149
+ assertEqual(resultB, { b: true });
150
+ });
151
+
152
+ // Test 6: Nested and flat coexist without overwriting each other
153
+ await test('buildResolvers nested and flat coexist without conflicts', async () => {
154
+ const { buildResolvers } = await import('../build/builder/runtime-context.mjs');
155
+
156
+ const mockController = {
157
+ 'crm.contacts.create': async () => ({ source: 'nested' }),
158
+ 'contactsCreate': async () => ({ source: 'flat' }),
159
+ '__autocomplete': async () => ({}),
160
+ '__endpoint': async () => ({}),
161
+ '__default': async () => ({}),
162
+ };
163
+
164
+ const methods = ['crm.contacts.create', 'contactsCreate', '__autocomplete', '__endpoint', '__default'];
165
+ const resolvers = buildResolvers(methods, mockController);
166
+
167
+ const nestedResult = await buildAndExecute(resolvers, ['crm', 'contacts', 'create'], {});
168
+ assertEqual(nestedResult, { source: 'nested' });
169
+
170
+ const flatResult = await buildAndExecute(resolvers, ['contactsCreate'], {});
171
+ assertEqual(flatResult, { source: 'flat' });
172
+ });
173
+
174
+ // --- SUMMARY ---
175
+ console.log(`\n${colors.yellow}📊 Nested Resolver Test Results:${colors.reset}`);
176
+ console.log(`${colors.green}✓ Passed: ${passed}${colors.reset}`);
177
+ console.log(`${colors.red}✗ Failed: ${failed}${colors.reset}`);
178
+
179
+ if (failed > 0) {
180
+ console.log(`\n${colors.red}❌ Some tests failed!${colors.reset}`);
181
+ process.exit(1);
182
+ } else {
183
+ console.log(`\n${colors.green}🎉 All nested resolver tests passed!${colors.reset}`);
184
+ }
@@ -0,0 +1,248 @@
1
+ import { OpenAPIToConnector } from '../build/openapi-to-connector.mjs';
2
+
3
+ // Simple test framework matching project convention
4
+ const colors = {
5
+ green: '\x1b[32m',
6
+ red: '\x1b[31m',
7
+ yellow: '\x1b[33m',
8
+ reset: '\x1b[0m',
9
+ cyan: '\x1b[36m'
10
+ };
11
+
12
+ let passed = 0;
13
+ let failed = 0;
14
+
15
+ async function test(name, fn) {
16
+ try {
17
+ console.log(`${colors.cyan}Running: ${name}${colors.reset}`);
18
+ await fn();
19
+ console.log(`${colors.green}✓ PASS: ${name}${colors.reset}\n`);
20
+ passed++;
21
+ } catch (error) {
22
+ console.log(`${colors.red}✗ FAIL: ${name}${colors.reset}`);
23
+ console.log(`${colors.red} Error: ${error.message}${colors.reset}\n`);
24
+ failed++;
25
+ }
26
+ }
27
+
28
+ function assert(condition, message) {
29
+ if (!condition) throw new Error(message || 'Assertion failed');
30
+ }
31
+
32
+ function assertEqual(actual, expected, message) {
33
+ if (actual !== expected) {
34
+ throw new Error(message || `Expected "${expected}", got "${actual}"`);
35
+ }
36
+ }
37
+
38
+ // Minimal OpenAPI spec for testing
39
+ const minimalSpec = {
40
+ openapi: '3.0.0',
41
+ info: { title: 'Test API', version: '1.0.0' },
42
+ paths: {}
43
+ };
44
+
45
+ // === BUG 1: Duplicate function names within a resource ===
46
+
47
+ await test('Bug1: Two ops producing same method name are deduplicated in resource output', async () => {
48
+ // Spec with two operations that both derive "archive" as method name:
49
+ // POST /batch/archive -> operationId batch_archive (suffix: archive)
50
+ // DELETE /contacts/{id} -> operationId contacts_archive (suffix: archive)
51
+ const spec = {
52
+ openapi: '3.0.0',
53
+ info: { title: 'Test', version: '1.0.0' },
54
+ paths: {
55
+ '/contacts/batch/archive': {
56
+ post: {
57
+ operationId: 'batch_archive',
58
+ summary: 'Batch archive contacts',
59
+ responses: { '200': { description: 'OK' } }
60
+ }
61
+ },
62
+ '/contacts/{contactId}': {
63
+ delete: {
64
+ operationId: 'contacts_archive',
65
+ summary: 'Archive single contact',
66
+ parameters: [{ name: 'contactId', in: 'path', required: true, schema: { type: 'string' } }],
67
+ responses: { '204': { description: 'Deleted' } }
68
+ }
69
+ }
70
+ }
71
+ };
72
+
73
+ const generator = new OpenAPIToConnector(spec, 'test', { nestedPaths: false });
74
+ const output = generator.generateResourceClass('ContactsResource');
75
+
76
+ // Count how many times "export function archive(" appears
77
+ const matches = output.match(/export function archive\(/g);
78
+ assert(
79
+ !matches || matches.length <= 1,
80
+ `Expected at most 1 "export function archive(" but found ${matches ? matches.length : 0}. Duplicate function names!`
81
+ );
82
+
83
+ // There should be two distinct function declarations (disambiguated)
84
+ const funcDecls = output.match(/export function \w+\(/g);
85
+ assert(funcDecls && funcDecls.length >= 2, `Expected at least 2 distinct function declarations, got ${funcDecls ? funcDecls.length : 0}`);
86
+ });
87
+
88
+ await test('Bug1: generateController with colliding method names produces distinct methods', async () => {
89
+ const spec = {
90
+ openapi: '3.0.0',
91
+ info: { title: 'Test', version: '1.0.0' },
92
+ paths: {
93
+ '/items/batch/read': {
94
+ post: {
95
+ operationId: 'batch_read',
96
+ summary: 'Batch read items',
97
+ responses: { '200': { description: 'OK' } }
98
+ }
99
+ },
100
+ '/items/{itemId}': {
101
+ get: {
102
+ operationId: 'items_read',
103
+ summary: 'Read single item',
104
+ parameters: [{ name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }],
105
+ responses: { '200': { description: 'OK' } }
106
+ }
107
+ }
108
+ }
109
+ };
110
+
111
+ const generator = new OpenAPIToConnector(spec, 'test', { nestedPaths: false });
112
+ const output = generator.generateController();
113
+
114
+ // Count occurrences of "async read(" — should be at most 1
115
+ const matches = output.match(/async read\(/g);
116
+ assert(
117
+ !matches || matches.length <= 1,
118
+ `Expected at most 1 "async read(" but found ${matches ? matches.length : 0}. Duplicate method names in controller!`
119
+ );
120
+ });
121
+
122
+ // === BUG 2: Dead code after return ===
123
+
124
+ await test('Bug2: Resource function for simple path+params has no statements after return', async () => {
125
+ // A simple GET with path param and no query/body triggers the "isSimple" branch
126
+ // which already emits a return, but then the code unconditionally appends another return
127
+ const spec = {
128
+ openapi: '3.0.0',
129
+ info: { title: 'Test', version: '1.0.0' },
130
+ paths: {
131
+ '/contacts/{contactId}': {
132
+ get: {
133
+ operationId: 'getContact',
134
+ summary: 'Get a contact',
135
+ parameters: [{ name: 'contactId', in: 'path', required: true, schema: { type: 'string' } }],
136
+ responses: { '200': { description: 'OK' } }
137
+ }
138
+ }
139
+ }
140
+ };
141
+
142
+ const generator = new OpenAPIToConnector(spec, 'test', { nestedPaths: false });
143
+ const output = generator.generateResourceClass('ContactsResource');
144
+
145
+ // Extract the function body for getContact
146
+ const funcMatch = output.match(/export function getContact[\s\S]*?\n\}/);
147
+ assert(funcMatch, 'Could not find getContact function in output');
148
+ const funcBody = funcMatch[0];
149
+
150
+ // Find all return statements
151
+ const returnStatements = funcBody.match(/return this\.api\.fetch/g);
152
+ assert(
153
+ returnStatements && returnStatements.length === 1,
154
+ `Expected exactly 1 return statement but found ${returnStatements ? returnStatements.length : 0}. Dead code after return!`
155
+ );
156
+ });
157
+
158
+ await test('Bug2: Controller function for simple path+params has no dead code after return', async () => {
159
+ const spec = {
160
+ openapi: '3.0.0',
161
+ info: { title: 'Test', version: '1.0.0' },
162
+ paths: {
163
+ '/items/{itemId}': {
164
+ get: {
165
+ operationId: 'getItem',
166
+ summary: 'Get an item',
167
+ parameters: [{ name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }],
168
+ responses: { '200': { description: 'OK' } }
169
+ }
170
+ }
171
+ }
172
+ };
173
+
174
+ const generator = new OpenAPIToConnector(spec, 'test', { nestedPaths: false });
175
+ const output = generator.generateController();
176
+
177
+ // Extract the method body for getItem
178
+ const methodMatch = output.match(/async getItem[\s\S]*?\n \}/);
179
+ assert(methodMatch, 'Could not find getItem method in output');
180
+ const methodBody = methodMatch[0];
181
+
182
+ // Find all return statements
183
+ const returnStatements = methodBody.match(/return this\.api\.fetch/g);
184
+ assert(
185
+ returnStatements && returnStatements.length === 1,
186
+ `Expected exactly 1 return statement but found ${returnStatements ? returnStatements.length : 0}. Dead code after return in controller!`
187
+ );
188
+ });
189
+
190
+ // === BUG 3: Raw URL path segments in method names ===
191
+
192
+ await test('Bug3: deriveMethodPath handles operationId with raw URL paths (lists style)', async () => {
193
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
194
+
195
+ // HubSpot lists-style operationId: "get-/crm/v3/lists_/crm/v3/lists"
196
+ const result = generator.deriveMethodPath('GET', '/crm/v3/lists', 'get-/crm/v3/lists_/crm/v3/lists');
197
+
198
+ // Should NOT contain slashes or version numbers
199
+ assert(!result.includes('/'), `Method path should not contain slashes, got: "${result}"`);
200
+ assert(!result.includes('v3'), `Method path should not contain version numbers, got: "${result}"`);
201
+ });
202
+
203
+ await test('Bug3: deriveMethodPath handles operationId suffix that IS a raw URL path', async () => {
204
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
205
+
206
+ // operationId: "post-/crm/v3/lists/search_/crm/v3/lists/search"
207
+ const result = generator.deriveMethodPath('POST', '/crm/v3/lists/search', 'post-/crm/v3/lists/search_/crm/v3/lists/search');
208
+
209
+ assert(!result.includes('/'), `Method path should not contain slashes, got: "${result}"`);
210
+ assert(!result.includes('v3'), `Method path should not contain version numbers, got: "${result}"`);
211
+ // Should contain "lists" and "search" in some form
212
+ assert(result.includes('lists'), `Method path should contain "lists", got: "${result}"`);
213
+ assert(result.includes('search'), `Method path should contain "search", got: "${result}"`);
214
+ });
215
+
216
+ await test('Bug3: deriveMethodPath does not produce doubled segments from URL-style operationId', async () => {
217
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
218
+
219
+ // operationId: "put-/crm/v3/lists/{listId}/restore_/crm/v3/lists/{listId}/restore"
220
+ const result = generator.deriveMethodPath('PUT', '/crm/v3/lists/{listId}/restore', 'put-/crm/v3/lists/{listId}/restore_/crm/v3/lists/{listId}/restore');
221
+
222
+ assert(!result.includes('/'), `Method path should not contain slashes, got: "${result}"`);
223
+ // Should not have doubled path segments like "lists.lists" or "restore.restore"
224
+ const parts = result.split('.');
225
+ for (let i = 0; i < parts.length - 1; i++) {
226
+ assert(parts[i] !== parts[i + 1], `Doubled segment "${parts[i]}" in method path: "${result}"`);
227
+ }
228
+ });
229
+
230
+ await test('Bug3: generateMethodName (flat mode) cleans operationId with raw URL paths', async () => {
231
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: false });
232
+
233
+ // When nestedPaths is false, deriveMethodPath calls generateMethodName
234
+ // This should still produce a clean identifier without slashes
235
+ const result = generator.deriveMethodPath('GET', '/crm/v3/lists', 'get-/crm/v3/lists_/crm/v3/lists');
236
+
237
+ assert(!result.includes('/'), `Flat method name should not contain slashes, got: "${result}"`);
238
+ assert(/^[a-zA-Z_]/.test(result), `Method name should start with letter or underscore, got: "${result}"`);
239
+ });
240
+
241
+ // --- Report ---
242
+ console.log(`\n${colors.yellow}===== Results =====${colors.reset}`);
243
+ console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
244
+ console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
245
+
246
+ if (failed > 0) {
247
+ process.exit(1);
248
+ }