@aloma.io/integration-sdk 3.8.57 → 3.8.59

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.
@@ -1,4 +1,15 @@
1
1
  import { AbstractController } from '../controller/index.mjs';
2
+ /**
3
+ * Build a resolvers object from a list of method names and a controller.
4
+ *
5
+ * If a method name contains dots (e.g. 'crm.contacts.create'), it is registered
6
+ * as a nested object tree so that resolveMethod(['crm', 'contacts', 'create'])
7
+ * finds the handler.
8
+ *
9
+ * If a method name has no dots (e.g. 'contactsCreate'), it is registered flat
10
+ * as before: resolvers.contactsCreate = handler.
11
+ */
12
+ export declare function buildResolvers(methods: string[], controller: any): any;
2
13
  /**
3
14
  * Runtime context to manage the lifecycle of the connector
4
15
  */
@@ -1,6 +1,41 @@
1
1
  import fs from 'node:fs';
2
2
  import { AbstractController } from '../controller/index.mjs';
3
3
  import { Connector } from '../internal/index.mjs';
4
+ /**
5
+ * Build a resolvers object from a list of method names and a controller.
6
+ *
7
+ * If a method name contains dots (e.g. 'crm.contacts.create'), it is registered
8
+ * as a nested object tree so that resolveMethod(['crm', 'contacts', 'create'])
9
+ * finds the handler.
10
+ *
11
+ * If a method name has no dots (e.g. 'contactsCreate'), it is registered flat
12
+ * as before: resolvers.contactsCreate = handler.
13
+ */
14
+ export function buildResolvers(methods, controller) {
15
+ const resolvers = {};
16
+ methods.forEach((method) => {
17
+ const handler = async (args) => {
18
+ if (!methods.includes(method))
19
+ throw new Error(`${method} not found`);
20
+ return controller[method](args);
21
+ };
22
+ if (method.includes('.')) {
23
+ const parts = method.split('.');
24
+ let node = resolvers;
25
+ for (let i = 0; i < parts.length - 1; i++) {
26
+ if (!node[parts[i]] || typeof node[parts[i]] !== 'object') {
27
+ node[parts[i]] = {};
28
+ }
29
+ node = node[parts[i]];
30
+ }
31
+ node[parts[parts.length - 1]] = handler;
32
+ }
33
+ else {
34
+ resolvers[method] = handler;
35
+ }
36
+ });
37
+ return resolvers;
38
+ }
4
39
  /**
5
40
  * Runtime context to manage the lifecycle of the connector
6
41
  */
@@ -36,15 +71,8 @@ export default class RuntimeContext {
36
71
  icon,
37
72
  });
38
73
  const configuration = connector.configure().config(data.config || {});
39
- const resolvers = {};
40
74
  const methods = [...data.methods, '__autocomplete', '__endpoint', '__default'];
41
- methods.forEach((method) => {
42
- resolvers[method] = async (args) => {
43
- if (!methods.includes(method))
44
- throw new Error(`${method} not found`);
45
- return controller[method](args);
46
- };
47
- });
75
+ const resolvers = buildResolvers(methods, controller);
48
76
  configuration.types(data.types).resolvers(resolvers);
