@aloma.io/integration-sdk 3.8.56 → 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.
- package/build/builder/runtime-context.d.mts +11 -0
- package/build/builder/runtime-context.mjs +36 -8
- package/build/cli.mjs +11 -5
- package/build/internal/dispatcher/index.mjs +3 -2
- package/build/openapi-to-connector.d.mts +11 -1
- package/build/openapi-to-connector.mjs +76 -24
- package/package.json +13 -1
- package/src/builder/runtime-context.mts +38 -9
- package/src/cli.mts +11 -5
- package/src/internal/dispatcher/index.mts +4 -2
- package/src/openapi-to-connector.mts +66 -6
- package/test/nested-resolver.test.mjs +184 -0
- package/test/openapi-nested-paths.test.mjs +100 -0
|
@@ -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
|
|
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
|
|
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
|
|
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()) {
|
|
@@ -139,10 +139,11 @@ ${arg.configurableClientScope}
|
|
|
139
139
|
query = query
|
|
140
140
|
.filter((what) => !!what?.trim() && !['constructor', '__proto__', 'toString', 'toSource', 'prototype'].includes(what))
|
|
141
141
|
.slice(0, 20);
|
|
142
|
+
const originalQuery = [...query];
|
|
142
143
|
const method = resolveMethod(query);
|
|
143
144
|
if (!method && !_resolvers.__default)
|
|
144
|
-
throw new Error(`${
|
|
145
|
-
return method ? method(variables) : _resolvers.__default(variables ? { ...variables, __method:
|
|
145
|
+
throw new Error(`${originalQuery} not found`);
|
|
146
|
+
return method ? method(variables) : _resolvers.__default(variables ? { ...variables, __method: originalQuery } : variables);
|
|
146
147
|
};
|
|
147
148
|
const introspect = () => local._types;
|
|
148
149
|
const configSchema = () => local._config;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -186,7 +216,7 @@ export class OpenAPIToConnector {
|
|
|
186
216
|
this.addRequestBodyProperties(operation.requestBody, optionProps);
|
|
187
217
|
}
|
|
188
218
|
// Check if options parameter is required (has required query params or required body)
|
|
189
|
-
const hasRequiredNonPathParams = queryParams.some(p => p.required) || (hasBody && operation.requestBody?.required);
|
|
219
|
+
const hasRequiredNonPathParams = queryParams.some((p) => p.required) || (hasBody && operation.requestBody?.required);
|
|
190
220
|
const optionsRequired = hasRequiredNonPathParams ? '' : '?';
|
|
191
221
|
// Only add options parameter if there are actual options
|
|
192
222
|
if (optionProps.length > 0) {
|
|
@@ -285,7 +315,7 @@ export class OpenAPIToConnector {
|
|
|
285
315
|
* Sanitize a name to be a valid TypeScript identifier
|
|
286
316
|
*/
|
|
287
317
|
sanitizeTypeName(name) {
|
|
288
|
-
return name
|
|
318
|
+
return (name
|
|
289
319
|
// Replace dots with underscores
|
|
290
320
|
.replace(/\./g, '_')
|
|
291
321
|
// Replace + with _Plus (common in OpenAPI for enums)
|
|
@@ -297,9 +327,9 @@ export class OpenAPIToConnector {
|
|
|
297
327
|
// Remove multiple consecutive underscores
|
|
298
328
|
.replace(/_+/g, '_')
|
|
299
329
|
// Remove trailing/leading underscores
|
|
300
|
-
.replace(/^_+|_+$/g, '')
|
|
330
|
+
.replace(/^_+|_+$/g, '') ||
|
|
301
331
|
// Ensure it's not empty
|
|
302
|
-
|
|
332
|
+
'UnknownType');
|
|
303
333
|
}
|
|
304
334
|
/**
|
|
305
335
|
* Get TypeScript type from schema object
|
|
@@ -323,7 +353,8 @@ export class OpenAPIToConnector {
|
|
|
323
353
|
if (schema.type === 'object' && schema.properties) {
|
|
324
354
|
const propNames = Object.keys(schema.properties);
|
|
325
355
|
// For response objects, generate inline type definitions
|
|
326
|
-
if (propNames.length <= 5) {
|
|
356
|
+
if (propNames.length <= 5) {
|
|
357
|
+
// Reasonable limit for inline types
|
|
327
358
|
const propTypes = Object.entries(schema.properties).map(([key, prop]) => {
|
|
328
359
|
const propType = this.getTypeFromSchema(prop);
|
|
329
360
|
return `${key}: ${propType}`;
|
|
@@ -420,14 +451,15 @@ export class OpenAPIToConnector {
|
|
|
420
451
|
if (schema.properties) {
|
|
421
452
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
422
453
|
const propType = this.getTypeFromSchema(propSchema);
|
|
423
|
-
const required = (schema.required && schema.required.includes(propName)) ||
|
|
454
|
+
const required = (schema.required && schema.required.includes(propName)) || requestBody.required;
|
|
424
455
|
const optional = required ? '' : '?';
|
|
425
456
|
// Add description as comment if available
|
|
426
457
|
const description = propSchema?.description;
|
|
427
458
|
if (description) {
|
|
428
459
|
// Clean up description for inline use
|
|
429
460
|
const cleanDesc = description.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
430
|
-
if (cleanDesc.length < 100) {
|
|
461
|
+
if (cleanDesc.length < 100) {
|
|
462
|
+
// Only add short descriptions inline
|
|
431
463
|
optionProps.push(`${propName}${optional}: ${propType} /** ${cleanDesc} */`);
|
|
432
464
|
}
|
|
433
465
|
else {
|
|
@@ -708,23 +740,34 @@ export class OpenAPIToConnector {
|
|
|
708
740
|
for (const resource of resources) {
|
|
709
741
|
const resourceName = resource.fileName;
|
|
710
742
|
// Find the corresponding spec for this resource
|
|
711
|
-
const resourceSpec = resourceSpecs?.find(rs => rs.fileName === resourceName);
|
|
743
|
+
const resourceSpec = resourceSpecs?.find((rs) => rs.fileName === resourceName);
|
|
712
744
|
if (resourceSpec) {
|
|
713
745
|
// Create a temporary generator for this resource's spec
|
|
714
|
-
const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName);
|
|
746
|
+
const resourceGenerator = new OpenAPIToConnector(resourceSpec.spec, resourceName, this.options);
|
|
715
747
|
const operations = resourceGenerator.extractOperations();
|
|
716
748
|
for (const operation of operations) {
|
|
717
749
|
const methodName = resourceGenerator.generateMethodName(operation);
|
|
718
750
|
const jsdoc = resourceGenerator.generateDetailedJSDoc(operation);
|
|
719
751
|
const signature = resourceGenerator.generateMethodSignature(operation);
|
|
720
752
|
// Generate the exposed method that delegates to the resource
|
|
721
|
-
|
|
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
|
+
}
|
|
722
761
|
// Generate parameter call based on operation details
|
|
723
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;
|
|
724
767
|
methods.push(` /**
|
|
725
768
|
${jsdoc}
|
|
726
769
|
*/
|
|
727
|
-
async ${
|
|
770
|
+
async ${methodDecl}${signature} {
|
|
728
771
|
return this.${resourceName}.${methodName}(${parameterCall});
|
|
729
772
|
}`);
|
|
730
773
|
}
|
|
@@ -756,10 +799,13 @@ ${jsdoc}
|
|
|
756
799
|
const paramMatch = signature.match(/\(([^)]+)\)/);
|
|
757
800
|
if (!paramMatch || paramMatch[1].trim() === '')
|
|
758
801
|
return '';
|
|
759
|
-
const allParams = paramMatch[1]
|
|
802
|
+
const allParams = paramMatch[1]
|
|
803
|
+
.split(',')
|
|
804
|
+
.map((p) => {
|
|
760
805
|
const paramName = p.trim().split(':')[0].trim();
|
|
761
806
|
return paramName.replace(/[?]/g, '');
|
|
762
|
-
})
|
|
807
|
+
})
|
|
808
|
+
.filter((p) => p.length > 0);
|
|
763
809
|
// Check if signature actually has options parameter
|
|
764
810
|
const hasOptionsParam = allParams.includes('options');
|
|
765
811
|
// Always extract path parameters as discrete parameters when they exist
|
|
@@ -807,7 +853,7 @@ ${jsdoc}
|
|
|
807
853
|
}
|
|
808
854
|
// Handle allOf, oneOf, anyOf
|
|
809
855
|
if (schema.allOf) {
|
|
810
|
-
lines.push(` // Inherits from: ${schema.allOf.map((s) => s.$ref ? this.resolveSchemaRef(s.$ref) : 'unknown').join(', ')}`);
|
|
856
|
+
lines.push(` // Inherits from: ${schema.allOf.map((s) => (s.$ref ? this.resolveSchemaRef(s.$ref) : 'unknown')).join(', ')}`);
|
|
811
857
|
}
|
|
812
858
|
lines.push('}');
|
|
813
859
|
return lines.join('\n');
|
|
@@ -845,7 +891,7 @@ ${jsdoc}
|
|
|
845
891
|
lines.push(' *');
|
|
846
892
|
// Split long descriptions into multiple lines
|
|
847
893
|
const descLines = operation.description.split('\n');
|
|
848
|
-
descLines.forEach(line => {
|
|
894
|
+
descLines.forEach((line) => {
|
|
849
895
|
lines.push(` * ${line}`);
|
|
850
896
|
});
|
|
851
897
|
}
|
|
@@ -866,7 +912,7 @@ ${jsdoc}
|
|
|
866
912
|
}
|
|
867
913
|
}
|
|
868
914
|
// Document discrete path parameters
|
|
869
|
-
pathParams.forEach(param => {
|
|
915
|
+
pathParams.forEach((param) => {
|
|
870
916
|
const paramType = this.getParameterType(param);
|
|
871
917
|
const paramDesc = param.description || '';
|
|
872
918
|
lines.push(` * @param {${paramType}} ${param.name} ${paramDesc}`);
|
|
@@ -875,7 +921,7 @@ ${jsdoc}
|
|
|
875
921
|
if (queryParams.length > 0 || operation.requestBody) {
|
|
876
922
|
lines.push(' * @param {Object} options - Request options');
|
|
877
923
|
// Document query parameters
|
|
878
|
-
queryParams.forEach(param => {
|
|
924
|
+
queryParams.forEach((param) => {
|
|
879
925
|
const paramType = this.getParameterType(param);
|
|
880
926
|
const paramDesc = param.description || '';
|
|
881
927
|
const required = param.required ? '(required)' : '(optional)';
|
|
@@ -938,7 +984,7 @@ ${jsdoc}
|
|
|
938
984
|
if (schema.properties) {
|
|
939
985
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
940
986
|
const propType = this.getTypeFromSchema(propSchema);
|
|
941
|
-
const propRequired = (schema.required && schema.required.includes(propName)) ||
|
|
987
|
+
const propRequired = (schema.required && schema.required.includes(propName)) || requestBody.required;
|
|
942
988
|
const requiredText = propRequired ? '(required)' : '(optional)';
|
|
943
989
|
const propDesc = propSchema?.description || '';
|
|
944
990
|
lines.push(` * @param {${propType}} options.${propName} ${requiredText} - ${propDesc} [body property]`);
|
|
@@ -1156,9 +1202,7 @@ ${functions}`;
|
|
|
1156
1202
|
const imports = resources
|
|
1157
1203
|
.map((resource) => `import * as ${resource.fileName}Functions from '../resources/${resource.fileName}.mjs';`)
|
|
1158
1204
|
.join('\n');
|
|
1159
|
-
const properties = resources
|
|
1160
|
-
.map((resource) => ` ${resource.fileName}: any = {};`)
|
|
1161
|
-
.join('\n');
|
|
1205
|
+
const properties = resources.map((resource) => ` ${resource.fileName}: any = {};`).join('\n');
|
|
1162
1206
|
const bindings = resources
|
|
1163
1207
|
.map((resource) => ` this.bindResourceFunctions('${resource.fileName}', ${resource.fileName}Functions);`)
|
|
1164
1208
|
.join('\n');
|
|
@@ -1219,11 +1263,19 @@ ${exposedMethods}
|
|
|
1219
1263
|
}
|
|
1220
1264
|
const methods = operations
|
|
1221
1265
|
.map((operation) => {
|
|
1222
|
-
|
|
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
|
+
}
|
|
1223
1273
|
const jsdoc = this.generateDetailedJSDoc(operation); // Use detailed JSDoc like multi-resource
|
|
1224
1274
|
const signature = this.generateMethodSignature(operation);
|
|
1225
1275
|
const implementation = this.generateControllerMethodImplementation(operation); // Use improved implementation
|
|
1226
|
-
|
|
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 }`;
|
|
1227
1279
|
})
|
|
1228
1280
|
.join('\n\n');
|
|
1229
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.
|
|
3
|
+
"version": "3.8.59",
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "aloma.io",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -53,5 +53,17 @@
|
|
|
53
53
|
"mocha": "^10",
|
|
54
54
|
"prettier": "^3",
|
|
55
55
|
"ts-node": "^10.9.2"
|
|
56
|
+
},
|
|
57
|
+
"resolutions": {
|
|
58
|
+
"path-to-regexp": "0.1.13"
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "https://github.com/aloma-io/integration.git",
|
|
63
|
+
"directory": "integration-sdk"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"registry": "https://registry.npmjs.org/"
|
|
56
68
|
}
|
|
57
69
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -172,10 +172,12 @@ ${arg.configurableClientScope}
|
|
|
172
172
|
)
|
|
173
173
|
.slice(0, 20);
|
|
174
174
|
|
|
175
|
+
const originalQuery = [...query];
|
|
176
|
+
|
|
175
177
|
const method = resolveMethod(query);
|
|
176
|
-
if (!method && !_resolvers.__default) throw new Error(`${
|
|
178
|
+
if (!method && !_resolvers.__default) throw new Error(`${originalQuery} not found`);
|
|
177
179
|
|
|
178
|
-
return method ? method(variables) : _resolvers.__default(variables ? {...variables, __method:
|
|
180
|
+
return method ? method(variables) : _resolvers.__default(variables ? {...variables, __method: originalQuery} : variables);
|
|
179
181
|
};
|
|
180
182
|
|
|
181
183
|
const introspect = () => local._types;
|
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|