@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.
- 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/openapi-to-connector.d.mts +16 -1
- package/build/openapi-to-connector.mjs +115 -9
- package/package.json +1 -1
- package/src/builder/runtime-context.mts +38 -9
- package/src/cli.mts +11 -5
- package/src/openapi-to-connector.mts +135 -9
- package/test/nested-resolver.test.mjs +184 -0
- package/test/openapi-generator-bugs.test.mjs +248 -0
- package/test/openapi-nested-paths.test.mjs +100 -0
- package/test/scenarios/complex/expected/orders-resource.mts +3 -5
- package/test/scenarios/complex/expected/products-resource.mts +3 -5
|
@@ -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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|