49
77
  if (data.options?.endpoint?.enabled) {
50
78
  configuration.endpoint((arg) => controller.__endpoint(arg), data.options?.endpoint?.required);
package/build/cli.mjs CHANGED
@@ -112,6 +112,7 @@ program
112
112
  .option('--resource <className>', 'Generate as a resource class with the specified class name (e.g., CompaniesResource)')
113
113
  .option('--multi-resource', 'Generate multiple resource files + main controller (requires multiple --spec files)')
114
114
  .option('--controller-only', 'Generate only the controller file, do not create full project structure')
115
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
115
116
  .option('--no-build', 'Skip installing dependencies and building the project')
116
117
  .action(async (name, options) => {
117
118
  name = name.replace(/[\/\.]/gi, '');
@@ -122,7 +123,8 @@ program
122
123
  const specContent = fs.readFileSync(options.spec, 'utf-8');
123
124
  const spec = OpenAPIToConnector.parseSpec(specContent);
124
125
  // Generate the controller from OpenAPI spec
125
- const generator = new OpenAPIToConnector(spec, name);
126
+ const generatorOptions = { nestedPaths: !!options.nestedPaths };
127
+ const generator = new OpenAPIToConnector(spec, name, generatorOptions);
126
128
  let controllerCode;
127
129
  if (options.resource) {
128
130
  console.log(`Generating resource class '${options.resource}' from OpenAPI specification...`);
@@ -220,12 +222,14 @@ program
220
222
  .requiredOption('--connector-id <id>', 'id of the connector')
221
223
  .requiredOption('--resources <specs>', 'comma-separated list of "className:specFile" pairs (e.g., "CompaniesResource:companies.json,ContactsResource:contacts.json")')
222
224
  .option('--base-url <url>', 'base URL for the API (if not specified, will be extracted from first OpenAPI spec)')
225
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
223
226
  .option('--no-build', 'Skip installing dependencies and building the project')
224
227
  .action(async (name, options) => {
225
228
  name = name.replace(/[\/\.]/gi, '');
226
229
  if (!name)
227
230
  throw new Error('name is empty');
228
231
  const target = `${process.cwd()}/${name}`;
232
+ const generatorOptions = { nestedPaths: !!options.nestedPaths };
229
233
  try {
230
234
  // Parse resources specification
231
235
  const resourceSpecs = options.resources.split(',').map((spec) => {
@@ -253,7 +257,7 @@ program
253
257
  baseUrl = spec.servers[0].url;
254
258
  }
255
259
  // Generate the resource class
256
- const generator = new OpenAPIToConnector(spec, name);
260
+ const generator = new OpenAPIToConnector(spec, name, generatorOptions);
257
261
  const resourceCode = generator.generateResourceClass(className);
258
262
  // Write the resource file
259
263
  const fileName = className.toLowerCase().replace('resource', '');
@@ -266,7 +270,7 @@ program
266
270
  // Generate the main controller
267
271
  console.log('Generating main controller...');
268
272
  const firstSpec = OpenAPIToConnector.parseSpec(fs.readFileSync(resourceSpecs[0].specFile, 'utf-8'));
269
- const mainGenerator = new OpenAPIToConnector(firstSpec, name);
273
+ const mainGenerator = new OpenAPIToConnector(firstSpec, name, generatorOptions);
270
274
  const mainControllerCode = mainGenerator.generateMainController(resources, parsedResourceSpecs);
271
275
  // Write the main controller
272
276
  const controllerPath = `${target}/src/controller/index.mts`;
@@ -310,6 +314,7 @@ program
310
314
  .argument('<projectPath>', 'path to the existing connector project')
311
315
  .requiredOption('--className <name>', 'class name for the resource (e.g., DealsResource)')
312
316
  .requiredOption('--spec <file>', 'OpenAPI specification file for the new resource')
317
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
313
318
  .option('--no-build', 'Skip building the project after adding the resource')
314
319
  .action(async (projectPath, options) => {
315
320
  const target = path.resolve(projectPath);
@@ -326,7 +331,8 @@ program
326
331
  const specContent = fs.readFileSync(options.spec, 'utf-8');
327
332
  const spec = OpenAPIToConnector.parseSpec(specContent);
328
333
  // Generate the resource functions file (new function-based pattern)
329
- const generator = new OpenAPIToConnector(spec, 'Resource');
334
+ const generatorOptions = { nestedPaths: !!options.nestedPaths };
335
+ const generator = new OpenAPIToConnector(spec, 'Resource', generatorOptions);
330
336
  const resourceCode = generator.generateResourceClass(options.className);
331
337
  // Write the resource file
332
338
  const fileName = options.className.toLowerCase().replace('resource', '');
@@ -384,7 +390,7 @@ program
384
390
  const resources = [{ className: options.className, fileName }];
385
391
  const resourceSpecs = [{ fileName, spec }];
386
392
  // Create a temporary generator to generate just the exposed methods for this resource
387
- const tempGenerator = new OpenAPIToConnector(spec, 'temp');
393
+ const tempGenerator = new OpenAPIToConnector(spec, 'temp', generatorOptions);
388
394
  const exposedMethods = tempGenerator.generateExposedResourceMethods(resources, resourceSpecs);
389
395
  // Add the exposed methods to the controller before the closing brace
390
396
  if (exposedMethods.trim()) {
@@ -1,9 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { OpenAPIV3 } from 'openapi-types';
3
+ export interface OpenAPIToConnectorOptions {
4
+ nestedPaths?: boolean;
5
+ }
3
6
  export declare class OpenAPIToConnector {
4
7
  private spec;
5
8
  private connectorName;
6
- constructor(spec: OpenAPIV3.Document, connectorName: string);
9
+ private options;
10
+ constructor(spec: OpenAPIV3.Document, connectorName: string, options?: OpenAPIToConnectorOptions);
11
+ /**
12
+ * Derive a dotted method path from URL path and operationId.
13
+ * When nestedPaths is enabled, builds a namespace from the URL segments.
14
+ * When disabled (default), falls back to the existing flat method name.
15
+ */
16
+ deriveMethodPath(httpMethod: string, urlPath: string, operationId: string): string;
7
17
  /**
8
18
  * Parse OpenAPI spec from JSON or YAML string
9
19
  */
@@ -23,9 +23,39 @@ const OpenAPISchema = z.object({
23
23
  export class OpenAPIToConnector {
24
24
  spec;
25
25
  connectorName;
26
- constructor(spec, connectorName) {
26
+ options;
27
+ constructor(spec, connectorName, options) {
27
28
  this.spec = spec;
28
29
  this.connectorName = connectorName;
30
+ this.options = options || {};
31
+ }
32
+ /**
33
+ * Derive a dotted method path from URL path and operationId.
34
+ * When nestedPaths is enabled, builds a namespace from the URL segments.
35
+ * When disabled (default), falls back to the existing flat method name.
36
+ */
37
+ deriveMethodPath(httpMethod, urlPath, operationId) {
38
+ if (!this.options.nestedPaths) {
39
+ return this.generateMethodName({ method: httpMethod, path: urlPath, operationId });
40
+ }
41
+ const VERSION_RE = /^v\d+$/;
42
+ const STRIP = new Set(['objects', 'items']);
43
+ // Build namespace parts from the URL path
44
+ const parts = urlPath
45
+ .replace(/\{[^}]+\}/g, '')
46
+ .split('/')
47
+ .filter(Boolean)
48
+ .filter((p) => !VERSION_RE.test(p))
49
+ .filter((p) => !STRIP.has(p));
50
+ // Extract the leaf action from operationId suffix
51
+ const suffix = operationId.includes('_')
52
+ ? operationId.split('_').pop()
53
+ : httpMethod.toLowerCase();
54
+ // Dedup: if suffix equals last path segment, don't repeat it
55
+ const last = parts[parts.length - 1];
56
+ if (suffix === last)
57
+ return parts.join('.');
58
+ return [...parts, suffix].join('.');
29
59
  }
30
60
  /**
31
61
  * Parse OpenAPI spec from JSON or YAML string
@@ -713,20 +743,31 @@ export class OpenAPIToConnector {
713
743
  const resourceSpec = resourceSpecs?.find((rs) => rs.fileName === resourceName);
714
744
  if (resourceSpec) {
715
745
  // Create a temporary generator for this resource's spec
716
- const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName);
746
+ const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName, this.options);
717
747
  const operations = resourceGenerator.extractOperations();
718
748
  for (const operation of operations) {
719
749
  const methodName = resourceGenerator.generateMethodName(operation);
720
750
  const jsdoc = resourceGenerator.generateDetailedJSDoc(operation);
721
751
  const signature = resourceGenerator.generateMethodSignature(operation);
722
752
  // Generate the exposed method that delegates to the resource
723
- const exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
753
+ let exposedMethodName;
754
+ if (this.options.nestedPaths && operation.operationId) {
755
+ // Use dotted path for nested method names
756
+ exposedMethodName = resourceGenerator.deriveMethodPath(operation.method, operation.path, operation.operationId);
757
+ }
758
+ else {
759
+ exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
760
+ }
724
761
  // Generate parameter call based on operation details
725
762
  const parameterCall = this.generateParameterCallForOperation(operation, signature);
763
+ // Use quoted method name if it contains dots
764
+ const methodDecl = exposedMethodName.includes('.')
765
+ ? `'${exposedMethodName}'`
766
+ : exposedMethodName;
726
767
  methods.push(` /**
727
768
  ${jsdoc}
728
769
  */
729
- async ${exposedMethodName}${signature} {
770
+ async ${methodDecl}${signature} {
730
771
  return this.${resourceName}.${methodName}(${parameterCall});
731
772
  }`);
732
773
  }
@@ -1222,11 +1263,19 @@ ${exposedMethods}
1222
1263
  }
1223
1264
  const methods = operations
1224
1265
  .map((operation) => {
1225
- const methodName = this.generateMethodName(operation);
1266
+ let methodName;
1267
+ if (this.options.nestedPaths && operation.operationId) {
1268
+ methodName = this.deriveMethodPath(operation.method, operation.path, operation.operationId);
1269
+ }
1270
+ else {
1271
+ methodName = this.generateMethodName(operation);
1272
+ }
1226
1273
  const jsdoc = this.generateDetailedJSDoc(operation); // Use detailed JSDoc like multi-resource
1227
1274
  const signature = this.generateMethodSignature(operation);
1228
1275
  const implementation = this.generateControllerMethodImplementation(operation); // Use improved implementation
1229
- return ` /**\n${jsdoc}\n */\n async ${methodName}${signature} {\n${implementation}\n }`;
1276
+ // Use quoted method name if it contains dots
1277
+ const methodDecl = methodName.includes('.') ? `'${methodName}'` : methodName;
1278
+ return ` /**\n${jsdoc}\n */\n async ${methodDecl}${signature} {\n${implementation}\n }`;
1230
1279
  })
1231
1280
  .join('\n\n');
1232
1281
  // Get base URL from servers if available
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloma.io/integration-sdk",
3
- "version": "3.8.57",
3
+ "version": "3.8.59",
4
4
  "description": "",
5
5
  "author": "aloma.io",
6
6
  "license": "Apache-2.0",
@@ -2,6 +2,43 @@ import fs from 'node:fs';
2
2
  import {AbstractController} from '../controller/index.mjs';
3
3
  import {Connector} from '../internal/index.mjs';
4
4
 
5
+ /**
6
+ * Build a resolvers object from a list of method names and a controller.
7
+ *
8
+ * If a method name contains dots (e.g. 'crm.contacts.create'), it is registered
9
+ * as a nested object tree so that resolveMethod(['crm', 'contacts', 'create'])
10
+ * finds the handler.
11
+ *
12
+ * If a method name has no dots (e.g. 'contactsCreate'), it is registered flat
13
+ * as before: resolvers.contactsCreate = handler.
14
+ */
15
+ export function buildResolvers(methods: string[], controller: any): any {
16
+ const resolvers: any = {};
17
+
18
+ methods.forEach((method) => {
19
+ const handler = async (args: any) => {
20
+ if (!methods.includes(method)) throw new Error(`${method} not found`);
21
+ return controller[method](args);
22
+ };
23
+
24
+ if (method.includes('.')) {
25
+ const parts = method.split('.');
26
+ let node = resolvers;
27
+ for (let i = 0; i < parts.length - 1; i++) {
28
+ if (!node[parts[i]] || typeof node[parts[i]] !== 'object') {
29
+ node[parts[i]] = {};
30
+ }
31
+ node = node[parts[i]];
32
+ }
33
+ node[parts[parts.length - 1]] = handler;
34
+ } else {
35
+ resolvers[method] = handler;
36
+ }
37
+ });
38
+
39
+ return resolvers;
40
+ }
41
+
5
42
  /**
6
43
  * Runtime context to manage the lifecycle of the connector
7
44
  */
@@ -42,16 +79,8 @@ export default class RuntimeContext {
42
79
 
43
80
  const configuration = connector.configure().config(data.config || {});
44
81
 
45
- const resolvers: any = {};
46
82
  const methods: string[] = [...data.methods, '__autocomplete', '__endpoint', '__default'];
47
-
48
- methods.forEach((method) => {
49
- resolvers[method] = async (args) => {
50
- if (!methods.includes(method)) throw new Error(`${method} not found`);
51
-
52
- return controller[method](args);
53
- };
54
- });
83
+ const resolvers = buildResolvers(methods, controller);
55
84
 
56
85
  configuration.types(data.types).resolvers(resolvers);
57
86
 
package/src/cli.mts CHANGED
@@ -145,6 +145,7 @@ program
145
145
  )
146
146
  .option('--multi-resource', 'Generate multiple resource files + main controller (requires multiple --spec files)')
147
147
  .option('--controller-only', 'Generate only the controller file, do not create full project structure')
148
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
148
149
  .option('--no-build', 'Skip installing dependencies and building the project')
149
150
  .action(async (name, options) => {
150
151
  name = name.replace(/[\/\.]/gi, '');
@@ -156,7 +157,8 @@ program
156
157
  const spec = OpenAPIToConnector.parseSpec(specContent);
157
158
 
158
159
  // Generate the controller from OpenAPI spec
159
- const generator = new OpenAPIToConnector(spec, name);
160
+ const generatorOptions = {nestedPaths: !!options.nestedPaths};
161
+ const generator = new OpenAPIToConnector(spec, name, generatorOptions);
160
162
  let controllerCode: string;
161
163
 
162
164
  if (options.resource) {
@@ -278,12 +280,14 @@ program
278
280
  'comma-separated list of "className:specFile" pairs (e.g., "CompaniesResource:companies.json,ContactsResource:contacts.json")'
279
281
  )
280
282
  .option('--base-url <url>', 'base URL for the API (if not specified, will be extracted from first OpenAPI spec)')
283
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
281
284
  .option('--no-build', 'Skip installing dependencies and building the project')
282
285
  .action(async (name, options) => {
283
286
  name = name.replace(/[\/\.]/gi, '');
284
287
  if (!name) throw new Error('name is empty');
285
288
 
286
289
  const target = `${process.cwd()}/${name}`;
290
+ const generatorOptions = {nestedPaths: !!options.nestedPaths};
287
291
 
288
292
  try {
289
293
  // Parse resources specification
@@ -319,7 +323,7 @@ program
319
323
  }
320
324
 
321
325
  // Generate the resource class
322
- const generator = new OpenAPIToConnector(spec, name);
326
+ const generator = new OpenAPIToConnector(spec, name, generatorOptions);
323
327
  const resourceCode = generator.generateResourceClass(className);
324
328
 
325
329
  // Write the resource file
@@ -335,7 +339,7 @@ program
335
339
  // Generate the main controller
336
340
  console.log('Generating main controller...');
337
341
  const firstSpec = OpenAPIToConnector.parseSpec(fs.readFileSync(resourceSpecs[0].specFile, 'utf-8'));
338
- const mainGenerator = new OpenAPIToConnector(firstSpec, name);
342
+ const mainGenerator = new OpenAPIToConnector(firstSpec, name, generatorOptions);
339
343
  const mainControllerCode = mainGenerator.generateMainController(resources, parsedResourceSpecs);
340
344
 
341
345
  // Write the main controller
@@ -386,6 +390,7 @@ program
386
390
  .argument('<projectPath>', 'path to the existing connector project')
387
391
  .requiredOption('--className <name>', 'class name for the resource (e.g., DealsResource)')
388
392
  .requiredOption('--spec <file>', 'OpenAPI specification file for the new resource')
393
+ .option('--nested-paths', 'Use dotted namespace paths derived from URL structure (e.g., crm.contacts.getPage)')
389
394
  .option('--no-build', 'Skip building the project after adding the resource')
390
395
  .action(async (projectPath, options) => {
391
396
  const target = path.resolve(projectPath);
@@ -409,7 +414,8 @@ program
409
414
  const spec = OpenAPIToConnector.parseSpec(specContent);
410
415
 
411
416
  // Generate the resource functions file (new function-based pattern)
412
- const generator = new OpenAPIToConnector(spec, 'Resource');
417
+ const generatorOptions = {nestedPaths: !!options.nestedPaths};
418
+ const generator = new OpenAPIToConnector(spec, 'Resource', generatorOptions);
413
419
  const resourceCode = generator.generateResourceClass(options.className);
414
420
 
415
421
  // Write the resource file
@@ -476,7 +482,7 @@ program
476
482
  const resourceSpecs = [{fileName, spec}];
477
483
 
478
484
  // Create a temporary generator to generate just the exposed methods for this resource
479
- const tempGenerator = new OpenAPIToConnector(spec, 'temp');
485
+ const tempGenerator = new OpenAPIToConnector(spec, 'temp', generatorOptions);
480
486
  const exposedMethods = tempGenerator.generateExposedResourceMethods(resources, resourceSpecs);
481
487
 
482
488
  // Add the exposed methods to the controller before the closing brace
@@ -37,13 +37,51 @@ 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
+ const suffix = operationId.includes('_')
78
+ ? operationId.split('_').pop()!
79
+ : httpMethod.toLowerCase();
80
+
81
+ // Dedup: if suffix equals last path segment, don't repeat it
82
+ const last = parts[parts.length - 1];
83
+ if (suffix === last) return parts.join('.');
84
+ return [...parts, suffix].join('.');
47
85
  }
48
86
 
49
87
  /**
@@ -823,7 +861,7 @@ export class OpenAPIToConnector {
823
861
 
824
862
  if (resourceSpec) {
825
863
  // Create a temporary generator for this resource's spec
826
- const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName);
864
+ const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName, this.options);
827
865
  const operations = resourceGenerator.extractOperations();
828
866
 
829
867
  for (const operation of operations) {
@@ -832,15 +870,30 @@ export class OpenAPIToConnector {
832
870
  const signature = resourceGenerator.generateMethodSignature(operation);
833
871
 
834
872
  // Generate the exposed method that delegates to the resource
835
- const exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
873
+ let exposedMethodName: string;
874
+ if (this.options.nestedPaths && operation.operationId) {
875
+ // Use dotted path for nested method names
876
+ exposedMethodName = resourceGenerator.deriveMethodPath(
877
+ operation.method,
878
+ operation.path,
879
+ operation.operationId
880
+ );
881
+ } else {
882
+ exposedMethodName = `${resourceName}${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
883
+ }
836
884
 
837
885
  // Generate parameter call based on operation details
838
886
  const parameterCall = this.generateParameterCallForOperation(operation, signature);
839
887
 
888
+ // Use quoted method name if it contains dots
889
+ const methodDecl = exposedMethodName.includes('.')
890
+ ? `'${exposedMethodName}'`
891
+ : exposedMethodName;
892
+
840
893
  methods.push(` /**
841
894
  ${jsdoc}
842
895
  */
843
- async ${exposedMethodName}${signature} {
896
+ async ${methodDecl}${signature} {
844
897
  return this.${resourceName}.${methodName}(${parameterCall});
845
898
  }`);
846
899
  }
@@ -1409,12 +1462,19 @@ ${exposedMethods}
1409
1462
 
1410
1463
  const methods = operations
1411
1464
  .map((operation) => {
1412
- const methodName = this.generateMethodName(operation);
1465
+ let methodName: string;
1466
+ if (this.options.nestedPaths && operation.operationId) {
1467
+ methodName = this.deriveMethodPath(operation.method, operation.path, operation.operationId);
1468
+ } else {
1469
+ methodName = this.generateMethodName(operation);
1470
+ }
1413
1471
  const jsdoc = this.generateDetailedJSDoc(operation); // Use detailed JSDoc like multi-resource
1414
1472
  const signature = this.generateMethodSignature(operation);
1415
1473
  const implementation = this.generateControllerMethodImplementation(operation); // Use improved implementation
1416
1474
 
1417
- return ` /**\n${jsdoc}\n */\n async ${methodName}${signature} {\n${implementation}\n }`;
1475
+ // Use quoted method name if it contains dots
1476
+ const methodDecl = methodName.includes('.') ? `'${methodName}'` : methodName;
1477
+ return ` /**\n${jsdoc}\n */\n async ${methodDecl}${signature} {\n${implementation}\n }`;
1418
1478
  })
1419
1479
  .join('\n\n');
1420
1480
 
@@ -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,100 @@
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
+ // --- TESTS ---
46
+
47
+ // Test 1: GET /crm/v3/objects/contacts with opId -> crm.contacts.getPage
48
+ await test('nestedPaths: GET /crm/v3/objects/contacts -> crm.contacts.getPage', async () => {
49
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
50
+ assert(typeof generator.deriveMethodPath === 'function', 'deriveMethodPath should be a method on the generator');
51
+ const result = generator.deriveMethodPath('GET', '/crm/v3/objects/contacts', 'get-/crm/v3/objects/contacts_getPage');
52
+ assertEqual(result, 'crm.contacts.getPage');
53
+ });
54
+
55
+ // Test 2: POST /crm/v3/objects/contacts with opId -> crm.contacts.create
56
+ await test('nestedPaths: POST /crm/v3/objects/contacts -> crm.contacts.create', async () => {
57
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
58
+ const result = generator.deriveMethodPath('POST', '/crm/v3/objects/contacts', 'post-/crm/v3/objects/contacts_create');
59
+ assertEqual(result, 'crm.contacts.create');
60
+ });
61
+
62
+ // Test 3: Dedup - POST /crm/v3/objects/contacts/merge with opId _merge -> crm.contacts.merge (NOT crm.contacts.merge.merge)
63
+ await test('nestedPaths: dedup - POST /crm/v3/objects/contacts/merge -> crm.contacts.merge', async () => {
64
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
65
+ const result = generator.deriveMethodPath('POST', '/crm/v3/objects/contacts/merge', 'post-/crm/v3/objects/contacts/merge_merge');
66
+ assertEqual(result, 'crm.contacts.merge');
67
+ });
68
+
69
+ // Test 4: Batch - POST /crm/v3/objects/contacts/batch/archive -> crm.contacts.batch.archive (NOT duplicated)
70
+ await test('nestedPaths: batch - POST /crm/v3/objects/contacts/batch/archive -> crm.contacts.batch.archive', async () => {
71
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: true });
72
+ const result = generator.deriveMethodPath('POST', '/crm/v3/objects/contacts/batch/archive', 'post-/crm/v3/objects/contacts/batch/archive_archive');
73
+ assertEqual(result, 'crm.contacts.batch.archive');
74
+ });
75
+
76
+ // Test 5: Flat mode (nestedPaths: false) -> just getPage (existing behavior unchanged)
77
+ await test('flat mode (nestedPaths: false): same input -> just getPage', async () => {
78
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot', { nestedPaths: false });
79
+ const result = generator.deriveMethodPath('GET', '/crm/v3/objects/contacts', 'get-/crm/v3/objects/contacts_getPage');
80
+ assertEqual(result, 'getPage');
81
+ });
82
+
83
+ // Test 6: Flat mode with no options (default behavior unchanged)
84
+ await test('flat mode (no options): same input -> just getPage', async () => {
85
+ const generator = new OpenAPIToConnector(minimalSpec, 'hubspot');
86
+ const result = generator.deriveMethodPath('GET', '/crm/v3/objects/contacts', 'get-/crm/v3/objects/contacts_getPage');
87
+ assertEqual(result, 'getPage');
88
+ });
89
+
90
+ // --- SUMMARY ---
91
+ console.log(`\n${colors.yellow}--- OpenAPI Nested Paths Test Results ---${colors.reset}`);
92
+ console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
93
+ console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
94
+
95
+ if (failed > 0) {
96
+ console.log(`\n${colors.red}Some tests failed!${colors.reset}`);
97
+ process.exit(1);
98
+ } else {
99
+ console.log(`\n${colors.green}All nested path tests passed!${colors.reset}`);
100
+ }